Compare commits

..

32 Commits

Author SHA1 Message Date
Gordon Lam (SH)
3bd9698073 Add Parallel project build 2025-10-11 10:03:54 +08:00
Mike Griese
cd5b76c988 CmdPal: make the context menu search look more like a cmdpal (#42081)
Replaces our styling with the same styleing we use for the search bar

But we can't _just_ do that, because the stupid "text cursors don't show
up on top of transparent backgrounds" thing.

So I just added the smoke backdrop to the search box. Seemed reasonable.

Screenshots below.

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2025-10-10 06:26:04 -05:00
Jaylyn Barbee
f44addb29c Fixed manual override event (#42280)
Issue: When using the shortcut to switch modes manually, the service
would try to correct the theme to the expected mode.
Problem: Event resetting between each loop.
Fix: Set the event to be manually reset only so that the event isn't
resetting between each loop. The rest of the code was already written to
expect this behavior so no other changes should be necessary.
2025-10-10 06:51:38 -04:00
Gordon Lam
1e3429dd3a Introduce worktree helper scripts for faster multi-branch development in PowerToys (#42076)
<!-- 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 suite of helper scripts for managing
Git worktrees in the `tools/build` directory, along with comprehensive
documentation. The scripts streamline common workflows such as creating,
reusing, and deleting worktrees for feature branches, forks, and
issue-based development, making it easier for developers to work on
multiple changes in parallel without duplicating the repository. Each
script is provided as both a PowerShell (`.ps1`) and Windows batch
(`.cmd`) wrapper for convenience. A detailed markdown guide explains
usage patterns, scenarios, and best practices.

**New worktree management scripts:**

* Added `New-WorktreeFromFork.ps1`/`.cmd` to create a worktree from a
branch in a personal fork, handling remote creation and branch tracking
automatically.
[[1]](diffhunk://#diff-ea4d43777029cdde7fb9fda8ee6a0ed3dcfd75b22ed6ae566c6a77797c8bef54R1-R111)
[[2]](diffhunk://#diff-1314b08f84ac8c2e7d020e5584d9f2f19dbf116bbc13c14de0de432006912cfeR1-R4)
* Added `New-WorktreeFromBranch.ps1`/`.cmd` to create or reuse a
worktree for an existing local or remote branch, with logic to fetch and
track branches as needed.
[[1]](diffhunk://#diff-07c08acfb570e1b54647370cae17e663e76ee8cb09614cac7a23a9367f625a3eR1-R69)
[[2]](diffhunk://#diff-6297be534792c3e6d1bc377b84bcd20b2eb5b3de84d4376a2592b25fc9a88a88R1-R4)
* Added `New-WorktreeFromIssue.ps1`/`.cmd` to create a new issue branch
from a base ref (default `origin/main`), slugifying the issue title for
branch naming.
[[1]](diffhunk://#diff-36cb35f3b814759c60f770fc9cc1cc9fa10ceee53811d95a85881d8e69c1ab07R1-R67)
[[2]](diffhunk://#diff-890880241ffc24b5d29ddb69ce4c19697a2fce6be6861d0a24d02ebf65b35694R1-R4)
* Added `Delete-Worktree.ps1`/`.cmd` to safely remove a worktree, with
options to force removal, keep the local branch, or retain orphan fork
remotes. Includes robust error handling and manual recovery guidance.
[[1]](diffhunk://#diff-8a335544864c1630d7f9bec6f4113c10d84b8e26054996735da41516ad93e173R1-R120)
[[2]](diffhunk://#diff-19a810e57f8b82e1dc2476f35d051eb43f2d31e4f68ca7c011c89fd297718020R1-R4)

**Documentation:**

* Introduced `Wokrtree-Guidelines.md`, a comprehensive guide covering
the purpose, usage, flows, naming conventions, troubleshooting, and best
practices for the new worktree scripts.
2025-10-10 12:11:28 +08:00
Michael Jolley
075bbb46cb CmdPal: Fixes for the build for the fixes 0.95 (#42279)
Closes #42241
Closes #42245 

Apps that were pinned would show in search results twice. Resolved.

Run & Calculator wouldn't show in search results. Resolved.
2025-10-09 20:37:23 -07:00
Jiří Polášek
4aa27316fb Spellchecker: Add unrecognized word for Light Switch (#42275)
## Summary of the Pull Request

- Adds "wmsg" to expected words dictionary (#42264).
- Removes words no longer needed.

<!-- 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-09 23:20:17 -04:00
Mike Griese
f55c49e15b CmdPal: adjust frecency weighting (#42242)
In #41959 we changed the string matcher's weighting. It ended up giving
us lower scores than before.

That meant that the weighting from recent commands was far too heavy,
and it was polluting the results. Basically any command that you'd run
would be like, 30 characters of weight heavier than anything you
haven't.


This increases the weight of all string matches by 10x. That means
something like
`Command Prompt` will get a string matched weight of `100` instead of
`10`. This balances better with the weighting from frecency (where the
MRU command gets +35, then `+min(5*uses,35)`, for up to 70 points of
weight)

It also adds a bunch of tests here, to try and catch this again in the
future.

Closes #42158
2025-10-09 15:35:22 -04:00
Jaylyn Barbee
b06cd9f896 Adding logger to Light Switch Service (#42264)
Adding proper logs to the Light Switch Service
2025-10-09 14:11:31 -04:00
Alex Mihaiuc
3e0d62d101 Reinstate ZoomIt branding (#42230)
This pull request restores dynamic branding and versioning for the
ZoomIt subproject.

## Summary of the Pull Request

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

- [ ] Closes: #xxx
- [x] **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
It was deleted by mistake in the previous commit.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

What was broken and this PR changes:
<img width="736" height="709" alt="image"
src="https://github.com/user-attachments/assets/803b0051-cf5b-4e81-a7e9-f562295896ea"
/>


Fixed behavior now, in PowerToys (official version up, with the changes
this commit overrides down):
<img width="1159" height="1345" alt="image"
src="https://github.com/user-attachments/assets/cf4d0c81-2d97-4ef5-a179-8f423dfe9739"
/>

Fixed behavior now, standalone:
<img width="1617" height="968" alt="image"
src="https://github.com/user-attachments/assets/467ffccd-f3d2-4543-bec3-1186941084c5"
/>
2025-10-09 10:55:44 -04:00
Jiří Polášek
b89237ff94 CmdPal: Fix "Open location" menu item for Win32 and UWP (#42248)
## Summary of the Pull Request

This PR fixes the regression of the "Open containing folder" context
menu items:
- For Win32 apps, the command now displays the correct icon, opens the
folder containing the shortcut, and selects the file.
- For UWP apps, it sets the same icon and item title as the equivalent
command for Win32 apps.
- For both, it updates the item's title to "Open file location" to align
with the Windows 11 menu naming convention.

<img width="1612" height="1106" alt="image"
src="https://github.com/user-attachments/assets/27fa7557-862e-4453-ba7b-7ac3d0af21d2"
/>


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

- [x] Closes: #42244 
- [ ] **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-09 15:07:47 +02:00
Kai Tao
df972447d4 Mouse Without Borders: A conflict machine Id will make connection fail (#42190)
<!-- 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 PR fixes a critical issue in Mouse Without Borders where
conflicting machine IDs would cause connection failures. The changes
ensure that when machine IDs are generated or updated, the settings are
immediately persisted to prevent loss of the new ID and maintain proper
synchronization between the property and backing field.

Key changes:

* Immediate settings persistence when a new machine ID is generated
(unless instant saving is paused)
* Proper synchronization of the machineId field when the MachineId
property is set
* Addition of settings saving logic in both getter and setter scenarios


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

- [ ] Closes: #42084
- [ ] **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
* Do a release bundle build and install new bundle with this fix on both
two machines
* Manually change two pc's machine ID to the same value
* Make sure they can connect
* Verify there is log entrance indicating that there is conflict for
machineID
* Close MWB, make sure next launch still connect
2025-10-09 14:46:31 +08:00
Jiří Polášek
668820cf2c Spellchecker: resolve warnings (#42202)
## Summary of the Pull Request

This PR resolves lingering spell-check warnings and other minor issues,
allowing us to focus on newly emerging problems.

**Changes:**

- Excludes empty and binary files  
- Converts invalid dictionary entries into patterns  
- Since dictionary entries can only contain letters, `0x6f677548` was
previously ignored
- Removes unused words  
- Adds a pattern marker to ignore all code on a line  
  - `/* #no-spell-check-line */`  
  - `// #no-spell-check`  

This should reduce outstanding spellchecker warnings and suggestions to
zero.

<!-- 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-08 15:36:05 -05:00
Jiří Polášek
ca4e8b2986 CmdPal: Cancel page load when superseded by a new page navigation (#42233)
## Summary of the Pull Request

This PR introduces cancellation support for navigation. If a user
navigates to page X and then returns back or navigates elsewhere before
the page X fully loads, this update ensures that page X will not set
itself as the current page and is ignored.

It resolves the issue where returning to the home page left the previous
page's icon and placeholder visible in the search bar, causing the
search functionality to fail.

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

- [x] Closes: #42247
- [ ] **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-08 15:25:33 -05:00
Jiří Polášek
0472e7dc78 Light Switch: Fix spellcheck and add version info to LightSwitchModuleInterface (#42220)
## Summary of the Pull Request

This PR addresses spellchecking issues in Light Switch module:

- Resolves the forbidden pattern “`, otherwise`” by rewriting it as “`;
otherwise`”.
- Updates `resource.h`, which was previously empty and therefore treated
as a binary file by the spellchecker.
The file now includes standard version information consistent with other
projects, ensuring it is properly recognized and that the correct
version information is included in
`PowerToys.LightSwitchModuleInterface.dll`.

<!-- 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-08 13:03:42 -04:00
Jaylyn Barbee
1f18088afe Adding versioning info for LightSwitchService.exe (#42240)
Title, fixes crash in build pipeline by adding versioning information to
the LightSwitchService.exe. Verified by building release and checking
the versioning data on the resulting app.
2025-10-08 10:14:37 -04:00
Mike Griese
494901b52d CmdPal: immediately move to page, while loading (#42227)
Regressed in #41358

We're synchronously waiting for the first FetchItems to return before
actually navigating to the page. Yikes.

Closes #42157

drive-by:
Closes #42231
Closes #42156
2025-10-07 19:29:18 -05:00
Jaylyn Barbee
3e213165a8 Light Switch: Updating UI tests (#42225)
Updating UI tests to match new UI
2025-10-07 12:20:49 -07:00
Mark Russinovich
e04e6a11d1 ZoomIt smooth image zooming (#42200)
Added smooth image option that results in GDI+ image smoothing for
static zoom and Magnifier API image smoothing for live zoom.

---------

Co-authored-by: Mark Russinovich <markruss@ntdev.microsoft.com>
Co-authored-by: Clint Rutkas <clint@rutkas.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
2025-10-07 11:20:00 -07:00
Kai Tao
14ff4dbc8c Find My Mouse: Handle default color for brand new settings (#42182)
<!-- 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
Give a default color transparency as before instead of leave background
black and foreground white.

<!-- 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
1. Delete setting for find my mouse
2. Start find my mouse, the backdrop and foreground are semi-transparent
3. Start settings, and the default settings should persist correctly.
4. Start powertoys again, make sure the default color is still
semi-transparent
5. Other settings change can be handled and persisted correctly

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2025-10-07 14:08:24 -04:00
Niels Laute
a8eb17d21a Bugfix: missing Crosshairs orientation string (#42207)
## Summary of the Pull Request

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

- UI string was not showing

Before:
<img width="933" height="146" alt="image"
src="https://github.com/user-attachments/assets/a6136040-0388-4349-b94c-99e6e77bb3e5"
/>

After:
<img width="943" height="150" alt="image"
src="https://github.com/user-attachments/assets/b1c9adc3-c29d-41f9-bebb-b7171fb81af6"
/>


- [ ] 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-07 14:05:35 -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
166 changed files with 8834 additions and 1973 deletions

View File

@@ -29,8 +29,6 @@ shortcutguide
# 8LWXpg is user name but user folder causes a flag
LWXpg
# 0x6f677548 is user name but user folder causes a flag
x6f677548
Adoumie
Advaith
alekhyareddy

View File

@@ -121,6 +121,10 @@
^src/modules/MouseWithoutBorders/App/Helper/.*\.resx$
^src/modules/MouseWithoutBorders/ModuleInterface/generateSecurityDescriptor\.h$
^src/modules/peek/Peek.Common/NativeMethods\.txt$
^src/modules/peek/Peek.UITests/TestAssets/4\.qoi$
^src/modules/powerrename/PowerRenameUITest/testItems/folder1/testCase2\.txt$
^src/modules/powerrename/PowerRenameUITest/testItems/folder2/SpecialCase\.txt$
^src/modules/powerrename/PowerRenameUITest/testItems/testCase1\.txt$
^src/modules/previewpane/SvgPreviewHandler/SvgHTMLPreviewGenerator\.cs$
^src/modules/previewpane/UnitTests-MarkdownPreviewHandler/HelperFiles/MarkdownWithHTMLImageTag\.txt$
^src/modules/registrypreview/RegistryPreviewUILib/Controls/HexBox/.*$

View File

@@ -26,8 +26,6 @@ ADMINS
adml
admx
advancedpaste
advancedpasteui
advancedpasteuishortcut
advapi
advfirewall
AFeature
@@ -45,7 +43,6 @@ ALLINPUT
Allman
Allmodule
ALLOWUNDO
allpc
ALLVIEW
ALPHATYPE
AModifier
@@ -136,7 +133,6 @@ bla
BLACKFRAME
BLENDFUNCTION
Blockquotes
blogs
Blt
BLURBEHIND
BLURREGION
@@ -368,6 +364,7 @@ desktopshorcutinstalled
DESKTOPVERTRES
devblogs
devdocs
devenv
devmgmt
DEVMODE
DEVMODEW
@@ -511,7 +508,6 @@ FANCYZONESDRAWLAYOUTTEST
FANCYZONESEDITOR
FARPROC
fesf
fff
FFFF
FILEEXPLORER
fileexploreraddons
@@ -584,6 +580,7 @@ GETSCREENSAVERRUNNING
GETSECKEY
GETSTICKYKEYS
GETTEXTLENGTH
gitmodules
GHND
GMEM
GNumber
@@ -671,11 +668,7 @@ Hostx
hotfixes
hotkeycontrol
HOTKEYF
hotkeylockmachine
hotkeyreconnect
hotkeys
hotkeyswitch
hotkeytoggleeasymouse
hotlight
hotspot
HPAINTBUFFER
@@ -734,8 +727,6 @@ IMAGERESIZERCONTEXTMENU
IMAGERESIZEREXT
imageresizerinput
imageresizersettings
imagetotext
imagetotextshortcut
imagingdevices
ime
imgflip
@@ -826,6 +817,7 @@ killrunner
kmph
kvp
Kybd
LARGEICON
lastcodeanalysissucceeded
LASTEXITCODE
LAYOUTRTL
@@ -854,6 +846,7 @@ linkid
LINKOVERLAY
LINQTo
listview
LIVEDRAW
LIVEZOOM
LLKH
llkhf
@@ -865,7 +858,6 @@ localappdata
localpackage
LOCALSYSTEM
LOCATIONCHANGE
LOCKMACHINE
LOCKTYPE
LOGFONT
LOGFONTW
@@ -874,7 +866,6 @@ LOGMSG
LOGPIXELSX
LOGPIXELSY
lng
LOn
lon
longdate
LONGNAMES
@@ -926,12 +917,10 @@ luid
LUMA
lusrmgr
LVal
lvm
LWA
lwin
LZero
MAGTRANSFORM
MAJORMINOR
MAKEINTRESOURCE
MAKEINTRESOURCEA
MAKEINTRESOURCEW
@@ -956,7 +945,6 @@ MDL
mdtext
mdtxt
mdwn
measuretool
meme
memicmp
MENUITEMINFO
@@ -1006,7 +994,6 @@ MOUSEHWHEEL
MOUSEINPUT
mousejump
mousepointer
mousepointercrosshairs
mouseutils
MOVESIZEEND
MOVESIZESTART
@@ -1051,7 +1038,6 @@ MWBEx
MYICON
NAMECHANGE
namespaceanddescendants
Namotion
nao
NCACTIVATE
ncc
@@ -1089,7 +1075,6 @@ NEWPLUSSHELLEXTENSIONWIN
newrow
nicksnettravels
NIF
NJson
NLog
NLSTEXT
NMAKE
@@ -1209,23 +1194,13 @@ PACL
PAINTSTRUCT
PALETTEWINDOW
PARENTNOTIFY
PARENTRELATIVE
PARENTRELATIVEEDITING
PARENTRELATIVEFORADDRESSBAR
PARENTRELATIVEFORUI
PARENTRELATIVEPARSING
parray
PARTIALCONFIRMATIONDIALOGTITLE
pasteashtmlfile
pasteashtmlfileshortcut
pasteasjson
pasteasjsonshortcut
pasteasmarkdown
pasteasmarkdownshortcut
pasteasplaintext
pasteasplaintextshortcut
pasteaspngfile
pasteaspngfileshortcut
pasteastxtfile
pasteastxtfileshortcut
PATCOPY
PATHMUSTEXIST
PATINVERT
@@ -1233,6 +1208,7 @@ PATPAINT
pbc
pbi
PBlob
pbrush
pcb
pcch
pcelt
@@ -1266,6 +1242,7 @@ pgp
pguid
phbm
phbmp
phicon
phwnd
pici
pidl
@@ -1274,6 +1251,7 @@ pinfo
pinvoke
pipename
PKBDLLHOOKSTRUCT
pkgfamily
plib
ploc
ploca
@@ -1293,7 +1271,6 @@ Pomodoro
Popups
POPUPWINDOW
POSITIONITEM
powerocr
POWERRENAMECONTEXTMENU
powerrenameinput
POWERRENAMETEST
@@ -1344,7 +1321,6 @@ PRODUCTVERSION
Progman
programdata
projectname
projitems
PROPERTYKEY
Propset
PROPVARIANT
@@ -1352,6 +1328,7 @@ PRTL
prvpane
psapi
pscid
pscustomobject
PSECURITY
psfgao
psfi
@@ -1437,7 +1414,6 @@ Removelnk
renamable
RENAMEONCOLLISION
reparented
reparenthotkey
reparenting
reportfileaccesses
requery
@@ -1463,7 +1439,6 @@ RIDEV
RIGHTSCROLLBAR
riid
RKey
Rns
RNumber
rop
ROUNDSMALL
@@ -1687,7 +1662,6 @@ STYLECHANGED
STYLECHANGING
subkeys
sublang
Subdomain
SUBMODULEUPDATE
subresource
Superbar
@@ -1760,7 +1734,6 @@ THICKFRAME
THEMECHANGED
THISCOMPONENT
throughs
thumbnailhotkey
TILEDWINDOW
TILLSON
timedate
@@ -1776,9 +1749,7 @@ tlbimp
tlc
tmain
TNP
TOGGLEEASYMOUSE
Toolhelp
toolkitconverters
toolwindow
TOPDOWNDIB
TOUCHEVENTF
@@ -1790,11 +1761,9 @@ tracelogging
tracerpt
trackbar
trafficmanager
transcodetomp
transicc
TRAYMOUSEMESSAGE
triaging
Tru
trl
trx
tsa
@@ -1830,7 +1799,6 @@ ULONGLONG
ums
uncompilable
UNCPRIORITY
undefining
UNDNAME
UNICODETEXT
unins
@@ -1997,6 +1965,7 @@ WMI
WMICIM
wmimgmt
wmp
wmsg
WMSYSCOMMAND
wnd
WNDCLASS
@@ -2010,6 +1979,7 @@ WORKSPACESEDITOR
WORKSPACESLAUNCHER
WORKSPACESSNAPSHOTTOOL
WORKSPACESWINDOWARRANGER
Worktree
wox
wparam
wpf

View File

@@ -1,5 +1,10 @@
# See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-patterns
# marker to ignore all code on line
^.*/\* #no-spell-check-line \*/.*$
# marker for ignoring a comment to the end of the line
// #no-spell-check.*$
# Gaelic
Gàidhlig
@@ -264,3 +269,7 @@ St&yle
# This matches a relative clause where the relative pronoun "that" is omitted.
# Example: "Gets or sets the window the TitleBar should configure."
\bthe\s+\w+\s+the\b
# Usernames with numbers
# 0x6f677548 is user name but user folder causes a flag
\bx6f677548\b

View File

@@ -258,6 +258,7 @@ jobs:
-restore -graph
/p:RestorePackagesConfig=true
/p:CIBuild=true
/p:BuildInParallel=true
/bl:$(LogOutputDirectory)\build-0-main.binlog
${{ parameters.additionalBuildOptions }}
$(MSBuildCacheParameters)
@@ -277,7 +278,7 @@ jobs:
condition: ne(variables['BuildPlatform'], 'x64')
inputs:
solution: src/dsc/v3/PowerToys.DSC/PowerToys.DSC.csproj
msbuildArgs: /t:Build /m /restore
msbuildArgs: /t:Build /m /restore /p:BuildInParallel=true
platform: x64
configuration: $(BuildConfiguration)
msbuildArchitecture: x64
@@ -323,6 +324,7 @@ jobs:
-restore -graph
/p:RestorePackagesConfig=true
/p:CIBuild=true
/p:BuildInParallel=true
/bl:$(LogOutputDirectory)\build-bug-report.binlog
${{ parameters.additionalBuildOptions }}
$(MSBuildCacheParameters)
@@ -344,6 +346,7 @@ jobs:
-restore -graph
/p:RestorePackagesConfig=true
/p:CIBuild=true
/p:BuildInParallel=true
/bl:$(LogOutputDirectory)\build-styles-report.binlog
${{ parameters.additionalBuildOptions }}
$(MSBuildCacheParameters)
@@ -365,7 +368,7 @@ jobs:
msbuildArgs: >-
/target:Publish
/graph
/p:Configuration=$(BuildConfiguration);Platform=$(BuildPlatform);AppxBundle=Never
/p:Configuration=$(BuildConfiguration);Platform=$(BuildPlatform);AppxBundle=Never;BuildInParallel=true
/p:VCRTForwarders-IncludeDebugCRT=false
/p:PowerToysRoot=$(Build.SourcesDirectory)
/p:PublishProfile=InstallationPublishProfile.pubxml

View File

@@ -90,6 +90,7 @@ jobs:
/p:RestorePackagesConfig=true
/p:BuildProjectReferences=true
/p:CIBuild=true
/p:BuildInParallel=true
/bl:$(LogOutputDirectory)\build-all-uitests.binlog
$(NUGET_RESTORE_MSBUILD_ARGS)
platform: $(BuildPlatform)
@@ -111,6 +112,7 @@ jobs:
/p:RestorePackagesConfig=true
/p:BuildProjectReferences=true
/p:CIBuild=true
/p:BuildInParallel=true
/bl:$(LogOutputDirectory)\build-${{ module }}.binlog
$(NUGET_RESTORE_MSBUILD_ARGS)
platform: $(BuildPlatform)

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

@@ -825,6 +825,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightSwitch.UITests", "src\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj", "{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.UI.ViewModels.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.UI.ViewModels.UnitTests\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|ARM64 = Debug|ARM64
@@ -2995,6 +2997,14 @@ Global
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|ARM64.Build.0 = Release|ARM64
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.ActiveCfg = Release|x64
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA}.Release|x64.Build.0 = Release|x64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.ActiveCfg = Debug|ARM64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|ARM64.Build.0 = Debug|ARM64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.ActiveCfg = Debug|x64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Debug|x64.Build.0 = Debug|x64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.ActiveCfg = Release|ARM64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.Build.0 = Release|ARM64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.ActiveCfg = Release|x64
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -3323,6 +3333,7 @@ Global
{3DCCD936-D085-4869-A1DE-CA6A64152C94} = {5B201255-53C8-490B-A34F-01F05D48A477}
{F5333ED7-06D8-4AB3-953A-36D63F08CB6F} = {3DCCD936-D085-4869-A1DE-CA6A64152C94}
{4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0}

View File

@@ -1,32 +1,36 @@
#include <windows.h>
#include "resource.h"
#include "../../../common/version/version.h"
1 VERSIONINFO
FILEVERSION 0,1,0,0
PRODUCTVERSION 0,1,0,0
FILEFLAGSMASK 0x3fL
FILEVERSION FILE_VERSION
PRODUCTVERSION PRODUCT_VERSION
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS 0x1L
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
FILEFLAGS 0x0L
#endif
FILEOS 0x40004L
FILETYPE 0x2L
FILESUBTYPE 0x0L
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_DLL
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset
BEGIN
VALUE "CompanyName", "Company Name"
VALUE "FileDescription", "Light Switch Module"
VALUE "FileVersion", "0.1.0.0"
VALUE "InternalName", "Light Switch"
VALUE "LegalCopyright", "Copyright (C) 2019 Company Name"
VALUE "OriginalFilename", "PowerToys.LightSwitchModuleInterface.dll"
VALUE "ProductName", "Light Switch"
VALUE "ProductVersion", "0.1.0.0"
VALUE "CompanyName", COMPANY_NAME
VALUE "FileDescription", FILE_DESCRIPTION
VALUE "FileVersion", FILE_VERSION_STRING
VALUE "InternalName", INTERNAL_NAME
VALUE "LegalCopyright", COPYRIGHT_NOTE
VALUE "OriginalFilename", ORIGINAL_FILENAME
VALUE "ProductName", PRODUCT_NAME
VALUE "ProductVersion", PRODUCT_VERSION_STRING
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset
END
END

View File

@@ -108,7 +108,7 @@ public:
m_force_light_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_LIGHT");
m_force_dark_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_FORCE_DARK");
m_manual_override_event_handle = CreateDefaultEvent(L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE");
m_manual_override_event_handle = CreateEventW(nullptr, TRUE, FALSE, L"POWERTOYS_LIGHTSWITCH_MANUAL_OVERRIDE");
init_settings();
};
@@ -460,7 +460,7 @@ public:
}
else if (hotkeyId == 0)
{
// get current will return true if in light mode, otherwise false
// get current will return true if in light mode; otherwise false
Logger::info(L"[Light Switch] Hotkey triggered: Toggle Theme");
if (g_settings.m_changeSystem)
{

View File

@@ -0,0 +1,13 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ generated include file.
// Used by CalculatorEngineCommon.rc
//////////////////////////////
// Non-localizable
#define FILE_DESCRIPTION "Light Switch Module"
#define INTERNAL_NAME "Light Switch"
#define ORIGINAL_FILENAME "PowerToys.LightSwitchModuleInterface.dll"
// Non-localizable
//////////////////////////////

View File

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

View File

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

View File

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

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

@@ -12,8 +12,8 @@ enum struct FindMyMouseActivationMethod : int
constexpr bool FIND_MY_MOUSE_DEFAULT_DO_NOT_ACTIVATE_ON_GAME_MODE = true;
// Default colors now include full alpha. Opacity is encoded directly in color alpha (legacy overlay_opacity migrated into A channel)
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 0, 0, 0);
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(255, 255, 255, 255);
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_BACKGROUND_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 0, 0, 0);
const winrt::Windows::UI::Color FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(128, 255, 255, 255);
constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_RADIUS = 100;
constexpr int FIND_MY_MOUSE_DEFAULT_ANIMATION_DURATION_MS = 500;
constexpr int FIND_MY_MOUSE_DEFAULT_SPOTLIGHT_INITIAL_ZOOM = 9;
@@ -43,4 +43,4 @@ int FindMyMouseMain(HINSTANCE hinst, const FindMyMouseSettings& settings);
void FindMyMouseDisable();
bool FindMyMouseIsEnabled();
void FindMyMouseApplySettings(const FindMyMouseSettings& settings);
HWND GetSonarHwnd() noexcept;
HWND GetSonarHwnd() noexcept;

View File

@@ -1055,8 +1055,13 @@ namespace MouseWithoutBorders.Class
if (machineId == 0)
{
_properties.MachineID.Value = Common.Ran.Next();
machineId = _properties.MachineID.Value;
var newMachineId = Common.Ran.Next();
_properties.MachineID.Value = newMachineId;
machineId = newMachineId;
if (!PauseInstantSaving)
{
SaveSettings();
}
}
}
@@ -1068,6 +1073,11 @@ namespace MouseWithoutBorders.Class
lock (_loadingSettingsLock)
{
_properties.MachineID.Value = value;
machineId = value;
if (!PauseInstantSaving)
{
SaveSettings();
}
}
}
}

View File

@@ -96,7 +96,10 @@ typedef struct {
#define SHALLOW_DESTROY 2
#define LIVE_DRAW_ZOOM 3
#define PEN_COLOR_HIGHLIGHT(Pencolor) (Pencolor >> 24) != 0xFF
#define PEN_COLOR_HIGHLIGHT(Pencolor) ((Pencolor >> 24) != 0xFF)
#define PEN_COLOR_BLUR(Pencolor) ((Pencolor & 0x00FFFFFF) == COLOR_BLUR)
#define CURSOR_SAVE_MARGIN 4
typedef BOOL (__stdcall *type_pGetMonitorInfo)(
@@ -143,7 +146,14 @@ typedef BOOL(__stdcall *type_pMagSetWindowFilterList)(
int count,
HWND* pHWND
);
typedef BOOL (__stdcall *type_pMagInitialize)(VOID);
typedef BOOL(__stdcall* type_pMagSetLensUseBitmapSmoothing)(
_In_ HWND,
_In_ BOOL
);
typedef BOOL(__stdcall* type_MagSetFullscreenUseBitmapSmoothing)(
BOOL fUseBitmapSmoothing
);
typedef BOOL(__stdcall* type_pMagInitialize)(VOID);
typedef BOOL(__stdcall *type_pGetPointerType)(
_In_ UINT32 pointerId,

View File

@@ -121,8 +121,8 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0
BEGIN
DEFPUSHBUTTON "OK",IDOK,166,306,50,14
PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14
LTEXT "ZoomIt v9.01",IDC_VERSION,42,7,73,10
LTEXT "Copyright © 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,166,8
LTEXT "ZoomIt v9.10",IDC_VERSION,42,7,73,10
LTEXT "Copyright © 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8
CONTROL "<a HREF=""https://www.sysinternals.com"">Sysinternals - www.sysinternals.com</a>",IDC_LINK,
"SysLink",WS_TABSTOP,42,26,150,9
ICON "APPICON",IDC_STATIC,12,9,20,20
@@ -149,7 +149,8 @@ BEGIN
CONTROL "",IDC_TIMER_POS7,"Button",BS_AUTORADIOBUTTON,63,108,10,10
CONTROL "",IDC_TIMER_POS8,"Button",BS_AUTORADIOBUTTON,79,108,10,10
CONTROL "",IDC_TIMER_POS9,"Button",BS_AUTORADIOBUTTON,97,108,10,10
CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT
CONTROL "Show background bitmap:",IDC_CHECK_BACKGROUND_FILE,
"Button",BS_AUTOCHECKBOX | WS_TABSTOP,3,122,99,10,WS_EX_RIGHT
CONTROL "Use faded desktop as background",IDC_STATIC_DESKTOP_BACKGROUND,
"Button",BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP,46,135,125,10
CONTROL "Use image file as background",IDC_STATIC_BACKGROUND_FILE,
@@ -165,23 +166,25 @@ BEGIN
CONTROL "",IDC_STATIC,"Static",SS_BLACKFRAME | SS_SUNKEN,7,196,193,1,WS_EX_CLIENTEDGE
END
ZOOM DIALOGEX 0, 0, 260, 158
ZOOM DIALOGEX 0, 0, 260, 170
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12
LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,246,26
LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,104,150,15,WS_EX_TRANSPARENT
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,91,215,10
LTEXT "1.25",IDC_STATIC,52,122,16,8
LTEXT "1.5",IDC_STATIC,82,122,12,8
LTEXT "1.75",IDC_STATIC,108,122,16,8
LTEXT "2.0",IDC_STATIC,138,122,12,8
LTEXT "3.0",IDC_STATIC,164,122,12,8
LTEXT "4.0",IDC_STATIC,190,122,12,8
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,215,10
LTEXT "1.25",IDC_STATIC,52,136,16,8
LTEXT "1.5",IDC_STATIC,82,136,12,8
LTEXT "1.75",IDC_STATIC,108,136,16,8
LTEXT "2.0",IDC_STATIC,138,136,12,8
LTEXT "3.0",IDC_STATIC,164,136,12,8
LTEXT "4.0",IDC_STATIC,190,136,12,8
CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,246,17
CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,246,17
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,246,18
END
DRAW DIALOGEX 0, 0, 260, 228
@@ -295,7 +298,8 @@ BEGIN
LTEXT "DemoType toggle:",IDC_STATIC,7,157,63,8
PUSHBUTTON "&...",IDC_DEMOTYPE_BROWSE,231,137,16,13
CONTROL "",IDC_DEMOTYPE_SPEED_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,52,202,150,11,WS_EX_TRANSPARENT
CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10
CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN,
"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10
LTEXT "DemoType typing speed:",IDC_STATIC,7,189,215,10
LTEXT "Slow",IDC_DEMOTYPE_STATIC1,51,213,18,8
LTEXT "Fast",IDC_DEMOTYPE_STATIC2,186,213,17,8
@@ -413,8 +417,8 @@ ACCELERATORS ACCELERATORS
BEGIN
"C", IDC_COPY, VIRTKEY, CONTROL, NOINVERT
"S", IDC_SAVE, VIRTKEY, CONTROL, NOINVERT
"C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
"S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
"C", IDC_COPY_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
"S", IDC_SAVE_CROP, VIRTKEY, SHIFT, CONTROL, NOINVERT
END

View File

@@ -14,6 +14,7 @@ DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6';
DWORD g_ShowExpiredTime = 1;
DWORD g_SliderZoomLevel = 3;
BOOLEAN g_AnimateZoom = TRUE;
BOOLEAN g_SmoothImage = TRUE;
DWORD g_PenColor = COLOR_RED;
DWORD g_BreakPenColor = COLOR_RED;
DWORD g_RootPenWidth = PEN_WIDTH;
@@ -72,6 +73,7 @@ REG_SETTING RegSettings[] = {
{ L"ShowTrayIcon", SETTING_TYPE_BOOLEAN, 0, &g_ShowTrayIcon, static_cast<DOUBLE>(g_ShowTrayIcon) },
// NOTE: AnimateZoom is misspelled, but since it is a user setting stored in the registry we must continue to misspell it.
{ L"AnimnateZoom", SETTING_TYPE_BOOLEAN, 0, &g_AnimateZoom, static_cast<DOUBLE>(g_AnimateZoom) },
{ L"SmoothImage", SETTING_TYPE_BOOLEAN, 0, &g_SmoothImage, static_cast<DOUBLE>(g_SmoothImage) },
{ L"TelescopeZoomOut", SETTING_TYPE_BOOLEAN, 0, &g_TelescopeZoomOut, static_cast<DOUBLE>(g_TelescopeZoomOut) },
{ L"SnapToGrid", SETTING_TYPE_BOOLEAN, 0, &g_SnapToGrid, static_cast<DOUBLE>(g_SnapToGrid) },
{ L"ZoominSliderLevel", SETTING_TYPE_DWORD, 0, &g_SliderZoomLevel, static_cast<DOUBLE>(g_SliderZoomLevel) },

View File

@@ -170,6 +170,8 @@ type_pMagSetFullscreenTransform pMagSetFullscreenTransform;
type_pMagSetInputTransform pMagSetInputTransform;
type_pMagShowSystemCursor pMagShowSystemCursor;
type_pMagSetWindowFilterList pMagSetWindowFilterList;
type_MagSetFullscreenUseBitmapSmoothing pMagSetFullscreenUseBitmapSmoothing;
type_pMagSetLensUseBitmapSmoothing pMagSetLensUseBitmapSmoothing;
type_pMagInitialize pMagInitialize;
type_pDwmIsCompositionEnabled pDwmIsCompositionEnabled;
type_pGetPointerType pGetPointerType;
@@ -1099,6 +1101,8 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr
// Create a new bitmap that's the size of the area covered by the line + 2 * g_PenWidth
Gdiplus::Rect lineBounds(min(x1, x2), min(y1, y2), abs(x2 - x1), abs(y2 - y1));
OutputDebug(L"DrawHighlightedShape\n");
// Expand for line drawing
if (Shape == DRAW_LINE)
lineBounds.Inflate(static_cast<int>(g_PenWidth / 2), static_cast<int>(g_PenWidth / 2));
@@ -1186,7 +1190,7 @@ void DrawHighlightedShape( DWORD Shape, HDC hdcScreenCompat, Gdiplus::Brush *pBr
DeleteDC(hdcDIBOrig);
// Invalidate the updated rectangle
// InvalidateGdiplusRect(hWnd, lineBounds);
//InvalidateGdiplusRect(hWnd, lineBounds);
}
//----------------------------------------------------------------------------
@@ -1284,7 +1288,12 @@ void ScaleImage( HDC hdcDst, float xDst, float yDst, float wDst, float hDst,
{
Gdiplus::Bitmap srcBitmap( bmSrc, NULL );
dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality );
// Use high quality interpolation when smooth image is enabled
if (g_SmoothImage) {
dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeHighQuality );
} else {
dstGraphics.SetInterpolationMode( Gdiplus::InterpolationModeLowQuality );
}
dstGraphics.SetPixelOffsetMode( Gdiplus::PixelOffsetModeHalf );
dstGraphics.DrawImage( &srcBitmap, Gdiplus::RectF(xDst,yDst,wDst,hDst), xSrc, ySrc, wSrc, hSrc, Gdiplus::UnitPixel );
@@ -2071,6 +2080,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
IsAutostartConfigured() ? BST_CHECKED: BST_UNCHECKED );
CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM,
g_AnimateZoom ? BST_CHECKED: BST_UNCHECKED );
CheckDlgButton( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE,
g_SmoothImage ? BST_CHECKED: BST_UNCHECKED );
SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETRANGE, false, MAKELONG(0,_countof(g_ZoomLevels)-1) );
SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_SETPOS, true, g_SliderZoomLevel );
@@ -2210,6 +2221,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
}
g_ShowTrayIcon = IsDlgButtonChecked( hDlg, IDC_SHOW_TRAY_ICON ) == BST_CHECKED;
g_AnimateZoom = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ANIMATE_ZOOM ) == BST_CHECKED;
g_SmoothImage = IsDlgButtonChecked( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_SMOOTH_IMAGE ) == BST_CHECKED;
g_DemoTypeUserDriven = IsDlgButtonChecked( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_USER_DRIVEN ) == BST_CHECKED;
newToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
@@ -2723,7 +2735,6 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false )
bool isBlur = false;
Gdiplus::Graphics dstGraphics(hDc);
if( ( GetWindowLong( g_hWndMain, GWL_EXSTYLE ) & WS_EX_LAYERED ) == 0 )
{
dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
@@ -2746,6 +2757,7 @@ VOID DrawShape( DWORD Shape, HDC hDc, RECT *Rect, bool UseGdiPlus = false )
InflateRect(Rect, g_PenWidth / 2, g_PenWidth / 2);
isBlur = true;
}
OutputDebug(L"Draw shape: highlight: %d pbrush: %d\n", PEN_COLOR_HIGHLIGHT(g_PenColor), pBrush != NULL);
switch (Shape) {
case DRAW_RECTANGLE:
@@ -2920,7 +2932,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height
{
int x, y;
RECT rc;
int invWidth = g_PenWidth;
int invWidth = g_PenWidth + CURSOR_SAVE_MARGIN;
if( DrawHighlightedCursor( zoomLevel, width, height ) ) {
@@ -2945,7 +2957,7 @@ void InvalidateCursorMoveArea( HWND hWnd, float zoomLevel, int width, int height
void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt )
{
OutputDebug( L"SaveCursorArea\n");
int penWidth = g_PenWidth + 2;
int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN;
BitBlt( hDcTarget, 0, 0, penWidth +CURSOR_ARM_LENGTH*2, penWidth +CURSOR_ARM_LENGTH*2,
hDcSource, static_cast<INT> (pt.x- penWidth /2)-CURSOR_ARM_LENGTH,
static_cast<INT>(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, SRCCOPY|CAPTUREBLT );
@@ -2959,7 +2971,7 @@ void SaveCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt )
void RestoreCursorArea( HDC hDcTarget, HDC hDcSource, POINT pt )
{
OutputDebug( L"RestoreCursorArea\n");
int penWidth = g_PenWidth + 2;
int penWidth = g_PenWidth + CURSOR_SAVE_MARGIN;
BitBlt( hDcTarget, static_cast<INT>(pt.x- penWidth /2)-CURSOR_ARM_LENGTH,
static_cast<INT>(pt.y- penWidth /2)-CURSOR_ARM_LENGTH, penWidth +CURSOR_ARM_LENGTH*2,
penWidth + CURSOR_ARM_LENGTH*2, hDcSource, 0, 0, SRCCOPY|CAPTUREBLT );
@@ -4178,6 +4190,11 @@ LRESULT APIENTRY MainWndProc(
}
#endif
}
OutputDebug(L"LIVEDRAW SMOOTHING: %d\n", g_SmoothImage);
if (!pMagSetLensUseBitmapSmoothing(g_hWndLiveZoomMag, g_SmoothImage))
{
OutputDebug(L"MagSetLensUseBitmapSmoothing failed: %d\n", GetLastError());
}
if ( g_RecordToggle )
{
@@ -5296,6 +5313,8 @@ LRESULT APIENTRY MainWndProc(
if( g_Drawing ) {
OutputDebug(L"Mousemove: Drawing\n");
POINT currentPt;
// Are we in pen mode on a tablet?
@@ -5334,7 +5353,15 @@ LRESULT APIENTRY MainWndProc(
}
else
{
DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle );
if (PEN_COLOR_HIGHLIGHT(g_PenColor))
{
// copy original bitmap to screen bitmap to erase previous highlight
BitBlt(hdcScreenCompat, 0, 0, bmp.bmWidth, bmp.bmHeight, drawUndoList->hDc, 0, 0, SRCCOPY | CAPTUREBLT);
}
else
{
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor));
}
}
}
@@ -5380,7 +5407,7 @@ LRESULT APIENTRY MainWndProc(
g_rcRectangle.top != g_rcRectangle.bottom) {
// Draw the new target rectangle.
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle);
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle, PEN_COLOR_HIGHLIGHT(g_PenColor));
OutputDebug(L"SHAPE: (%d, %d) - (%d, %d)\n", g_rcRectangle.left, g_rcRectangle.top,
g_rcRectangle.right, g_rcRectangle.bottom);
}
@@ -5418,9 +5445,6 @@ LRESULT APIENTRY MainWndProc(
Gdiplus::BitmapData* lineData = LockGdiPlusBitmap(lineBitmap);
BYTE* pPixels = static_cast<BYTE*>(lineData->Scan0);
// Copy the contents of the screen bitmap to the temporary bitmap
GetOldestUndo(drawUndoList);
// Create a GDI bitmap that's the size of the lineBounds rectangle
Gdiplus::Bitmap *blurBitmap = CreateGdiplusBitmap( hdcScreenCompat, // oldestUndo->hDc,
lineBounds.X, lineBounds.Y, lineBounds.Width, lineBounds.Height);
@@ -5445,6 +5469,8 @@ LRESULT APIENTRY MainWndProc(
}
else if(PEN_COLOR_HIGHLIGHT(g_PenColor)) {
OutputDebug(L"HIGHLIGHT\n");
// This is a highlighting pen color
Gdiplus::Rect lineBounds = GetLineBounds(prevPt, currentPt, g_PenWidth);
Gdiplus::Bitmap* lineBitmap = DrawBitmapLine(lineBounds, prevPt, currentPt, &pen);
@@ -5784,26 +5810,30 @@ LRESULT APIENTRY MainWndProc(
if( !g_DrawingShape ) {
// If the point has changed, draw a line to it
if (prevPt.x != LOWORD(lParam) || prevPt.y != HIWORD(lParam)) {
Gdiplus::Graphics dstGraphics(hdcScreenCompat);
if ((GetWindowLong(g_hWndMain, GWL_EXSTYLE) & WS_EX_LAYERED) == 0)
if (!PEN_COLOR_HIGHLIGHT(g_PenColor))
{
if (prevPt.x != LOWORD(lParam) || prevPt.y != HIWORD(lParam))
{
dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
Gdiplus::Graphics dstGraphics(hdcScreenCompat);
if ((GetWindowLong(g_hWndMain, GWL_EXSTYLE) & WS_EX_LAYERED) == 0)
{
dstGraphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
}
Gdiplus::Color color = ColorFromColorRef(g_PenColor);
Gdiplus::Pen pen(color, static_cast<Gdiplus::REAL>(g_PenWidth));
Gdiplus::GraphicsPath path;
pen.SetLineJoin(Gdiplus::LineJoinRound);
path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam));
dstGraphics.DrawPath(&pen, &path);
}
// Draw a dot at the current point, if the point hasn't changed
else
{
MoveToEx(hdcScreenCompat, prevPt.x, prevPt.y, NULL);
LineTo(hdcScreenCompat, LOWORD(lParam), HIWORD(lParam));
InvalidateRect(hWnd, NULL, FALSE);
}
Gdiplus::Color color = ColorFromColorRef(g_PenColor);
Gdiplus::Pen pen(color, static_cast<Gdiplus::REAL>(g_PenWidth));
Gdiplus::GraphicsPath path;
pen.SetLineJoin(Gdiplus::LineJoinRound);
path.AddLine(prevPt.x, prevPt.y, LOWORD(lParam), HIWORD(lParam));
dstGraphics.DrawPath(&pen, &path);
}
// Draw a dot at the current point, if the point hasn't changed
else {
MoveToEx(hdcScreenCompat, prevPt.x, prevPt.y, NULL);
LineTo(hdcScreenCompat, LOWORD(lParam), HIWORD(lParam));
InvalidateRect(hWnd, NULL, FALSE);
}
prevPt.x = LOWORD( lParam );
prevPt.y = HIWORD( lParam );
@@ -5818,8 +5848,11 @@ LRESULT APIENTRY MainWndProc(
g_rcRectangle.left != g_rcRectangle.right ) {
// erase previous
SetROP2(hdcScreenCompat, R2_NOTXORPEN);
DrawShape( g_DrawingShape, hdcScreenCompat, &g_rcRectangle );
if (!PEN_COLOR_HIGHLIGHT(g_PenColor))
{
SetROP2(hdcScreenCompat, R2_NOTXORPEN);
DrawShape(g_DrawingShape, hdcScreenCompat, &g_rcRectangle);
}
// Draw the final shape
HBRUSH hBrush = static_cast<HBRUSH>(GetStockObject( NULL_BRUSH ));
@@ -6185,8 +6218,14 @@ LRESULT APIENTRY MainWndProc(
SetStretchBltMode( hInterimSaveDc, HALFTONE );
SetStretchBltMode( hSaveDc, HALFTONE );
#else
SetStretchBltMode( hInterimSaveDc, COLORONCOLOR );
SetStretchBltMode( hSaveDc, COLORONCOLOR );
// Use HALFTONE for better quality when smooth image is enabled
if (g_SmoothImage) {
SetStretchBltMode( hInterimSaveDc, HALFTONE );
SetStretchBltMode( hSaveDc, HALFTONE );
} else {
SetStretchBltMode( hInterimSaveDc, COLORONCOLOR );
SetStretchBltMode( hSaveDc, COLORONCOLOR );
}
#endif
StretchBlt( hInterimSaveDc,
0, 0,
@@ -6309,7 +6348,12 @@ LRESULT APIENTRY MainWndProc(
#if SCALE_HALFTONE
SetStretchBltMode( hSaveDc, HALFTONE );
#else
SetStretchBltMode( hSaveDc, COLORONCOLOR );
// Use HALFTONE for better quality when smooth image is enabled
if (g_SmoothImage) {
SetStretchBltMode( hSaveDc, HALFTONE );
} else {
SetStretchBltMode( hSaveDc, COLORONCOLOR );
}
#endif
StretchBlt( hSaveDc,
0, 0,
@@ -6646,8 +6690,8 @@ LRESULT APIENTRY MainWndProc(
(float)x, (float)y,
width/zoomLevel, height/zoomLevel );
} else {
// do a fast, less accurate render
SetStretchBltMode( hDc, HALFTONE );
// do a fast, less accurate render (but use smooth if enabled)
SetStretchBltMode( hDc, g_SmoothImage ? HALFTONE : COLORONCOLOR );
StretchBlt( ps.hdc,
0, 0,
bmp.bmWidth, bmp.bmHeight,
@@ -6660,7 +6704,12 @@ LRESULT APIENTRY MainWndProc(
#if SCALE_HALFTONE
SetStretchBltMode( hDc, zoomLevel == zoomTelescopeTarget ? HALFTONE : COLORONCOLOR );
#else
SetStretchBltMode( hDc, COLORONCOLOR );
// Use HALFTONE for better quality when smooth image is enabled
if (g_SmoothImage) {
SetStretchBltMode( hDc, HALFTONE );
} else {
SetStretchBltMode( hDc, COLORONCOLOR );
}
#endif
StretchBlt( ps.hdc,
0, 0,
@@ -6683,7 +6732,7 @@ LRESULT APIENTRY MainWndProc(
BITMAP local_bmp;
GetObject(g_hBackgroundBmp, sizeof(local_bmp), &local_bmp);
SetStretchBltMode( hdcScreenCompat, HALFTONE );
SetStretchBltMode( hdcScreenCompat, g_SmoothImage ? HALFTONE : COLORONCOLOR );
if( g_BreakBackgroundStretch ) {
StretchBlt( hdcScreenCompat, 0, 0, width, height,
g_hDcBackgroundFile, 0, 0, local_bmp.bmWidth, local_bmp.bmHeight, SRCCOPY|CAPTUREBLT );
@@ -6842,7 +6891,6 @@ LRESULT CALLBACK LiveZoomWndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM
WS_CHILD | MS_SHOWMAGNIFIEDCURSOR | WS_VISIBLE,
0, 0, 0, 0, hWnd, NULL, g_hInstance, NULL );
}
ShowWindow( hWnd, SW_SHOW );
InvalidateRect( g_hWndLiveZoomMag, NULL, TRUE );
@@ -7555,6 +7603,10 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance
"MagSetWindowTransform" );
pMagSetFullscreenTransform = (type_pMagSetFullscreenTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
"MagSetFullscreenTransform");
pMagSetFullscreenUseBitmapSmoothing = (type_MagSetFullscreenUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
"MagSetFullscreenUseBitmapSmoothing");
pMagSetLensUseBitmapSmoothing = (type_pMagSetLensUseBitmapSmoothing)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
"MagSetLensUseBitmapSmoothing");
pMagSetInputTransform = (type_pMagSetInputTransform)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),
"MagSetInputTransform");
pMagShowSystemCursor = (type_pMagShowSystemCursor)GetProcAddress(LoadLibrarySafe(L"magnification.dll", DLL_LOAD_LOCATION_SYSTEM),

View File

@@ -95,6 +95,7 @@
#define IDC_COPYRIGHT 1075
#define IDC_PEN_WIDTH 1105
#define IDC_TIMER 1106
#define IDC_SMOOTH_IMAGE 1107
#define IDC_SAVE 40002
#define IDC_COPY 40004
#define IDC_RECORD 40006
@@ -109,7 +110,7 @@
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 118
#define _APS_NEXT_COMMAND_VALUE 40013
#define _APS_NEXT_CONTROL_VALUE 1076
#define _APS_NEXT_CONTROL_VALUE 1078
#define _APS_NEXT_SYMED_VALUE 101
#endif
#endif

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

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.ViewModels;
/// <summary>
/// Encapsulates a navigation request within Command Palette view models.
/// </summary>
/// <param name="TargetViewModel">A view model that should be navigated to.</param>
/// <param name="NavigationToken"> A <see cref="CancellationToken"/> that can be used to cancel the pending navigation.</param>
public record AsyncNavigationRequest(object? TargetViewModel, CancellationToken NavigationToken);

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

@@ -4,6 +4,4 @@
namespace Microsoft.CmdPal.Core.ViewModels.Messages;
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation)
{
}
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken);

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.
namespace Microsoft.CmdPal.Core.ViewModels;
internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
: PageViewModel(null, scheduler, extensionHost);

View File

@@ -23,6 +23,9 @@ public partial class ShellViewModel : ObservableObject,
private readonly Lock _invokeLock = new();
private Task? _handleInvokeTask;
// Cancellation token source for page loading/navigation operations
private CancellationTokenSource? _navigationCts;
[ObservableProperty]
public partial bool IsLoaded { get; set; } = false;
@@ -66,6 +69,8 @@ public partial class ShellViewModel : ObservableObject,
public bool IsNested => _isNested;
public PageViewModel NullPage { get; private set; }
public ShellViewModel(
TaskScheduler scheduler,
IRootPageService rootPageService,
@@ -77,6 +82,7 @@ public partial class ShellViewModel : ObservableObject,
_rootPageService = rootPageService;
_appHostService = appHostService;
NullPage = new NullPageViewModel(_scheduler, appHostService.GetDefaultHost());
_currentPage = new LoadingPageViewModel(null, _scheduler, appHostService.GetDefaultHost());
// Register to receive messages
@@ -113,7 +119,7 @@ public partial class ShellViewModel : ObservableObject,
return true;
}
public async Task LoadPageViewModelAsync(PageViewModel viewModel)
private async Task LoadPageViewModelAsync(PageViewModel viewModel, CancellationToken cancellationToken = default)
{
// Note: We removed the general loading state, extensions sometimes use their `IsLoading`, but it's inconsistently implemented it seems.
// IsInitialized is our main indicator of the general overall state of loading props/items from a page we use for the progress bar
@@ -125,44 +131,80 @@ public partial class ShellViewModel : ObservableObject,
if (!viewModel.IsInitialized
&& viewModel.InitializeCommand is not null)
{
var outer = Task.Run(async () =>
{
// You know, this creates the situation where we wait for
// both loading page properties, AND the items, before we
// display anything.
//
// We almost need to do an async await on initialize, then
// just a fire-and-forget on FetchItems.
// RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here.
// Definitely some more clean-up to do, but at least its centralized to one spot now.
viewModel.InitializeCommand.Execute(null);
await viewModel.InitializeCommand.ExecutionTask!;
if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
var outer = Task.Run(
async () =>
{
if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex)
// You know, this creates the situation where we wait for
// both loading page properties, AND the items, before we
// display anything.
//
// We almost need to do an async await on initialize, then
// just a fire-and-forget on FetchItems.
// RE: We do set the CurrentPage in ShellPage.xaml.cs as well, so, we kind of are doing two different things here.
// Definitely some more clean-up to do, but at least its centralized to one spot now.
viewModel.InitializeCommand.Execute(null);
await viewModel.InitializeCommand.ExecutionTask!;
if (viewModel.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
{
CoreLogger.LogError(ex.ToString());
}
}
else
{
var t = Task.Factory.StartNew(
() =>
if (viewModel.InitializeCommand.ExecutionTask.Exception is AggregateException ex)
{
CurrentPage = viewModel;
},
CancellationToken.None,
TaskCreationOptions.None,
_scheduler);
await t;
}
});
CoreLogger.LogError(ex.ToString());
}
}
else
{
var t = Task.Factory.StartNew(
() =>
{
if (cancellationToken.IsCancellationRequested)
{
if (viewModel is IDisposable disposable)
{
try
{
disposable.Dispose();
}
catch (Exception ex)
{
CoreLogger.LogError(ex.ToString());
}
}
return;
}
CurrentPage = viewModel;
},
cancellationToken,
TaskCreationOptions.None,
_scheduler);
await t;
}
},
cancellationToken);
await outer;
}
else
{
if (cancellationToken.IsCancellationRequested)
{
if (viewModel is IDisposable disposable)
{
try
{
disposable.Dispose();
}
catch (Exception ex)
{
CoreLogger.LogError(ex.ToString());
}
}
return;
}
CurrentPage = viewModel;
}
}
@@ -174,6 +216,28 @@ public partial class ShellViewModel : ObservableObject,
private void PerformCommand(PerformCommandMessage message)
{
// Create/replace the navigation cancellation token.
// If one already exists, cancel and dispose it first.
var newCts = new CancellationTokenSource();
var oldCts = Interlocked.Exchange(ref _navigationCts, newCts);
if (oldCts is not null)
{
try
{
oldCts.Cancel();
}
catch (Exception ex)
{
CoreLogger.LogError(ex.ToString());
}
finally
{
oldCts.Dispose();
}
}
var navigationToken = newCts.Token;
var command = message.Command.Unsafe;
if (command is null)
{
@@ -202,15 +266,26 @@ public partial class ShellViewModel : ObservableObject,
}
// Kick off async loading of our ViewModel
LoadPageViewModelAsync(pageViewModel)
LoadPageViewModelAsync(pageViewModel, navigationToken)
.ContinueWith(
(Task t) =>
{
OnUIThread(() => { WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)); });
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation));
// clean up the navigation token if it's still ours
if (Interlocked.CompareExchange(ref _navigationCts, null, newCts) == newCts)
{
newCts.Dispose();
}
// When we're done loading the page, then update the command bar to match
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
},
navigationToken,
TaskContinuationOptions.None,
_scheduler);
// While we're loading in the background, immediately move to the next page.
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation, navigationToken));
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
// See RootFrame_Navigated event handler.
}
@@ -368,4 +443,9 @@ public partial class ShellViewModel : ObservableObject,
TaskCreationOptions.None,
_scheduler);
}
public void CancelNavigation()
{
_navigationCts?.Cancel();
}
}

View File

@@ -10,6 +10,8 @@ using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.Ext.Apps.State;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -36,6 +38,7 @@ public partial class MainListPage : DynamicListPage,
private List<Scored<IListItem>>? _filteredItems;
private List<Scored<IListItem>>? _filteredApps;
private List<Scored<IListItem>>? _fallbackItems;
private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
private int _appResultLimit = 10;
@@ -160,7 +163,7 @@ public partial class MainListPage : DynamicListPage,
{
lock (_tlcManager.TopLevelCommands)
{
List<Scored<IListItem>> limitedApps = new List<Scored<IListItem>>();
var limitedApps = new List<Scored<IListItem>>();
// Fuzzy matching can produce a lot of results, so we want to limit the
// number of apps we show at once if it's a large set.
@@ -171,6 +174,7 @@ public partial class MainListPage : DynamicListPage,
var items = Enumerable.Empty<Scored<IListItem>>()
.Concat(_filteredItems is not null ? _filteredItems : [])
.Concat(_scoredFallbackItems is not null ? _scoredFallbackItems : [])
.Concat(limitedApps)
.OrderByDescending(o => o.Score)
@@ -184,6 +188,14 @@ public partial class MainListPage : DynamicListPage,
}
}
private void ClearResults()
{
_filteredItems = null;
_filteredApps = null;
_fallbackItems = null;
_scoredFallbackItems = null;
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
var timer = new Stopwatch();
@@ -216,8 +228,7 @@ public partial class MainListPage : DynamicListPage,
lock (_tlcManager.TopLevelCommands)
{
_filteredItemsIncludesApps = _includeApps;
_filteredItems = null;
_filteredApps = null;
ClearResults();
}
}
@@ -244,9 +255,7 @@ public partial class MainListPage : DynamicListPage,
if (string.IsNullOrEmpty(newSearch))
{
_filteredItemsIncludesApps = _includeApps;
_filteredItems = null;
_filteredApps = null;
_fallbackItems = null;
ClearResults();
RaiseItemsChanged(commands.Count);
return;
}
@@ -255,17 +264,13 @@ public partial class MainListPage : DynamicListPage,
// re-use previous results. Reset _filteredItems, and keep er moving.
if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
{
_filteredItems = null;
_filteredApps = null;
_fallbackItems = null;
ClearResults();
}
// If the internal state has changed, reset _filteredItems to reset the list.
if (_filteredItemsIncludesApps != _includeApps)
{
_filteredItems = null;
_filteredApps = null;
_fallbackItems = null;
ClearResults();
}
if (token.IsCancellationRequested)
@@ -273,9 +278,9 @@ public partial class MainListPage : DynamicListPage,
return;
}
IEnumerable<IListItem> newFilteredItems = Enumerable.Empty<IListItem>();
IEnumerable<IListItem> newFallbacks = Enumerable.Empty<IListItem>();
IEnumerable<IListItem> newApps = Enumerable.Empty<IListItem>();
var newFilteredItems = Enumerable.Empty<IListItem>();
var newFallbacks = Enumerable.Empty<IListItem>();
var newApps = Enumerable.Empty<IListItem>();
if (_filteredItems is not null)
{
@@ -314,7 +319,7 @@ public partial class MainListPage : DynamicListPage,
// We're going to start over with our fallbacks
newFallbacks = Enumerable.Empty<IListItem>();
newFilteredItems = commands.Where(s => !s.IsFallback || _specialFallbacks.Contains(s.CommandProviderId));
newFilteredItems = commands.Where(s => !s.IsFallback);
// Fallbacks are always included in the list, even if they
// don't match the search text. But we don't want to
@@ -330,7 +335,20 @@ public partial class MainListPage : DynamicListPage,
if (_includeApps)
{
newApps = AllAppsCommandProvider.Page.GetItems().ToList();
var allNewApps = AllAppsCommandProvider.Page.GetItems().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
if (pinnedApps.Length > 0)
{
newApps = allNewApps.Where(w =>
pinnedApps.IndexOf(((AppListItem)w).AppIdentifier) < 0);
}
else
{
newApps = allNewApps;
}
}
if (token.IsCancellationRequested)
@@ -339,8 +357,25 @@ public partial class MainListPage : DynamicListPage,
}
}
var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands!;
Func<string, IListItem, int> scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); };
// Produce a list of everything that matches the current filter.
_filteredItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, ScoreTopLevelItem)];
_filteredItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, scoreItem)];
if (token.IsCancellationRequested)
{
return;
}
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && _specialFallbacks.Contains(s.CommandProviderId));
if (token.IsCancellationRequested)
{
return;
}
_scoredFallbackItems = ListHelpers.FilterListWithScores<IListItem>(newFallbacksForScoring ?? [], SearchText, scoreItem);
if (token.IsCancellationRequested)
{
@@ -358,7 +393,7 @@ public partial class MainListPage : DynamicListPage,
// Produce a list of filtered apps with the appropriate limit
if (newApps.Any())
{
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, ScoreTopLevelItem);
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, scoreItem);
if (token.IsCancellationRequested)
{
@@ -425,7 +460,7 @@ public partial class MainListPage : DynamicListPage,
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
// fact that we want fallback handlers down-weighted, so that they don't
// _always_ show up first.
private int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem)
internal static int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem, IRecentCommandsManager history)
{
var title = topLevelOrAppItem.Title;
if (string.IsNullOrWhiteSpace(title))
@@ -501,10 +536,9 @@ public partial class MainListPage : DynamicListPage,
// here we add the recent command weight boost
//
// Otherwise something like `x` will still match everything you've run before
var finalScore = matchSomething;
var finalScore = matchSomething * 10;
if (matchSomething > 0)
{
var history = _serviceProvider.GetService<AppStateModel>()!.RecentCommands;
var recentWeightBoost = history.GetCommandHistoryWeight(id);
finalScore += recentWeightBoost;
}
@@ -521,7 +555,7 @@ public partial class MainListPage : DynamicListPage,
AppStateModel.SaveState(state);
}
private string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
{
if (topLevelOrAppItem is TopLevelViewModel topLevel)
{

View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Microsoft.CmdPal.UI.ViewModels.UnitTests")]

View File

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

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

@@ -14,7 +14,6 @@
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
PreviewKeyDown="UserControl_PreviewKeyDown"
mc:Ignorable="d">
@@ -22,7 +21,7 @@
<ResourceDictionary>
<cmdpalUI:KeyChordToStringConverter x:Key="KeyChordToStringConverter" />
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<Thickness x:Key="DefaultContextMenuItemPadding">12,8,12,8</Thickness>
<cmdpalUI:ContextItemTemplateSelector
x:Key="ContextItemTemplateSelector"
Critical="{StaticResource CriticalContextMenuViewModelTemplate}"
@@ -31,7 +30,7 @@
<!-- Template for context items in the context item menu -->
<DataTemplate x:Key="DefaultContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title}">
<Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -42,7 +41,7 @@
Height="16"
Margin="4,0,0,0"
HorizontalAlignment="Left"
SourceKey="{x:Bind Icon}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
<TextBlock
x:Name="TitleTextBlock"
@@ -51,11 +50,11 @@
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{x:Bind Title}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap">
<ToolTipService.ToolTip>
<ToolTip Content="{x:Bind Title}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Converter={StaticResource BoolToVisibilityConverter}}" />
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
</ToolTipService.ToolTip>
</TextBlock>
<TextBlock
@@ -65,13 +64,13 @@
VerticalAlignment="Center"
Foreground="{ThemeResource MenuFlyoutItemKeyboardAcceleratorTextForeground}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind RequestedShortcut, Converter={StaticResource KeyChordToStringConverter}}" />
Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" />
</Grid>
</DataTemplate>
<!-- Template for context items flagged as critical -->
<DataTemplate x:Key="CriticalContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title}">
<Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -83,7 +82,7 @@
Margin="4,0,0,0"
HorizontalAlignment="Left"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
SourceKey="{x:Bind Icon}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
<TextBlock
x:Name="TitleTextBlock"
@@ -93,11 +92,11 @@
VerticalAlignment="Center"
MaxLines="1"
Style="{StaticResource ContextItemTitleTextBlockCriticalStyle}"
Text="{x:Bind Title}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap">
<ToolTipService.ToolTip>
<ToolTip Content="{x:Bind Title}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Converter={StaticResource BoolToVisibilityConverter}}" />
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
</ToolTipService.ToolTip>
</TextBlock>
<TextBlock
@@ -106,7 +105,7 @@
HorizontalAlignment="Right"
VerticalAlignment="Center"
Style="{StaticResource ContextItemCaptionTextBlockCriticalStyle}"
Text="{x:Bind RequestedShortcut, Converter={StaticResource KeyChordToStringConverter}}" />
Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" />
</Grid>
</DataTemplate>
@@ -114,7 +113,7 @@
<DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel">
<Rectangle
Height="1"
Margin="-16,-12,-12,-12"
Margin="0,2,0,2"
Fill="{ThemeResource MenuFlyoutSeparatorBackground}" />
</DataTemplate>
</ResourceDictionary>
@@ -125,35 +124,39 @@
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<StackPanel x:Name="CommandsPanel">
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</StackPanel>
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
Margin="0,4,0,2"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
<Border BorderBrush="{ThemeResource MenuFlyoutSeparatorBackground}" BorderThickness="0,0,0,1" />
<TextBox
x:Name="ContextFilterBox"
x:Uid="ContextFilterBox"
Margin="4"
Margin="0"
Padding="10,7,6,8"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
BorderThickness="0,0,0,2"
CornerRadius="8, 8, 0, 0"
IsTextScaleFactorEnabled="True"
KeyDown="ContextFilterBox_KeyDown"
PreviewKeyDown="ContextFilterBox_PreviewKeyDown"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="ContextFilterBox_TextChanged" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ContextMenuOrder">
@@ -162,9 +165,11 @@
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="True" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="CommandsPanel.(Grid.Row)" Value="1" />
<Setter Target="CommandsDropdown.(Grid.Row)" Value="1" />
<Setter Target="ContextFilterBox.(Grid.Row)" Value="0" />
<Setter Target="CommandsDropdown.Margin" Value="0, 0, 0, 4" />
<Setter Target="CommandsDropdown.Margin" Value="0, 3, 0, 4" />
<Setter Target="ContextFilterBox.CornerRadius" Value="8, 8, 0, 0" />
<Setter Target="ContextFilterBox.Margin" Value="0,0,0,-1" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="FilterOnBottom">
@@ -172,9 +177,11 @@
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="False" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="CommandsPanel.(Grid.Row)" Value="0" />
<Setter Target="CommandsDropdown.(Grid.Row)" Value="0" />
<Setter Target="ContextFilterBox.(Grid.Row)" Value="1" />
<Setter Target="CommandsDropdown.Margin" Value="0, 4, 0, 0" />
<Setter Target="CommandsDropdown.Margin" Value="0, 4, 0, 4" />
<Setter Target="ContextFilterBox.CornerRadius" Value="0, 0, 8, 8" />
<Setter Target="ContextFilterBox.Margin" Value="0,0,0,-2" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>

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

@@ -46,11 +46,18 @@ public sealed partial class ContentPage : Page,
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is ContentPageViewModel vm)
if (e.Parameter is not AsyncNavigationRequest navigationRequest)
{
ViewModel = vm;
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
}
if (navigationRequest.TargetViewModel is not ContentPageViewModel contentPageViewModel)
{
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ContentPageViewModel)}");
}
ViewModel = contentPageViewModel;
if (!WeakReferenceMessenger.Default.IsRegistered<ActivateSelectedListItemMessage>(this))
{
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);

View File

@@ -59,11 +59,18 @@ public sealed partial class ListPage : Page,
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is ListViewModel lvm)
if (e.Parameter is not AsyncNavigationRequest navigationRequest)
{
ViewModel = lvm;
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
}
if (navigationRequest.TargetViewModel is not ListViewModel listViewModel)
{
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ListViewModel)}");
}
ViewModel = listViewModel;
if (e.NavigationMode == NavigationMode.Back
|| (e.NavigationMode == NavigationMode.New && ItemView.Items.Count > 0))
{

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

@@ -23,24 +23,30 @@ public sealed partial class LoadingPage : Page
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is ShellViewModel shellVM
&& shellVM.LoadCommand is not null)
if (e.Parameter is not AsyncNavigationRequest request)
{
// This will load the built-in commands, then navigate to the main page.
// Once the mainpage loads, we'll start loading extensions.
shellVM.LoadCommand.Execute(null);
_ = Task.Run(async () =>
{
await shellVM.LoadCommand.ExecutionTask!;
if (shellVM.LoadCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
{
// TODO: Handle failure case
}
});
throw new InvalidOperationException($"Invalid navigation parameter: {nameof(e.Parameter)} must be {nameof(AsyncNavigationRequest)}");
}
if (request.TargetViewModel is not ShellViewModel shellVM)
{
throw new InvalidOperationException($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(ShellViewModel)}");
}
// This will load the built-in commands, then navigate to the main page.
// Once the mainpage loads, we'll start loading extensions.
shellVM.LoadCommand.Execute(null);
_ = Task.Run(async () =>
{
await shellVM.LoadCommand.ExecutionTask!;
if (shellVM.LoadCommand.ExecutionTask.Status != TaskStatus.RanToCompletion)
{
// TODO: Handle failure case
}
});
base.OnNavigatedTo(e);
}
}

View File

@@ -95,12 +95,24 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
RootFrame.Navigate(typeof(LoadingPage), ViewModel);
RootFrame.Navigate(typeof(LoadingPage), new AsyncNavigationRequest(ViewModel, CancellationToken.None));
var pageAnnouncementFormat = ResourceLoaderInstance.GetString("ScreenReader_Announcement_NavigatedToPage0");
_pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat);
}
/// <summary>
/// Gets the default page animation, depending on the settings
/// </summary>
private NavigationTransitionInfo DefaultPageAnimation
{
get
{
var settings = App.Current.Services.GetService<SettingsModel>()!;
return settings.DisableAnimations ? _noAnimation : _slideRightTransition;
}
}
public void Receive(NavigateBackMessage message)
{
var settings = App.Current.Services.GetService<SettingsModel>()!;
@@ -141,8 +153,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
ContentPageViewModel => typeof(ContentPage),
_ => throw new NotSupportedException(),
},
message.Page,
message.WithAnimation ? _slideRightTransition : _noAnimation);
new AsyncNavigationRequest(message.Page, message.CancellationToken),
message.WithAnimation ? DefaultPageAnimation : _noAnimation);
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
@@ -391,6 +403,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
{
HideDetails();
ViewModel.CancelNavigation();
// Note: That we restore the VM state below in RootFrame_Navigated call back after this occurs.
// In the future, we may want to manage the back stack ourselves vs. relying on Frame
// We could replace Frame with a ContentPresenter, but then have to manage transition animations ourselves.
@@ -444,11 +458,32 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// This listens to the root frame to ensure that we also track the content's page VM as well that we passed as a parameter.
// This is currently used for both forward and backward navigation.
// As when we go back that we restore ourselves to the proper state within our VM
if (e.Parameter is PageViewModel page)
if (e.Parameter is AsyncNavigationRequest request)
{
// Note, this shortcuts and fights a bit with our LoadPageViewModel above, but we want to better fast display and incrementally load anyway
// We just need to reconcile our loading systems a bit more in the future.
ViewModel.CurrentPage = page;
if (request.NavigationToken.IsCancellationRequested && e.NavigationMode is not (Microsoft.UI.Xaml.Navigation.NavigationMode.Back or Microsoft.UI.Xaml.Navigation.NavigationMode.Forward))
{
return;
}
switch (request.TargetViewModel)
{
case PageViewModel pageViewModel:
ViewModel.CurrentPage = pageViewModel;
break;
case ShellViewModel:
// This one is an exception, for now (LoadingPage is tied to ShellViewModel,
// but ShellViewModel is not PageViewModel.
ViewModel.CurrentPage = ViewModel.NullPage;
break;
default:
ViewModel.CurrentPage = ViewModel.NullPage;
Logger.LogWarning($"Invalid navigation target: AsyncNavigationRequest.{nameof(AsyncNavigationRequest.TargetViewModel)} must be {nameof(PageViewModel)}");
break;
}
}
else
{
Logger.LogWarning("Unrecognized target for shell navigation: " + e.Parameter);
}
if (e.Content is Page element)
@@ -549,19 +584,25 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private static void ShellPage_OnPreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Left && e.KeyStatus.IsMenuKeyDown)
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed;
if (e.Key == VirtualKey.Left && onlyAlt)
{
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
e.Handled = true;
}
else if (e.Key == VirtualKey.Home && onlyAlt)
{
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(WithAnimation: false));
e.Handled = true;
}
else
{
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
// The CommandBar is responsible for handling all the item keybindings,
// since the bound context item may need to then show another
// context menu

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

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

View File

@@ -0,0 +1,444 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
using WyHash;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class RecentCommandsTests : CommandPaletteUnitTestBase
{
private static RecentCommandsManager CreateHistory(IList<string>? commandIds = null)
{
var history = new RecentCommandsManager();
if (commandIds != null)
{
foreach (var item in commandIds)
{
history.AddHistoryItem(item);
}
}
return history;
}
private static RecentCommandsManager CreateBasicHistoryService()
{
var commonCommands = new List<string>
{
"com.microsoft.cmdpal.shell",
"com.microsoft.cmdpal.windowwalker",
"Visual Studio 2022 Preview_6533433915015224980",
"com.microsoft.cmdpal.reload",
"com.microsoft.cmdpal.shell",
};
return CreateHistory(commonCommands);
}
[TestMethod]
public void ValidateHistoryFunctionality()
{
// Setup
var history = CreateHistory();
// Act
history.AddHistoryItem("com.microsoft.cmdpal.shell");
// Assert
Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0);
}
[TestMethod]
public void ValidateHistoryWeighting()
{
// Setup
var history = CreateBasicHistoryService();
// Act
var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell");
var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker");
var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980");
var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload");
var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command");
// Assert
Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses");
Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency");
Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight");
Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency");
Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight");
}
private sealed partial record ListItemMock(
string Title,
string? Subtitle = "",
string? GivenId = "",
string? ProviderId = "") : IListItem
{
public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId;
public IDetails Details => throw new System.NotImplementedException();
public string Section => throw new System.NotImplementedException();
public ITag[] Tags => throw new System.NotImplementedException();
public string TextToSuggest => throw new System.NotImplementedException();
public ICommand Command => new NoOpCommand() { Id = Id };
public IIconInfo Icon => throw new System.NotImplementedException();
public IContextItem[] MoreCommands => throw new System.NotImplementedException();
#pragma warning disable CS0067
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
#pragma warning restore CS0067
private string GenerateId()
{
// Use WyHash64 to generate stable ID hashes.
// manually seeding with 0, so that the hash is stable across launches
var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0);
return $"{ProviderId}{result}";
}
}
private static RecentCommandsManager CreateHistory(IList<ListItemMock> items)
{
var history = new RecentCommandsManager();
foreach (var item in items)
{
history.AddHistoryItem(item.Id);
}
return history;
}
[TestMethod]
public void ValidateMocksWork()
{
// Setup
var items = new List<ListItemMock>
{
new("Command A", "Subtitle A", "idA", "providerA"),
new("Command B", "Subtitle B", GivenId: "idB"),
new("Command C", "Subtitle C", ProviderId: "providerC"),
new("Command A", "Subtitle A", "idA", "providerA"), // Duplicate to test incrementing uses
};
// Act
var history = CreateHistory(items);
// Assert
foreach (var item in items)
{
var weight = history.GetCommandHistoryWeight(item.Id);
Assert.IsTrue(weight > 0, $"Item {item.Title} should have a weight greater than zero.");
}
// Check that the duplicate item has a higher weight due to increased uses
var weightA = history.GetCommandHistoryWeight("idA");
var weightB = history.GetCommandHistoryWeight("idB");
var weightC = history.GetCommandHistoryWeight(items[2].Id); // providerC generated ID
Assert.IsTrue(weightA > weightB, "Item A should have a higher weight than Item B due to more uses.");
Assert.IsTrue(weightA > weightC, "Item A should have a higher weight than Item C due to more uses.");
Assert.AreEqual(weightC, weightB, "Item C and Item B were used in the last 3 commands");
}
[TestMethod]
public void ValidateHistoryBuckets()
{
// Setup
// (these will be checked in reverse order, so that A is the most recent)
var items = new List<ListItemMock>
{
new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0
new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0
new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0
new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1
new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1
new("Command F", "Subtitle F", GivenId: "idF"), // #5 -> bucket 1
new("Command G", "Subtitle G", GivenId: "idG"), // #6 -> bucket 1
new("Command H", "Subtitle H", GivenId: "idH"), // #7 -> bucket 1
new("Command I", "Subtitle I", GivenId: "idI"), // #8 -> bucket 1
new("Command J", "Subtitle J", GivenId: "idJ"), // #9 -> bucket 1
new("Command K", "Subtitle K", GivenId: "idK"), // #10 -> bucket 1
new("Command L", "Subtitle L", GivenId: "idL"), // #11 -> bucket 2
new("Command M", "Subtitle M", GivenId: "idM"), // #12 -> bucket 2
new("Command N", "Subtitle N", GivenId: "idN"), // #13 -> bucket 2
new("Command O", "Subtitle O", GivenId: "idO"), // #14 -> bucket 2
};
for (var i = items.Count; i <= 50; i++)
{
items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}"));
}
// Act
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
// Assert
// First three items should be in the top bucket
var weightA = history.GetCommandHistoryWeight("idA");
var weightB = history.GetCommandHistoryWeight("idB");
var weightC = history.GetCommandHistoryWeight("idC");
Assert.AreEqual(weightA, weightB, "Items A and B were used in the last 3 commands");
Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands");
// Next eight items (3-10 inclusive) should be in the second bucket
var weightD = history.GetCommandHistoryWeight("idD");
var weightE = history.GetCommandHistoryWeight("idE");
var weightF = history.GetCommandHistoryWeight("idF");
var weightG = history.GetCommandHistoryWeight("idG");
var weightH = history.GetCommandHistoryWeight("idH");
var weightI = history.GetCommandHistoryWeight("idI");
var weightJ = history.GetCommandHistoryWeight("idJ");
var weightK = history.GetCommandHistoryWeight("idK");
Assert.AreEqual(weightD, weightE, "Items D and E were used in the last 10 commands");
Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands");
Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands");
Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands");
Assert.AreEqual(weightH, weightI, "Items H and I were used in the last 10 commands");
Assert.AreEqual(weightI, weightJ, "Items I and J were used in the last 10 commands");
Assert.AreEqual(weightJ, weightK, "Items J and K were used in the last 10 commands");
// Items up to the 15th should be in the third bucket
var weightL = history.GetCommandHistoryWeight("idL");
var weightM = history.GetCommandHistoryWeight("idM");
var weightN = history.GetCommandHistoryWeight("idN");
var weightO = history.GetCommandHistoryWeight("idO");
var weight15 = history.GetCommandHistoryWeight("id15");
Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands");
Assert.AreEqual(weightM, weightN, "Items M and N were used in the last 15 commands");
Assert.AreEqual(weightN, weightO, "Items N and O were used in the last 15 commands");
Assert.AreEqual(weightO, weight15, "Items O and 15 were used in the last 15 commands");
// Items after that should be in the lowest buckets
var weight0 = history.GetCommandHistoryWeight(items[0].Id);
var weight3 = history.GetCommandHistoryWeight(items[3].Id);
var weight11 = history.GetCommandHistoryWeight(items[11].Id);
var weight16 = history.GetCommandHistoryWeight("id16");
var weight20 = history.GetCommandHistoryWeight("id20");
var weight30 = history.GetCommandHistoryWeight("id30");
var weight40 = history.GetCommandHistoryWeight("id40");
var weight49 = history.GetCommandHistoryWeight("id49");
Assert.IsTrue(weight0 > weight3);
Assert.IsTrue(weight3 > weight11);
Assert.IsTrue(weight11 > weight16);
Assert.AreEqual(weight16, weight20);
Assert.AreEqual(weight20, weight30);
Assert.IsTrue(weight30 > weight40);
Assert.AreEqual(weight40, weight49);
// The 50th item has fallen out of the list now
var weight50 = history.GetCommandHistoryWeight("id50");
Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list");
}
[TestMethod]
public void ValidateSimpleScoring()
{
// Setup
var items = new List<ListItemMock>
{
new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0
new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0
new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0
};
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history);
var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history);
var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history);
// Assert
// All of these equally match the query, and they're all in the same bucket,
// so they should all have the same score.
Assert.AreEqual(scoreA, scoreB, "Items A and B should have the same score");
Assert.AreEqual(scoreB, scoreC, "Items B and C should have the same score");
}
private static List<ListItemMock> CreateMockHistoryItems()
{
var items = new List<ListItemMock>
{
new("Visual Studio 2022"), // #0 -> bucket 0
new("Visual Studio Code"), // #1 -> bucket 0
new("Explore Mastodon", GivenId: "social.mastodon.explore"), // #2 -> bucket 0
new("Run commands", Subtitle: "Executes commands (e.g. ping, cmd)", GivenId: "com.microsoft.cmdpal.run"), // #3 -> bucket 1
new("Windows Settings"), // #4 -> bucket 1
new("Command Prompt"), // #5 -> bucket 1
new("Terminal Canary"), // #6 -> bucket 1
};
return items;
}
private static RecentCommandsManager CreateMockHistoryService(List<ListItemMock>? items = null)
{
var history = CreateHistory((items ?? CreateMockHistoryItems()).Reverse<ListItemMock>().ToList());
return history;
}
private sealed record ScoredItem(ListItemMock Item, int Score)
{
public string Title => Item.Title;
public override string ToString() => $"[{Score}]{Title}";
}
private static IEnumerable<ScoredItem> TieScoresToMatches(List<ListItemMock> items, List<int> scores)
{
if (items.Count != scores.Count)
{
throw new ArgumentException("Items and scores must have the same number of elements");
}
for (var i = 0; i < items.Count; i++)
{
yield return new ScoredItem(items[i], scores[i]);
}
}
private static IEnumerable<ScoredItem> GetMatches(IEnumerable<ScoredItem> scoredItems)
{
var matches = scoredItems
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ToList();
return matches;
}
private static IEnumerable<ScoredItem> GetMatches(List<ListItemMock> items, List<int> scores)
{
return GetMatches(TieScoresToMatches(items, scores));
}
[TestMethod]
public void ValidateScoredWeightingSimple()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items");
for (var i = 0; i < unweightedScores.Count; i++)
{
var unweighted = unweightedScores[i];
var weighted = weightedScores[i];
var item = items[i];
if (item.Title.Contains('C', System.StringComparison.CurrentCultureIgnoreCase))
{
Assert.IsTrue(unweighted >= 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero");
Assert.IsTrue(weighted > unweighted, $"Item {item.Title} should have a higher weighted ({weighted}) score than unweighted ({unweighted})");
}
else
{
Assert.AreEqual(unweighted, 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero");
Assert.AreEqual(unweighted, weighted);
}
}
var unweightedMatches = GetMatches(items, unweightedScores).ToList();
Assert.AreEqual(4, unweightedMatches.Count);
Assert.AreEqual("Command Prompt", unweightedMatches[0].Title, "Command Prompt should be the top match");
Assert.AreEqual("Visual Studio Code", unweightedMatches[1].Title, "Visual Studio Code should be the second match");
Assert.AreEqual("Terminal Canary", unweightedMatches[2].Title);
Assert.AreEqual("Run commands", unweightedMatches[3].Title);
// Even after weighting for 1 use, Command Prompt should still be the top match.
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);
Assert.AreEqual("Command Prompt", weightedMatches[0].Title);
Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title);
Assert.AreEqual("Terminal Canary", weightedMatches[2].Title);
Assert.AreEqual("Run commands", weightedMatches[3].Title);
}
[TestMethod]
public void ValidateTitlesAreMoreImportantThanHistory()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
// Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches
// the title better
Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match");
Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal");
Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle");
}
[TestMethod]
public void ValidateTitlesAreMoreImportantThanUsage()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
// Add extra uses of VS Code to try and push it above Terminal
for (var i = 0; i < 10; i++)
{
history.AddHistoryItem(items[1].Id);
}
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
// Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches
// the title better
Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match");
Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal");
Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle");
}
[TestMethod]
public void ValidateUsageEventuallyHelps()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
// We're gonna run this test and keep adding more uses of VS Code till
// it breaks past Command Prompt
var vsCodeId = items[1].Id;
for (var i = 0; i < 10; i++)
{
history.AddHistoryItem(vsCodeId);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);
var expectedCmdIndex = i < 5 ? 0 : 1;
var expectedCodeIndex = i < 5 ? 1 : 0;
Assert.AreEqual("Command Prompt", weightedMatches[expectedCmdIndex].Title);
Assert.AreEqual("Visual Studio Code", weightedMatches[expectedCodeIndex].Title);
}
}
}

View File

@@ -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
public sealed partial class AppListItem : ListItem
{
private readonly AppItem _app;
private static readonly Tag _appTag = new("App");
private readonly AppCommand _appCommand;
private readonly AppItem _app;
private readonly Lazy<Details> _details;
private readonly Lazy<IconInfo> _icon;
private readonly Lazy<Task<IconInfo?>> _iconLoadTask;
private InterlockedBoolean _isLoadingIcon;
public override IDetails? Details { get => _details.Value; set => base.Details = value; }
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
public override IIconInfo? Icon
{
get
{
if (_isLoadingIcon.Set())
{
_ = LoadIconAsync();
}
return base.Icon;
}
set => base.Icon = value;
}
public string AppIdentifier => _app.AppIdentifier;
public AppListItem(AppItem app, bool useThumbnails, bool isPinned)
: base(new AppCommand(app))
{
Command = _appCommand = new AppCommand(app);
_app = app;
Title = app.Name;
Subtitle = app.Subtitle;
Tags = [_appTag];
Icon = Icons.GenericAppIcon;
MoreCommands = AddPinCommands(_app.Commands!, isPinned);
@@ -43,12 +60,19 @@ internal sealed partial class AppListItem : ListItem
return t.Result;
});
_icon = new Lazy<IconInfo>(() =>
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails));
}
private async Task LoadIconAsync()
{
try
{
var t = FetchIcon(useThumbnails);
t.Wait();
return t.Result;
});
Icon = _appCommand.Icon = await _iconLoadTask.Value ?? Icons.GenericAppIcon;
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to load icon for {AppIdentifier}\n{ex}");
}
}
private async Task<Details> BuildDetails()
@@ -87,12 +111,12 @@ internal sealed partial class AppListItem : ListItem
return new Details()
{
Title = this.Title,
HeroImage = heroImage ?? this.Icon ?? new IconInfo(string.Empty),
HeroImage = heroImage ?? this.Icon ?? Icons.GenericAppIcon,
Metadata = metadata.ToArray(),
};
}
public async Task<IconInfo> FetchIcon(bool useThumbnails)
private async Task<IconInfo> FetchIcon(bool useThumbnails)
{
IconInfo? icon = null;
if (_app.IsPackaged)
@@ -108,12 +132,12 @@ internal sealed partial class AppListItem : ListItem
var stream = await ThumbnailHelper.GetThumbnail(_app.ExePath);
if (stream is not null)
{
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
icon = new IconInfo(data, data);
icon = IconInfo.FromStream(stream);
}
}
catch
catch (Exception ex)
{
Logger.LogDebug($"Failed to load icon for {AppIdentifier}:\n{ex}");
}
icon = icon ?? new IconInfo(_app.IcoPath);

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

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

View File

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

View File

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

View File

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

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

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