Compare commits

...

13 Commits

Author SHA1 Message Date
Jaylyn Barbee
794b26d8dd Merge branch 'main' into jay/lsv2 2025-10-07 12:39:46 -04:00
Jaylyn Barbee
0d5220561d [New Module] Light Switch (#41987)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
This pull request introduces a new module called "Light Switch" which
allows users to automatically switch between light and dark mode on a
timer.

![Light
Switch](https://github.com/user-attachments/assets/d24d7364-445f-4f23-ab5e-4b8c6a4147ab)

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #1331
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [x] **Dev docs:** Added/updated
- [x] **New binaries:** Added on the required places
- [x] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [x] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [x] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here:
[#5867](https://github.com/MicrosoftDocs/windows-dev-docs-pr/pull/5867)

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

### Known bugs:
- Default settings not saving correctly when switching modes
- Issue: Sometimes when you switch from one mode to another, they are
supposed to update with new defaults but sometimes this fails for the
second variable. Potentially has to do with accessing the settings file
while another chunk of code is still updating.
- Sometimes the system looks "glitched" when switching themes

### To do:
- [x] OOBE page and assets
- [x] Logic to disable the chart when no location has been selected
- [x] Localization

### How to and what to test
Grab the latest installer from the pipeline below for your architecture
and install PowerToys from there.
- Toggle theme shortcutSystem only, Apps only, Both system and apps
selected
- Does changing the values on the settings page update the settings
file? %LOCALAPPDATA%/Microsoft/PowerToys/LightSwitch/settings.json
- Manual mode: System only, Apps only, Both system and apps selected
- Sunrise modes:  Are the times accurate?
- If you manage to let this run through sunset/rise does the theme
change?
- Set your theme to change within the next minute using manual mode and
set your device to sleepOpen your device and login once the time you set
has passed. --> Do your settings resync once the next minute ticks after
logging back into your device?
- Disable the service and ensure the tasks actually ends.
- While the module is disabled:
     - Make sure the shortcut no longer works
     - Make sure the last time you set doesn't trigger a theme change
- Bonus: Toggle GPO Configuration and make sure you are unable to enable
the module

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Gordon Lam (SH) <yeelam@microsoft.com>
2025-10-06 13:44:07 -07:00
Mike Griese
ccc31c13ae CmdPal: A couple more run commands bugs (#42174)
Caching bugs are hard.

This fixes like, three different run commands bugs:
* typing `c:\windows\p`, then backspacing to `c:\windows` would populate
the cache for `c:\` with the files in `c:\` that matched `windows*`.
* Now when the dir chenges, we correctly fill the cache with everything
in that dir, then filter it.
* that also caused a similar edge case for `c:\windows\` -> `c:\windows`
(the first should show results under c:\windows\` the second should only
show things in `c:\` matching `windows`
* As of my last PR, we support commandlines with spaces. We however
forgot to handle _paths_ with spaces. We'll now correctly show path
results for something like `c:\program files\`
2025-10-06 12:33:38 -05:00
Niels Laute
233ca4c05b MarkdownTextBlock crash fix (#42171)
## Summary of the Pull Request

Bumping MarkdownTextBlock to `0.1.251002-build.2316` that includes the
fix for this crashing bug.

cc @jiripolasek it seems to work?

<img width="831" height="508" alt="image"
src="https://github.com/user-attachments/assets/1b53144c-516f-4df9-b47d-0d4e80dbe1a2"
/>

## PR Checklist

- [x] Closes: #42142
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-10-06 12:32:29 -05:00
Jiří Polášek
f42d6dbc3d CmdPal: Add keyboard shortcut Alt+Home for Go home action (#42095)
## Summary of the Pull Request

This PR adds a new keyboard shortcut Alt+Home that takes user
immediately to the home page in a single action.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41747 
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-10-06 09:48:04 -05:00
Jiří Polášek
466a94eb40 CmdPal: Fix updating primary command and context menu and app icons (#42155)
## Summary of the Pull Request

This PR fixes three issues in one go:
- Restores missing icons in app context menus.
- Fixes propagation of changes from a command item to the context menu
item for the primary action.
- Ensures the context menus stay in sync when underlying command items
change.

Details:
- Correctly propagates updates of name, icon, and subtitle from a
command item to its primary command
(`CommandItemViewModel._defaultCommandContextItemViewModel`).
- Correctly propagate updates of command's name to title
(`CommandItem.ctor`).
- Fixes icon loading for application items: `AppCommand` no longer loads
an app icon by default but instead relies on the caller to provide one
(since `AppListItem` also handles icon loading).
- Adds a generic fallback icon for apps when an icon cannot be loaded.
- Updates bindings on context menu items to `OneWay`, ensuring the UI
properly reflects item changes.
- Adds a sample that showcases dynamically updated commands (with cats
and dolphins!) to _Samples → List Page Sample Command_.

⚠️ Toolkit changes:
- `CommandItem` won't capture assigned Command's name as its `Title`.
This will allow it to propagate future changes to `Command.Name`.

Pictures? Moving ones!


https://github.com/user-attachments/assets/1a482394-d222-4f7c-9922-bb67d47dc566

<img width="864" height="538" alt="image"
src="https://github.com/user-attachments/assets/12f07b3e-f41c-4c40-a4e5-315f40676c52"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #40946
- [x] Related: #40991 
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-10-06 09:45:10 -05:00
Software2
26ec8c6bd5 Fix for #42186 (#42187)
## Summary of the Pull Request
Move/rename a documentation file to fit a refactor that missed this
file.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [X] Closes: #42186
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [X] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

## Validation Steps Performed
I verified the link in readme.md matches the moved file in this commit.
2025-10-06 15:02:31 +02:00
Jiří Polášek
8a218860d4 CmdPal: Sync a RESX designer file with its RESX (#42165)
## Summary of the Pull Request

This PR adds changes missing from #42115 - RESX designer file wasn't
changed to matched updated RESX (blame Skynet).

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-10-06 06:27:37 -05:00
Jiří Polášek
e748f31593 CmdPal: Handle DWM cloak failure by keeping window hidden (#42107)
## Summary of the Pull Request

This PR introduces a workaround for cases where DWM cloaking of the main
window fails.

If the main window cannot be cloaked by DWM, it will remain hidden until
the user explicitly summons it. (Normally, we cloak the window and
immediately display it under DWM's cover of darkness. When cloaking
fails, the windows would be displayed permanently.)

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #42082 
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-10-02 12:46:48 -05:00
Jiří Polášek
b6944b432c CmdPal: Allow any image format as icon for protocol bookmarks (#42145)
## Summary of the Pull Request

This change attempts to load any supported image format as a protocol
bookmark icon, instead of restricting it to PNG only. The original
implementation handled only PNG (which are common), but the manifest
also supports JPG/JPEG extensions.

Reference: [UWP manifest schema –
uap:VisualElements](https://learn.microsoft.com/en-us/uwp/schemas/appxpackage/uapmanifestschema/element-uap-visualelements)


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #42144 
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-10-02 08:39:01 -05:00
Mike Griese
8ce4b635cf CmdPal: add a setting for the page transition animation (#42093)
Basically title.

Closes #41869
2025-10-02 06:37:18 -05:00
Mike Griese
87af08630a CmdPal: collection of Run Commands nits (#42092)
* Path items were being treated inconsistently
* We shouldn't re-enumerate a directory on every keystroke
* A bunch of elements had empty TextToSuggest (which makes it crazier
  that it ever worked right)


Vaguely regressed in #41956
related to #39091
2025-10-02 06:36:59 -05:00
Jiří Polášek
55f0bcc441 CmdPal: Make Bookmarks Great and Fast Again (#41961)
## Summary of the Pull Request


This PR improves recognition and classification of bookmarks, allowing
CmdPal to recognize almost anything sensible a user can throw at
it—while being forgiving of common input issues (such as unquoted spaces
in paths, etc.).

Extended classification and exploration of edge cases also revealed
limitations in the current implementation, which reloaded all bookmarks
on every change. This caused visible UI lag and could lead to issues
like unintentionally adding the same bookmark multiple times.

### tl;dr

More details below

- Introduces `BookmarkManager` (async saves, thread-safe, immutable,
unique IDs, separate persistence).
- Adds `BookmarkResolver` (classification, Shell-like path/exe
resolution, better icons).
- `BookmarkListItem` now refreshes independently; Name is optional
(Shell fallback).
- Uses Shell API for user-friendly names and paths.  
- Adds `IIconLocator`, protocol icon support, Steam custom icon,
fallback icons and improved `FaviconLoader` (handles redirects). Every
bookmark should now have icon, so we have consistent UI without gaps.
- Refactors placeholders (`IPlaceholderParser`), adds tests, restricts
names to `[a-zA-Z0-9_-]`, excludes GUIDs.
- Reorganizes structure, syncs icons/key chords with AllApps/Indexer.  
- For web and protocol bookmarks URL-encodes placeholder values
- **Performance:** avoids full reloads, improves scalability, reduces UI
lag.
- **Breaking change:** stricter placeholder rules, bookmark command ids.


<img width="786" height="1392" alt="image"
src="https://github.com/user-attachments/assets/88d6617a-9f7c-47d1-bd60-80593fe414d3"
/>

<img width="786" height="1389" alt="image"
src="https://github.com/user-attachments/assets/8cdd3a09-73ae-439a-94ef-4e14d14c1ef3"
/>

<img width="896" height="461" alt="image"
src="https://github.com/user-attachments/assets/1f32e230-7d32-4710-b4c5-28e202c0e37b"
/>

<img width="862" height="391" alt="image"
src="https://github.com/user-attachments/assets/7649ce6a-3471-46f2-adc4-fb21bd4ecfed"
/>

<img width="844" height="356" alt="image"
src="https://github.com/user-attachments/assets/0c0b1941-fe5c-474e-94e9-de3817cb5470"
/>

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41705
- [x] Closes: #41892
- [x] Closes: #41872
- [x] Closes: #41545
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

### Changes

- **Bookmark Manager**  
  - Introduces a `BookmarkManager` class that:  
    - Holds bookmarks in memory and saves changes asynchronously.  
    - Is safe to operate from multiple threads.  
    - Uses immutable data for transport.  
    - Separates the **persistence model** from in-memory data.  
    - Assigns explicit unique IDs to bookmarks.  
- These IDs also serve as stable top-level command identifiers, enabling
aliases and shortcuts to be bound reliably.

- **Bookmark Resolver**  
- Determines the type of a bookmark (`CommandKind`: file, web link,
command, etc.).
  - Detects its target and parameters.  
- Returns a `Classification` object containing all information needed to
present the bookmark to the user (icon, primary command, context menu
actions, etc.).
- For unquoted local paths, attempts to find the *longest viable
matching path* to a file or executable, automatically handling spaces in
paths (e.g., `C:\Program Files`).
- The resolution of executables from the command line now more closely
matches **Windows Shell** behavior.
    - Users are more likely to get the correct result.  
    - Icons can be determined more reliably.  

- **Bookmark List Items**  
- Each top-level bookmark item (`BookmarkListItem`) is now responsible
for presenting itself.
  - Items refresh their state independently on load or after changes.  
  - The **Name** field is now optional.  
- If no explicit name is provided, a user-friendly fallback name is
computed automatically using the Shell API.
- Context actions are now more in line with **All Apps** and **Indexer**
built-in extensions, matching items, icons, and shortcuts (still a work
in progress).

- **Shell API Integration**  
- Uses the Shell API to provide friendly names and paths for shell or
file system items, keeping the UI aligned with the OS.

- **Protocol and Icon Support**  
  - Adds `IIconLocator` and protocol icon support.  
- Provides a custom icon for **Steam**, since Steam registers its
protocol to an executable not on the path (and the Steam protocol is
expected to be a common case).
  - Adds `FaviconLoader` for web links.  
- Can now follow redirects and retrieve the favicon even if the server
takes the request on a “sightseeing tour.”
- Provides **Fluent Segoe fallback icons** that match the bookmark
classification when no specific icon is available.

- **Refactors and Reorganization**  
  - Extracts `IPlaceholderParser` for testability and reusability.  
- Renames `Bookmarks` → `BookmarksData` to prevent naming collisions.
  - Reorganizes the structure (reducing root-level file clutter).  
  - Synchronizes icons and key chords with AllApps/Indexer.  
- Refactors placeholder parsing logic and **adds tests** to improve
reliability.

- **Misc**
- Correctly URL-encodes placeholder values in Web URL or protocol
bookmarks.

---

### Performance Improvements

- Eliminates full reloads of all bookmarks on every change.  
- Improves scalability when working with a large number of bookmarks.  
- Independent refresh of list items reduces UI lag and improves
responsiveness.
- Asynchronous persistence prevents blocking the UI thread on saves.  

---

### Breaking Changes

- **Placeholders**  
- Placeholder names are now restricted to letters (`a–z`, `A–Z`), digits
(`0–9`), uderscore (`_`), hyphen (`-`).
- GUIDs are explicitly excluded as valid placeholders to prevent
collisions with shell IDs.
- When presented to the user, placeholders are considered
case-insensitive.
- ** Bookmark Top-Level Command
- **Bookmark Top-Level Command**  
  - IDs for bookmark commands are now based on a unique identifier.  
  - This breaks existing bindings to shortcuts and aliases.  
- Newly created bindings will be stable regardless of changes to the
bookmark (name, address, or having placeholders).
  - 
<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

---------

Co-authored-by: Michael Jolley <mike@baldbeardedbuilder.com>
2025-10-01 16:45:01 -05:00
110 changed files with 6993 additions and 1504 deletions

View File

@@ -368,6 +368,7 @@ desktopshorcutinstalled
DESKTOPVERTRES
devblogs
devdocs
devenv
devmgmt
DEVMODE
DEVMODEW
@@ -826,6 +827,7 @@ killrunner
kmph
kvp
Kybd
LARGEICON
lastcodeanalysissucceeded
LASTEXITCODE
LAYOUTRTL
@@ -1209,8 +1211,10 @@ PACL
PAINTSTRUCT
PALETTEWINDOW
PARENTNOTIFY
PARENTRELATIVE
PARENTRELATIVEEDITING
PARENTRELATIVEFORADDRESSBAR
PARENTRELATIVEFORUI
PARENTRELATIVEPARSING
parray
PARTIALCONFIRMATIONDIALOGTITLE
@@ -1266,6 +1270,7 @@ pgp
pguid
phbm
phbmp
phicon
phwnd
pici
pidl
@@ -1274,6 +1279,7 @@ pinfo
pinvoke
pipename
PKBDLLHOOKSTRUCT
pkgfamily
plib
ploc
ploca

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,8 @@ public partial class SettingsModel : ObservableObject
public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse;
public bool DisableAnimations { get; set; } = true;
// END SETTINGS
///////////////////////////////////////////////////////////////////////////

View File

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

View File

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

View File

@@ -31,7 +31,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 AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -42,7 +42,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 +51,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 +65,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 AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -83,7 +83,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 +93,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 +106,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>

View 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

View File

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

View File

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

View File

@@ -101,6 +101,18 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_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>()!;
@@ -142,7 +154,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_ => throw new NotSupportedException(),
},
message.Page,
message.WithAnimation ? _slideRightTransition : _noAnimation);
message.WithAnimation ? DefaultPageAnimation : _noAnimation);
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
@@ -549,19 +561,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

View File

@@ -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=&#xE945;}">
<ToggleSwitch IsOn="{x:Bind viewModel.DisableAnimations, Mode=TwoWay}" />
</controls:SettingsCard>
<!-- 'For Developers' section -->
<TextBlock x:Uid="ForDevelopersSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
],
];
}
}

View File

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

View File

@@ -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 commandsAdd + 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);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
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];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
public enum LaunchMethod
{
ShellExecute, // UseShellExecute = true (Explorer/associations/protocols)
ExplorerOpen, // explorer.exe <folder/shell:uri>
ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app)
}

View File

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

View File

@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.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);
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,33 +2,33 @@
// 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.Properties;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Bookmarks;
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
internal sealed partial class AddBookmarkPage : ContentPage
{
private readonly AddBookmarkForm _addBookmark;
internal event TypedEventHandler<object, BookmarkData>? AddedCommand
{
add => _addBookmark.AddedCommand += value;
remove => _addBookmark.AddedCommand -= value;
add => _addBookmarkForm.AddedCommand += value;
remove => _addBookmarkForm.AddedCommand -= value;
}
public override IContent[] GetContent() => [_addBookmark];
private readonly AddBookmarkForm _addBookmarkForm;
public AddBookmarkPage(BookmarkData? bookmark)
{
var name = bookmark?.Name ?? string.Empty;
var url = bookmark?.Bookmark ?? string.Empty;
Icon = Icons.BookmarkIcon;
var isAdd = string.IsNullOrEmpty(name) && string.IsNullOrEmpty(url);
Title = isAdd ? Resources.bookmarks_add_title : Resources.bookmarks_edit_name;
Name = isAdd ? Resources.bookmarks_add_name : Resources.bookmarks_edit_name;
_addBookmark = new(bookmark);
_addBookmarkForm = new AddBookmarkForm(bookmark);
}
public override IContent[] GetContent() => [_addBookmarkForm];
}

View File

@@ -0,0 +1,304 @@
// 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.IO;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Core.Common.Commands;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Commands;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
using Microsoft.CmdPal.Ext.Indexer;
using Microsoft.CommandPalette.Extensions;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
internal sealed partial class BookmarkListItem : ListItem, IDisposable
{
private readonly IBookmarksManager _bookmarksManager;
private readonly IBookmarkResolver _commandResolver;
private readonly IBookmarkIconLocator _iconLocator;
private readonly IPlaceholderParser _placeholderParser;
private readonly SupersedingAsyncValueGate<BookmarkListItemReclassifyResult> _classificationGate;
private readonly TaskCompletionSource _initializationTcs = new();
private BookmarkData _bookmark;
public Task IsInitialized => _initializationTcs.Task;
public string BookmarkAddress => _bookmark.Bookmark;
public string BookmarkTitle => _bookmark.Name;
public Guid BookmarkId => _bookmark.Id;
public BookmarkListItem(BookmarkData bookmark, IBookmarksManager bookmarksManager, IBookmarkResolver commandResolver, IBookmarkIconLocator iconLocator, IPlaceholderParser placeholderParser)
{
ArgumentNullException.ThrowIfNull(bookmark);
ArgumentNullException.ThrowIfNull(bookmarksManager);
ArgumentNullException.ThrowIfNull(commandResolver);
_bookmark = bookmark;
_bookmarksManager = bookmarksManager;
_bookmarksManager.BookmarkUpdated += BookmarksManagerOnBookmarkUpdated;
_commandResolver = commandResolver;
_iconLocator = iconLocator;
_placeholderParser = placeholderParser;
_classificationGate = new SupersedingAsyncValueGate<BookmarkListItemReclassifyResult>(ClassifyAsync, ApplyClassificationResult);
_ = _classificationGate.ExecuteAsync();
}
private void BookmarksManagerOnBookmarkUpdated(BookmarkData original, BookmarkData @new)
{
if (original.Id == _bookmark.Id)
{
Update(@new);
}
}
public void Dispose()
{
_classificationGate.Dispose();
var existing = Command;
if (existing != null)
{
existing.PropChanged -= CommandPropertyChanged;
}
}
private void Update(BookmarkData data)
{
ArgumentNullException.ThrowIfNull(data);
try
{
_bookmark = data;
OnPropertyChanged(nameof(BookmarkTitle));
OnPropertyChanged(nameof(BookmarkAddress));
Subtitle = Resources.bookmarks_item_refreshing;
_ = _classificationGate.ExecuteAsync();
}
catch (Exception ex)
{
Logger.LogError("Failed to update bookmark", ex);
}
}
private async Task<BookmarkListItemReclassifyResult> ClassifyAsync(CancellationToken ct)
{
TypedEventHandler<object, BookmarkData> bookmarkSavedHandler = BookmarkSaved;
List<IContextItem> contextMenu = [];
var classification = (await _commandResolver.TryClassifyAsync(_bookmark.Bookmark, ct)).Result;
var title = BuildTitle(_bookmark, classification);
var subtitle = BuildSubtitle(_bookmark, classification);
ICommand command = classification.IsPlaceholder
? new BookmarkPlaceholderPage(_bookmark, _iconLocator, _commandResolver, _placeholderParser)
: new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver);
BuildSpecificContextMenuItems(classification, contextMenu);
AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu);
return new BookmarkListItemReclassifyResult(
command,
title,
subtitle,
contextMenu.ToArray());
}
private void ApplyClassificationResult(BookmarkListItemReclassifyResult classificationResult)
{
var existing = Command;
if (existing != null)
{
existing.PropChanged -= CommandPropertyChanged;
}
classificationResult.Command.PropChanged += CommandPropertyChanged;
Command = classificationResult.Command;
OnPropertyChanged(nameof(Icon));
Title = classificationResult.Title;
Subtitle = classificationResult.Subtitle;
MoreCommands = classificationResult.MoreCommands;
_initializationTcs.TrySetResult();
}
private void CommandPropertyChanged(object sender, IPropChangedEventArgs args) =>
OnPropertyChanged(args.PropertyName);
private static void BuildSpecificContextMenuItems(Classification classification, List<IContextItem> contextMenu)
{
// TODO: unify across all built-in extensions
var bookmarkTargetType = classification.Kind;
// TODO: add "Run as administrator" for executables/shortcuts
if (!classification.IsPlaceholder)
{
if (bookmarkTargetType == CommandKind.FileDocument && File.Exists(classification.Target))
{
contextMenu.Add(new CommandContextItem(new OpenWithCommand(classification.Input)));
}
}
string? directoryPath = null;
var targetPath = classification.Target;
switch (bookmarkTargetType)
{
case CommandKind.Directory:
directoryPath = targetPath;
contextMenu.Add(new CommandContextItem(new DirectoryPage(directoryPath))); // Browse
break;
case CommandKind.FileExecutable:
case CommandKind.FileDocument:
case CommandKind.Shortcut:
case CommandKind.InternetShortcut:
try
{
directoryPath = Path.GetDirectoryName(targetPath);
}
catch
{
// ignore any path parsing errors
}
break;
case CommandKind.WebUrl:
case CommandKind.Protocol:
case CommandKind.Aumid:
case CommandKind.PathCommand:
case CommandKind.Unknown:
default:
break;
}
// Add "Copy Path" or "Copy Address" command
if (!string.IsNullOrWhiteSpace(classification.Input))
{
var copyCommand = new CopyPathCommand(targetPath)
{
Name = bookmarkTargetType is CommandKind.WebUrl or CommandKind.Protocol
? Resources.bookmarks_copy_address_name
: Resources.bookmarks_copy_path_name,
Icon = Icons.CopyPath,
};
contextMenu.Add(new CommandContextItem(copyCommand) { RequestedShortcut = KeyChords.CopyPath });
}
// Add "Open in Console" and "Show in Folder" commands if we have a valid directory path
if (!string.IsNullOrWhiteSpace(directoryPath) && Directory.Exists(directoryPath))
{
contextMenu.Add(new CommandContextItem(new ShowFileInFolderCommand(targetPath)) { RequestedShortcut = KeyChords.OpenFileLocation });
contextMenu.Add(new CommandContextItem(OpenInConsoleCommand.FromDirectory(directoryPath)) { RequestedShortcut = KeyChords.OpenInConsole });
}
if (!string.IsNullOrWhiteSpace(targetPath) && (File.Exists(targetPath) || Directory.Exists(targetPath)))
{
contextMenu.Add(new CommandContextItem(new OpenPropertiesCommand(targetPath)));
}
}
private static string BuildSubtitle(BookmarkData bookmark, Classification classification)
{
var subtitle = BuildSubtitleCore(bookmark, classification);
#if DEBUG
subtitle = $" ({classification.Kind}) • " + subtitle;
#endif
return subtitle;
}
private static string BuildSubtitleCore(BookmarkData bookmark, Classification classification)
{
if (classification.Kind == CommandKind.Unknown)
{
return bookmark.Bookmark;
}
if (classification.Kind is CommandKind.VirtualShellItem &&
ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName))
{
return friendlyName;
}
if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) &&
!string.IsNullOrWhiteSpace(displayName))
{
return displayName;
}
return bookmark.Bookmark;
}
private static string BuildTitle(BookmarkData bookmark, Classification classification)
{
if (!string.IsNullOrWhiteSpace(bookmark.Name))
{
return bookmark.Name;
}
if (classification.Kind is CommandKind.Unknown or CommandKind.WebUrl or CommandKind.Protocol)
{
return bookmark.Bookmark;
}
if (ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName))
{
return friendlyName;
}
if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) &&
!string.IsNullOrWhiteSpace(displayName))
{
return displayName;
}
return bookmark.Bookmark;
}
private static void AddCommonContextMenuItems(
BookmarkData bookmark,
IBookmarksManager bookmarksManager,
TypedEventHandler<object, BookmarkData> bookmarkSavedHandler,
List<IContextItem> contextMenu)
{
contextMenu.Add(new Separator());
var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon };
edit.AddedCommand += bookmarkSavedHandler;
contextMenu.Add(new CommandContextItem(edit));
var confirmableCommand = new ConfirmableCommand
{
Command = new DeleteBookmarkCommand(bookmark, bookmarksManager),
ConfirmationTitle = Resources.bookmarks_delete_prompt_title!,
ConfirmationMessage = Resources.bookmarks_delete_prompt_message!,
Name = Resources.bookmarks_delete_name,
Icon = Icons.DeleteIcon,
};
var delete = new CommandContextItem(confirmableCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteBookmark };
contextMenu.Add(delete);
}
private void BookmarkSaved(object sender, BookmarkData args)
{
ExtensionHost.LogMessage($"Saving bookmark ({args.Name},{args.Bookmark})");
_bookmarksManager.Update(args.Id, args.Name, args.Bookmark);
}
private readonly record struct BookmarkListItemReclassifyResult(
ICommand Command,
string Title,
string Subtitle,
IContextItem[] MoreCommands
);
}

View File

@@ -0,0 +1,119 @@
// 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.Linq;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
internal sealed partial class BookmarkPlaceholderForm : FormContent
{
private static readonly CompositeFormat ErrorMessage = CompositeFormat.Parse(Resources.bookmarks_required_placeholder);
private readonly BookmarkData _bookmarkData;
private readonly IBookmarkResolver _commandResolver;
public BookmarkPlaceholderForm(BookmarkData data, IBookmarkResolver commandResolver, IPlaceholderParser placeholderParser)
{
ArgumentNullException.ThrowIfNull(data);
ArgumentNullException.ThrowIfNull(commandResolver);
_bookmarkData = data;
_commandResolver = commandResolver;
placeholderParser.ParsePlaceholders(data.Bookmark, out _, out var placeholders);
var inputs = placeholders.Distinct(PlaceholderInfoNameEqualityComparer.Instance).Select(placeholder =>
{
var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, placeholder.Name);
return $$"""
{
"type": "Input.Text",
"style": "text",
"id": "{{placeholder.Name}}",
"label": "{{placeholder.Name}}",
"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": [
{
"type": "TextBlock",
"size": "Medium",
"weight": "Bolder",
"text": "{{_bookmarkData.Name}}"
},
{{allInputs}}
],
"actions": [
{
"type": "Action.Submit",
"title": "{{Resources.bookmarks_form_open}}",
"data": {
"placeholder": "placeholder"
}
}
]
}
""";
}
public override CommandResult SubmitForm(string payload)
{
// 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();
}
// we need to classify this twice:
// first we need to know if the original bookmark is a URL or protocol, because that determines how we encode the placeholders
// then we need to classify the final target to be sure the classification didn't change by adding the placeholders
var placeholderClassification = _commandResolver.ClassifyOrUnknown(_bookmarkData.Bookmark);
var placeholders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in formObject)
{
var placeholderData = value?.ToString();
placeholders[key] = placeholderData ?? string.Empty;
}
var target = ReplacePlaceholders(_bookmarkData.Bookmark, placeholders, placeholderClassification);
var classification = _commandResolver.ClassifyOrUnknown(target);
var success = CommandLauncher.Launch(classification);
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
private static string ReplacePlaceholders(string input, Dictionary<string, string> placeholders, Classification classification)
{
var result = input;
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;
}
}

View File

@@ -0,0 +1,48 @@
// 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.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.Pages;
internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable
{
private readonly FormContent _bookmarkPlaceholder;
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
public BookmarkPlaceholderPage(BookmarkData bookmarkData, IBookmarkIconLocator iconLocator, IBookmarkResolver resolver, IPlaceholderParser placeholderParser)
{
Name = Resources.bookmarks_command_name_open;
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
_bookmarkPlaceholder = new BookmarkPlaceholderForm(bookmarkData, resolver, placeholderParser);
_iconReloadGate = new(
async ct =>
{
var c = resolver.ClassifyOrUnknown(bookmarkData.Bookmark);
return await iconLocator.GetIconForPath(c, ct);
},
icon =>
{
Icon = icon as IconInfo ?? Icons.PinIcon;
});
RequestIconReloadAsync();
}
public override IContent[] GetContent() => [_bookmarkPlaceholder];
private void RequestIconReloadAsync()
{
Icon = Icons.Reloading;
OnPropertyChanged(nameof(Icon));
_ = _iconReloadGate.ExecuteAsync();
}
public void Dispose() => _iconReloadGate.Dispose();
}

View File

@@ -0,0 +1,38 @@
// 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.Text.Json.Serialization;
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
public sealed record BookmarkData
{
public Guid Id { get; init; }
public required string Name { get; init; }
public required string Bookmark { get; init; }
[JsonConstructor]
[SetsRequiredMembers]
public BookmarkData(Guid id, string? name, string? bookmark)
{
Id = id == Guid.Empty ? Guid.NewGuid() : id;
Name = name ?? string.Empty;
Bookmark = bookmark ?? string.Empty;
}
[SetsRequiredMembers]
public BookmarkData(string? name, string? bookmark)
: this(Guid.NewGuid(), name, bookmark)
{
}
[SetsRequiredMembers]
public BookmarkData()
: this(Guid.NewGuid(), string.Empty, string.Empty)
{
}
}

View File

@@ -2,11 +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.Text.Json;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Bookmarks;
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
public class BookmarkJsonParser
{
@@ -14,32 +12,32 @@ public class BookmarkJsonParser
{
}
public Bookmarks ParseBookmarks(string json)
public BookmarksData ParseBookmarks(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return new Bookmarks();
return new BookmarksData();
}
try
{
var bookmarks = JsonSerializer.Deserialize<Bookmarks>(json, BookmarkSerializationContext.Default.Bookmarks);
return bookmarks ?? new Bookmarks();
var bookmarks = JsonSerializer.Deserialize<BookmarksData>(json, BookmarkSerializationContext.Default.BookmarksData);
return bookmarks ?? new BookmarksData();
}
catch (JsonException ex)
{
ExtensionHost.LogMessage($"parse bookmark data failed. ex: {ex.Message}");
return new Bookmarks();
return new BookmarksData();
}
}
public string SerializeBookmarks(Bookmarks? bookmarks)
public string SerializeBookmarks(BookmarksData? bookmarks)
{
if (bookmarks == null)
{
return string.Empty;
}
return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.Bookmarks);
return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.BookmarksData);
}
}

View File

@@ -2,19 +2,16 @@
// 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.Text.Json.Serialization;
namespace Microsoft.CmdPal.Ext.Bookmarks;
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
[JsonSerializable(typeof(float))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(BookmarkData))]
[JsonSerializable(typeof(Bookmarks))]
[JsonSerializable(typeof(BookmarksData))]
[JsonSerializable(typeof(List<BookmarkData>), TypeInfoPropertyName = "BookmarkList")]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
internal sealed partial class BookmarkSerializationContext : JsonSerializerContext
{
}
internal sealed partial class BookmarkSerializationContext : JsonSerializerContext;

View File

@@ -2,13 +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.IO;
using System.Text.Json;
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
namespace Microsoft.CmdPal.Ext.Bookmarks;
public sealed class Bookmarks
public sealed class BookmarksData
{
public List<BookmarkData> Data { get; set; } = [];
}

View File

@@ -2,13 +2,11 @@
// 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.IO;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Bookmarks;
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
public class FileBookmarkDataSource : IBookmarkDataSource
public sealed partial class FileBookmarkDataSource : IBookmarkDataSource
{
private readonly string _filePath;

View File

@@ -1,9 +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.
namespace Microsoft.CmdPal.Ext.Bookmarks;
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
public interface IBookmarkDataSource
internal interface IBookmarkDataSource
{
string GetBookmarkData();

View File

@@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Failed to open {0}.
/// </summary>
public static string bookmark_toast_failed_open_text {
get {
return ResourceManager.GetString("bookmark_toast_failed_open_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add bookmark.
/// </summary>
@@ -87,6 +96,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copy address.
/// </summary>
public static string bookmarks_copy_address_name {
get {
return ResourceManager.GetString("bookmarks_copy_address_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Copy path.
/// </summary>
public static string bookmarks_copy_path_name {
get {
return ResourceManager.GetString("bookmarks_copy_path_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
@@ -96,6 +123,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete this bookmark?.
/// </summary>
public static string bookmarks_delete_prompt_message {
get {
return ResourceManager.GetString("bookmarks_delete_prompt_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete bookmark?.
/// </summary>
public static string bookmarks_delete_prompt_title {
get {
return ResourceManager.GetString("bookmarks_delete_prompt_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete bookmark.
/// </summary>
@@ -177,6 +222,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to (Refreshing bookmark...).
/// </summary>
public static string bookmarks_item_refreshing {
get {
return ResourceManager.GetString("bookmarks_item_refreshing", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Open in Terminal.
/// </summary>
@@ -194,5 +248,14 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
return ResourceManager.GetString("bookmarks_required_placeholder", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unpin.
/// </summary>
public static string bookmarks_unpin_name {
get {
return ResourceManager.GetString("bookmarks_unpin_name", resourceCulture);
}
}
}
}

View File

@@ -164,4 +164,25 @@
<value>{0} is required</value>
<comment>{0} will be replaced by a parameter name provided by the user</comment>
</data>
<data name="bookmarks_item_refreshing" xml:space="preserve">
<value>(Refreshing bookmark...)</value>
</data>
<data name="bookmarks_delete_prompt_title" xml:space="preserve">
<value>Delete bookmark?</value>
</data>
<data name="bookmarks_delete_prompt_message" xml:space="preserve">
<value>Are you sure you want to delete this bookmark?</value>
</data>
<data name="bookmarks_copy_path_name" xml:space="preserve">
<value>Copy path</value>
</data>
<data name="bookmarks_copy_address_name" xml:space="preserve">
<value>Copy address</value>
</data>
<data name="bookmarks_unpin_name" xml:space="preserve">
<value>Unpin</value>
</data>
<data name="bookmark_toast_failed_open_text" xml:space="preserve">
<value>Failed to open {0}</value>
</data>
</root>

View File

@@ -0,0 +1,547 @@
// 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.IO;
using System.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
internal sealed partial class BookmarkResolver : IBookmarkResolver
{
private readonly IPlaceholderParser _placeholderParser;
private const string UriSchemeShell = "shell";
public BookmarkResolver(IPlaceholderParser placeholderParser)
{
ArgumentNullException.ThrowIfNull(placeholderParser);
_placeholderParser = placeholderParser;
}
public async Task<(bool Success, Classification Result)> TryClassifyAsync(
string? input,
CancellationToken cancellationToken = default)
{
try
{
var result = await Task.Run(
() => TryClassify(input, out var classification)
? classification
: Classification.Unknown(input ?? string.Empty),
cancellationToken);
return (true, result);
}
catch (Exception ex)
{
Logger.LogError("Failed to classify", ex);
var result = Classification.Unknown(input ?? string.Empty);
return (false, result);
}
}
public Classification ClassifyOrUnknown(string input)
{
return TryClassify(input, out var c) ? c : Classification.Unknown(input);
}
private bool TryClassify(string? input, out Classification result)
{
try
{
bool success;
if (string.IsNullOrWhiteSpace(input))
{
result = Classification.Unknown(input ?? string.Empty);
success = false;
}
else
{
input = input.Trim();
// is placeholder?
var isPlaceholder = _placeholderParser.ParsePlaceholders(input, out var inputUntilFirstPlaceholder, out _);
success = ClassifyCore(input, out result, isPlaceholder, inputUntilFirstPlaceholder, _placeholderParser);
}
return success;
}
catch (Exception ex)
{
Logger.LogError($"Failed to classify bookmark \"{input}\"", ex);
result = Classification.Unknown(input ?? string.Empty);
return false;
}
}
private static bool ClassifyCore(string input, out Classification result, bool isPlaceholder, string inputUntilFirstPlaceholder, IPlaceholderParser placeholderParser)
{
// 1) Try URI parsing first (accepts custom schemes, e.g., shell:, ms-settings:)
// File URIs must start with "file:" to avoid confusion with local paths - which are handled below, in more sophisticated ways -
// as TryCreate would automatically add "file://" to bare paths like "C:\path\to\file.txt" which we don't want.
if (Uri.TryCreate(input, UriKind.Absolute, out var uri)
&& !string.IsNullOrWhiteSpace(uri.Scheme)
&& (uri.Scheme != Uri.UriSchemeFile || input.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
&& uri.Scheme != UriSchemeShell)
{
// http/https → Url; any other scheme → Protocol (mailto:, ms-settings:, slack://, etc.)
var isWeb = uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
result = new Classification(
isWeb ? CommandKind.WebUrl : CommandKind.Protocol,
input,
input,
string.Empty,
LaunchMethod.ShellExecute, // Shell picks the right handler
null,
isPlaceholder);
return true;
}
// 1a) We're a placeholder and start look like a protocol scheme (e.g. "myapp:{{placeholder}}")
if (isPlaceholder && UriHelper.TryGetScheme(inputUntilFirstPlaceholder, out var scheme, out _))
{
// single letter schemes are probably drive letters, ignore, file and shell protocols are handled elsewhere
if (scheme.Length > 1 && scheme != Uri.UriSchemeFile && scheme != UriSchemeShell)
{
var isWeb = scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
result = new Classification(
isWeb ? CommandKind.WebUrl : CommandKind.Protocol,
input,
input,
string.Empty,
LaunchMethod.ShellExecute, // Shell picks the right handler
null,
isPlaceholder);
return true;
}
}
// 2) Existing file/dir or "longest plausible prefix"
// Try to grow head (only for unquoted original) to include spaces until a path exists.
// Find longest unquoted argument string
var (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input);
if (longestUnquotedHead == string.Empty)
{
(longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitHeadAndArgs(input);
}
var (headPath, tailArgs) = ExpandToBestExistingPath(longestUnquotedHead, tailAfterLongestUnquotedHead, isPlaceholder, placeholderParser);
if (headPath is not null)
{
var args = tailArgs ?? string.Empty;
if (Directory.Exists(headPath))
{
result = new Classification(
CommandKind.Directory,
input,
headPath,
string.Empty,
LaunchMethod.ExplorerOpen,
headPath,
isPlaceholder);
return true;
}
var ext = Path.GetExtension(headPath);
if (ShellHelpers.IsExecutableExtension(ext))
{
result = new Classification(
CommandKind.FileExecutable,
input,
headPath,
args,
LaunchMethod.ShellExecute, // direct exec; or ShellExecute if you want verb support
Path.GetDirectoryName(headPath),
isPlaceholder);
return true;
}
var isShellLink = ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase);
var isUrlLink = ext.Equals(".url", StringComparison.OrdinalIgnoreCase);
if (isShellLink || isUrlLink)
{
// In the future we can fetch data out of the link
result = new Classification(
isUrlLink ? CommandKind.InternetShortcut : CommandKind.Shortcut,
input,
headPath,
string.Empty,
LaunchMethod.ShellExecute,
Path.GetDirectoryName(headPath),
isPlaceholder);
return true;
}
result = new Classification(
CommandKind.FileDocument,
input,
headPath,
args,
LaunchMethod.ShellExecute,
Path.GetDirectoryName(headPath),
isPlaceholder);
return true;
}
if (TryGetAumid(longestUnquotedHead, out var aumid))
{
result = new Classification(
CommandKind.Aumid,
longestUnquotedHead,
aumid,
tailAfterLongestUnquotedHead,
LaunchMethod.ActivateAppId,
null,
isPlaceholder);
return true;
}
// 3) Bare command resolution via PATH + executable ext
// At this point 'head' is our best intended command token.
var (firstHead, tail) = SplitHeadAndArgs(input);
CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head);
// 3.1) UWP/AppX via AppsFolder/AUMID or pkgfamily!app
// Since the AUMID can be actually anything, we either take a full shell:AppsFolder\AUMID
// as entered and we try to detect packaged app ids (pkgfamily!app).
if (TryGetAumid(head, out var aumid2))
{
result = new Classification(
CommandKind.Aumid,
head,
aumid2,
tail,
LaunchMethod.ActivateAppId,
null,
isPlaceholder);
return true;
}
// 3.2) It's a virtual shell item (e.g. Control Panel, Recycle Bin, This PC)
// Shell items that are backed by filesystem paths (e.g. Downloads) should be already handled above.
if (CommandLineHelper.HasShellPrefix(head))
{
ShellNames.TryGetFriendlyName(input, out var displayName);
ShellNames.TryGetFileSystemPath(input, out var fsPath);
result = new Classification(
CommandKind.VirtualShellItem,
input,
input,
string.Empty,
LaunchMethod.ShellExecute,
fsPath is not null && Directory.Exists(fsPath) ? fsPath : null,
isPlaceholder,
fsPath,
displayName);
return true;
}
// 3.3) Search paths for the file name (with or without ext)
// If head is a file name with extension, we look only for that. If there's no extension
// we go and follow Windows Shell resolution rules.
if (TryResolveViaPath(head, out var resolvedFilePath))
{
result = new Classification(
CommandKind.PathCommand,
input,
resolvedFilePath,
tail,
LaunchMethod.ShellExecute,
null,
isPlaceholder);
return true;
}
// 3.4) If it looks like a path with ext but missing file, treat as document (Shell will handle assoc / error)
if (LooksPathy(head) && Path.HasExtension(head))
{
var extension = Path.GetExtension(head);
// if the path extension contains placeholders, we can't assume what it is so, skip it and treat it as unknown
var hasSpecificExtension = !isPlaceholder || !extension.Contains('{');
if (hasSpecificExtension)
{
result = new Classification(
ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument,
input,
head,
tail,
LaunchMethod.ShellExecute,
HasDir(head) ? Path.GetDirectoryName(head) : null,
isPlaceholder);
return true;
}
}
// 4) looks like a web URL without scheme, but not like a file with extension
if (head.Contains('.', StringComparison.OrdinalIgnoreCase) && head.StartsWith("www", StringComparison.OrdinalIgnoreCase))
{
// treat as URL, add https://
var url = "https://" + input;
result = new Classification(
CommandKind.WebUrl,
input,
url,
string.Empty,
LaunchMethod.ShellExecute,
null,
isPlaceholder);
return true;
}
// 5) Fallback: let ShellExecute try the whole input
result = new Classification(
CommandKind.Unknown,
input,
head,
tail,
LaunchMethod.ShellExecute,
null,
isPlaceholder);
return true;
}
private static (string Head, string Tail) SplitHeadAndArgs(string input) => CommandLineHelper.SplitHeadAndArgs(input);
// Finds the best existing path prefix in an *unquoted* input by scanning
// whitespace boundaries. Prefers files to directories; for same kind,
// prefers the longer path.
// Returns (head, tail) or (null, null) if nothing found.
private static (string? Head, string? Tail) ExpandToBestExistingPath(string head, string tail, bool containsPlaceholders, IPlaceholderParser placeholderParser)
{
try
{
// This goes greedy from the longest head down to shortest; exactly opposite of what
// CreateProcess rules are for the first token. But here we operate with a slightly different goal.
var (greedyHead, greedyTail) = GreedyFind(head, containsPlaceholders, placeholderParser);
// put tails back together:
return (Head: greedyHead, string.Join(" ", greedyTail, tail).Trim());
}
catch (Exception ex)
{
Logger.LogError("Failed to find best path", ex);
throw;
}
}
private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser)
{
// Be greedy: try to find the longest existing path prefix
for (var i = input.Length; i >= 0; i--)
{
if (i < input.Length && !char.IsWhiteSpace(input[i]))
{
continue;
}
var candidate = input.AsSpan(0, i).TrimEnd().ToString();
if (candidate.Length == 0)
{
continue;
}
// If we have placeholders, check if this candidate would contain a non-path placeholder
if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser))
{
continue; // Skip this candidate, try a shorter one
}
try
{
if (CommandLineHelper.ExpandPathToPhysicalFile(candidate, true, out var full))
{
var tail = i < input.Length ? input[i..].TrimStart() : string.Empty;
return (full, tail);
}
}
catch
{
// Ignore malformed paths; keep scanning
}
}
return (null, null);
}
// Attempts to guess if any placeholders in the candidate string are likely not part of a filesystem path.
private static bool ContainsNonPathPlaceholder(string candidate, IPlaceholderParser placeholderParser)
{
placeholderParser.ParsePlaceholders(candidate, out _, out var placeholders);
foreach (var match in placeholders)
{
var placeholderContext = GuessPlaceholderContextInFileSystemPath(candidate, match.Index);
// If placeholder appears after what looks like a command-line flag/option
if (placeholderContext.IsAfterFlag)
{
return true;
}
// If placeholder doesn't look like a typical path component
if (!placeholderContext.LooksLikePathComponent)
{
return true;
}
}
return false;
}
// Heuristically determines the context of a placeholder inside a filesystem-like input string.
// Sets:
// - IsAfterFlag: true if immediately preceded by a token that looks like a command-line flag prefix (" -", " /", " --").
// - LooksLikePathComponent: true if (a) not after a flag or (b) nearby text shows path separators.
private static PlaceholderContext GuessPlaceholderContextInFileSystemPath(string input, int placeholderIndex)
{
var beforePlaceholder = input[..placeholderIndex].TrimEnd();
var isAfterFlag = beforePlaceholder.EndsWith(" -", StringComparison.OrdinalIgnoreCase) ||
beforePlaceholder.EndsWith(" /", StringComparison.OrdinalIgnoreCase) ||
beforePlaceholder.EndsWith(" --", StringComparison.OrdinalIgnoreCase);
var looksLikePathComponent = !isAfterFlag;
var nearbyText = input.Substring(Math.Max(0, placeholderIndex - 20), Math.Min(40, input.Length - Math.Max(0, placeholderIndex - 20)));
var hasPathSeparators = nearbyText.Contains('\\') || nearbyText.Contains('/');
if (!hasPathSeparators && isAfterFlag)
{
looksLikePathComponent = false;
}
return new PlaceholderContext(isAfterFlag, looksLikePathComponent);
}
private static bool TryGetAumid(string input, out string aumid)
{
// App ids are a lot of fun, since they can look like anything.
// And yes, they can contain spaces too, like Zoom:
// shell:AppsFolder\zoom.us.Zoom Video Meetings
// so unless that thing is quoted, we can't just assume the first token is the AUMID.
const string appsFolder = "shell:AppsFolder\\";
// Guard against null or empty input
if (string.IsNullOrEmpty(input))
{
aumid = string.Empty;
return false;
}
// Already a fully qualified AUMID path
if (input.StartsWith(appsFolder, StringComparison.OrdinalIgnoreCase))
{
aumid = input;
return true;
}
aumid = string.Empty;
return false;
}
private static bool LooksPathy(string input)
{
// Basic: drive:\, UNC, relative with . or .., or has dir separator
if (input.Contains('\\') || input.Contains('/'))
{
return true;
}
if (input is [_, ':', ..])
{
return true;
}
if (input.StartsWith(@"\\", StringComparison.InvariantCulture) || input.StartsWith("./", StringComparison.InvariantCulture) || input.StartsWith(".\\", StringComparison.InvariantCulture) || input.StartsWith("..\\", StringComparison.InvariantCulture))
{
return true;
}
return false;
}
private static bool HasDir(string path) => !string.IsNullOrEmpty(Path.GetDirectoryName(path));
private static bool TryResolveViaPath(string head, out string resolvedFile)
{
resolvedFile = string.Empty;
if (string.IsNullOrWhiteSpace(head))
{
return false;
}
if (Path.HasExtension(head) && ShellHelpers.FileExistInPath(head, out resolvedFile))
{
return true;
}
// If head has dir, treat as path probe
if (HasDir(head))
{
if (Path.HasExtension(head))
{
var p = TryProbe(Environment.CurrentDirectory, head);
if (p is not null)
{
resolvedFile = p;
return true;
}
return false;
}
foreach (var ext in ShellHelpers.ExecutableExtensions)
{
var p = TryProbe(null, head + ext);
if (p is not null)
{
resolvedFile = p;
return true;
}
}
return false;
}
return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile);
}
private static string? TryProbe(string? dir, string name)
{
try
{
var path = dir is null ? name : Path.Combine(dir, name);
if (File.Exists(path))
{
return Path.GetFullPath(path);
}
}
catch
{
/* ignore */
}
return null;
}
private record PlaceholderContext(bool IsAfterFlag, bool LooksLikePathComponent);
}

View File

@@ -0,0 +1,157 @@
// 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.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
public sealed partial class FaviconLoader : IFaviconLoader, IDisposable
{
private readonly HttpClient _http = CreateClient();
private bool _disposed;
private static HttpClient CreateClient()
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 10,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
};
var client = new HttpClient(handler, disposeHandler: true);
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WindowsCommandPalette/1.0");
client.DefaultRequestHeaders.Accept.ParseAdd("image/*");
return client;
}
public async Task<IRandomAccessStream?> TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default)
{
if (siteUri.Scheme != Uri.UriSchemeHttp && siteUri.Scheme != Uri.UriSchemeHttps)
{
return null;
}
// 1) First attempt: favicon on the original authority (preserves port).
var first = BuildFaviconUri(siteUri);
// Try download; if this fails (non-image or path lost), retry on final host.
var stream = await TryDownloadImageAsync(first, ct).ConfigureAwait(false);
if (stream is not null)
{
return stream;
}
// 2) If the server redirected and "lost" the path, try /favicon.ico on the *final* host.
// We discover the final host by doing a HEAD/GET to the original URL and inspecting the final RequestUri.
var finalAuthority = await ResolveFinalAuthorityAsync(first, ct).ConfigureAwait(false);
if (finalAuthority is null || UriEqualsAuthority(first, finalAuthority))
{
return null;
}
var second = BuildFaviconUri(finalAuthority);
if (second == first)
{
return null; // nothing new to try
}
return await TryDownloadImageAsync(second, ct).ConfigureAwait(false);
}
private static Uri BuildFaviconUri(Uri anyUriOnSite)
{
var b = new UriBuilder(anyUriOnSite.Scheme, anyUriOnSite.Host)
{
Port = anyUriOnSite.IsDefaultPort ? -1 : anyUriOnSite.Port,
Path = "/favicon.ico",
};
return b.Uri;
}
private async Task<Uri?> ResolveFinalAuthorityAsync(Uri url, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
// We only need headers to learn the final RequestUri after redirects
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)
.ConfigureAwait(false);
var final = resp.RequestMessage?.RequestUri;
return final is null ? null : new UriBuilder(final.Scheme, final.Host)
{
Port = final.IsDefaultPort ? -1 : final.Port,
Path = "/",
}.Uri;
}
private async Task<IRandomAccessStream?> TryDownloadImageAsync(Uri url, CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)
.ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
return null;
}
// If the redirect chain dumped us on an HTML page (common for root), bail.
var mediaType = resp.Content.Headers.ContentType?.MediaType;
if (mediaType is not null &&
!mediaType.StartsWith("image", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var bytes = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
var stream = new InMemoryRandomAccessStream();
using (var output = stream.GetOutputStreamAt(0))
using (var writer = new DataWriter(output))
{
writer.WriteBytes(bytes);
await writer.StoreAsync().AsTask(ct);
await writer.FlushAsync().AsTask(ct);
}
stream.Seek(0);
return stream;
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return null;
}
}
private static bool UriEqualsAuthority(Uri a, Uri b)
=> a.Scheme.Equals(b.Scheme, StringComparison.OrdinalIgnoreCase)
&& a.Host.Equals(b.Host, StringComparison.OrdinalIgnoreCase)
&& (a.IsDefaultPort ? -1 : a.Port) == (b.IsDefaultPort ? -1 : b.Port);
public void Dispose()
{
if (_disposed)
{
return;
}
_http.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}

View File

@@ -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.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
public interface IBookmarkIconLocator
{
Task<IIconInfo> GetIconForPath(Classification classification, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
internal interface IBookmarkResolver
{
Task<(bool Success, Classification Result)> TryClassifyAsync(string input, CancellationToken cancellationToken = default);
Classification ClassifyOrUnknown(string input);
}

View File

@@ -0,0 +1,17 @@
// 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.Threading;
using System.Threading.Tasks;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
/// <summary>
/// Service to load favicons for websites.
/// </summary>
public interface IFaviconLoader
{
Task<IRandomAccessStream?> TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
// 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.Services;
public interface IPlaceholderParser
{
bool ParsePlaceholders(string input, out string head, out List<PlaceholderInfo> placeholders);
}

View File

@@ -0,0 +1,258 @@
// 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.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Win32;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
internal class IconLocator : IBookmarkIconLocator
{
private readonly IFaviconLoader _faviconLoader;
public IconLocator()
: this(new FaviconLoader())
{
}
private IconLocator(IFaviconLoader faviconLoader)
{
ArgumentNullException.ThrowIfNull(faviconLoader);
_faviconLoader = faviconLoader;
}
public async Task<IIconInfo> GetIconForPath(
Classification classification,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(classification);
var icon = classification.Kind switch
{
CommandKind.WebUrl => await TryGetWebIcon(classification.Target),
CommandKind.Protocol => await TryGetProtocolIcon(classification.Target),
CommandKind.FileExecutable => await TryGetExecutableIcon(classification.Target),
CommandKind.Unknown => FallbackIcon(classification),
_ => await MaybeGetIconForPath(classification.Target),
};
return icon ?? FallbackIcon(classification);
}
private async Task<IIconInfo?> TryGetWebIcon(string target)
{
// Get the base url up to the first placeholder
var placeholderIndex = target.IndexOf('{');
var baseString = placeholderIndex > 0 ? target[..placeholderIndex] : target;
try
{
var uri = new Uri(baseString);
var iconStream = await _faviconLoader.TryGetFaviconAsync(uri, CancellationToken.None);
if (iconStream != null)
{
return IconInfo.FromStream(iconStream);
}
}
catch (Exception ex)
{
Logger.LogError("Failed to get web bookmark favicon for " + baseString, ex);
}
return null;
}
private static async Task<IIconInfo?> TryGetExecutableIcon(string target)
{
IIconInfo? icon = null;
var exeExists = false;
var fullExePath = string.Empty;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation
var pathResolutionTask = Task.Run(
() =>
{
// Don't check cancellation token here - let the Task timeout handle it
exeExists = ShellHelpers.FileExistInPath(target, out fullExePath);
},
CancellationToken.None);
// Wait for either completion or timeout
pathResolutionTask.Wait(cts.Token);
}
catch (OperationCanceledException)
{
// Debug.WriteLine("Operation was canceled.");
}
if (exeExists)
{
// If the executable exists, try to get the icon from the file
icon = await MaybeGetIconForPath(fullExePath);
if (icon is not null)
{
return icon;
}
}
return icon;
}
private static async Task<IconInfo?> TryGetProtocolIcon(string target)
{
// Special case for steam: protocol - use game icon
// Steam protocol have only a file name (steam.exe) associated with it, but is not
// in PATH or AppPaths. So we can't resolve it to an executable. But at the same time,
// this is a very common protocol, so we special-case it here.
if (target.StartsWith("steam:", StringComparison.OrdinalIgnoreCase))
{
return Icons.BookmarkTypes.Game;
}
// extract protocol from classification.Target (until the first ':'):
IconInfo? icon = null;
var colonIndex = target.IndexOf(':');
string protocol;
if (colonIndex > 0)
{
protocol = target[..colonIndex];
}
else
{
return icon;
}
icon = await ThumbnailHelper.GetProtocolIconStream(protocol, true) is { } stream
? IconInfo.FromStream(stream)
: null;
if (icon is null)
{
var protocolIconPath = ProtocolIconResolver.GetIconString(protocol);
if (protocolIconPath is not null)
{
icon = new IconInfo(protocolIconPath);
}
}
return icon;
}
private static IconInfo FallbackIcon(Classification classification)
{
return classification.Kind switch
{
CommandKind.FileExecutable => Icons.BookmarkTypes.Application,
CommandKind.FileDocument => Icons.BookmarkTypes.FilePath,
CommandKind.Directory => Icons.BookmarkTypes.FolderPath,
CommandKind.PathCommand => Icons.BookmarkTypes.Command,
CommandKind.Aumid => Icons.BookmarkTypes.Application,
CommandKind.Shortcut => Icons.BookmarkTypes.Application,
CommandKind.InternetShortcut => Icons.BookmarkTypes.WebUrl,
CommandKind.WebUrl => Icons.BookmarkTypes.WebUrl,
CommandKind.Protocol => Icons.BookmarkTypes.Application,
_ => Icons.BookmarkTypes.Unknown,
};
}
private static async Task<IconInfo?> MaybeGetIconForPath(string target)
{
try
{
var stream = await ThumbnailHelper.GetThumbnail(target);
if (stream is not null)
{
return IconInfo.FromStream(stream);
}
if (ShellNames.TryGetFileSystemPath(target, out var fileSystemPath))
{
stream = await ThumbnailHelper.GetThumbnail(fileSystemPath);
if (stream is not null)
{
return IconInfo.FromStream(stream);
}
}
}
catch (Exception ex)
{
Logger.LogDebug($"Failed to load icon for {target}\n" + ex);
}
return null;
}
internal static class ProtocolIconResolver
{
/// <summary>
/// Gets the icon resource string for a given URI protocol (e.g. "steam" or "mailto").
/// Returns something like "C:\Path\app.exe,0" or null if not found.
/// </summary>
public static string? GetIconString(string protocol)
{
try
{
if (string.IsNullOrWhiteSpace(protocol))
{
return null;
}
protocol = protocol.TrimEnd(':').ToLowerInvariant();
// Try HKCR\<protocol>\DefaultIcon
using (var di = Registry.ClassesRoot.OpenSubKey(protocol + "\\DefaultIcon"))
{
var value = di?.GetValue(null) as string;
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
// Fallback: HKCR\<protocol>\shell\open\command
using (var cmd = Registry.ClassesRoot.OpenSubKey(protocol + "\\shell\\open\\command"))
{
var command = cmd?.GetValue(null) as string;
if (!string.IsNullOrWhiteSpace(command))
{
var exe = ExtractExecutable(command);
if (!string.IsNullOrWhiteSpace(exe))
{
return exe; // default index 0 implied
}
}
}
}
catch (Exception ex)
{
Logger.LogError("Failed to get protocol information from registry; will return nothing instead", ex);
}
return null;
}
private static string ExtractExecutable(string command)
{
command = command.Trim();
if (command.StartsWith('\"'))
{
var end = command.IndexOf('"', 1);
if (end > 1)
{
return command[1..end];
}
}
var space = command.IndexOf(' ');
return space > 0 ? command[..space] : command;
}
}
}

View File

@@ -0,0 +1,57 @@
// 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.Services;
public sealed class PlaceholderInfo
{
public string Name { get; }
public int Index { get; }
public PlaceholderInfo(string name, int index)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentOutOfRangeException.ThrowIfLessThan(index, 0);
Name = name;
Index = index;
}
private bool Equals(PlaceholderInfo other) => Name == other.Name && Index == other.Index;
public override bool Equals(object? obj)
{
if (obj is null)
{
return false;
}
if (ReferenceEquals(this, obj))
{
return true;
}
if (obj.GetType() != GetType())
{
return false;
}
return Equals((PlaceholderInfo)obj);
}
public override int GetHashCode() => HashCode.Combine(Name, Index);
public static bool operator ==(PlaceholderInfo? left, PlaceholderInfo? right)
{
return Equals(left, right);
}
public static bool operator !=(PlaceholderInfo? left, PlaceholderInfo? right)
{
return !Equals(left, right);
}
public override string ToString() => Name;
}

View File

@@ -0,0 +1,34 @@
// 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;
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
public class PlaceholderInfoNameEqualityComparer : IEqualityComparer<PlaceholderInfo>
{
public static PlaceholderInfoNameEqualityComparer Instance { get; } = new();
public bool Equals(PlaceholderInfo? x, PlaceholderInfo? y)
{
if (x is null && y is null)
{
return true;
}
if (x is null || y is null)
{
return false;
}
return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(PlaceholderInfo obj)
{
ArgumentNullException.ThrowIfNull(obj);
return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name);
}
}

View File

@@ -0,0 +1,94 @@
// 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.Services;
public class PlaceholderParser : IPlaceholderParser
{
public bool ParsePlaceholders(string input, out string head, out List<PlaceholderInfo> placeholders)
{
ArgumentNullException.ThrowIfNull(input);
head = string.Empty;
placeholders = [];
if (string.IsNullOrEmpty(input))
{
head = string.Empty;
return false;
}
var foundPlaceholders = new List<PlaceholderInfo>();
var searchStart = 0;
var firstPlaceholderStart = -1;
var hasValidPlaceholder = false;
while (searchStart < input.Length)
{
var openBrace = input.IndexOf('{', searchStart);
if (openBrace == -1)
{
break;
}
var closeBrace = input.IndexOf('}', openBrace + 1);
if (closeBrace == -1)
{
break;
}
// Extract potential placeholder name
var placeholderContent = input.Substring(openBrace + 1, closeBrace - openBrace - 1);
// Check if it's a valid placeholder
if (!string.IsNullOrEmpty(placeholderContent) &&
!IsGuidFormat(placeholderContent) &&
IsValidPlaceholderName(placeholderContent))
{
// Valid placeholder found
foundPlaceholders.Add(new PlaceholderInfo(placeholderContent, openBrace));
hasValidPlaceholder = true;
// Remember the first valid placeholder position
if (firstPlaceholderStart == -1)
{
firstPlaceholderStart = openBrace;
}
}
// Continue searching after this brace pair
searchStart = closeBrace + 1;
}
// Convert to Placeholder objects
placeholders = foundPlaceholders;
if (hasValidPlaceholder)
{
head = input[..firstPlaceholderStart];
return true;
}
else
{
head = input;
return false;
}
}
private static bool IsValidPlaceholderName(string name)
{
for (var i = 0; i < name.Length; i++)
{
var c = name[i];
if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-'))
{
return false;
}
}
return true;
}
private static bool IsGuidFormat(string content) => Guid.TryParse(content, out _);
}

View File

@@ -1,191 +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.Threading;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Bookmarks;
public partial class UrlCommand : InvokableCommand
{
private readonly Lazy<IconInfo> _icon;
public string Url { get; }
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
public UrlCommand(BookmarkData data)
: this(data.Name, data.Bookmark)
{
}
public UrlCommand(string name, string url)
{
Name = Properties.Resources.bookmarks_command_name_open;
Url = url;
_icon = new Lazy<IconInfo>(() =>
{
ShellHelpers.ParseExecutableAndArgs(Url, out var exe, out var args);
var t = GetIconForPath(exe);
t.Wait();
return t.Result;
});
}
public override CommandResult Invoke()
{
var success = LaunchCommand(Url);
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
internal static bool LaunchCommand(string target)
{
ShellHelpers.ParseExecutableAndArgs(target, out var exe, out var args);
return LaunchCommand(exe, args);
}
internal static bool LaunchCommand(string exe, string args)
{
if (string.IsNullOrEmpty(exe))
{
var message = "No executable found in the command.";
Logger.LogError(message);
return false;
}
if (ShellHelpers.OpenInShell(exe, args))
{
return true;
}
// If we reach here, it means the command could not be executed
// If there aren't args, then try again as a https: uri
if (string.IsNullOrEmpty(args))
{
var uri = GetUri(exe);
if (uri is not null)
{
_ = Launcher.LaunchUriAsync(uri);
}
else
{
Logger.LogError("The provided URL is not valid.");
}
return true;
}
return false;
}
internal static Uri? GetUri(string url)
{
Uri? uri;
if (!Uri.TryCreate(url, UriKind.Absolute, out uri))
{
if (!Uri.TryCreate("https://" + url, UriKind.Absolute, out uri))
{
return null;
}
}
return uri;
}
public static async Task<IconInfo> GetIconForPath(string target)
{
IconInfo? icon = null;
// First, try to get the icon from the thumbnail helper
// This works for local files and folders
icon = await MaybeGetIconForPath(target);
if (icon is not null)
{
return icon;
}
// Okay, that failed. Try to resolve the full path of the executable
var exeExists = false;
var fullExePath = string.Empty;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation
var pathResolutionTask = Task.Run(
() =>
{
// Don't check cancellation token here - let the Task timeout handle it
exeExists = ShellHelpers.FileExistInPath(target, out fullExePath);
},
CancellationToken.None);
// Wait for either completion or timeout
pathResolutionTask.Wait(cts.Token);
}
catch (OperationCanceledException)
{
// Debug.WriteLine("Operation was canceled.");
}
if (exeExists)
{
// If the executable exists, try to get the icon from the file
icon = await MaybeGetIconForPath(fullExePath);
if (icon is not null)
{
return icon;
}
}
// Get the base url up to the first placeholder
var placeholderIndex = target.IndexOf('{');
var baseString = placeholderIndex > 0 ? target.Substring(0, placeholderIndex) : target;
try
{
var uri = GetUri(baseString);
if (uri is not null)
{
var hostname = uri.Host;
var faviconUrl = $"{uri.Scheme}://{hostname}/favicon.ico";
icon = new IconInfo(faviconUrl);
}
}
catch (UriFormatException)
{
}
// If we still don't have an icon, use the target as the icon
icon = icon ?? new IconInfo(target);
return icon;
}
private static async Task<IconInfo?> MaybeGetIconForPath(string target)
{
try
{
var stream = await ThumbnailHelper.GetThumbnail(target);
if (stream is not null)
{
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
return new IconInfo(data, data);
}
}
catch
{
}
return null;
}
}

View File

@@ -150,6 +150,15 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open URL.
/// </summary>
public static string open_url_command_name {
get {
return ResourceManager.GetString("open_url_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>

View File

@@ -1,228 +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.ComponentModel;
using System.Diagnostics;
using System.IO;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell.Commands;
internal sealed partial class ExecuteItem : InvokableCommand
{
private readonly ISettingsInterface _settings;
private readonly RunAsType _runas;
public string Cmd { get; internal set; } = string.Empty;
private static readonly char[] Separator = [' '];
public ExecuteItem(string cmd, ISettingsInterface settings, RunAsType type = RunAsType.None)
{
if (type == RunAsType.Administrator)
{
Name = Properties.Resources.cmd_run_as_administrator;
Icon = Icons.AdminIcon;
}
else if (type == RunAsType.OtherUser)
{
Name = Properties.Resources.cmd_run_as_user;
Icon = Icons.UserIcon;
}
else
{
Name = Properties.Resources.generic_run_command;
Icon = Icons.RunV2Icon;
}
Cmd = cmd;
_settings = settings;
_runas = type;
}
private void Execute(Func<ProcessStartInfo, Process?> startProcess, ProcessStartInfo info)
{
if (startProcess is null)
{
return;
}
try
{
startProcess(info);
}
catch (FileNotFoundException e)
{
var name = "Plugin: " + Properties.Resources.cmd_plugin_name;
var message = $"{Properties.Resources.cmd_command_not_found}: {e.Message}";
// GH TODO #138 -- show this message once that's wired up
// _context.API.ShowMsg(name, message);
}
catch (Win32Exception e)
{
var name = "Plugin: " + Properties.Resources.cmd_plugin_name;
var message = $"{Properties.Resources.cmd_command_failed}: {e.Message}";
ExtensionHost.LogMessage(new LogMessage() { Message = name + message });
// GH TODO #138 -- show this message once that's wired up
// _context.API.ShowMsg(name, message);
}
}
public static ProcessStartInfo SetProcessStartInfo(string fileName, string workingDirectory = "", string arguments = "", string verb = "")
{
var info = new ProcessStartInfo
{
FileName = fileName,
WorkingDirectory = workingDirectory,
Arguments = arguments,
Verb = verb,
};
return info;
}
private ProcessStartInfo PrepareProcessStartInfo(string command, RunAsType runAs = RunAsType.None)
{
command = Environment.ExpandEnvironmentVariables(command);
var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Set runAsArg
var runAsVerbArg = string.Empty;
if (runAs == RunAsType.OtherUser)
{
runAsVerbArg = "runAsUser";
}
else if (runAs == RunAsType.Administrator || _settings.RunAsAdministrator)
{
runAsVerbArg = "runAs";
}
if (Enum.TryParse<ExecutionShell>(_settings.ShellCommandExecution, out var executionShell))
{
ProcessStartInfo info;
if (executionShell == ExecutionShell.Cmd)
{
var arguments = _settings.LeaveShellOpen ? $"/k \"{command}\"" : $"/c \"{command}\" & pause";
info = SetProcessStartInfo("cmd.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (executionShell == ExecutionShell.Powershell)
{
var arguments = _settings.LeaveShellOpen
? $"-NoExit \"{command}\""
: $"\"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
info = SetProcessStartInfo("powershell.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (executionShell == ExecutionShell.PowerShellSeven)
{
var arguments = _settings.LeaveShellOpen
? $"-NoExit -C \"{command}\""
: $"-C \"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
info = SetProcessStartInfo("pwsh.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (executionShell == ExecutionShell.WindowsTerminalCmd)
{
var arguments = _settings.LeaveShellOpen ? $"cmd.exe /k \"{command}\"" : $"cmd.exe /c \"{command}\" & pause";
info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (executionShell == ExecutionShell.WindowsTerminalPowerShell)
{
var arguments = _settings.LeaveShellOpen ? $"powershell -NoExit -C \"{command}\"" : $"powershell -C \"{command}\"";
info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (executionShell == ExecutionShell.WindowsTerminalPowerShellSeven)
{
var arguments = _settings.LeaveShellOpen ? $"pwsh.exe -NoExit -C \"{command}\"" : $"pwsh.exe -C \"{command}\"";
info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
}
else if (executionShell == ExecutionShell.RunCommand)
{
// Open explorer if the path is a file or directory
if (Directory.Exists(command) || File.Exists(command))
{
info = SetProcessStartInfo("explorer.exe", arguments: command, verb: runAsVerbArg);
}
else
{
var parts = command.Split(Separator, 2);
if (parts.Length == 2)
{
var filename = parts[0];
if (ShellListPageHelpers.FileExistInPath(filename))
{
var arguments = parts[1];
if (_settings.LeaveShellOpen)
{
// Wrap the command in a cmd.exe process
info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{filename} {arguments}\"", runAsVerbArg);
}
else
{
info = SetProcessStartInfo(filename, workingDirectory, arguments, runAsVerbArg);
}
}
else
{
if (_settings.LeaveShellOpen)
{
// Wrap the command in a cmd.exe process
info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
}
else
{
info = SetProcessStartInfo(command, verb: runAsVerbArg);
}
}
}
else
{
if (_settings.LeaveShellOpen)
{
// Wrap the command in a cmd.exe process
info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
}
else
{
info = SetProcessStartInfo(command, verb: runAsVerbArg);
}
}
}
}
else
{
throw new NotImplementedException();
}
info.UseShellExecute = true;
_settings.AddCmdHistory(command);
return info;
}
else
{
ExtensionHost.LogMessage(new LogMessage() { Message = "Error extracting setting" });
throw new NotImplementedException();
}
}
public override CommandResult Invoke()
{
try
{
Execute(Process.Start, PrepareProcessStartInfo(Cmd, _runas));
}
catch
{
ExtensionHost.LogMessage(new LogMessage() { Message = "Error starting the process " });
}
return CommandResult.Dismiss();
}
}

View File

@@ -2,9 +2,7 @@
// 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.IO;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell;
@@ -13,18 +11,33 @@ internal sealed partial class OpenUrlWithHistoryCommand : OpenUrlCommand
{
private readonly Action<string>? _addToHistory;
private readonly string _url;
private readonly ITelemetryService? _telemetryService;
public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null)
public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null, ITelemetryService? telemetryService = null)
: base(url)
{
_addToHistory = addToHistory;
_url = url;
_telemetryService = telemetryService;
}
public override CommandResult Invoke()
{
_addToHistory?.Invoke(_url);
var result = base.Invoke();
return result;
var success = ShellHelpers.OpenInShell(_url);
var isWebUrl = false;
if (Uri.TryCreate(_url, UriKind.Absolute, out var uri))
{
if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
{
isWebUrl = true;
}
}
_telemetryService?.LogOpenUri(_url, isWebUrl, success);
return CommandResult.Dismiss();
}
}

View File

@@ -2,14 +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;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell;
@@ -19,18 +14,20 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
private static readonly char[] _systemDirectoryRoots = ['\\', '/'];
private readonly Action<string>? _addToHistory;
private readonly ITelemetryService _telemetryService;
private CancellationTokenSource? _cancellationTokenSource;
private Task? _currentUpdateTask;
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory)
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory, ITelemetryService telemetryService)
: base(
new NoOpCommand() { Id = "com.microsoft.run.fallback" },
Resources.shell_command_display_title)
ResourceLoaderInstance.GetString("shell_command_display_title"))
{
Title = string.Empty;
Subtitle = Properties.Resources.generic_run_command;
Subtitle = ResourceLoaderInstance.GetString("generic_run_command");
Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon.
_addToHistory = addToHistory;
_telemetryService = telemetryService;
}
public override void UpdateQuery(string query)
@@ -147,7 +144,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
if (exeExists)
{
// TODO we need to probably get rid of the settings for this provider entirely
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory);
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory, telemetryService: _telemetryService);
Title = exeItem.Title;
Subtitle = exeItem.Subtitle;
Icon = exeItem.Icon;
@@ -156,7 +153,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
}
else if (pathIsDir)
{
var pathItem = new PathListItem(exe, query, _addToHistory);
var pathItem = new PathListItem(exe, query, _addToHistory, _telemetryService);
Command = pathItem.Command;
MoreCommands = pathItem.MoreCommands;
Title = pathItem.Title;
@@ -165,7 +162,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
}
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
{
Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory) { Result = CommandResult.Dismiss() };
Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory, _telemetryService) { Result = CommandResult.Dismiss() };
Title = searchText;
}
else

View File

@@ -2,10 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Storage.FileSystem;
namespace Microsoft.CmdPal.Ext.Shell.Helpers;
@@ -19,38 +19,11 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers;
public static class CommandLineNormalizer
{
#pragma warning disable SA1310 // Field names should not contain underscore
private const int MAX_PATH = 260;
private const uint INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF;
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
private const int MAX_PATH = 260;
#pragma warning restore SA1310 // Field names should not contain underscore
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint ExpandEnvironmentStringsW(
[MarshalAs(UnmanagedType.LPWStr)] string lpSrc,
[MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpDst,
uint nSize);
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr CommandLineToArgvW(
[MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine,
out int pNumArgs);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint SearchPathW(
[MarshalAs(UnmanagedType.LPWStr)] string? lpPath,
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName,
[MarshalAs(UnmanagedType.LPWStr)] string? lpExtension,
uint nBufferLength,
[MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpBuffer,
out IntPtr lpFilePart);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFileAttributesW(
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName);
[DllImport("kernel32.dll")]
private static extern IntPtr LocalFree(IntPtr hMem);
/// <summary>
/// Normalizes a command line string by expanding environment variables, resolving executable paths,
/// and standardizing the format for comparison purposes.
@@ -78,7 +51,7 @@ public static class CommandLineNormalizer
///
/// The resulting strings are used for comparisons in profile matching.
/// </remarks>
public static string NormalizeCommandLine(string commandLine)
public static string NormalizeCommandLine(string commandLine, bool allowDirectory)
{
if (string.IsNullOrEmpty(commandLine))
{
@@ -106,7 +79,7 @@ public static class CommandLineNormalizer
// The given commandLine should start with an executable name or path.
// This loop tries to resolve relative paths, as well as executable names in %PATH%
// into absolute paths and normalizes them.
var executablePath = ResolveExecutablePath(argv, ref startOfArguments);
var executablePath = ResolveExecutablePath(argv, allowDirectory, ref startOfArguments);
// We've (hopefully) finished resolving the path to the executable.
// We're now going to append all remaining arguments to the resulting string.
@@ -129,9 +102,9 @@ public static class CommandLineNormalizer
private static string ExpandEnvironmentVariables(string input)
{
const int initialBufferSize = 1024;
var buffer = new StringBuilder(initialBufferSize);
var buffer = new char[initialBufferSize];
var result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity);
var result = PInvoke.ExpandEnvironmentStrings(input, buffer);
if (result == 0)
{
@@ -139,11 +112,11 @@ public static class CommandLineNormalizer
return input;
}
if (result > buffer.Capacity)
if (result > buffer.Length)
{
// Buffer was too small, resize and try again
buffer.Capacity = (int)result;
result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity);
buffer = new char[result];
result = PInvoke.ExpandEnvironmentStrings(input, buffer);
if (result == 0)
{
@@ -151,7 +124,7 @@ public static class CommandLineNormalizer
}
}
return buffer.ToString();
return new string(buffer, 0, (int)result - 1); // -1 to exclude null terminator
}
/// <summary>
@@ -159,28 +132,30 @@ public static class CommandLineNormalizer
/// </summary>
private static string[] ParseCommandLineToArguments(string commandLine)
{
var argv = CommandLineToArgvW(commandLine, out var argc);
if (argv == IntPtr.Zero || argc == 0)
unsafe
{
return Array.Empty<string>();
}
var argv = PInvoke.CommandLineToArgv(commandLine, out var argc);
try
{
var args = new string[argc];
for (var i = 0; i < argc; i++)
if (argv == null || argc == 0)
{
var argPtr = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
args[i] = Marshal.PtrToStringUni(argPtr) ?? string.Empty;
return Array.Empty<string>();
}
return args;
}
finally
{
LocalFree(argv);
try
{
var args = new string[argc];
for (var i = 0; i < argc; i++)
{
args[i] = new string(argv[i]);
}
return args;
}
finally
{
PInvoke.LocalFree(new HLOCAL(argv));
}
}
}
@@ -188,7 +163,7 @@ public static class CommandLineNormalizer
/// Resolves the executable path from the command line arguments.
/// Handles cases where the path contains spaces and was split during parsing.
/// </summary>
private static string ResolveExecutablePath(string[] argv, ref int startOfArguments)
private static string ResolveExecutablePath(string[] argv, bool allowDirectory, ref int startOfArguments)
{
if (argv.Length == 0)
{
@@ -208,7 +183,7 @@ public static class CommandLineNormalizer
}
var candidatePath = pathBuilder.ToString();
var resolvedPath = TryResolveExecutable(candidatePath);
var resolvedPath = TryResolveExecutable(candidatePath, allowDirectory);
if (!string.IsNullOrEmpty(resolvedPath))
{
@@ -225,41 +200,52 @@ public static class CommandLineNormalizer
/// <summary>
/// Attempts to resolve an executable path using SearchPathW.
/// </summary>
private static string TryResolveExecutable(string executableName)
private static string TryResolveExecutable(string executableName, bool allowDirectory)
{
var buffer = new StringBuilder(MAX_PATH);
var buffer = new char[MAX_PATH];
var result = SearchPathW(
null, // Use default search path
executableName,
".exe", // Default extension
(uint)buffer.Capacity,
buffer,
out var _); // We don't need the file part
if (result == 0)
unsafe
{
return string.Empty;
}
var outParam = default(PWSTR); // ultimately discarded
if (result > buffer.Capacity)
{
// Buffer was too small, resize and try again
buffer.Capacity = (int)result;
result = SearchPathW(null, executableName, ".exe", (uint)buffer.Capacity, buffer, out var _);
var result = PInvoke.SearchPath(
null, // Use default search path
executableName,
".exe", // Default extension
buffer,
&outParam); // We don't need the file part
if (result == 0)
{
return string.Empty;
}
if (result > buffer.Length)
{
// Buffer was too small, resize and try again
buffer = new char[result];
result = PInvoke.SearchPath(null, executableName, ".exe", buffer, &outParam);
if (result == 0)
{
return string.Empty;
}
}
var resolvedPath = new string(buffer, 0, (int)result);
// Verify the resolved path exists...
var attributes = PInvoke.GetFileAttributes(resolvedPath);
// ... and if we don't want to allow directories, reject paths that are dirs
var rejectDirectory = !allowDirectory &&
(attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0;
return attributes == INVALID_FILE_ATTRIBUTES ||
rejectDirectory ?
string.Empty :
resolvedPath;
}
var resolvedPath = buffer.ToString();
// Verify the resolved path exists and is not a directory
var attributes = GetFileAttributesW(resolvedPath);
return attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 ? string.Empty : resolvedPath;
}
/// <summary>

View File

@@ -2,13 +2,8 @@
// 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.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Shell.Commands;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,37 +11,6 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers;
public class ShellListPageHelpers
{
private static readonly CompositeFormat CmdHasBeenExecutedTimes = System.Text.CompositeFormat.Parse(Properties.Resources.cmd_has_been_executed_times);
private readonly ISettingsInterface _settings;
public ShellListPageHelpers(ISettingsInterface settings)
{
_settings = settings;
}
private ListItem GetCurrentCmd(string cmd)
{
var result = new ListItem(new ExecuteItem(cmd, _settings))
{
Title = cmd,
Subtitle = Properties.Resources.cmd_plugin_name + ": " + Properties.Resources.cmd_execute_through_shell,
Icon = new IconInfo(string.Empty),
};
return result;
}
public List<CommandContextItem> LoadContextMenus(ListItem listItem)
{
var resultList = new List<CommandContextItem>
{
new(new ExecuteItem(listItem.Title, _settings, RunAsType.Administrator)),
new(new ExecuteItem(listItem.Title, _settings, RunAsType.OtherUser )),
};
return resultList;
}
internal static bool FileExistInPath(string filename)
{
return FileExistInPath(filename, out var _);
@@ -54,11 +18,10 @@ public class ShellListPageHelpers
internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
{
// TODO! remove this method and just use ShellHelpers.FileExistInPath directly
return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None);
}
internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory)
internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory, ITelemetryService? telemetryService)
{
var li = new ListItem();
@@ -100,7 +63,7 @@ public class ShellListPageHelpers
if (exeExists)
{
// TODO we need to probably get rid of the settings for this provider entirely
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory);
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory, telemetryService);
li.Command = exeItem.Command;
li.Title = exeItem.Title;
li.Subtitle = exeItem.Subtitle;
@@ -109,7 +72,7 @@ public class ShellListPageHelpers
}
else if (pathIsDir)
{
var pathItem = new PathListItem(exe, query, addToHistory);
var pathItem = new PathListItem(exe, query, addToHistory, telemetryService);
li.Command = pathItem.Command;
li.Title = pathItem.Title;
li.Subtitle = pathItem.Subtitle;
@@ -118,7 +81,7 @@ public class ShellListPageHelpers
}
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
{
li.Command = new OpenUrlWithHistoryCommand(searchText) { Result = CommandResult.Dismiss() };
li.Command = new OpenUrlWithHistoryCommand(searchText, addToHistory, telemetryService) { Result = CommandResult.Dismiss() };
li.Title = searchText;
}
else
@@ -145,7 +108,7 @@ public class ShellListPageHelpers
/// </summary>
public static void NormalizeCommandLineAndArgs(string input, out string executable, out string arguments)
{
var normalized = CommandLineNormalizer.NormalizeCommandLine(input);
var normalized = CommandLineNormalizer.NormalizeCommandLine(input, allowDirectory: true);
var segments = normalized.Split('\0', StringSplitOptions.RemoveEmptyEntries);
executable = string.Empty;
arguments = string.Empty;

View File

@@ -1,11 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\..\Common.Dotnet.AotCompatibility.props" />
<Import Project="..\Common.ExtDependencies.props" />
<Import Project="..\..\CoreCommonProps.props" />
<PropertyGroup>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.CmdPal.Ext.Shell</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@@ -16,7 +12,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://aka.ms/CsWin32.schema.json",
"allowMarshaling": false,
"comInterop": {
"preserveSigMethods": [ "*" ]
}
}

View File

@@ -0,0 +1,22 @@
GetCurrentPackageFullName
SetWindowLong
GetWindowLong
WINDOW_EX_STYLE
SFBS_FLAGS
MAX_PATH
GetDpiForWindow
GetWindowRect
GetMonitorInfo
SetWindowPos
MonitorFromWindow
SHOW_WINDOW_CMD
ShellExecuteEx
SEE_MASK_INVOKEIDLIST
ExpandEnvironmentStringsW
CommandLineToArgvW
SearchPathW
GetFileAttributesW
LocalFree
FILE_FLAGS_AND_ATTRIBUTES

View File

@@ -2,9 +2,8 @@
// 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.IO;
using Microsoft.CmdPal.Core.Common.Commands;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
@@ -13,13 +12,18 @@ namespace Microsoft.CmdPal.Ext.Shell;
internal sealed partial class PathListItem : ListItem
{
private readonly Lazy<IconInfo> _icon;
private readonly bool _isDirectory;
private readonly Lazy<bool> fetchedIcon;
private readonly bool isDirectory;
private readonly string path;
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
public override IIconInfo? Icon { get => fetchedIcon.Value ? _icon : _icon; set => base.Icon = value; }
public PathListItem(string path, string originalDir, Action<string>? addToHistory)
: base(new OpenUrlWithHistoryCommand(path, addToHistory))
private IIconInfo? _icon;
internal bool IsDirectory => isDirectory;
public PathListItem(string path, string originalDir, Action<string>? addToHistory, ITelemetryService? telemetryService = null)
: base(new OpenUrlWithHistoryCommand(path, addToHistory, telemetryService))
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrEmpty(fileName))
@@ -27,8 +31,8 @@ internal sealed partial class PathListItem : ListItem
fileName = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty;
}
_isDirectory = Directory.Exists(path);
if (_isDirectory)
isDirectory = Directory.Exists(path);
if (isDirectory)
{
if (!path.EndsWith('\\'))
{
@@ -41,40 +45,35 @@ internal sealed partial class PathListItem : ListItem
}
}
this.path = path;
Title = fileName; // Just the name of the file is the Title
Subtitle = path; // What the user typed is the subtitle
// NOTE ME:
// If there are spaces on originalDir, trim them off, BEFORE combining originalDir and fileName.
// THEN add quotes at the end
// Trim off leading & trailing quote, if there is one
var trimmed = originalDir.Trim('"');
var originalPath = Path.Combine(trimmed, fileName);
var suggestion = originalPath;
var hasSpace = originalPath.Contains(' ');
if (hasSpace)
{
// wrap it in quotes
suggestion = string.Concat("\"", suggestion, "\"");
}
TextToSuggest = suggestion;
TextToSuggest = path;
MoreCommands = [
new CommandContextItem(new OpenWithCommand(path)),
new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E) },
new CommandContextItem(new CopyPathCommand(path) { Name = Properties.Resources.copy_path_command_name }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) },
new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) },
new CommandContextItem(new OpenInConsoleCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R) },
new CommandContextItem(new OpenPropertiesCommand(path)),
];
_icon = new Lazy<IconInfo>(() =>
fetchedIcon = new Lazy<bool>(() =>
{
var iconStream = ThumbnailHelper.GetThumbnail(path).Result;
var icon = iconStream is not null ? IconInfo.FromStream(iconStream) :
_isDirectory ? Icons.FolderIcon : Icons.RunV2Icon;
return icon;
_ = Task.Run(FetchIconAsync);
return true;
});
}
private async Task FetchIconAsync()
{
var iconStream = await ThumbnailHelper.GetThumbnail(path);
var icon = iconStream != null ?
IconInfo.FromStream(iconStream) :
isDirectory ? Icons.FolderIcon : Icons.RunV2Icon;
_icon = icon;
OnPropertyChanged(nameof(Icon));
}
}

View File

@@ -2,8 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams;
@@ -15,6 +15,7 @@ internal sealed partial class RunExeItem : ListItem
{
private readonly Lazy<IconInfo> _icon;
private readonly Action<string>? _addToHistory;
private readonly ITelemetryService? _telemetryService;
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
@@ -26,13 +27,18 @@ internal sealed partial class RunExeItem : ListItem
private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}";
public RunExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
public RunExeItem(
string exe,
string args,
string fullExePath,
Action<string>? addToHistory,
ITelemetryService? telemetryService = null)
{
FullExePath = fullExePath;
Exe = exe;
var command = new AnonymousCommand(Run)
{
Name = Properties.Resources.generic_run_command,
Name = ResourceLoaderInstance.GetString("generic_run_command"),
Result = CommandResult.Dismiss(),
};
Command = command;
@@ -46,6 +52,7 @@ internal sealed partial class RunExeItem : ListItem
});
_addToHistory = addToHistory;
_telemetryService = telemetryService;
UpdateArgs(args);
@@ -53,13 +60,13 @@ internal sealed partial class RunExeItem : ListItem
new CommandContextItem(
new AnonymousCommand(RunAsAdmin)
{
Name = Properties.Resources.cmd_run_as_administrator,
Name = ResourceLoaderInstance.GetString("cmd_run_as_administrator"),
Icon = Icons.AdminIcon,
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) },
new CommandContextItem(
new AnonymousCommand(RunAsOther)
{
Name = Properties.Resources.cmd_run_as_user,
Name = ResourceLoaderInstance.GetString("cmd_run_as_user"),
Icon = Icons.UserIcon,
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) },
];
@@ -97,20 +104,26 @@ internal sealed partial class RunExeItem : ListItem
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args);
var success = ShellHelpers.OpenInShell(FullExePath, _args);
_telemetryService?.LogRunCommand(FullString, false, success);
}
public void RunAsAdmin()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator);
var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator);
_telemetryService?.LogRunCommand(FullString, true, success);
}
public void RunAsOther()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser);
var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser);
_telemetryService?.LogRunCommand(FullString, false, success);
}
}

View File

@@ -2,15 +2,8 @@
// 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;
using System.Threading.Tasks;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -18,13 +11,13 @@ namespace Microsoft.CmdPal.Ext.Shell.Pages;
internal sealed partial class ShellListPage : DynamicListPage, IDisposable
{
private readonly ShellListPageHelpers _helper;
private readonly List<ListItem> _topLevelItems = [];
private readonly Dictionary<string, ListItem> _historyItems = [];
private readonly List<ListItem> _currentHistoryItems = [];
private readonly IRunHistoryService _historyService;
private readonly ITelemetryService? _telemetryService;
private readonly Dictionary<string, ListItem> _currentPathItems = new();
private ListItem? _exeItem;
private List<ListItem> _pathItems = [];
@@ -35,27 +28,26 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
private bool _loadedInitialHistory;
public ShellListPage(ISettingsInterface settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false)
private string _currentSubdir = string.Empty;
public ShellListPage(
ISettingsInterface settingsManager,
IRunHistoryService runHistoryService,
ITelemetryService? telemetryService)
{
Icon = Icons.RunV2Icon;
Id = "com.microsoft.cmdpal.shell";
Name = Resources.cmd_plugin_name;
PlaceholderText = Resources.list_placeholder_text;
_helper = new(settingsManager);
Name = ResourceLoaderInstance.GetString("cmd_plugin_name");
PlaceholderText = ResourceLoaderInstance.GetString("list_placeholder_text");
_historyService = runHistoryService;
_telemetryService = telemetryService;
EmptyContent = new CommandItem()
{
Title = Resources.cmd_plugin_name,
Title = ResourceLoaderInstance.GetString("cmd_plugin_name"),
Icon = Icons.RunV2Icon,
Subtitle = Resources.list_placeholder_text,
Subtitle = ResourceLoaderInstance.GetString("list_placeholder_text"),
};
if (addBuiltins)
{
// here, we _could_ add built-in providers if we wanted. links to apps, calc, etc.
// That would be a truly run-first experience
}
}
public override void UpdateSearchText(string oldSearch, string newSearch)
@@ -123,8 +115,13 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken)
{
var timer = System.Diagnostics.Stopwatch.StartNew();
// Check for cancellation at the start
cancellationToken.ThrowIfCancellationRequested();
if (cancellationToken.IsCancellationRequested)
{
return;
}
// If the search text is the start of a path to a file (it might be a
// UNC path), then we want to list all the files that start with that text:
@@ -136,7 +133,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
var expanded = Environment.ExpandEnvironmentVariables(searchText);
// Check for cancellation after environment expansion
cancellationToken.ThrowIfCancellationRequested();
if (cancellationToken.IsCancellationRequested)
{
return;
}
// TODO we can be smarter about only re-reading the filesystem if the
// new search is just the oldSearch+some chars
@@ -206,7 +206,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
couldResolvePath = false;
}
cancellationToken.ThrowIfCancellationRequested();
if (cancellationToken.IsCancellationRequested)
{
return;
}
_pathItems.Clear();
@@ -221,7 +224,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
}
// Check for cancellation before creating exe items
cancellationToken.ThrowIfCancellationRequested();
if (cancellationToken.IsCancellationRequested)
{
return;
}
if (couldResolvePath && exeExists)
{
@@ -278,17 +284,31 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
_currentHistoryItems.AddRange(filteredHistory);
// Final cancellation check
cancellationToken.ThrowIfCancellationRequested();
if (cancellationToken.IsCancellationRequested)
{
return;
}
timer.Stop();
_telemetryService?.LogRunQuery(newSearch, GetItems().Length, (ulong)timer.ElapsedMilliseconds);
}
private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? addToHistory = null)
private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? addToHistory = null, ITelemetryService? telemetryService = null)
{
var pathItem = new PathListItem(path, originalPath, addToHistory);
var pathItem = new PathListItem(path, originalPath, addToHistory, telemetryService);
if (pathItem.IsDirectory)
{
return pathItem;
}
// Is this path an executable? If so, then make a RunExeItem
if (IsExecutable(path))
{
var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory);
var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory, telemetryService)
{
TextToSuggest = path,
};
exeItem.MoreCommands = [
.. exeItem.MoreCommands,
@@ -306,24 +326,22 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
LoadInitialHistory();
}
var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText);
List<ListItem> uriItems = _uriItem is not null ? [_uriItem] : [];
List<ListItem> exeItems = _exeItem is not null ? [_exeItem] : [];
return
exeItems
.Concat(filteredTopLevel)
.Concat(_currentHistoryItems)
.Concat(_pathItems)
.Concat(uriItems)
.ToArray();
}
internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory, ITelemetryService? telemetryService)
{
// PathToListItem will return a RunExeItem if it can find a executable.
// It will ALSO add the file search commands to the RunExeItem.
return PathToListItem(fullExePath, exe, args, addToHistory);
return PathToListItem(fullExePath, exe, args, addToHistory, telemetryService);
}
private void CreateAndAddExeItems(string exe, string args, string fullExePath)
@@ -335,7 +353,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
}
else
{
_exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory);
_exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory, _telemetryService);
}
}
@@ -389,7 +407,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
}
// Check for cancellation before directory operations
cancellationToken.ThrowIfCancellationRequested();
if (cancellationToken.IsCancellationRequested)
{
return;
}
var dirExists = Directory.Exists(directoryPath);
@@ -408,30 +429,72 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
if (dirExists)
{
// Check for cancellation before file system enumeration
cancellationToken.ThrowIfCancellationRequested();
// Get all the files in the directory that start with the search text
// Run this on a background thread to avoid blocking
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken);
// Check for cancellation after file enumeration
cancellationToken.ThrowIfCancellationRequested();
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length));
var originalBeginning = originalPath.Remove(originalPath.Length - searchPathTrailer.Length);
if (isDriveRoot)
if (cancellationToken.IsCancellationRequested)
{
originalBeginning = string.Concat(originalBeginning, '\\');
return;
}
// Create a list of commands for each file
var commands = files.Select(f => PathToListItem(f, originalBeginning)).ToList();
// If the directory we're in changed, then first rebuild the cache
// of all the items in the directory, _then_ filter them below.
if (directoryPath != _currentSubdir)
{
// Get all the files in the directory.
// Run this on a background thread to avoid blocking
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath), cancellationToken);
// Final cancellation check before updating results
cancellationToken.ThrowIfCancellationRequested();
// Check for cancellation after file enumeration
if (cancellationToken.IsCancellationRequested)
{
return;
}
// Add the commands to the list
_pathItems = commands;
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length));
var originalBeginning = originalPath.EndsWith(searchPathTrailer, StringComparison.CurrentCultureIgnoreCase) ?
originalPath.Remove(originalPath.Length - searchPathTrailer.Length) :
originalPath;
if (isDriveRoot)
{
originalBeginning = string.Concat(originalBeginning, '\\');
}
// Create a list of commands for each file
var newPathItems = files
.Select(f => PathToListItem(f, originalBeginning))
.ToDictionary(item => item.Title, item => item);
// Final cancellation check before updating results
if (cancellationToken.IsCancellationRequested)
{
return;
}
// Add the commands to the list
_pathItems.Clear();
_currentSubdir = directoryPath;
_currentPathItems.Clear();
foreach ((var k, IListItem v) in newPathItems)
{
_currentPathItems[k] = (ListItem)v;
}
}
// Filter the items from this directory
var fuzzyString = searchPattern.TrimEnd('*');
var newMatchedPathItems = new List<ListItem>();
foreach (var kv in _currentPathItems)
{
var score = string.IsNullOrEmpty(fuzzyString) ?
1 :
FuzzyStringMatcher.ScoreFuzzy(fuzzyString, kv.Key);
if (score > 0)
{
newMatchedPathItems.Add(kv.Value);
}
}
ListHelpers.InPlaceUpdateList(_pathItems, newMatchedPathItems);
}
else
{
@@ -458,7 +521,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
{
var hist = _historyService.GetRunHistory();
var histItems = hist
.Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory)))
.Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory, _telemetryService)))
.Where(tuple => tuple.Item2 is not null)
.Select(tuple => (tuple.h, tuple.Item2!))
.ToList();

View File

@@ -0,0 +1,13 @@
// 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.Shell;
internal static class ResourceLoaderInstance
{
public static string GetString(string resourceKey)
{
return Properties.Resources.ResourceManager.GetString(resourceKey, Properties.Resources.Culture) ?? throw new InvalidOperationException($"Resource key '{resourceKey}' not found.");
}
}

Some files were not shown because too many files have changed in this diff Show More