Compare commits

..

41 Commits

Author SHA1 Message Date
dependabot[bot]
25b8b2d0c7 Build(deps): Bump actions/github-script from 7 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 05:43:55 +00:00
Copilot
9dff42627a Rename issue triage workflow, remove legacy product auto-label action, and sync with main (#47911)
## Summary of the Pull Request

Renames the issue-triage GitHub Action to **Automatic Triaging on Issue
Creation** and removes the redundant `auto-label-product.yml` workflow.
This consolidates issue labeling/triage under a single workflow surface.

Also syncs this PR branch with the latest `main` via a merge commit to
keep it up to date with upstream.

## PR Checklist

- [ ] **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

- **Workflow rename**
  - Updated `.github/workflows/auto-label-issues.yml`:
- `name: Auto-label Issues by Area` → `name: Automatic Triaging on Issue
Creation`
- Updated the manual-trigger comment to reference the new action name.

- **Workflow cleanup**
- Removed `.github/workflows/auto-label-product.yml` to eliminate
overlapping automation.

- **Branch sync requested in PR comments**
- Merged latest `origin/main` into this branch (`4f831bc`) to keep the
PR current.

```yaml
# .github/workflows/auto-label-issues.yml
name: Automatic Triaging on Issue Creation
```

## Validation Steps Performed

- Verified clean merge of `origin/main` into this PR branch (no merge
conflicts).
- Confirmed targeted workflow changes remain present after merge.
- Ran PR validation tooling:
  - Code Review completed successfully.
  - CodeQL scan timed out in validation tooling.

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-05-19 05:40:18 +00:00
Mike Griese
df4f130023 CmdPal: allow users to pin individual perf metrics (#47967)
title.

Basically just exposes bands that have each perf metric as the only
item.

Built on top of https://github.com/microsoft/PowerToys/pull/47870

Closes #46200
2026-05-18 19:31:10 -05:00
Jiří Polášek
2d0aadee9c Revert "Move storyboards used to show/hide breadcrumbs to XAML" (#47971)
<!-- 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

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

This PR reverts #47900: it causes an exception in the Release+AOT build
configuration when retrieving a storyboard from XAML resources and
casting it to a local field.

- [x] Closes: #47970 
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
2026-05-18 22:24:37 +00:00
Mike Griese
b02e53dda5 CmdPal: Update the shell provider to be run (#47642)
This PR updates the shell command provider to work (almost) exactly like
run. The current shell provider is close, but not technically correct.
It does enumerate files. Sure. But as it turns out, it doesn't enumerate
things **exactly** correctly. It doesn't handle network paths super
well. It doesn't handle NTFS file paths. Basically, there's a lot of
weird edge cases in the way the run dialog enumerates file paths for
suggestions. And the only way to match that is to just use the code from
the old run dialog.

This is code that is taken pretty verbatim from the new run dialog.
Instead of trying to enumerate paths manually and shellexecuting command
lines, We're using the actual APIs that the original run dialog used,
more or less. They've been pretty much ported to C#.

This should make us feel just as correct as the original run dialog did.
And exactly the same as the new Run dialog.

The one major change is the introduction of a static item at the top of
the list for running the command that the user typed. This command is
used to just immediately take whatever is in the search box and fire it
off as the command the user typed. This is essentially what happens with
the run dialog. When you press the button, we run the command in the
text box.

See: [The new Run dialog: faster, cleaner, and more capable - Windows
Command
Line](https://devblogs.microsoft.com/commandline/the-new-run-dialog-faster-cleaner-and-more-capable/)

Honestly, most of this PR is just deleting the files we no longer need
from the shell list provider and adding the tests from the OS side here.

I also had to update CsWinRT for this.
2026-05-18 12:59:51 -05:00
Knyrps
95ef94d35f [CmdPal][PerfMon] Add battery widget (charge, status, time remaining) (#47870)
## Summary

Adds a Battery widget to the CmdPal Performance Monitor dock, exposing
live charge percentage, charging/AC status, and estimated time
remaining. The dock-band icon updates each tick to reflect the current
charge level and charging state, matching the existing
CPU/RAM/GPU/Network widget pattern.

Closes #47218

## Detail

- New `SystemBatteryUsageWidgetPage` in `PerformanceWidgetsPage.cs`,
mirroring the existing widget pattern.
- Data source: `GetSystemPowerStatus` via CsWin32 (AOT-safe), wrapped in
`BatteryStats` and surfaced through `SystemData` / `DataManager` on the
shared 1 s timer.
- Live dock-band icon: `Icons.cs` caches 23 `IconInfo` instances
(`BatteryIcons[0..10]`, `BatteryChargingIcons[0..10]`,
`BatteryUnknownIcon`); alloc-free `BatteryGlyph()` selects the glyph per
tick. Codepoints `0xEBA0`–`0xEBB5` / `0xEC02` (Segoe Fluent Icons
MobBattery family).
- Adaptive card template `SystemBatteryTemplate.json` registered as
`<None Update … PreserveNewest>`, consistent with the other PerfMon
templates.
- New `Battery_*` strings under `Strings\en-US\Resources.resw`.
- Handles `GetSystemPowerStatus` sentinels: `BatteryLifePercent == 255`,
`BatteryLifeTime == 0xFFFFFFFF`, `BatteryFlag == 0xFF`, `0x80` (no
battery), `0x08` (charging).

## Screenshots

<img width="1660" height="401" alt="image"
src="https://github.com/user-attachments/assets/6e26f10a-267b-4f6b-b43a-b2c63c092a6d"
/>

When the battery is at 100%, the widget will always show the "full" icon
(`MobBatteryCharging10`).
<img width="537" height="37" alt="image"
src="https://github.com/user-attachments/assets/d925a328-b9b6-4760-a488-f936910a1715"
/>
Otherwise and when on AC, it will show the "charging" variant of the
icon:
<img width="85" height="61" alt="image"
src="https://github.com/user-attachments/assets/065e3a53-c210-4d65-8ca2-02dccf63edf6"
/>
The icon also always reflects the current charging state: It resolves to
the correct glyph in both charging and discharging states, rounded to
the nearest 10% (so 0%, 10%, … 100%).

## How tested

- `MSBuild CommandPalette.slnf /p:Configuration=Release /p:Platform=x64
/m /restore` — green, 0 errors, 0 warnings.
- Launched dev `Microsoft.CmdPal.UI.exe`, opened PerfMon dock, confirmed
Battery widget renders charge %, status, time remaining, and that the
dock-band glyph matches the current charge level (verified on AC, on
battery, and while charging).
2026-05-18 17:07:25 +00:00
Michael Jolley
8da01581f6 CmdPal: Cap visible tags at 3 with +N overflow badge (#47140)
## Summary

Fixes #38317 — "Too many tags on a list item looks hilarious"

When CmdPal list items have many tags (e.g., GitHub issue labels), the
tag pills dominated the row and pushed the title text out of view.

### Changes

**Two-pronged fix:**

1. **Cap visible tags at 3** — ViewModel exposes `VisibleTags` (first 3)
and `OverflowTagText` (`+N`) properties. Original `Tags`/`HasTags`
bindings are preserved (no contract breaks).

2. **Overflow badge** — A `[+N]` badge appears when more than 3 tags
exist, reusing existing `Tag*` theme resources for visual consistency.
Includes `AutomationProperties.Name` for accessibility.

### Files Changed

| File | Change |
|------|--------|
| `ListItemViewModel.cs` | Added `MaxVisibleTags`, `VisibleTags`,
`OverflowTagCount`, `HasOverflowTags`, `OverflowTagText` properties and
`UpdateVisibleTags()` |
| `ListPage.xaml` | `ItemsRepeater` binds `VisibleTags`, wrapped in
`StackPanel` with overflow `Border` badge |

### Validation

- [x] Build clean (`Microsoft.CmdPal.UI.ViewModels` +
`Microsoft.CmdPal.UI`)
- [x] Handles 0, 1-3, and 4+ tags correctly
- [x] No ABI/contract breaks (original `Tags`/`HasTags` preserved)
- [x] Overflow badge reuses existing theme resources
- [x] Accessibility: `AutomationProperties.Name` on overflow badge

### Follow-up

- Overflow badge accessible name could be enriched (e.g., "+3 more tags"
instead of "+3")

### Screenshots

<img width="972" height="545" alt="image"
src="https://github.com/user-attachments/assets/327dcdd0-2e62-435a-9b07-0432189cc947"
/>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 11:40:32 -05:00
Michael Jolley
c7bb7f7e79 CmdPal: Set IsLoading in Window Walker during search (#47919)
## Summary of the Pull Request
Sets `IsLoading = true` before querying open windows in
`WindowWalkerListPage.GetItems()` and resets it in a `finally` block, so
the UI shows a loading indicator while Window Walker results are being
fetched.

Closes #38314

## PR Checklist
- [x] Closes: #38314
- [ ] **Communication:** I've discussed this with core contributors
already.
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized

## Detailed Description
The Window Walker extension was not setting `IsLoading` during its
search operation. This meant users got no visual feedback that a search
was in progress. This follows the same pattern used by other extensions
(AllAppsPage, IndexerPage, etc.).

## Validation Steps Performed
- Verified the change follows existing patterns in AllAppsPage.cs and
IndexerPage.cs
- The try/finally ensures IsLoading is always reset even if Query throws

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 11:38:50 -05:00
Michael Jolley
d9725649bc CmdPal: Enable cross-monitor drag-and-drop in dock edit mode (#47921)
## Summary of the Pull Request
Enables dragging dock bands between monitors in edit mode. Previously,
drag-and-drop only worked within bands on the same monitor's dock.

Closes #47920

## PR Checklist
- [x] Closes: #47920
- [ ] **Communication:** I've discussed this with core contributors
already.
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized

## Detailed Description
The existing drag-and-drop implementation used a local `_draggedBand`
field per `DockControl` instance, which meant the target DockControl on
another monitor had no reference to the dragged band. This PR fixes that
by:

1. **DataPackage properties** — `DragItemsStarting` now stores the band
ID and source monitor device ID in `DataPackage.Properties`, making the
identity available to any drop target in the same process.

2. **Cross-window drop acceptance** — `DragOver` and `DragEnter` now
check for `DockBandId` in `DataView.Properties` in addition to the local
`_draggedBand` field.

3. **CrossMonitorBandDropMessage** — A new `WeakReferenceMessenger`
message coordinates removal from the source dock after a successful
cross-monitor drop.

4. **ViewModel methods** — `DockViewModel.AcceptBandFromMonitor()`
creates a new band ViewModel on the target dock using the same factory
pattern as `AddBandToSection`. `RemoveBandById()` handles cleanup on the
source dock.

Both methods call `EnsureMonitorForked()` to fork per-monitor band
settings from global when needed, maintaining the existing customization
model.

## Validation Steps Performed
- Verified existing same-monitor drag-and-drop behavior is preserved
(local path unchanged)
- Verified cross-monitor handler properly stores/retrieves DataPackage
properties
- Reviewed against existing patterns in DockControl (AddBandToSection,
MoveBandWithoutSaving)

---------

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 06:46:21 -05:00
Copilot
4e8c7aa3a6 Fix: Quick Access flyout shortcut editor crashes on Reset (#47407)
## Summary of the Pull Request

Clicking **Reset** in the Quick Access hotkey shortcut dialog crashes
PowerToys Settings. The root cause is that `C_ResetClick` sets the
`HotkeySettingsProperty` DependencyProperty to `null`, which causes the
WinUI3 XAML runtime to dereference a null pointer during two-way binding
property-change notification — a native E_POINTER crash (0x80004003).

## PR Checklist

- [ ] **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

### Root Cause

`C_ResetClick` previously did:

```csharp
hotkeySettings = null;
SetValue(HotkeySettingsProperty, null);
```

The `HotkeySettingsProperty` is bound two-way (`{x:Bind Mode=TwoWay}`)
to the ViewModel. `SetValue(null)` synchronously triggers the binding
chain: ViewModel setter → `NotifyPropertyChanged` → XAML runtime reads
back the DP value → null pointer → **native E_POINTER crash** in
`CoreMessagingXP.dll`.

### Fix

Use `new HotkeySettings()` instead of `null` — matching the existing
`C_ClearClick` pattern that never crashes:

```csharp
// C_ResetClick — fixed
hotkeySettings = new HotkeySettings();
SetValue(HotkeySettingsProperty, hotkeySettings);
SetKeys();
lastValidSettings = hotkeySettings;
shortcutDialog.Hide();
```

An empty `HotkeySettings` (`IsEmpty() == true`) semantically means "no
shortcut configured" — the same intent as null, but the XAML binding
chain always has a valid object to dereference.

Also added null-conditional guards in `OpenDialogButton_Click` as
defense-in-depth:
- `HotkeySettings?.GetKeysList() ?? new List<object>()` ensures `c.Keys`
is never null, preventing `{x:Bind Keys.Count}` in
`ShortcutDialogContentControl.xaml` from throwing.
- `hotkeySettings?.HasConflict ?? false` and
`hotkeySettings?.ConflictDescription` guard the remaining property
accesses.
- A null guard in `Hotkey_KeyDown` prevents a crash if the user presses
keys after Reset.

## Validation Steps Performed

- Reproduced crash: Settings → General → Quick Access shortcut → Edit →
Reset → crash (before fix)
- Verified fix: Same steps → no crash, dialog closes cleanly, shortcut
shows as empty
- Verified reopening dialog after Reset works without crash
- Verified pressing keys in dialog after Reset works without crash
- Built and tested with full Runner IPC (debug PowerToys.exe → Settings)

---------

Co-authored-by: Muyuan Li (from Dev Box) <muyuanli@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
2026-05-18 15:56:26 +08:00
Copilot
49cdb9249d Peek: Add setting to disable file metadata tooltip (#46624)
Adds a **"Show file preview tooltip"** toggle to Peek's Behavior
settings, letting users disable the metadata tooltip (filename, type,
date modified, size) shown on hover over the preview.

## Changes

- **`PeekProperties`** — new `ShowFilePreviewTooltip: BoolProperty`
(default `true`)
- **`IUserSettings` / `UserSettings`** — expose the setting; updated by
the existing file watcher on settings change
- **`FilePreview.xaml`** — `ImagePreview` and `VideoPreview` now bind
`ToolTipService.ToolTip` directly to `InfoTooltip` (the same
attached-property form already used by `AudioControl`). The previous
explicit `<ToolTip Content="{x:Bind InfoTooltip}"/>` element form left a
`ToolTip` instance permanently attached, so nulling the content still
produced an empty popup on hover. With the attached-property form, a
`null` `InfoTooltip` detaches the tooltip entirely and no popup is
shown.
- **`FilePreview.xaml.cs`** — new `ShowFilePreviewTooltip`
DependencyProperty; sets `InfoTooltip = null` when disabled; re-triggers
`UpdateTooltipAsync` when re-enabled with a file loaded; `infoTooltip`
field changed to `string?`
- **`MainWindow.xaml.cs`** — reads setting from `IUserSettings` and
pushes it to `FilePreviewer.ShowFilePreviewTooltip` on each
`Initialize()` call, picking up the latest user preference each time
Peek activates
- **`PeekViewModel`** — `ShowFilePreviewTooltip` property for two-way
Settings UI binding
- **`PeekPage.xaml`** — toggle in the Behavior group, consistent with
existing toggles
- **`Resources.resw`** — localizable strings for header and description

## PR Checklist

- [x] Closes: #46621
- [ ] **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

Setting defaults to `true` — no behavior change for existing users.

The tooltip is suppressed by setting `InfoTooltip` to `null` while bound
through `ToolTipService.ToolTip` as an attached property. A `null` value
on the attached property prevents WinUI from creating a tooltip popup at
all (this is the same pattern `AudioControl` already used and behaves
correctly with null). The earlier explicit `<ToolTip>` element form did
*not* behave this way — the `ToolTip` instance stayed attached and an
empty popup appeared on hover — so the XAML was switched to the
attached-property form on `ImagePreview` and `VideoPreview`. The custom
tooltip placement logic (top/bottom based on cursor Y) is unaffected.

## Validation Steps Performed

- Verified toggle appears in Peek Settings > Behavior
- Toggling off fully suppresses the hover tooltip on image, video, and
audio previews (no empty bubble)
- Toggling back on restores tooltip with file metadata on next file load
- Default `true` preserves existing behavior

<img width="688" height="99" alt="image"
src="https://github.com/user-attachments/assets/369d0ba7-fc9a-495e-9603-f4b2b95ba68e"
/>

<img width="692" height="305" alt="image"
src="https://github.com/user-attachments/assets/cb5dfc69-a445-46b0-9c0a-041fbf4c89c2"
/>

<img width="684" height="73" alt="image"
src="https://github.com/user-attachments/assets/8088caa7-9536-4384-91dc-dbdbbe0ae755"
/>

<img width="686" height="430" alt="image"
src="https://github.com/user-attachments/assets/0d9d2d6f-bc42-4497-89c5-06ac9c18a2b7"
/>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Muyuan Li (from Dev Box) <muyuanli@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 15:54:04 +08:00
Copilot
fb59905d41 Fix Markdown preview crash on UTF-8 files with >2MB size and <1.5M characters (#47391)
`NavigateToString` throws `ArgumentException` when previewing Markdown
files containing many multi-byte UTF-8 characters (e.g., CJK) — file
size exceeds 2MB but character count stays under 1.5M, bypassing the
guard.

## Summary of the Pull Request

The size guard in `MarkdownPreviewHandlerControl` used
`markdownHTML.Length` (character count / UTF-16 code units), but
WebView2's `NavigateToString` limit is measured in **bytes**. A string
with 700K CJK characters has only 700K `.Length` units but ~2.1MB of
UTF-8 bytes — enough to crash the API while passing the old check.

**Changes:**
- **`MarkdownPreviewHandlerControl.cs`**: Replace character-count guard
with UTF-8 byte count:
  ```csharp
  // Before
  if (markdownHTML.Length > 1_500_000)

  // After
  if (System.Text.Encoding.UTF8.GetByteCount(markdownHTML) > 1_500_000)
  ```
When the byte threshold is exceeded, content is written to a temp file
and loaded via `_browser.Source` instead of `NavigateToString` —
existing fallback path, now correctly triggered.

- **`MarkdownPreviewHandlerTest.cs`**: Added 3 regression tests to
prevent this class of bug from recurring:
1. Multi-byte UTF-8 content (CJK, <1.5M chars but >2MB bytes) →
temp-file navigation path
2. Small ASCII content within both thresholds → `NavigateToString` path
3. Large ASCII content exceeding 1.5M chars → temp-file navigation path

## PR Checklist

- [ ] **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
- [ ] **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

The existing fallback (write HTML to a temp file, navigate via
`_browser.Source`) was already correct and handles arbitrarily large
content safely. The only bug was in the guard condition that decides
when to use it — it measured the wrong unit (characters vs. bytes).
Single-byte ASCII content is unaffected; only multi-byte Unicode content
was under-counted.

The tests use reflection to read the private `_localFileURI` field.
Since this field is set synchronously before `Controls.Add(_browser)`,
the check is race-free: once the wait loop exits with `Controls.Count >
0`, `_localFileURI` is guaranteed to have its final value.

## Validation Steps Performed

- Verified the fix by reasoning through the byte math: a file with 700K
CJK characters → `Length` = 700K (passes old check) → UTF-8 bytes ≈
2.1MB (fails new check → uses temp file path, no crash).
- Added 3 targeted unit tests in `UnitTests-MarkdownPreviewHandler`
covering the multi-byte threshold boundary; all 14 tests in the suite
pass (14/14).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
Co-authored-by: Muyuan Li (from Dev Box) <muyuanli@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-18 14:30:48 +08:00
moooyo
38882fd392 [PowerDisplay] Rescan monitors on display wake (#47876)
<!-- 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

1. Subscribes to GUID_CONSOLE_DISPLAY_STATE so PowerDisplay rescans
monitors when the console display wakes from sleep — previously, woken
monitors stayed unrecognized until the user manually re-triggered
discovery.
2. Locks the PowerDisplay UI immediately on wake to block stale
interactions before the rescan completes.

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

- [x] Closes: #47951
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [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

<!-- 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: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:26:26 +08:00
Jiří Polášek
75ac1521a8 CmdPal: Extension Gallery - Move storyboards used to show/hide breadcrumbs to XAML (#47900)
<!-- 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 moves storyboard that handles showing and hiding breadcrumbs in
Settings windows to a XAML resources.

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

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
2026-05-17 19:43:12 +02:00
Dave Rayment
e17454b553 [CmdPal Calculator] Add rand() and randi(). Expand result responses to differentiate between NaN and ParseError (#47725)
## Summary of the Pull Request
This adds `rand()` and `randi()` functions to Command Palette's
Calculator, making it consistent with Run.

It also expands upon the return values from `ToWStringFullPrecision()`,
so NaN, ParseError and +/-infinity results are passed back to the
caller, improving the specificity of the error message display.

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

- [x] Closes: #47707
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
- [ ] **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
The two new functions have been added to **ExprtkEvaluator.cpp**,
alongside `sign()` and `factorial()`. As they need to handle the state
of the RNG, they're slightly more complex in implementation. I used the
Mersenne Twister RNG with a uniform distribution, and the instances are
marked `static thread_local` in case the engine moves to multithreaded
evaluation in the future.

It's possible for the RNG to return a value out of the range of
`double`, and this is caught and `quiet_NaN()` is returned. To prevent
this being caught as a generic parse error, I updated
`ToWStringFullPrecision()` to distinguish between `NaN`, expression
parsing errors and infinity values. This should improve the accuracy of
error messages for other expressions, too.

Finally, I corrected a comment in **CalculateEngine.cs,** which still
referred to the Mages calculation engine. The log/ln mapping is the same
for both engines, so the comment was still accurate except for this
reference.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Unit tests were added to exercise the new functions. All Calculator
tests pass:

<img width="375" height="59" alt="image"
src="https://github.com/user-attachments/assets/5a33e1ed-a4fd-4d53-b9ba-6b44000f1bf4"
/>

Confirmed that error messages are displaying correctly for the
newly-exposed result types:

**Not a number**
<img width="787" height="128" alt="image"
src="https://github.com/user-attachments/assets/8c73dcf6-122b-4af8-bf1a-62284842433a"
/>

<img width="786" height="145" alt="image"
src="https://github.com/user-attachments/assets/fe14338c-1160-4aae-83dd-5ca3491ae59e"
/>

**+/- Infinity**
<img width="898" height="137" alt="image"
src="https://github.com/user-attachments/assets/20cfacda-72a7-44bb-a875-af7be39ee7e2"
/>

**Parser failure**
<img width="607" height="139" alt="image"
src="https://github.com/user-attachments/assets/7d7120b2-a2cf-45b6-ab89-79af4051fa50"
/>

<img width="587" height="140" alt="image"
src="https://github.com/user-attachments/assets/2dc7a365-7ee6-4379-8b3f-47b3912e6891"
/>
2026-05-16 19:50:58 +00:00
Dave Rayment
703dc92c04 [CmdPal Calculator] Fix issue for multi-argument functions where comma is both the number group separator and list separator (#47731)
## Summary of the Pull Request
This fixes Calculator functions with multiple arguments in cultures
where the number group separator and list separator are identical, e.g.
**en-US** and **en-GB**.

It maintains existing parsing behaviour for other cultures where the
separators differ, e.g. **de-DE**.

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

- [x] Closes: #47726
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
- [ ] **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

The root issue was in the number translation step. In cultures such as
en-US, commas were being consumed as part of numeric tokens and were
treated as number group separators, which broke functions whcih took
multiple arguments. For example, `max(1,2)` would be translated as
`max(12)` and `pow(2,3)` as `pow(23)`. The number translator's result
would be passed on to ExprTK, which could sometimes still interpret the
input and would give a result (`12` in the case of `max(1,2)`); for
cases like `pow(2,3)`, it would surface an error, as the expression
`pow(23)` is invalid.

This fix resolves the ambiguity in two ways:
- It uses stricter grouped number matching when the culture's list
separator and number group separator are the same.
- It preserves separator characters for multi-argument functions. This
means that expressions like `max(123,456)` are correctly interpreted as
two arguments inside the function call, while `123,456` outside a
function call is still interpreted as a grouped number.

Care has been taken to preserve grouped numbers inside single-argument
functions, e.g. `ceil(123,456.23)`.

The more permissive parsing for cultures which do not have this
ambiguity has been retained. (Arguably this is too loose, but that's
something to consider separately.)

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Added and updated unit tests, with **en-US** used as the representative
'ambiguous culture'. Coverage includes:

- `max()`, `min()` and `pow()`
- Grouped numbers in single-argument functions like `ceil()`, `floor()`,
`round()`, `log()` and `sin()`.
- Nested expressions
- Spacing around function calls
- Scientific notation
- Hexadecimal, binary and octal literals

All tests pass:

<img width="696" height="52" alt="image"
src="https://github.com/user-attachments/assets/ed89fbb9-70e0-4cf7-8e13-12f1f36b6037"
/>

Manual testing was also performed to confirm:

- Multi-argument functions now evaluate correctly
<img width="243" height="136" alt="image"
src="https://github.com/user-attachments/assets/b5c96954-ee6d-4842-b599-561ccbd10607"
/>

- Grouped numbers inside single-argument functions still work
<img width="356" height="135" alt="image"
src="https://github.com/user-attachments/assets/918425b0-cce4-4708-855b-c8b4916e6a4a"
/>

- Nested expressions and spacing variations are handled correctly
<img width="606" height="137" alt="image"
src="https://github.com/user-attachments/assets/12be5aa9-ba33-4000-96d5-444a2932bbe7"
/>

<img width="482" height="135" alt="image"
src="https://github.com/user-attachments/assets/aea2ebce-7c88-469e-b9e4-bdb7099ef538"
/>
2026-05-16 19:47:41 +00:00
Jiří Polášek
012fb4a5c8 CmdPal: Extension Gallery - Fix WMC1506 warnings (#47899)
## Summary of the Pull Request

This PR fixes WMC1506 warnings introduced by the extension gallery PR.

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

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
2026-05-16 17:59:22 +00:00
Jiří Polášek
36f172cedf CmdPal: Extension Gallery - Allow only HTTP/HTTPS URIs as links in the UI (#47898)
## Summary of the Pull Request

This PR filters URIs from extension gallery and allows only HTTP/HTTPS
URIs as links for the installation page.

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

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
2026-05-16 17:51:38 +00:00
Mike Griese
2d3f93537f CmdPal: Move bookmarks with placeholders to be parameters (#47886)
The placeholders page for a bookmark is already goofy. It's a seemingly
simple form, for just filling in something that really should be an
inline value.

So let's do that!

This moves the placeholders out of a whole adaptive card, and into an
inline parameter.

Targets #47885 

<img width="790" height="635" alt="image"
src="https://github.com/user-attachments/assets/d0faa5a2-e967-4860-b3d2-b6bcb0c91c0b"
/>
<img width="787" height="798" alt="image"
src="https://github.com/user-attachments/assets/36ff41c3-8c6e-48bf-a141-9655b463d049"
/>
2026-05-16 02:31:42 +00:00
Michael Jolley
a187bfc2bb CmdPal: Pass extension log messages into logging system (#47896)
This pull request updates the logging behavior in the `LogMessage`
method to ensure that log messages are categorized and logged according
to their severity (Error, Warning, or Info), instead of always using
debug-level logging.

Logging improvements:

* Updated the `LogMessage` method in `AppExtensionHost.cs` to log
messages using `CoreLogger.LogError`, `CoreLogger.LogWarning`, or
`CoreLogger.LogInfo` based on the `MessageState` of the incoming
message, improving log clarity and severity categorization.

Previously, any logging sent to LogMessage (which is exposed in the
toolkit) would only be written when debugging. Any error/warning/info
messages sent as part of the normal operation would be bypassed because
it only used `CoreLogger.LogDebug`

Now extension developers can log errors/warnings and use PowerToys log
export / bug report feature to help their users provide them with
actionable data to make their extensions better.
2026-05-15 20:34:08 -05:00
Mike Griese
42902eeba5 CmdPal: Add support for pages with parameters (redux) (#47826)
(this PR is an updated version of #43784)

This PR adds a new type of page to Command Palette: 
The `ParametersPage`.

This allows extensions to create commands that require a set of
parameters
before invoking the command. Previously, extensions could create
commands with a
form page to use an adaptive card for parameter input, but that was a
relatively
heavyweight UX. 

Instead, the `ParametersPage` allows extensions to define a set of
lightweight
inputs, which allows for a more streamlined experience. 

The parameters page is made up of a set of "runs". Each run represents a
single element in the search box. Runs can be either:
* A label run: a static piece of text
* An value run: some input for the user to provide a value. These fall
into several categories:
  * String input
  * Command Input
    * `IInvokableCommand`s become buttons in the search box
* `IListPage`s become a list input to pick from (**these will be added
in a follow-up PR**)

There are a ton of samples included. 

I also added all my draft notes in the drafts folder, to see how we got
here.
I'd skip reviewing those.

Furthermore, I added the "dumb" token support, where an extension can
opt in to
having tokens in the search box, delimited by ZWSP characters. 

The XAML styling was fixed by Niels a few months back

Closes #40948

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-05-15 20:31:32 -05:00
Michael Jolley
b99defbc0d CmdPal: Add setting to hide app descriptions in All Apps (#47128)
## Summary

Adds a **Hide app descriptions** toggle setting to the Command Palette
All Apps extension. When enabled, the descriptive subtitle text next to
app results is hidden for a cleaner look.

### Changes

| File | Change |
|------|--------|
| `AllAppsSettings.cs` | New `HideAppDescriptions` `ToggleSetting`
(default: `false`), following the existing `EnableStartMenuSource`
pattern |
| `AllAppsPage.cs` | Conditionally clears `Subtitle` on each
`AppListItem` in `GetPrograms()` when setting is enabled |
| `Resources.resx` / `Resources.Designer.cs` | Resource strings for the
setting label and description |
| `AllAppsPageTests.cs` | Two new tests verifying subtitle visibility
with setting on/off |

### Validation

- [x] Build clean (exit code 0)
- [x] All 14 unit tests pass (including 2 new tests)
- [x] Setting defaults to `false` (preserves current behavior)
- [x] Empty subtitle renders gracefully (no placeholder shown)
- [x] No ABI breaks — all changes scoped to All Apps extension

Closes #46634

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 16:15:37 -05:00
Michael Jolley
08caf10d84 CmdPal: Fix CmdPal command bar not refreshing on back navigation (#47126)
## Summary

Fixes #46810 — When navigating backward in CmdPal (Esc/Backspace), the
bottom command bar retained stale commands from the previous page until
the user changed selection.

## Root Cause

In `ListPage.xaml.cs` `OnNavigatedTo()`, the back-navigation path sets
selection using `SuppressSelectionChangedScope()`, which prevents
`Items_SelectionChanged` from firing. This means `PushSelectionToVm()`
is never called, so `UpdateCommandBarMessage` is never sent to
`CommandBarViewModel`, and the command bar displays stale state.

## Fix

Added `PushSelectionToVm()` after the selection restoration block inside
the back-navigation dispatcher callback. This is safe because:
- `PushSelectionToVm()` is idempotent (guards with
`ReferenceEquals(_lastPushedToVm, li)`)
- Handles both selected items (pushes to VM) and no selection (sends
null)
- Triggers `UpdateCommandBarMessage` which refreshes the command bar

## Validation

### Manual Test Steps

| Scenario | Expected |
|----------|----------|
| **Esc navigation** — Navigate into nested page, press Esc | Bottom bar
commands update immediately to parent page |
| **Backspace navigation** — Navigate into nested page, press Backspace
| Bottom bar commands update immediately to parent page |
| **Forward navigation** — Navigate forward into a page | Commands
update (no regression) |
| **Selection change** — Change selection on any page | Commands update
(no regression) |
| **No flicker** — Navigate back and observe command bar | Single clean
update, no double-fire |
| **Empty page** — Navigate back to page with no selectable items |
Graceful handling, no crash |

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 16:14:16 -05:00
Michael Jolley
eaaba455dd CmdPal: Fix pluralization in CmdPal Extensions settings page (#47125)
## Summary

Fixes the pluralization bug in Command Palette settings where extensions
with a single command displayed "1 commands" and "1 fallback commands"
instead of the correct singular forms.

### Changes

**`ProviderSettingsViewModel.cs`** — Rewrote the `ExtensionSubtext`
property to use tuple pattern matching that selects the correct
singular/plural format string based on whether command count and
fallback count equal 1.

**`Resources.resx`** — Added 4 new resource strings for singular form
combinations:
- `builtin_extension_subtext_singular` — "{0}, {1} command"
- `builtin_extension_subtext_with_fallback_singular_command` — "{0}, {1}
command, {2} fallback commands"
- `builtin_extension_subtext_with_fallback_singular_fallback` — "{0},
{1} commands, {2} fallback command"
- `builtin_extension_subtext_with_fallback_singular_both` — "{0}, {1}
command, {2} fallback command"

**`ProviderSettingsViewModelPluralizationTests.cs`** — 17 new test cases
covering all singular/plural combinations for command and fallback
command counts.

### Validation

- [x] Matches existing `CompositeFormat` pattern used elsewhere in
CmdPal
- [x] Follows `.editorconfig` and StyleCop conventions
- [x] All files within `CommandPalette.slnf` scope

Closes #47110

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 16:09:52 -05:00
Boliang Zhang
b68b2a5583 Fix GrabAndMove LNK2038 C++/WinRT version mismatch (PowerToys CI break) (#47910)
Diagnostic / prototype fix for the LNK2038 C++/WinRT version mismatch
that has been failing PowerToys CI on every batched-CI run since commit
`59eefd9581` (5/14):

`
SettingsAPI.lib(settings_objects.obj): error LNK2038: mismatch detected
for 'C++/WinRT version':
  value '2.0.250303.1' doesn't match value '2.0.250303.5' in main.obj
  [src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj]
`

## Root cause

GrabAndMove.vcxproj does not import the `Microsoft.Windows.CppWinRT`
NuGet package, so `main.cpp` picks up `<winrt/Windows.Foundation.h>`
(included transitively via `SettingsAPI/settings_objects.h` ->
`common/utils/json.h`) from the **Windows SDK's in-box CppWinRT**
instead of the repo-pinned NuGet version.

After the SHINE-VS18-Latest agent image picked up a newer Windows SDK
shipping `CppWinRT 2.0.250303.5`, `main.obj` began emitting that version
via `#pragma detect_mismatch`, while `SettingsAPI.lib` continued to be
built against the pinned NuGet `2.0.250303.1`. The linker rejects the
mix.

This was masked while the agent SDK happened to ship a matching CppWinRT
version, and surfaced after #47470 (Bump WindowsAppSDK to 2.0.1) plus
the agent image roll.

## Fix

Mirror the canonical CppWinRT NuGet wiring used by every other native
vcxproj in the repo (see `src/common/SettingsAPI/SettingsAPI.vcxproj`
for the reference pattern):

- Add `packages.config` pinning `Microsoft.Windows.CppWinRT
2.0.250303.1`.
- Import the props after `Microsoft.Cpp.Default.props`.
- Import the targets in an `ExtensionTargets` `ImportGroup`.
- Add `EnsureNuGetPackageBuildImports` for restore-time validation.

## Validation

- Local x64/Release build of GrabAndMove.vcxproj clean (linked against
SettingsAPI.lib without LNK2038).
- (Local SDK on the dev box already ships matching CppWinRT
2.0.250303.1, so the LNK2038 cannot reproduce locally; the CI pool agent
has the newer SDK that exposes the latent issue.)
- Awaiting PowerToys CI to confirm fix on the agent image.

## Related

- #47470 (Bump WindowsAppSDK to 2.0.1) — preceded but did not directly
cause this; just changed which CppWinRT was sitting in the include path.
- Failing CI runs: 319304, 319351, 319593 (all on shine-oss PowerToys CI
definition 3).
2026-05-15 14:13:24 -05:00
Copilot
34e78bd8c3 Add validation to prevent empty names in ImageResizer size presets (#45425)
## Summary of the Pull Request

Prevents users from clearing the name field in ImageResizer size preset
edit dialog. Empty names made the UI confusing without causing errors.

## PR Checklist

- [ ] **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
- [ ] **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

Added validation guard in `ImageSize.Name` property setter:

```csharp
public string Name
{
    get => _name;
    set
    {
        if (!string.IsNullOrWhiteSpace(value))
        {
            SetProperty(ref _name, value);
        }
    }
}
```

Invalid assignments (empty, null, whitespace) are silently ignored,
preserving the existing value. This matches the existing pattern used
for `FileName` validation in `ImageResizerViewModel`.

TwoWay binding in UI causes the TextBox to revert when users attempt to
clear the field—standard behavior for required fields.

## Validation Steps Performed

- Added unit test `ImageSizeNameShouldNotBeSetToEmptyOrNull()` covering
all rejection and acceptance cases
- Verified silent rejection behavior matches `FileName` property pattern

> [!WARNING]
>
> <details>
> <summary>Firewall rules blocked me from connecting to one or more
addresses (expand for details)</summary>
>
> #### I tried to connect to the following addresses, but was blocked by
firewall rules:
>
> - `i1qvsblobprodcus353.vsblob.vsassets.io`
> - Triggering command: `/usr/bin/dotnet dotnet build
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/ResultHelper.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Helpers/RegistryHelper.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Enumerations/TruncateSide.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Constants/KeyName.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Constants/MaxTextLength.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/CopyRegistryInfoCommand.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Commands/OpenKeyInEditorCommand.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Re
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/CopyType.cs
/home/REDACTED/work/PowerToys/PowerToys/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Registry/Icons.cs`
(dns block)
>
> If you need me to access, download, or install something from one of
these locations, you can either:
>
> - Configure [Actions setup
steps](https://gh.io/copilot/actions-setup-steps) to set up my
environment, which run before the firewall is enabled
> - Add the appropriate URLs or hosts to the custom allowlist in this
repository's [Copilot coding agent
settings](https://github.com/microsoft/PowerToys/settings/copilot/coding_agent)
(admins only)
>
> </details>

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>[Settings: ImageResizer] Edit size dialog: Add validation
against empty name</issue_title>
> <issue_description>There is one small thing we should add. (But I
don't know how.)
> The data in the name field of the edit dialog should be validated
against `.NullOrEmpty`!
> _Originally posted by @htcfreek in
https://github.com/microsoft/PowerToys/pull/13285#issuecomment-924113251_
> 
> **Detailed description:**
> While users edit an image size in the ImageResizer settings on PT's
settings ux they are able to clear the name of the image size. This
doesn't lead to errors/exceptions, but it also doesn't make sense to
have sizes without a name. So we should prevent user from doing this in
the edit dialog.
> 
> _**Note:**_
> This is the second and last change that is needed to prevent the users
from having incorrect image size settings (#8698). But I think this is
not as relevant, because a missing name won't break image resizing._
> 
> ---
> 
> cc: @niels9001 </issue_description>
> 
> <agent_instructions>Implement a proper fix for this issue. Do NOT
create placeholder stub files. Create actual working code that addresses
the issue described. Follow PowerToys coding guidelines and
conventions.</agent_instructions>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> <comment_new><author>@TheJoeFin</author><body>
> does this issue still happen with v0.73.0?
/needinfo</body></comment_new>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes microsoft/PowerToys#13336

<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for
you](https://github.com/microsoft/PowerToys/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yeelam-gordon <73506701+yeelam-gordon@users.noreply.github.com>
2026-05-15 11:39:01 +02:00
Boliang Zhang
e932fe6e61 Remove unused dependencies and shrink installer size (#47233)
## Summary of the Pull Request

Two related installer changes to (1) eliminate genuinely-unused
dependencies and (2) deduplicate shared WinAppSDK files between
`<install>\` and `<install>\WinUI3Apps\` to shrink the installer
download.

### 1. Remove unused dependencies (~11 MB savings per output location)

- **System.Data.SqlClient**: Removed from MouseWithoutBorders projects
and the central `Directory.Packages.props` pin. It was a transitive
dependency of `Microsoft.Windows.Compatibility` but PowerToys has zero
SQL database usage.
- **Unused `using` import**: Removed `using
System.ServiceModel.Channels` from MouseWithoutBorders `Program.cs` (no
WCF usage).
- **MFC / C++ AMP / OpenMP DLLs**: Added `RemoveUnusedVCRuntimeDlls`
target in `Directory.Build.targets` to clean up `mfc140*`, `mfcm140*`,
`vcamp140*`, and `vcomp140*` DLLs that leak from the VC++
Redistributable tree but are not imported by any PowerToys binary
(verified with `dumpbin /dependents` across all installed binaries).
Also excluded MFC DLLs from installer file collection.

### 2. WinAppSDK file deduplication (build-time only; install-time uses
copy)

**Background**: The `WinUI3Apps` subfolder must remain a real directory
because MSIX sparse package registration applies DACL changes to the
`ExternalLocation` folder (PR #47177). Flattening is not viable.

**Build-time** (`generateAllFileComponents.ps1`): computes the SHA256
intersection of root and `WinUI3Apps` files, and for each file that is
also present in the BaseApplications WXS file list, removes the
duplicate from the WinUI3Apps WXS component list and writes its name to
a `hardlinks.txt` manifest. The BaseApplications cross-check ensures we
never deduplicate a file the MSI does not actually deploy at the install
root, which would otherwise leave both copies missing post-install. The
manifest is written as UTF-8 without BOM (via
`[System.IO.File]::WriteAllLines` with `UTF8Encoding($false)`) so its
encoding is identical regardless of the build host's PowerShell version.
This step produces the **MSI download-size win** (~97 MB smaller cab;
LZX:21 was already deduplicating most byte-identical content
automatically inside the cab).

**Install-time** (`CreateWinAppSDKHardlinksCA` custom action):
- Reads `hardlinks.txt` after `InstallFiles` as a raw byte stream and
converts each line to a `std::wstring` via `MultiByteToWideChar(CP_UTF8,
MB_ERR_INVALID_CHARS, ...)`. Avoids `std::wifstream`'s ANSI-codepage
codecvt so non-ASCII paths can never be silently mangled.
- For each entry, computes `(installDir / fileName).lexically_normal()`
and `(winui3Dir / fileName).lexically_normal()`, then verifies via
`std::mismatch` that each resolved path is still rooted at its
respective folder. Manifest entries containing `..`, absolute paths, or
alternate-stream syntax are logged and skipped.
- Materialises each validated entry from `<install>\<name>` into
`<install>\WinUI3Apps\<name>` via `fs::copy_file` (overwrite_existing).
- Reports the per-file copy / failure counts to the install log. If
every entry failed (`created == 0 && failed > 0`), the CA escalates to
`E_FAIL` so the install does not silently succeed with an unusable
WinUI3Apps tree.

`DeleteWinAppSDKHardlinksCA` removes the materialised copies before
`RemoveFiles` on uninstall, using the same UTF-8 reader and per-entry
containment check.

**WiX sequencing**: `CreateWinAppSDKHardlinks` runs
`After="InstallFiles"` with `Condition="NOT Installed OR
WIX_UPGRADE_DETECTED OR REINSTALL"` so a `msiexec /fa` repair refreshes
the deduplicated copies (otherwise `RemoveFiles` would orphan them).

#### Why copy and not hard-link

A hard-linked variant of this CA was originally proposed but caused a
Monaco preview-handler regression. Hard-links share an NTFS inode (and
therefore one DACL) between `<install>\<file>` and
`<install>\WinUI3Apps\<file>`. The MSIX sparse-package registrations for
PowerRename / ImageResizer / FileLocksmith / NewPlus run after the dedup
CA and propagate the `WinUI3Apps` parent's rich DACL (Capability SID, 5×
Package SIDs, 5× conditional SYSAPPID ACE, RC SID) onto the shared
inode. The root path then also exposes the rich DACL, which trips a
kernel "stricter access evaluation" path that blocks the LOW-IL
`prevhost.exe` from `LoadLibrary`-ing `hostfxr.dll` (and the rest of the
.NET runtime), turning the Monaco preview pane blank for `.json` / `.md`
/ `.cs` / `.xaml` / `.svg` / `.xml` files.

`fs::copy_file` creates a **fresh inode** for the WinUI3Apps copy. The
root inode keeps its simple DACL (`SY:F + BA:F + owner:F` + inherited
`BU:RX`) so LOW-IL `prevhost.exe` can still load it — Monaco preview
works. The WinUI3Apps copy inherits the WinUI3Apps parent's rich DACL
via normal NTFS inheritance (matches 0.99.1 behaviour exactly) — MSIX
context-menu shells continue to work.

#### Trade-off

| Metric | Hard-link variant (rejected) | This PR (file copy) | 0.99.1
(no dedup) |
|---|---|---|---|
| MSI size | ~296 MB | ~296 MB | ~393 MB |
| On-disk after install | ~2,475 MB | ~2,772 MB | ~2,772 MB |
| DACL contamination risk | YES (broke Monaco) | NO | NO |

The on-disk savings (~297 MB) are given up in exchange for eliminating
the DACL contamination risk; the **installer download savings (~97 MB)**
are preserved by the build-time WiX/cab dedup.

#### Edge cases handled

- Empty duplicate list: `hardlinks.txt` always written, CA handles
empty.
- All files duplicated: `Generate-FileComponents` returns early for
empty list.
- File stripped from BaseApplications by an earlier build step:
BaseApplications cross-check skips it during dedup so neither copy goes
missing.
- Manifest entry escapes install root (`..`, absolute path): rejected
per-entry, install continues.
- Manifest line is non-UTF-8: rejected per-entry, install continues.
- Source missing at install time: per-entry skip, install continues.
- All copies fail: install aborts loudly via `E_FAIL` (catastrophic-case
escalation).
- Upgrade or `msiexec /fa` repair: CA fires (`NOT Installed OR
WIX_UPGRADE_DETECTED OR REINSTALL`).

**MSI repair risk**: Burn bundle uses `SuppressRepair=yes` and
`MajorUpgrade` (full uninstall + reinstall) for all version upgrades, so
the standard upgrade path is unaffected. The `OR REINSTALL` clause
covers power users running `msiexec /fa` directly.

## PR Checklist

- [x] **Communication:** Discussed approach via PRs #46866, #47177,
#46745
- [ ] **Tests:** Installer infrastructure only — no runtime behaviour
changes
- [ ] **Localization:** N/A
- [ ] **Dev docs:** N/A
- [ ] **New binaries:** N/A

## Detailed Description of the Pull Request / Additional comments

Based on the approach from PR #46745 by @yeelam-gordon, rebased onto
latest main and switched from hard-links to file copies after the DACL
contamination root cause was identified. Hardening (UTF-8 read, path
containment, catastrophic-case escalation, REINSTALL repair,
BaseApplications-filtered dedup) added in response to review feedback.

These changes are purely build/installer infrastructure — no runtime
behaviour changes to any PowerToys module.

## Validation Steps Performed

Validated on a 0.99.4 / 0.99.5 local install (per-user
`%LocalAppData%\PowerToys`):

-  `dumpbin /dependents` across the installed PowerToys tree confirmed
zero binaries import `mfc140*`, `mfcm140*`, `vcamp140*`, or `vcomp140*`
— the cleanup target removes ~11 MB of genuinely unused VC runtime DLLs.
-  `System.Data.SqlClient` has zero call-sites in PowerToys source.
-  Local installer build produces a 296 MB MSI (down from 393 MB
pre-dedup, ~97 MB cab savings purely from the build-time WiX dedup).
-  MSI table inspection (`wix msi decompile`) confirms the deferred CAs
are present (`CreateWinAppSDKHardlinks`, `DeleteWinAppSDKHardlinks`) and
the `hardlinks.txt` File row is registered.
-  MSI table inspection confirms .NET runtime DLLs (`hostfxr.dll`,
`coreclr.dll`, `hostpolicy.dll`, `clretwrc.dll`, `Accessibility.dll`,
`backup_restore_settings.json`) appear ONLY in
`BaseApplicationsFiles_File_*`, NOT in `WinUI3ApplicationsFiles_File_*`
— proving the build-time dedup worked.
-  Post-install verification: deduplicated files materialised at both
root and WinUI3Apps with byte-identical SHA256 hashes, and `fsutil
hardlink list` returns link-count == 1 for each — proving the
install-time copy approach worked, not hard-link.
-  DACL on root .NET runtime DLLs is clean: no Package SID, no
Capability SID, no SYSAPPID conditional ACE, no `ALL APPLICATION
PACKAGES` ACE — Monaco preview load path is safe.
-  DACL on WinUI3Apps copies has the rich MSIX inheritance —
context-menu shells continue to work (matches 0.99.1).
-  All four MSIX sparse packages (PowerRename, ImageResizer,
FileLocksmith, NewPlus) registered after install.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 17:15:11 +08:00
Niels Laute
c7d458b71d Fix auto-label-issues workflow (#47820)
## Summary

Fixes the `Auto-label Issues by Area` workflow, which currently logs
`GITHUB_TOKEN is not set; skipping.` on every run and never applies
labels.

Failing run on issue #47818:
https://github.com/microsoft/PowerToys/actions/runs/25722067064/job/75525452318

## Root cause

`actions/github-script@v7` consumes its `github-token` input only to
authenticate the injected `github` Octokit object. It does **not**
export that value to `process.env.GITHUB_TOKEN`. The inline script reads
`process.env.GITHUB_TOKEN` to authorize a direct `fetch()` against
`https://models.inference.ai.azure.com/chat/completions`, so the token
check at the top of `labelIssue()` always fails and the function returns
early before calling the model.

## Fix

Add a step-level `env:` block exposing `GITHUB_TOKEN` to the Node
process running the inline script. The existing `with.github-token`
input is preserved so the injected `github` Octokit continues to
authenticate.

`yaml
- name: Apply area labels with AI
  uses: actions/github-script@v7
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
`

5 lines added, 0 removed.

## Security

- `secrets.GITHUB_TOKEN` is the workflow's built-in ephemeral token
(auto-issued per run, auto-revoked at job end). Not a PAT.
- Scope is already constrained by `permissions: models: read, issues:
write` at the top of the workflow. No widening.
- Exposure is unchanged: the token is already loaded into the same Node
process by `actions/github-script` via `github-token:`. The `env:`
mapping just lets the script body read what the action already has in
the same process.
- The token is sent only to GitHub's own GitHub Models inference
endpoint, which is the documented use of `models: read`.
- Triggers are safe: `issues: opened/reopened` (issue body is
JSON-encoded into the request body, never interpolated into a shell) and
`workflow_dispatch` (write-access required).
- Token is never logged.

## Validation

After merge, re-run the workflow on issue #47818 via Actions ->
"Auto-label Issues by Area" -> Run workflow, and confirm logs show
`Model response: ...` instead of `GITHUB_TOKEN is not set; skipping.`

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-15 14:39:00 +08:00
Jiří Polášek
c4ff073d01 CmdPal: Extension Gallery (#46636)
## Summary of the Pull Request

Adds the **Extension Gallery** to Command Palette — a built-in page
where users can discover, browse, and install community extensions
without leaving the app.


https://github.com/user-attachments/assets/e4565333-b970-4085-9e40-5cfd207e533b

## How it works

### 1. The extension author's side

Extensions are listed in the external repo
**[`microsoft/CmdPal-Extensions`](https://github.com/microsoft/CmdPal-Extensions)**.
To get an extension into the in-app gallery, an author opens a PR there
that adds a single entry to `extensions.json`. Nothing in PowerToys
itself needs to change. A typical entry looks like:

```json
{
  "id": "contoso.sample",
  "title": "Sample Extension",
  "description": "Short blurb shown in the list and detail view.",
  "author": { "name": "Contoso", "url": "https://github.com/contoso" },
  "homepage": "https://github.com/contoso/sample",
  "iconUrl": "https://.../icon.png",
  "screenshotUrls": ["https://.../screenshot-1.png"],
  "tags": ["sample"],
  "installSources": [
    { "type": "winget",  "id":  "Contoso.SampleExtension" },
    { "type": "msstore", "id":  "9P..." },
    { "type": "url",     "uri": "https://github.com/contoso/sample/releases/latest" }
  ],
  "detection": { "packageFamilyName": "Contoso.SampleExtension_8wekyb..." }
}
```

- `id`, `title`, `description`, `author.name`, and at least one
`installSources` entry are required; everything else is optional.
- `installSources` can mix and match `winget` / `msstore` / `url`. The
gallery shows an install button for the first source it can handle
(WinGet preferred) and exposes any remaining sources as links.
- `detection.packageFamilyName` lets CmdPal recognise an
already-installed packaged extension before any WinGet lookup resolves,
so the "Installed" badge appears instantly.

Once the PR is merged into `CmdPal-Extensions`, every running copy of
CmdPal picks the new entry up the next time its feed cache expires
(within 4 hours) or when the user clicks **Refresh**.

### 2. What CmdPal does with it

`ExtensionGalleryService` (in `Microsoft.CmdPal.Common`) owns the whole
pipeline:

1. **Resolve the feed URL.** Default is
`https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`.
A hidden setting (`GalleryFeedUrl`) lets developers point at a custom
URL or a `file://` path for local testing.
2. **Fetch** the feed through `ExtensionGalleryHttpClient`, which wraps
`HttpCachingClient` — a conditional-GET + on-disk cache layer built on
`HttpClient` (ETag / `If-None-Match`, 30 s timeout, UA
`PowerToys-CmdPal/1.0`).
3. **Parse** with the source-generated `GallerySerializationContext`
into a strongly-typed `GalleryRemoteIndex` (`{ "extensions": [ ... ]
}`). Entries without an `id` are dropped.
4. **Normalize** relative `iconUrl` / `screenshotUrls` against the feed
URL (useful for local `file://` feeds).
5. **Localize icons.** Each HTTP icon URL is pulled through the same
cache and rewritten to a local `file://` URI before the view model binds
to it, so the list renders instantly on subsequent loads and works
offline.
6. **Prune** cached resources that are no longer referenced, but only
after a successful forced refresh.

The gallery page itself is built on top of `ExtensionGalleryViewModel`,
with `ExtensionGalleryItemViewModel` handling per-entry concerns —
install/update/uninstall (via the shared WinGet service),
installed-state detection, and joining in-flight install progress so the
global `WinGetOperationsButton` in the top bar stays in sync.

### 3. Caching + offline behaviour

The cache lives under
`ApplicationData.Current.LocalCacheFolder\GalleryCache\` when CmdPal
runs packaged, or
`%LOCALAPPDATA%\Microsoft\PowerToys\Microsoft.CmdPal\Cache\GalleryCache\`
when unpackaged.

| Resource        | TTL      |
|-----------------|----------|
| `extensions.json` feed | 4 hours |
| Icons (per URL) | 24 hours |

Each fetch returns a `GalleryFetchResult` whose flags drive the UI:

- `FromCache` — cache was still fresh, no network call was made.
- `UsedFallbackCache` — network failed; the last-known-good cached copy
was served instead. The page shows a "showing cached data" info bar.
- `RateLimited` — origin returned `429` and no fallback was available.
The page shows a rate-limit error.

`RefreshAsync` (wired up to the gallery's refresh button) forces a fresh
conditional GET, then prunes any cached files that the new feed no
longer references.

### 4. WinGet install flow

- `installSources[type=winget].id` is handed to the shared WinGet
service for install/update/uninstall.
- In-flight operations are surfaced by `WinGetOperationsButton` in the
top bar with per-operation progress.
- `detection.packageFamilyName` is consulted first so that the gallery
can show "Installed" / "Update available" without waiting on WinGet
metadata.

### Top-level command cleanup

- Removed the separate "Find extensions from WinGet" and "Find
extensions from the Store" top-level commands — the gallery replaces
both.
- Renamed the gallery command to **"Find and install Command Palette
extensions"** and gave it the extensions puzzle-piece icon.

## PR Checklist

- [ ] **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

## New projects / areas

| Area | What |
|------|------|
| Microsoft.CmdPal.Common | Gallery models, `ExtensionGalleryService`
(fetch + cache), HTTP caching layer (`HttpCachingClient`,
`FileSystemHttpResourceCacheStore`), WinGet service abstractions and
implementations |
| Microsoft.CmdPal.UI.ViewModels | `ExtensionGalleryViewModel`,
`ExtensionGalleryItemViewModel`, WinGet operation view models, gallery
sort options |
| Microsoft.CmdPal.UI | `ExtensionGalleryPage.xaml`,
`ExtensionGalleryItemPage.xaml`, `IconCarouselControl`,
`WinGetOperationsButton`, service registrations |
| Microsoft.CmdPal.Ext.WinGet | Streamlined — removed the two redundant
"find extensions" top-level commands, kept the general WinGet search
page |
| Tests | Unit tests for gallery service, gallery view models, WinGet
services |
| Docs |
[`doc/devdocs/modules/cmdpal/extension-gallery/extension-gallery.md`](https://github.com/microsoft/PowerToys/blob/dev/jpolasek/f/46628-cmdpal-extension-gallery/doc/devdocs/modules/cmdpal/extension-gallery/extension-gallery.md)
— dev reference for the runtime, caching, and feed shape |

## Validation Steps Performed

- Gallery loads and displays extensions from the remote index
- Search, sort, and filtering work as expected
- WinGet install/update/uninstall flow works end-to-end with progress
tracking
- Loading state correctly hides all content until data is fetched
- Offline / cache-fallback path surfaces the info bar as expected
- Spell-check CI workflow passes

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-05-14 16:30:20 -05:00
Dave Rayment
59eefd9581 [CmdPal and Run Calculator] Fix issues with log functions and spaces (#47767)
## Summary of the Pull Request

The Calculator components in Command Palette and Run produced incorrect
results or errors for `log` and `ln` inputs where spaces exist between
the function name and the argument list. This is because input
validation allowed for those spaces, but this was not respected in the
log mapping code which transforms user input into a string for
consumption by the expression evaluator engine.

The result of this is discrepancy was that:

- Natural log would be called instead of log base 10 for `log (n)`.
- An error would be shown for `ln (n)`.

For example:
<img width="556" height="166" alt="image"
src="https://github.com/user-attachments/assets/d2110292-ca8f-4635-bdef-9fa4f2211deb"
/>

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

- [x] Closes: #47759
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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
- [ ] **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
The issue was with this code:


a650504640/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.Calculator/CalculateEngine.cs (L56-L58)

This maps user input such as `log(10)` or `ln(10)` to the functions the
back-end expression evaluator understands. For Mages and ExprTK, this is
mapped to `log10(n)` for base 10 logs or `log(n)` for natural logs.

Unfortunately, the input validator regex allows for spaces between the
function name and the start of the argument list, and the string replace
does not. When spaces exist:

- For `log (n)` - the log10 match is missed and the expression is
interpreted as a natural log, producing incorrect results.
- For `ln (n)` - the string is passed as-is to the back-end. Neither
Mages nor ExprTK recognise it, so an error is returned.

The fix is to replace the string replacement code with two regexes.
These are both forgiving of spaces between the function name and
argument list:

For log base 10: `"log(?![0-9])\\s*\\("`
For natural log: `"ln\\s*\\("`

Both are culture-agnostic and have `IgnoreCase` set. I took the
opportunity to remove the "en-US" culture from the
`DivisionByZeroRegex`, as it is not required.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
Added unit tests which ensure the log and ln functions perform
identically with or without spaces. Confirmed all unit tests for Command
Palette and Run still pass.

Manually confirmed that `log` and `ln` now produce correct results in
both applications.
2026-05-14 16:16:03 -05:00
Boliang Zhang
42ff04d4bc Fix uitest pipeline install dotnet10 sdk (#47852)
## Problem

The scheduled UI Test Automation pipeline
([Dart/161438](https://microsoft.visualstudio.com/Dart/_build?definitionId=161438))
has been failing daily since 2026-04-30 because PR #41280 (`.NET 10
Upgrade`) updated the main build template (`job-build-project.yml`) for
.NET 10 / VS 2026 but the parallel changes were missed in the UI Test
Automation templates.

Symptoms in recent runs (e.g. build
[#20260512.1](https://microsoft.visualstudio.com/Dart/_build/results?buildId=146803928)):

```
error NETSDK1045: The current .NET SDK does not support targeting .NET 10.0.
   Either target .NET 9.0 or lower, or use a version of the .NET SDK that supports .NET 10.0.
```

…emitted ×179 across every csproj during the *Restore solution-level
NuGet packages* step on both `Build UI Tests Only Release_x64` and
`…_arm64` jobs.

## Changes

This PR mirrors the two pipeline changes that PR #41280 already applied
to the main CI templates.

**1. Install the .NET 10 SDK on the build agent**
- File: `.pipelines/v2/templates/job-build-ui-tests.yml`
- Bump the pinned `steps-ensure-dotnet-version.yml` parameter from
`version: '9.0'` → `'10.0'`.
- Matches `job-build-project.yml`, which already installs the .NET 10
SDK alongside 6.0/8.0.

**2. Pin the build agent image to VS 2026 (MSBuild 18)**
- File: `.pipelines/v2/templates/pipeline-ui-tests-official-build.yml`
- Add `demands: ImageOverride -equals SHINE-VS18-Latest` to the agent
pool spec.
- The .NET 10 SDK (≥ 10.0.300) requires MSBuild 18, which only ships
with VS 2026. Without this demand, the SHINE pool selects a default
image with VS 2022 / MSBuild 17 and restore fails with `MSB4236: The SDK
'Microsoft.NET.Sdk' specified could not be found`.
- Mirrors the unconditional pattern in `pipeline-ci-build.yml` (lines
56–67), which applies the same demand across both `SHINE-INT-L` and
`SHINE-OSS-L` pools.

## Validation

Queued pipeline 161438 against this branch:

| Build | Commit | Result |
| --- | --- | --- |
|
[`146821554`](https://microsoft.visualstudio.com/Dart/_build/results?buildId=146821554)
| `4dd13b9` (SDK fix only) | `NETSDK1045` gone  — exposed VS 2022 /
MSBuild 17 mismatch |
|
[`146825355`](https://microsoft.visualstudio.com/Dart/_build/results?buildId=146825355)
| `97cadbb` (SDK + agent demand) | Both errors gone  — agent now
`Visual Studio\18\Enterprise\` |

After this PR, the pipeline reaches NuGet restore and now hits a
separate, **pre-existing** issue from the April 13–29 failure window:
`401 Unauthorized` on the `shine-oss/PowerToysPublicDependencies`
Artifacts feed for newly-released `Microsoft.NETCore.App.*/8.0.27`
packages. That requires feed-admin action (granting the Dart pipeline
build identity "save from upstream" on the feed, or pre-seeding those
package versions) and is **out of scope for this PR**.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 22:08:28 +08:00
Boliang Zhang
3948fcc19d [release-notes skill] Use local agent for PR summaries; vendor prepare-release-assets.ps1 (#47651)
## Summary

Two related changes to the `release-note-generation` agent skill:

### 1. Step 3 reviews: use the local agent instead of
`mcp_github_request_copilot_review`

Step 3.1 previously instructed the agent to call
`mcp_github_request_copilot_review` for every milestone PR so that the
`CopilotSummary` column in `sorted_prs.csv` would be populated by the
GitHub-side Copilot bot.

When this skill is driven from a CLI / coding agent, that request comes
from a bot identity, and the GitHub API rejects it (`Bot reviewers
cannot be requested`). The PR ends up with no Copilot review and
`CopilotSummary` stays empty.

`references/step3-review-grouping.md` has been rewritten to instead have
**the local agent** that is running the skill perform the review itself:

- Fetch each PR's diff with a non-mutating tool
(`mcp_github_pull_request_read` `get_diff` / `get_files`, or `gh pr
diff`).
- Produce a 1-3 sentence user-facing summary in the same style as a
Copilot PR review.
- Write the summary directly into the `CopilotSummary` column of
`Generated Files/ReleaseNotes/sorted_prs.csv`, preserving row order and
skipping rows that already have a non-empty summary.

Step 3.2 (re-run `dump-prs-since-commit.ps1`) is demoted to optional,
with a note that re-running the dump will overwrite the
locally-generated summaries.

`SKILL.md` was updated to match: front-matter description, "When to
Use", workflow diagram, the 3.1-3.3 row in the summary table,
prerequisites (no longer requires "GitHub Copilot code review enabled
for the org/repo"; mentions MCP for fetching diffs), and the
troubleshooting row for empty `CopilotSummary` now points at Step 3.1
with the bot-rejection caveat.

### 2. Vendor `prepare-release-assets.ps1` into the skill

Added `scripts/prepare-release-assets.ps1` -- previously kept in
OneDrive at `Tools/prepare-release.ps1`. Renamed because the script does
more than download installers: it also pulls per-arch symbol archives,
computes SHA256, and emits the **Installer Hashes** markdown table for
the GitHub release page. "Release assets" captures all of that.

Header `.SYNOPSIS` / `.DESCRIPTION` / `.EXAMPLE` blocks were updated to
reflect the new filename and the symbol-archive behavior (the original
synopsis only mentioned installers).

`SKILL.md` registers the new script in the "Available Scripts" table,
lists Azure CLI + the `azure-devops` extension as a prerequisite (only
when running this script), adds a "Prepare GitHub release assets" entry
to "When to Use", and adds a troubleshooting row for the most common
failure (`Failed to acquire ADO access token` -> `az login`).

## Files changed

| File | Change |
|------|--------|
| `.github/skills/release-note-generation/SKILL.md` | Updated
description, prerequisites, workflow, scripts table, troubleshooting |
|
`.github/skills/release-note-generation/references/step3-review-grouping.md`
| Rewritten to use local-agent review; demoted refresh step |
|
`.github/skills/release-note-generation/scripts/prepare-release-assets.ps1`
| New (vendored from OneDrive) |

## Validation

- PowerShell parser ([`Parser]::ParseFile`) reports no errors on the new
script.
- Documentation-only / scripts-only change -- no product code touched,
so the standard PowerToys build / test gates do not apply.
- The change preserves the existing CSV schema (`Id, Title, Labels,
Author, Url, Body, CopilotSummary, NeedThanks`), so downstream Step 3.3
(`group-prs-by-label.ps1`) and Step 4 summarization continue to work
without modification.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-14 22:07:38 +08:00
moooyo
c5d17913e4 [PowerDisplay] Add max compatibility mode setting (#47875)
<!-- 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

1. Adds an opt-in Max compatibility mode in PowerDisplay's Advanced
settings. When enabled, DDC discovery probes monitors that don't
advertise capabilities, picking up displays that would otherwise be
skipped.
2. Toggling the setting triggers an immediate rescan via a new
RescanPowerDisplayMonitorsEvent IPC event from Settings to PowerDisplay.
3. Hides the brightness slider on monitors that lack VCP 0x10.

Also fixed animation issue #47868

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

- [x] Closes: #47878
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [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

<!-- 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: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-14 07:20:22 +00:00
Jiří Polášek
5715f694ea CmdPal: Add a pinned commands section to the Home page and enable reordering (#45869)
## Summary of the Pull Request

This PR adds a new section for pinned to Home page and commands to
re-order them

- Updates the settings model for pinned command persistence, moving from
per-provider lists to a single ordered list.
- Adds context menu commands for pinned items to reorder them (move up,
move down, move to top).

<img width="902" height="689" alt="image"
src="https://github.com/user-attachments/assets/7d9ef34f-fa00-4155-b62c-2dacc5b85603"
/>


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

- [x] Related to: #45865
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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

---------

Co-authored-by: Michael Jolley <mjolley@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 20:35:58 -05:00
Michael Jolley
966c1db76a CmdPal Dock: Multi-monitor support (#46915)
This pull request introduces per-monitor dock customization support and
refactors how dock band settings are managed to enable independent
layouts on different monitors. The changes add a new
`DockMonitorConfigViewModel` for monitor-specific configuration, update
`DockViewModel` to handle per-monitor band lists and settings, and
refactor band movement and ordering logic to respect per-monitor
overrides.

**Per-monitor dock customization:**

* Added `DockMonitorConfigViewModel` to encapsulate the configuration
and state for each monitor, exposing properties for binding and
persisting changes using `ISettingsService`.
* Updated `DockViewModel` to track an optional `MonitorDeviceId`,
enabling docks to be associated with a specific monitor and to expose
per-monitor settings and methods.
[[1]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L17-R33)
[[2]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L41-R56)

**Band management refactor for per-monitor settings:**

* Refactored band retrieval and update logic in `DockViewModel` to use
new helper methods (`GetActiveBands`, `WithActiveBands`) that select and
modify either global or per-monitor band lists as appropriate.
* Updated band movement and ordering methods (`SyncBandPosition`,
`MoveBandWithoutSaving`, `SaveBandOrder`) to operate on the correct band
lists for each monitor, ensuring that changes apply to the intended
scope (global or per-monitor).
[[1]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L205-R399)
[[2]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L250-R413)
[[3]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L263-R424)
[[4]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L280-R437)
[[5]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L290-R447)
[[6]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L300-R465)
[[7]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L329-R491)

**Resource management:**

* Implemented `IDisposable` on `DockViewModel` to clean up event
handlers and prevent resource leaks.
[[1]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L17-R33)
[[2]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06R85-R235)

Closes #46939

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Mike Griese <migrie@microsoft.com>
2026-05-13 17:37:58 -05:00
Knyrps
34cebb8285 [CmdPal][PerfMon] Use % Processor Time for system CPU dock (#47864)
## Summary of the Pull Request

The system-wide CPU counter in the Command Palette Performance monitor
used `% Processor Utility`, which is scaled by `% Processor Performance`
and is intentionally unbounded above 100% when cores boost above their
nominal base frequency. Under load this produced values like 144% in the
dock, as reported in #46381.

Switch to `% Processor Time` — the same counter Task Manager renders —
which is naturally bounded to 0-100%.

The per-process branch is unaffected; it already divides by
`Environment.ProcessorCount` and continues to use `% Processor Time`, so
individual process readings remain correct.

## PR Checklist

- [x] **Closes:** #46381
- [x] **Communication:** I've discussed this with the team/maintainers
via the linked issue.
- [x] **Tests:** Manually tested locally. Built
`Microsoft.CmdPal.Ext.PerformanceMonitor` and the full
`CommandPalette.slnf` (Debug|x64). Verified the built DLL contains `%
Processor Time` and no longer contains `% Processor Utility`.
- [x] **Manual tests:** Ran the rebuilt dev build under sustained CPU
load; the system CPU figure now stays bounded to 100%.
- [x] **Localization:** Not applicable — counter name is an OS API
string, not user-visible text.

## Detailed Description of the Pull Request / Additional comments

`% Processor Utility` is documented by Microsoft as deliberately
unbounded; it represents an "effective" utilization that accounts for
turbo/boost frequencies. It is well-suited for billing/capacity contexts
but not for a "% of CPU used" UI element where users expect a 0-100%
reading consistent with Task Manager and Resource Monitor.

`_procPerformance` (the `% Processor Performance` counter) is retained
because it is still used by `CpuSpeed` to compute the live frequency
display, so removing it would regress the speed readout.

### Before
System CPU dock showed values >100% under load (reported 144% in the
issue).

### After
System CPU dock is bounded to 0-100%, matching Task Manager.
2026-05-13 16:43:16 -05:00
Mike Griese
9b28e6d5a2 cmdpal: bump to 0.11 (#47841)
title
2026-05-13 10:35:29 +02:00
Copilot
ba68b88ca1 Fix ZoomIt Record hotkey ignoring Alt modifier when Alt is the only modifier key (#47388)
## Summary of the Pull Request

ZoomIt derives three recording hotkeys from one base key via XOR:
fullscreen (base), crop (base XOR Shift), window (base XOR Alt). When
Alt is the sole modifier, `base XOR Alt = 0`, registering a
modifier-less hotkey that captures every bare keypress (e.g., pressing
`5` triggers window recording).

**`Zoomit.cpp`** — 4 hotkey registration sites:
- Guard `RECORD_CROP_HOTKEY` registration behind `(g_RecordToggleMod ^
MOD_SHIFT) != 0`
- Guard `RECORD_WINDOW_HOTKEY` registration behind `(g_RecordToggleMod ^
MOD_ALT) != 0`

```cpp
// Before (all 4 sites):
registerHotkey( RECORD_CROP_HOTKEY, ( g_RecordToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, ... );
registerHotkey( RECORD_WINDOW_HOTKEY, ( g_RecordToggleMod ^ MOD_ALT ) | MOD_NOREPEAT, ... );

// After:
if ( g_RecordToggleMod ^ MOD_SHIFT ) {
    registerHotkey( RECORD_CROP_HOTKEY, ( g_RecordToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, ... );
}
if ( g_RecordToggleMod ^ MOD_ALT ) {
    registerHotkey( RECORD_WINDOW_HOTKEY, ( g_RecordToggleMod ^ MOD_ALT ) | MOD_NOREPEAT, ... );
}
```

**`ZoomItViewModel.cs`** — `RecordToggleKeyCrop` /
`RecordToggleKeyWindow` computed properties:
- Return `null` when the derived hotkey would have no modifier keys, so
the Settings UI omits the inapplicable shortcut description rather than
displaying a bare-key shortcut.

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

## Detailed Description of the Pull Request / Additional comments

ZoomIt registers crop and window recording variants by XOR-ing the base
modifier with `MOD_SHIFT` and `MOD_ALT` respectively. This design breaks
when the base modifier equals the XOR target — the result is `0`, and
`RegisterHotKey` with no modifiers intercepts every bare keypress of
that key system-wide.

The fix is symmetric across all 4 registration sites (standalone
`registerHotkey()` in `RegisterAllHotkeys`, the legacy dialog OK path,
and two `WM_CREATE`-adjacent paths): skip the derived hotkey
registration when the computed modifier is zero. The Settings UI
ViewModel mirrors this logic by returning `null` for the affected
computed properties, causing the converter to emit an empty string
instead of a modifier-less shortcut label.

## Validation Steps Performed

- Code review confirmed fix is applied consistently across all 4
`RegisterHotKey` call sites in `Zoomit.cpp`
- Verified `HotkeySettingsToLocalizedStringConverter` returns
`string.Empty` for `null` input — no display regression in Settings UI
- Confirmed the default `Ctrl+5` hotkey is unaffected (`MOD_CONTROL ^
MOD_ALT = MOD_CONTROL | MOD_ALT ≠ 0`)

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MuyuanMS <116717757+MuyuanMS@users.noreply.github.com>
Co-authored-by: Muyuan Li (from Dev Box) <muyuanli@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 15:20:23 +08:00
Copilot
3e60249326 FancyZones Editor: Add translator comments for "Space around zones" and "Highlight distance" (#47226)
Two FancyZones Editor strings have misleading Japanese translations due
to ambiguous English phrasing:

- **"Space around zones"** → 「ゾーン周りのスペース」 (generic space/room) — should
be 「ゾーン周りの余白」 (margin/padding)
- **"Highlight distance"** → 「距離を強調表示」 (distance to visually emphasize)
— should be 「隣接するゾーンの検知距離」 (detection distance for adjacent zones)

## PR Checklist

- [ ] **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 to `FancyZonesEditor/Properties/Resources.resx`:

- **`Distance_adjacent_zones`** — Rewrote existing comment to explicitly
state this is a *detection/snapping proximity distance* (px at which a
nearby zone activates), not a visual-emphasis concept. Prevents
translators from reaching for 強調表示 ("visually highlight/emphasize").
- **`Space_Around_Zones`** — Added comment clarifying this is
*padding/margin* (empty gap in pixels) between zones, not a generic
space/area. Guides translators toward 余白 over スペース.
- **`Show_Space_Zones`** — Added comment clarifying this is a toggle for
enabling/disabling the padding display.

```xml
<data name="Distance_adjacent_zones" xml:space="preserve">
  <value>Highlight distance</value>
  <comment>The pixel distance at which an adjacent zone highlights (lights up) when a window is dragged near it. This is about detection/snapping range proximity, not about making something visually stand out.</comment>
</data>

<data name="Space_Around_Zones" xml:space="preserve">
  <value>Space around zones</value>
  <comment>The size (in pixels) of the padding or margin (empty gap) surrounding each zone. This is about blank space/whitespace between zones, not an area or region.</comment>
</data>
```

## Validation Steps Performed

Verified `Resources.resx` is well-formed XML and that the modified
entries render correctly in the resource file. No runtime behavior is
changed — comments are translator-only metadata consumed by the
Touchdown Build localization pipeline.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-05-13 14:20:33 +08:00
Copilot
af1c0a313c Fix Korean mistranslation of "Activation modifier key" in Grab And Move settings (#47352)
The Korean translation of "Activation modifier key"
(`GrabAndMove_ModifierKey.Header`) was rendered as "정품인증 보조키" ("product
authentication/genuine certification modifier key") — conflating feature
activation with Windows product licensing. The correct translation is
"활성화 보조 키".

## Change

- **`src/settings-ui/Settings.UI/Strings/en-us/Resources.resw`**:
Expanded the `<comment>` on `GrabAndMove_ModifierKey.Header` to
explicitly disambiguate "Activation" for translators:

```xml
<comment>Drop-down to choose which modifier key (Alt or Win) activates Grab And Move drag and resize.
"Activation" here means to enable/trigger the feature (활성화 in Korean),
NOT product authentication/genuine certification (정품인증 in Korean).</comment>
```

## PR Checklist

- [ ] **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

The word "Activation" is ambiguous in Korean: it can refer to Windows
product license activation (정품인증) or feature enablement (활성화). The
localization pipeline picked the wrong interpretation. Adding an
explicit in-source comment with both the correct and incorrect Korean
terms guides the translation tooling and human reviewers to use "활성화 보조
키" going forward.

## Validation Steps Performed

- Verified the updated comment appears correctly in the `.resw` file.
- No runtime behavior changes; comment-only modification to the resource
file.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-05-13 14:20:20 +08:00
Copilot
3b76597981 Power Display: Add "do not translate" comments to all product name references (#47351)
The German locale translated the system tray tooltip `AppName` ("Power
Display") to "Leistungsanzeige", despite the Settings UI keeping the
utility name untranslated. The affected resource entries had no
translator guidance, so "Power Display" was treated as a localizable
phrase rather than a fixed product name. This PR adds consistent "do not
translate" comments across all touchpoints in both resource files.

## Summary of the Pull Request

- `src/modules/powerdisplay/PowerDisplay/Strings/en-us/Resources.resw`:
Added `Product name, do not translate.` comment to the `AppName` entry
(system tray tooltip)
- `src/settings-ui/Settings.UI/Strings/en-us/Resources.resw`: Added
`{Locked="Power Display"}` comments to all 15 entries containing "Power
Display" — including the module title, enable/toggle/open/launch
strings, OOBE title/description/activation text, LearnMore link,
QuickProfiles description, group header, and flyout header — following
the same convention already used for FancyZones and other product names
in that file

## PR Checklist

- [ ] **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
- [x] **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

The system tray tooltip is populated via `GetString("AppName")` in
`TrayIconService.cs`. Because the resource had no comment, translators
localized "Power Display" as a common phrase ("performance display")
rather than treating it as a fixed product name.

To ensure consistency across all touchpoints, `{Locked="Power Display"}`
comments have been added to every entry in the Settings UI resource file
that contains the product name, matching the convention already in use
for `Shell_PowerDisplay.Content` (`Product name: Navigation view item
name for Power Display`) and for other utilities such as FancyZones
(`{Locked="FancyZones"}`).

## Validation Steps Performed

Verified that all `AppName` and Settings UI resource entries containing
"Power Display" now carry the appropriate translator comment. No runtime
behavior changes — the English values are unchanged; the comments solely
guide translators to leave the product name as-is in all locales.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-05-13 14:20:02 +08:00
156 changed files with 7116 additions and 3413 deletions

View File

@@ -186,6 +186,12 @@ xmlutil
# Prefix
pcs
# EXPRTK / C++ MATH
ifunction
isinf
isnan
# User32.SYSTEM_METRICS_INDEX.cs
CLEANBOOT
@@ -355,6 +361,7 @@ URLIS
WAITTIMEOUT
DEFAULTTONEAREST
# COM/WinRT interface prefixes and type fragments
BAlt
BShift

View File

@@ -143,3 +143,4 @@ ignore$
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
^src/modules/powerrename/unittests/testdata/avif_test\.avif$
^src/modules/powerrename/unittests/testdata/heif_test\.heic$
^deps/spdlog-msvc-fix/

View File

@@ -215,6 +215,7 @@ clickable
clickonce
clientedge
clientside
cliextensions
CLIPBOARDUPDATE
CLIPCHILDREN
CLIPSIBLINGS
@@ -360,7 +361,6 @@ DEFAULTICON
defaultlib
DEFAULTONLY
DEFAULTSIZE
DEFAULTTONEAREST
DEFAULTTONULL
DEFAULTTOPRIMARY
DEFERERASE
@@ -440,6 +440,7 @@ DString
DSVG
dto
DUMMYUNIONNAME
dumpbin
dutil
DVASPECT
DVASPECTINFO
@@ -710,6 +711,7 @@ HOOKPROC
HORZRES
HORZSIZE
Hostbackdropbrush
hostfxr
hostsfileeditor
hotfixes
hotkeycontrol
@@ -1022,6 +1024,7 @@ Metadatas
metafile
metapackage
mfc
mfcm
Mgmt
Microwaved
middleclickaction
@@ -1449,6 +1452,7 @@ ptcontrols
ptd
PTOKEN
ptstr
ptsym
pui
pvct
PWAs
@@ -1588,7 +1592,6 @@ scrollviewer
sddl
SDKDDK
sdns
SDTVDONGLE
searchterm
SEARCHUI
secondaryclickaction
@@ -1896,6 +1899,7 @@ trx
tsa
tskill
tstoi
tsv
tweakable
TWF
tymed
@@ -1934,8 +1938,8 @@ unitconverter
unittests
UNLEN
UNORM
unparsable
unremapped
unsubscribes
untriaged
unvirtualized
unwide

View File

@@ -1,12 +1,12 @@
---
name: release-note-generation
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, request Copilot reviews for PRs, update README for a new release, manage PR milestones, or collect PRs between commits/tags. Supports PR collection by milestone or commit range, milestone assignment, grouping by label, summarization with external contributor attribution, and README version bumping.
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, generate PR review summaries locally for release notes, update README for a new release, manage PR milestones, collect PRs between commits/tags, or prepare release assets (download installers and compute installer hashes).
license: Complete terms in LICENSE.txt
---
# Release Note Generation Skill
Generate professional release notes for PowerToys milestones by collecting merged PRs, requesting Copilot code reviews, grouping by label, and producing user-facing summaries.
Generate professional release notes for PowerToys milestones by collecting merged PRs, summarizing each PR with the local CLI agent, grouping by label, and producing user-facing summaries.
## Output Directory
@@ -26,16 +26,17 @@ Generated Files/ReleaseNotes/
- Generate release notes for a milestone
- Summarize PRs merged in a release
- Request Copilot reviews for milestone PRs
- Generate per-PR review summaries locally for release-notes copy
- Assign milestones to PRs missing them
- Collect PRs between two commits/tags
- Update README.md for a new version
- Prepare GitHub release assets (download installers/symbols + compute hashes)
## Prerequisites
- **GitHub CLI (`gh`) installed and authenticated** — The collection script uses `gh pr view` and `gh api graphql` to fetch PR metadata and co-author information. Run `gh auth status` to verify; if not logged in, run `gh auth login` first. See [Step 1.0.0](./references/step1-collection.md) for details.
- MCP Server: github-mcp-server installed
- GitHub Copilot code review enabled for the org/repo
- MCP Server: github-mcp-server installed (used to fetch PR diffs/files for the local-agent review step)
- For [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) only: **Azure CLI** authenticated against the Microsoft tenant (`az login`) with the `azure-devops` extension; access to the `microsoft/Dart` ADO project
## Required Variables
@@ -65,12 +66,12 @@ Generated Files/ReleaseNotes/
└────────────────────────────────┘
┌────────────────────────────────┐
│ 3.1 Request Reviews (Copilot)
│ 3.1 Local-agent PR summaries
│ (writes CopilotSummary) │
└────────────────────────────────┘
┌────────────────────────────────┐
│ 3.2 Refresh PR data
│ (CopilotSummary) │
│ 3.2 (Optional) Refresh PR data │
└────────────────────────────────┘
┌────────────────────────────────┐
@@ -93,7 +94,7 @@ Generated Files/ReleaseNotes/
| 1.1 | Collect PRs | From previous release tag on `stable` branch → `sorted_prs.csv` |
| 1.2 | Assign Milestones | Ensure all PRs have correct milestone |
| 2.12.4 | Label PRs | Auto-suggest + human label low-confidence |
| 3.13.3 | Reviews & Grouping | Request Copilot reviews → refresh → group by label |
| 3.13.3 | Reviews & Grouping | Local agent summarizes each PR diff into `CopilotSummary` → (optional refresh) → group by label |
| 4.14.2 | Summaries & Final | Generate grouped summaries, then consolidate |
## Detailed workflow docs
@@ -114,6 +115,7 @@ Do not read all steps at once—only read the step you are executing.
| [group-prs-by-label.ps1](./scripts/group-prs-by-label.ps1) | Group PRs into CSVs |
| [collect-or-apply-milestones.ps1](./scripts/collect-or-apply-milestones.ps1) | Assign milestones |
| [diff_prs.ps1](./scripts/diff_prs.ps1) | Incremental PR diff |
| [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) | Download installers + symbols from an ADO build, compute SHA256, emit the "Installer Hashes" markdown table for the GitHub release page |
## References
@@ -133,5 +135,6 @@ Do not read all steps at once—only read the step you are executing.
|-------|----------|
| `gh` command not found | Install GitHub CLI and add to PATH |
| No PRs returned | Verify milestone title matches exactly |
| Empty CopilotSummary | Request Copilot reviews first, then re-run dump |
| Empty `CopilotSummary` for many PRs | Run Step 3.1 (local-agent summaries). Do **not** use `mcp_github_request_copilot_review` from a CLI/coding agent — the GitHub API rejects bot-initiated review requests, so the column will stay empty. |
| Many unlabeled PRs | Return to labeling step before grouping |
| `prepare-release-assets.ps1` fails with "Failed to acquire ADO access token" | Run `az login` and ensure you have access to the `microsoft/Dart` ADO project |

View File

@@ -1,22 +1,40 @@
# Step 3: Copilot Reviews and Grouping
# Step 3: Local Agent Reviews and Grouping
## 3.0 To-do
- 3.1 Request Copilot Reviews (Agent Mode)
- 3.2 Refresh PR Data
- 3.1 Generate PR Summaries with the Local Agent
- 3.2 (Optional) Refresh PR Data
- 3.3 Group PRs by Label
## 3.1 Request Copilot Reviews (Agent Mode)
## 3.1 Generate PR Summaries with the Local Agent
Use MCP tools to request Copilot reviews for all PRs in `Generated Files/ReleaseNotes/sorted_prs.csv`:
> ⚠️ **Do not use `mcp_github_request_copilot_review` (or any "request Copilot review" tool that calls the GitHub API).**
> When this skill is driven from a CLI / coding agent, the request is made from a bot identity and the GitHub API rejects it ("Bot reviewers cannot be requested"). The PR ends up with no Copilot review and `CopilotSummary` stays empty.
>
> Instead, **the local agent that is running this skill performs the review itself** and writes the summary directly into `sorted_prs.csv`.
- Use `mcp_github_request_copilot_review` for each PR ID
- Do NOT generate or run scripts for this step
For every PR listed in `Generated Files/ReleaseNotes/sorted_prs.csv` whose `CopilotSummary` is empty:
1. Fetch the PR diff using a tool that does **not** post anything back to GitHub. Any of these works:
- `mcp_github_pull_request_read` with `method: get_diff`
- `mcp_github_pull_request_read` with `method: get_files` (when the diff is large)
- `gh pr diff <PR_NUMBER> --repo microsoft/PowerToys`
2. Read the PR title, body, and diff. Produce a 13 sentence, user-facing summary in the same style as a Copilot PR review (focus on observable behavior change, not implementation details).
3. Write the summary into the `CopilotSummary` column for that PR row in `Generated Files/ReleaseNotes/sorted_prs.csv`. Preserve all other columns and the existing row order.
**Batching guidance**
- Process PRs in the order they appear in `sorted_prs.csv`.
- Generate summaries for **all** PRs in one pass before continuing to Step 3.3, so the human reviewer can validate them together.
- For very large diffs, summarize from `get_files` (filenames + per-file patches) rather than the full diff.
- Skip PRs that already have a non-empty `CopilotSummary` (e.g. PRs where a human reviewer already pasted one). Do not overwrite existing summaries.
**Why not post the summary back to the PR?** Posting a comment from the agent's identity would not be picked up by `dump-prs-since-commit.ps1` (which only matches Copilot bot authors), and it adds noise to the PR. Writing straight into the CSV keeps the artifact self-contained.
---
## 3.2 Refresh PR Data
## 3.2 (Optional) Refresh PR Data
Re-run the collection script to capture Copilot review summaries into the `CopilotSummary` column:
Only re-run the collection script if PR metadata on GitHub has changed (new labels, retitled PRs, etc.) since Step 1.1. **Skip this step if you only want to preserve the locally generated `CopilotSummary` values from Step 3.1**, because re-running the dump will overwrite the CSV.
```powershell
pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
@@ -24,6 +42,8 @@ pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1
-OutputDir 'Generated Files/ReleaseNotes'
```
If you do refresh, redo Step 3.1 afterwards to repopulate `CopilotSummary`.
---
## 3.3 Group PRs by Label
@@ -35,3 +55,4 @@ pwsh ./.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 -Cs
Creates `Generated Files/ReleaseNotes/grouped_csv/` with one CSV per label combination.
**Validation:** The `Unlabeled.csv` file should be minimal (ideally empty). If many PRs remain unlabeled, return to Step 2 (see [step2-labeling.md](./step2-labeling.md)).

View File

@@ -0,0 +1,334 @@
<#
.SYNOPSIS
Prepares the binary assets for a PowerToys GitHub release: downloads the
four installers (per-user/per-machine x x64/arm64) and the symbol archives
from an ADO pipeline build, computes SHA256 hashes, and emits the
"Installer Hashes" markdown table.
.DESCRIPTION
Given an ADO Dart pipeline build id (e.g. from
https://microsoft.visualstudio.com/Dart/_build/results?buildId=NNN),
downloads the four installer EXEs and the per-arch symbol zips into a
single per-version folder, then writes a hashes.md alongside them with a
markdown table ready to paste into the GitHub release notes.
Requires: az login (Azure CLI authenticated), az devops extension.
.EXAMPLE
.\prepare-release-assets.ps1 -BuildId 145505247
.\prepare-release-assets.ps1 -BuildId 145505247 -OutputFolder D:\Releases
#>
param(
[Parameter(Mandatory = $true)]
[int]$BuildId,
[string]$OutputFolder = "$env:USERPROFILE\Downloads",
[string]$Organization = "https://dev.azure.com/microsoft",
[string]$Project = "Dart",
[string]$GitHubRepo = "microsoft/PowerToys"
)
$ErrorActionPreference = "Stop"
$env:AZURE_CORE_NO_PROMPT = "true"
# --- Helpers -----------------------------------------------------------------
# Invoke an `az` CLI command and capture stderr in $script:LastAzError so
# callers can surface the underlying message (expired login, blocked extension,
# tenant policy, ...) instead of swallowing it with `2>$null`.
function Invoke-Az {
$tmpErr = [System.IO.Path]::GetTempFileName()
try {
$output = & az @args 2>$tmpErr
# Get-Content -Raw returns $null for an empty file, and calling .Trim()
# on $null throws under $ErrorActionPreference = 'Stop' -- which would
# turn every successful (no-stderr) az call into a fatal error. Guard
# explicitly so $script:LastAzError is always a (possibly empty) string.
$rawErr = Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue
$script:LastAzError = if ($null -eq $rawErr) { '' } else { $rawErr.Trim() }
return $output
}
finally {
Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue
}
}
# Build an ADO artifact download URL from scratch instead of regex-replacing
# the URL returned by `az pipelines runs artifact list`. Preserves any other
# query parameters and only swaps `format` and `subPath`, so we don't break if
# the upstream URL shape ever changes.
function Get-ArtifactDownloadUrl {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$SubPath,
[Parameter(Mandatory)][ValidateSet('file', 'zip')][string]$Format
)
$encodedSubPath = [Uri]::EscapeDataString($SubPath)
$idx = $BaseUrl.IndexOf('?')
if ($idx -lt 0) {
return "${BaseUrl}?format=${Format}&subPath=${encodedSubPath}"
}
$base = $BaseUrl.Substring(0, $idx)
$kept = $BaseUrl.Substring($idx + 1) -split '&' | Where-Object {
$_ -and -not ($_ -match '^(format|subPath)=')
}
$kept = @($kept) + @("format=$Format", "subPath=$encodedSubPath")
return "${base}?$($kept -join '&')"
}
# Download a single ADO artifact file with bearer auth and a small retry/backoff
# loop. A transient network blip on a ~200 MB installer or symbol zip otherwise
# aborts the entire release-prep run.
function Invoke-AdoDownload {
param(
[Parameter(Mandatory)][string]$Url,
[Parameter(Mandatory)][string]$DestPath,
[Parameter(Mandatory)][string]$Token,
[int]$MaxAttempts = 3
)
$lastError = $null
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("Authorization", "Bearer $Token")
try {
$webClient.DownloadFile($Url, $DestPath)
return
}
catch {
$lastError = $_
if (Test-Path $DestPath) {
Remove-Item $DestPath -Force -ErrorAction SilentlyContinue
}
if ($attempt -lt $MaxAttempts) {
$backoffSec = [int][Math]::Pow(2, $attempt) # 2, 4, 8 ...
Write-Host " Attempt $attempt failed: $($_.Exception.Message). Retrying in ${backoffSec}s..." -ForegroundColor Yellow
Start-Sleep -Seconds $backoffSec
}
}
finally {
$webClient.Dispose()
}
}
throw "Download failed after $MaxAttempts attempts. Last error: $($lastError.Exception.Message)`nURL: $Url"
}
# -----------------------------------------------------------------------------
# Work around broken az extensions: if the default extension dir has
# inaccessible files, redirect to a clean directory.
$defaultExtDir = "$env:USERPROFILE\.azure\cliextensions"
if (-not $env:AZURE_EXTENSION_DIR -and (Test-Path $defaultExtDir)) {
$broken = Get-ChildItem "$defaultExtDir\*\*.dist-info" -Directory -ErrorAction SilentlyContinue | Where-Object {
try { [System.IO.Directory]::GetFiles($_.FullName) | Out-Null; $false } catch { $true }
}
if ($broken) {
$cleanDir = "$env:USERPROFILE\.azure\cliextensions_clean"
Write-Host " Detected broken az extension, redirecting to $cleanDir" -ForegroundColor Yellow
$env:AZURE_EXTENSION_DIR = $cleanDir
if (-not (Test-Path $cleanDir)) { New-Item -ItemType Directory -Path $cleanDir -Force | Out-Null }
}
}
# Ensure azure-devops extension is installed
$ext = Invoke-Az extension list --query "[?name=='azure-devops']" -o tsv
if (-not $ext) {
Write-Host "Installing azure-devops extension..." -ForegroundColor Yellow
Invoke-Az extension add --name azure-devops --yes | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install azure-devops extension. (az: $script:LastAzError)"
exit 1
}
}
# Configure az devops defaults
Invoke-Az devops configure --defaults organization=$Organization project=$Project | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to configure az devops defaults. (az: $script:LastAzError)"
exit 1
}
# --- Step 1: Get build info to determine version ---
Write-Host "Fetching build $BuildId info..." -ForegroundColor Cyan
$buildJson = Invoke-Az pipelines build show --id $BuildId --output json
if (-not $buildJson) {
Write-Error "Could not fetch build $BuildId. Are you logged in (az login)? (az: $script:LastAzError)"
exit 1
}
$build = $buildJson | ConvertFrom-Json
$versionParam = $build.templateParameters.VersionNumber
if (-not $versionParam) {
Write-Error "Could not determine version from build $BuildId"
exit 1
}
Write-Host " Version: $versionParam" -ForegroundColor DarkGray
# --- Step 2: Get artifact metadata once ---
Write-Host "Fetching artifact metadata..." -ForegroundColor Cyan
$artifactsJson = Invoke-Az pipelines runs artifact list --run-id $BuildId --output json
if (-not $artifactsJson) {
Write-Error "Could not list artifacts for build $BuildId. (az: $script:LastAzError)"
exit 1
}
$artifacts = $artifactsJson | ConvertFrom-Json
# --- Step 3: Prepare destination folder ---
$destFolder = Join-Path $OutputFolder "PowerToys-v$versionParam"
if (-not (Test-Path $destFolder)) {
New-Item -ItemType Directory -Path $destFolder -Force | Out-Null
}
Write-Host " Destination: $destFolder" -ForegroundColor DarkGray
# --- Step 4: Get an ADO access token once ---
$token = Invoke-Az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query accessToken -o tsv
if (-not $token) {
Write-Error "Failed to acquire ADO access token. Run 'az login' first. (az: $script:LastAzError)"
exit 1
}
# --- Step 5: Define the four installers to download ---
$targets = @(
[pscustomobject]@{ Description = "Per user - x64"; Scope = "perUser"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysUserSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Per user - ARM64"; Scope = "perUser"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysUserSetup-$versionParam-arm64.exe" }
[pscustomobject]@{ Description = "Machine wide - x64"; Scope = "perMachine"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Machine wide - ARM64"; Scope = "perMachine"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysSetup-$versionParam-arm64.exe" }
)
# --- Step 6: Download each installer (skip if already present) ---
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
if (Test-Path $destPath) {
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host "[skip] $($t.FileName) already exists ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $t.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($t.Artifact)' not found in build $BuildId. Available: $(($artifacts | ForEach-Object name) -join ', ')"
exit 1
}
$fileUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath "/$($t.FileName)" -Format file
Write-Host "Downloading $($t.FileName) ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $fileUrl -DestPath $destPath -Token $token
}
catch {
Write-Error "Download failed for $($t.FileName): $_"
exit 1
}
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host " Saved ($sizeMB MB)" -ForegroundColor Green
}
# --- Step 6b: Download symbols (one zip per arch) ---
$symbolTargets = @(
[pscustomobject]@{ Arch = "x64"; Artifact = "build-x64-Release"; SubPath = "/symbols-x64" }
[pscustomobject]@{ Arch = "arm64"; Artifact = "build-arm64-Release"; SubPath = "/symbols-arm64" }
)
foreach ($s in $symbolTargets) {
$finalZip = Join-Path $destFolder "symbols-$($s.Arch).zip"
if (Test-Path $finalZip) {
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host "[skip] symbols-$($s.Arch).zip already exists ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $s.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($s.Artifact)' not found in build $BuildId."
exit 1
}
# Symbols are downloaded as a folder => keep format=zip and append subPath
$symbolsUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath $s.SubPath -Format zip
$tmpZip = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N')).zip")
$tmpExtract = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N'))")
$stageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-stage-$([Guid]::NewGuid().ToString('N'))")
try {
Write-Host "Downloading symbols-$($s.Arch).zip ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $symbolsUrl -DestPath $tmpZip -Token $token
}
catch {
Write-Error "Symbols download failed for $($s.Arch): $_"
exit 1
}
Write-Host " Extracting..." -ForegroundColor DarkGray
Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force
# Walk down while the current dir holds exactly one subfolder and no files.
$current = Get-Item $tmpExtract
while ($true) {
$children = Get-ChildItem -LiteralPath $current.FullName -Force
$subDirs = @($children | Where-Object { $_.PSIsContainer })
$files = @($children | Where-Object { -not $_.PSIsContainer })
if ($subDirs.Count -eq 1 -and $files.Count -eq 0) {
$current = $subDirs[0]
}
else {
break
}
}
# Stage to a folder named symbols-<arch> so the zip extracts to that name.
$stageInner = Join-Path $stageRoot "symbols-$($s.Arch)"
New-Item -ItemType Directory -Path $stageInner -Force | Out-Null
Get-ChildItem -LiteralPath $current.FullName -Force | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $stageInner -Recurse -Force
}
Write-Host " Repacking to $finalZip ..." -ForegroundColor DarkGray
if (Test-Path $finalZip) { Remove-Item $finalZip -Force }
Compress-Archive -Path "$stageInner\*" -DestinationPath $finalZip -CompressionLevel Optimal
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host " Saved symbols-$($s.Arch).zip ($sizeMB MB)" -ForegroundColor Green
}
catch {
# Don't leave a half-built zip behind if anything in the pipeline blew up.
if (Test-Path $finalZip) { Remove-Item $finalZip -Force -ErrorAction SilentlyContinue }
throw
}
finally {
Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue
Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
# --- Step 7: Compute SHA256 and build markdown ---
Write-Host "`nComputing SHA256 hashes..." -ForegroundColor Cyan
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("## Installer Hashes")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("| Description | Filename | sha256 hash |")
[void]$sb.AppendLine("| --- | --- | --- |")
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
$hash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpper()
[void]$sb.AppendLine("| $($t.Description) | $($t.FileName) | $hash |")
Write-Host " $($t.FileName) $hash" -ForegroundColor DarkGray
}
$markdown = $sb.ToString()
$mdPath = Join-Path $destFolder "hashes.md"
Set-Content -Path $mdPath -Value $markdown -Encoding UTF8
Write-Host "`nMarkdown written to: $mdPath" -ForegroundColor Green
Write-Host "`n----- Installer Hashes -----`n" -ForegroundColor Yellow
Write-Host $markdown
Write-Host "Draft a new GitHub release at: https://github.com/$GitHubRepo/releases/new?tag=v$versionParam" -ForegroundColor Green

View File

@@ -1,9 +1,9 @@
name: Auto-label Issues by Area
name: Automatic Triaging on Issue Creation
on:
issues:
types: [opened, reopened]
# Manual trigger: go to Actions → "Auto-label Issues by Area" → Run workflow.
# Manual trigger: go to Actions → "Automatic Triaging on Issue Creation" → Run workflow.
# Enter one or more comma-separated issue numbers (e.g. "1234" or "1234,1235,1236")
# to apply AI-generated area labels to existing untriaged issues.
workflow_dispatch:
@@ -28,7 +28,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply area labels with AI
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
# actions/github-script does not propagate `github-token` to
# process.env. Expose it explicitly so the inline script can
# authenticate against the GitHub Models inference endpoint.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

View File

@@ -1,275 +0,0 @@
name: Auto Label Product on Issue Creation
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
mode:
description: 'single: label one issue, batch: label all issues missing Product- labels'
required: true
type: choice
options:
- single
- batch
default: single
issue_number:
description: 'Issue number (only used in single mode)'
required: false
type: number
dry_run:
description: 'If true, only log what labels would be applied without applying them'
required: false
type: boolean
default: true
batch_limit:
description: 'Max issues to process in batch mode (default: 50)'
required: false
type: number
default: 50
permissions:
issues: write
models: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || 'batch' }}
cancel-in-progress: true
jobs:
label-product:
runs-on: ubuntu-latest
steps:
- name: Auto-apply Product labels
uses: actions/github-script@v7
with:
script: |
const isManual = context.eventName === 'workflow_dispatch';
const dryRun = isManual ? (context.payload.inputs.dry_run === 'true') : false;
const batchMode = isManual && context.payload.inputs.mode === 'batch';
const batchLimit = isManual ? parseInt(context.payload.inputs.batch_limit || '50') : 1;
// Mapping from issue template "Area(s) with issue?" values to Product- labels
const AREA_TO_LABEL = {
'Advanced Paste': 'Product-Advanced Paste',
'Always on Top': 'Product-Always On Top',
'Awake': 'Product-Awake',
'ColorPicker': 'Product-Color Picker',
'Command not found': 'Product-CommandNotFound',
'Command Palette': 'Product-Command Palette',
'Crop and Lock': 'Product-CropAndLock',
'Environment Variables': 'Product-Environment Variables',
'FancyZones': 'Product-FancyZones',
'FancyZones Editor': 'Product-FancyZones',
'File Locksmith': 'Product-File Locksmith',
'File Explorer: Preview Pane': 'Product-File Explorer',
'File Explorer: Thumbnail preview': 'Product-File Explorer',
'Hosts File Editor': 'Product-Hosts File Editor',
'Image Resizer': 'Product-Image Resizer',
'Keyboard Manager': 'Product-Keyboard Shortcut Manager',
'Light Switch': 'Product-LightSwitch',
'Mouse Utilities': 'Product-Mouse Utilities',
'Mouse Without Borders': 'Product-Mouse Without Borders',
'New+': 'Product-New+',
'Peek': 'Product-Peek',
'Power Display': 'Product-PowerDisplay',
'PowerRename': 'Product-PowerRename',
'PowerToys Run': 'Product-PowerToys Run',
'Quick Accent': 'Product-Quick Accent',
'Registry Preview': 'Product-Registry Preview',
'Screen ruler': 'Product-Screen Ruler',
'Settings': 'Product-Settings',
'Shortcut Guide': 'Product-Shortcut Guide',
'TextExtractor': 'Product-Text Extractor',
'Workspaces': 'Product-Workspaces',
'ZoomIt': 'Product-ZoomIt',
'General': 'Product-General',
'Grab And Move': 'Product-Grab And Move',
};
const ALL_PRODUCT_LABELS = [...new Set(Object.values(AREA_TO_LABEL))].sort();
// ─── Collect issues to process ───
let issues = [];
if (batchMode) {
// Fetch open issues that have no Product-* label
core.info(`Batch mode: fetching up to ${batchLimit} issues without Product- labels...`);
let page = 1;
while (issues.length < batchLimit) {
const { data } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
page: page++,
sort: 'created',
direction: 'desc',
});
if (data.length === 0) break;
for (const issue of data) {
if (issue.pull_request) continue; // skip PRs
const hasProductLabel = issue.labels.some(l => l.name.startsWith('Product-'));
if (!hasProductLabel) {
issues.push(issue);
if (issues.length >= batchLimit) break;
}
}
}
core.info(`Found ${issues.length} issues to process.`);
} else if (isManual) {
const issueNumber = parseInt(context.payload.inputs.issue_number);
if (!issueNumber) { core.setFailed('issue_number is required in single mode'); return; }
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issues = [data];
} else {
issues = [context.payload.issue];
}
// ─── Process each issue ───
const summaryRows = [];
const labelExistsCache = new Map();
for (const issue of issues) {
const body = issue.body || '';
const title = issue.title || '';
// Parse the "Area(s) with issue?" field
const areaMatch = body.match(/### Area\(s\) with issue\?\s*\r?\n\r?\n([\s\S]*?)(?=\r?\n\r?\n###|\r?\n*$)/);
let selectedAreas = [];
if (areaMatch) {
const areaText = areaMatch[1].trim();
selectedAreas = areaText.split(',').map(s => s.trim()).filter(Boolean);
}
// Resolve labels from the structured field
const resolvedLabels = new Set();
for (const area of selectedAreas) {
if (AREA_TO_LABEL[area]) {
resolvedLabels.add(AREA_TO_LABEL[area]);
}
}
// AI fallback if no deterministic match
if (resolvedLabels.size === 0) {
core.info(`#${issue.number}: No deterministic match, trying AI inference...`);
try {
const prompt = `You are a GitHub issue triage assistant for the PowerToys project.
Given the following issue title and body, determine which PowerToys product(s) this issue is PRIMARILY about.
Rules:
- Only include products the issue is directly reporting a bug for or requesting a feature in.
- Do NOT include products that are merely mentioned as examples or comparisons.
- When in doubt, prefer fewer labels over more. One correct label is better than many guesses.
- If the issue is about general PowerToys infrastructure (installer, settings app, system tray), use "Product-General" or "Product-Settings" as appropriate.
Respond with ONLY a JSON array of label strings from this list:
${JSON.stringify(ALL_PRODUCT_LABELS)}
If you cannot determine the product, respond with an empty array: []
Issue title: ${title}
Issue body (first 2000 chars):
${body.substring(0, 2000)}`;
const response = await fetch('https://models.github.ai/inference/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-4.1-mini',
messages: [{ role: 'user', content: prompt }],
temperature: 0,
}),
});
if (response.ok) {
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '';
const jsonMatch = content.match(/\[[\s\S]*?\]/);
if (jsonMatch) {
const inferred = JSON.parse(jsonMatch[0]);
for (const label of inferred) {
if (ALL_PRODUCT_LABELS.includes(label)) {
resolvedLabels.add(label);
}
}
}
core.info(`#${issue.number}: AI inferred: ${[...resolvedLabels].join(', ') || '(none)'}`);
} else {
core.warning(`#${issue.number}: AI inference failed (${response.status})`);
}
} catch (err) {
core.warning(`#${issue.number}: AI error: ${err.message}`);
}
}
if (resolvedLabels.size === 0) {
summaryRows.push([`#${issue.number}`, title.substring(0, 60), '(none)', 'skipped']);
continue;
}
// Validate labels exist (cached to reduce API calls in batch mode)
const labelsToApply = [];
for (const label of resolvedLabels) {
let exists = labelExistsCache.get(label);
if (exists === undefined) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
exists = true;
} catch (err) {
if (err.status === 404) {
exists = false;
} else {
throw err;
}
}
labelExistsCache.set(label, exists);
}
if (exists) {
labelsToApply.push(label);
} else {
core.warning(`Label "${label}" not found in repo, skipping.`);
}
}
if (labelsToApply.length === 0) {
summaryRows.push([`#${issue.number}`, title.substring(0, 60), '(labels not found)', 'skipped']);
continue;
}
// Apply or dry-run
if (dryRun) {
core.info(`[DRY RUN] #${issue.number}: would apply ${labelsToApply.join(', ')}`);
summaryRows.push([`#${issue.number}`, title.substring(0, 60), labelsToApply.join(', '), 'dry-run']);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labelsToApply,
});
core.info(`#${issue.number}: applied ${labelsToApply.join(', ')}`);
summaryRows.push([`#${issue.number}`, title.substring(0, 60), labelsToApply.join(', '), 'applied']);
}
}
// Write job summary
if (summaryRows.length > 0) {
core.summary.addHeading(`Auto-Label Results (${dryRun ? 'Dry Run' : 'Applied'})`, 3);
core.summary.addTable([
[{data: 'Issue', header: true}, {data: 'Title', header: true}, {data: 'Labels', header: true}, {data: 'Status', header: true}],
...summaryRows,
]);
await core.summary.write();
}

View File

@@ -64,7 +64,7 @@ jobs:
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '9.0'
version: '10.0'
- template: .\steps-restore-nuget.yml

View File

@@ -27,6 +27,7 @@ stages:
name: SHINE-INT-L
${{ else }}:
name: SHINE-OSS-L
demands: ImageOverride -equals SHINE-VS18-Latest
buildPlatforms:
- ${{ parameters.platform }}
uiTestModules: ${{ parameters.uiTestModules }}

View File

@@ -65,4 +65,20 @@
<!-- Note: For C++ skipped test projects, build is effectively skipped by removing all compile items above.
We don't define empty Build/Rebuild/Clean targets here because MSBuild Target definitions with Condition
on the Target element still override the default targets even when condition is false. -->
<!-- Clean up unused VC++ runtime DLLs that CopyCppRuntimeToOutputDir copies from the full
VCRedist tree (MFC, C++ AMP, OpenMP). No PowerToys binary links against these — verified
with dumpbin /dependents across all installed binaries. -->
<Target Name="RemoveUnusedVCRuntimeDlls"
AfterTargets="Build"
Condition="'$(CopyCppRuntimeToOutputDir)' == 'true' and '$(MSBuildProjectExtension)' == '.vcxproj'">
<ItemGroup>
<_UnusedVCRuntimeDlls Include="$(OutDir)mfc140*.dll" />
<_UnusedVCRuntimeDlls Include="$(OutDir)mfcm140*.dll" />
<_UnusedVCRuntimeDlls Include="$(OutDir)vcamp140*.dll" />
<_UnusedVCRuntimeDlls Include="$(OutDir)vcomp140*.dll" />
</ItemGroup>
<Delete Files="@(_UnusedVCRuntimeDlls)" Condition="'@(_UnusedVCRuntimeDlls)' != ''" />
<Message Importance="normal" Text="Cleaned up unused VC runtime DLLs: @(_UnusedVCRuntimeDlls)" Condition="'@(_UnusedVCRuntimeDlls)' != ''" />
</Target>
</Project>

View File

@@ -110,8 +110,6 @@
<PackageVersion Include="System.ComponentModel.Composition" Version="10.0.7" />
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="10.0.7" />
<PackageVersion Include="System.Data.OleDb" Version="10.0.7" />
<!-- Package System.Data.SqlClient added to force it as a dependency of Microsoft.Windows.Compatibility to the latest version available at this time. -->
<PackageVersion Include="System.Data.SqlClient" Version="4.9.1" />
<!-- Package System.Diagnostics.EventLog added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Data.OleDb but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.7" />
<!-- Package System.Diagnostics.PerformanceCounter added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.11. -->

View File

@@ -0,0 +1,94 @@
// spdlog-msvc-fix.h
//
// Workaround for MSVC 14.51 (compiler version 19.51, _MSC_VER >= 1951) removing
// stdext::checked_array_iterator. Force-included for all spdlog consumers via
// deps/spdlog.props, because spdlog v1.8.5's bundled fmt format.h(357) still
// references this type inside #if defined(_SECURE_SCL) && _SECURE_SCL -- a
// branch entered in Debug builds where _ITERATOR_DEBUG_LEVEL > 0.
//
// On MSVC 14.50 and earlier, the type still exists in <iterator>, so this shim
// is a no-op via the _MSC_VER guard. On MSVC 14.51+, it provides a minimal
// pointer-backed substitute that satisfies the bundled fmt's usage:
//
// template <typename T> using checked_ptr = stdext::checked_array_iterator<T*>;
// template <typename T> checked_ptr<T> make_checked(T* p, size_t size) {
// return {p, size};
// }
// ... return make_checked(get_data(c) + size, n);
//
// When deps/spdlog is bumped past v1.14 (which ships fmt 10.2 and drops this
// dependency), this shim and its <ForcedIncludeFiles> entry in deps/spdlog.props
// can be deleted.
#pragma once
#if defined(__cplusplus) && defined(_MSC_VER) && _MSC_VER >= 1951
#include <cstddef>
#include <iterator>
#include <type_traits>
namespace stdext
{
template <typename _Ptr>
class checked_array_iterator
{
_Ptr _Myarray = nullptr;
std::size_t _Mysize = 0;
std::size_t _Myindex = 0;
public:
using iterator_category = std::random_access_iterator_tag;
using value_type = std::remove_cv_t<std::remove_pointer_t<_Ptr>>;
using difference_type = std::ptrdiff_t;
using pointer = _Ptr;
using reference = std::remove_pointer_t<_Ptr>&;
constexpr checked_array_iterator() = default;
constexpr checked_array_iterator(_Ptr arr, std::size_t size, std::size_t idx = 0) noexcept
: _Myarray(arr), _Mysize(size), _Myindex(idx)
{
}
constexpr reference operator*() const noexcept { return _Myarray[_Myindex]; }
constexpr pointer operator->() const noexcept { return _Myarray + _Myindex; }
constexpr reference operator[](difference_type n) const noexcept
{
return _Myarray[_Myindex + static_cast<std::size_t>(n)];
}
constexpr checked_array_iterator& operator++() noexcept { ++_Myindex; return *this; }
constexpr checked_array_iterator operator++(int) noexcept { auto t = *this; ++_Myindex; return t; }
constexpr checked_array_iterator& operator--() noexcept { --_Myindex; return *this; }
constexpr checked_array_iterator operator--(int) noexcept { auto t = *this; --_Myindex; return t; }
constexpr checked_array_iterator& operator+=(difference_type n) noexcept
{
_Myindex = static_cast<std::size_t>(static_cast<difference_type>(_Myindex) + n);
return *this;
}
constexpr checked_array_iterator& operator-=(difference_type n) noexcept
{
_Myindex = static_cast<std::size_t>(static_cast<difference_type>(_Myindex) - n);
return *this;
}
friend constexpr checked_array_iterator operator+(checked_array_iterator it, difference_type n) noexcept { it += n; return it; }
friend constexpr checked_array_iterator operator+(difference_type n, checked_array_iterator it) noexcept { return it + n; }
friend constexpr checked_array_iterator operator-(checked_array_iterator it, difference_type n) noexcept { it -= n; return it; }
friend constexpr difference_type operator-(checked_array_iterator a, checked_array_iterator b) noexcept
{
return static_cast<difference_type>(a._Myindex) - static_cast<difference_type>(b._Myindex);
}
friend constexpr bool operator==(checked_array_iterator a, checked_array_iterator b) noexcept { return a._Myindex == b._Myindex; }
friend constexpr bool operator!=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(a == b); }
friend constexpr bool operator<(checked_array_iterator a, checked_array_iterator b) noexcept { return a._Myindex < b._Myindex; }
friend constexpr bool operator>(checked_array_iterator a, checked_array_iterator b) noexcept { return b < a; }
friend constexpr bool operator<=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(b < a); }
friend constexpr bool operator>=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(a < b); }
};
} // namespace stdext
#endif // __cplusplus && _MSC_VER >= 1951

1
deps/spdlog.props vendored
View File

@@ -3,6 +3,7 @@
<ClCompile>
<AdditionalIncludeDirectories>$(MSBuildThisFileDirectory)spdlog\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<PreprocessorDefinitions>SPDLOG_WCHAR_TO_UTF8_SUPPORT;SPDLOG_COMPILED_LIB;SPDLOG_WCHAR_FILENAMES;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ForcedIncludeFiles>$(MSBuildThisFileDirectory)spdlog-msvc-fix\include\spdlog-msvc-fix.h;%(ForcedIncludeFiles)</ForcedIncludeFiles>
</ClCompile>
</ItemDefinitionGroup>
</Project>

View File

@@ -9,7 +9,7 @@ the in-app **Extension gallery** page.
HTTPS URL, parses it, and renders the entries.
- The default feed lives in the external repo
**`microsoft/CmdPal-Extensions`** at
`https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`.
`https://aka.ms/CmdPal-ExtensionsJson`.
- Feed content + icon images are cached on disk so the page works offline and
survives short network hiccups.
- There is no WinGet discovery, no per-extension `manifest.json` fetch, and no
@@ -32,7 +32,7 @@ the in-app **Extension gallery** page.
exposed via the hidden `InternalPage` settings page). Any non-empty value
wins. Mostly used for local testing against a custom feed.
2. Otherwise, the built-in default
`https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`.
`https://aka.ms/CmdPal-ExtensionsJson`.
Local `file://` URIs are allowed too — `FetchFeedDocumentAsync` reads the file
directly and bypasses the HTTP cache.
@@ -109,7 +109,7 @@ cacheable icon URLs are cached.
| Cache root | `{AppCache}\GalleryCache\` | `ExtensionGalleryHttpClient.CacheDirectoryName` |
| Feed TTL | 4 hours | `ExtensionGalleryHttpClient.DefaultTimeToLive` |
| Icon TTL | 24 hours | `ExtensionGalleryService.IconCacheTtl` |
| HTTP timeout | 30 s | `ExtensionGalleryHttpClient` |
| HTTP timeout | 15 s | `ExtensionGalleryHttpClient` |
| `User-Agent` | `PowerToys-CmdPal/1.0` | `ExtensionGalleryHttpClient` |
`{AppCache}` resolves to `ApplicationData.Current.LocalCacheFolder` when

View File

@@ -4,6 +4,7 @@
#include <ProjectTelemetry.h>
#include <spdlog/sinks/base_sink.h>
#include <filesystem>
#include <fstream>
#include <string_view>
#include "../../src/common/logger/logger.h"
@@ -1807,6 +1808,223 @@ void initSystemLogger()
} });
}
// Naming note: the *Hardlinks* names in this CA, the matching WiX CustomAction Ids
// in Product.wxs, and the manifest filename "hardlinks.txt" are kept for continuity
// with the original PR design. The implementation uses fs::copy_file -- not
// CreateHardLinkW -- because hard-links share an inode (and DACL) between root and
// WinUI3Apps, which lets MSIX sparse-package registration propagate a rich DACL onto
// the root copy of files like hostfxr.dll and break LOW-IL prevhost.exe loads,
// turning the Monaco preview pane blank. Copies create a fresh inode in WinUI3Apps so the root
// copy keeps its simple DACL. See the in-body comment for the full RCA reference.
UINT __stdcall CreateWinAppSDKHardlinksCA(MSIHANDLE hInstall)
{
HRESULT hr = S_OK;
UINT er = ERROR_SUCCESS;
std::wstring installationFolder;
hr = WcaInitialize(hInstall, "CreateWinAppSDKHardlinks");
ExitOnFailure(hr, "Failed to initialize");
hr = getInstallFolder(hInstall, installationFolder);
ExitOnFailure(hr, "Failed to get installFolder.");
{
namespace fs = std::filesystem;
const fs::path installDir(installationFolder);
const fs::path winui3Dir = installDir / L"WinUI3Apps";
const fs::path manifestPath = winui3Dir / L"hardlinks.txt";
if (!fs::exists(manifestPath))
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: No hardlinks.txt manifest found, skipping.");
goto LExit;
}
std::ifstream manifestFile(manifestPath); // Read as bytes, then convert UTF-8 -> wide explicitly.
std::string narrowLine;
int created = 0;
int failed = 0;
// INSTALLFOLDER from MSI typically arrives with a trailing backslash. lexically_normal
// preserves that as an empty trailing path component, which would later make the
// per-component std::mismatch containment check below reject every legitimate entry.
// Strip any trailing separators before normalizing.
auto stripTrailingSep = [](fs::path p) {
auto s = p.native();
while (s.size() > 1 && (s.back() == L'\\' || s.back() == L'/')) s.pop_back();
return fs::path(s);
};
// Normalize once so the per-line containment check below is cheap.
const fs::path installDirNorm = stripTrailingSep(installDir).lexically_normal();
const fs::path winui3DirNorm = stripTrailingSep(winui3Dir).lexically_normal();
while (std::getline(manifestFile, narrowLine))
{
if (narrowLine.empty())
{
continue;
}
// Strip CR if the manifest uses CRLF line endings.
if (narrowLine.back() == '\r')
{
narrowLine.pop_back();
if (narrowLine.empty()) continue;
}
// Manifest is written as UTF-8 (no BOM) -- convert to wide string explicitly
// rather than relying on the locale-default codecvt of std::wifstream, which is
// the ANSI code page on Windows and would silently mangle any non-ASCII path.
const int wideLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, nullptr, 0);
if (wideLen <= 0)
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Skipping non-UTF-8 entry: %hs", narrowLine.c_str());
failed++;
continue;
}
std::wstring fileName(static_cast<size_t>(wideLen) - 1, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, fileName.data(), wideLen);
// Defense-in-depth: reject manifest entries that would escape the install root
// via "..", absolute paths, or alternate stream syntax. lexically_normal collapses
// any "." / ".." / repeated separators, then std::mismatch verifies the resolved
// path is still rooted at installDir / winui3Dir respectively.
const fs::path source = (installDir / fileName).lexically_normal();
const fs::path target = (winui3Dir / fileName).lexically_normal();
const auto sourceIn = std::mismatch(installDirNorm.begin(), installDirNorm.end(), source.begin(), source.end());
const auto targetIn = std::mismatch(winui3DirNorm.begin(), winui3DirNorm.end(), target.begin(), target.end());
if (sourceIn.first != installDirNorm.end() || targetIn.first != winui3DirNorm.end())
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Rejecting entry outside install root: %ls", fileName.c_str());
failed++;
continue;
}
if (!fs::exists(source))
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Source not found: %ls", source.c_str());
failed++;
continue;
}
// Remove existing file if present (leftover from previous install)
std::error_code ec;
fs::remove(target, ec);
// Use a regular file copy (not a hard-link). Hard-links share an
// NTFS inode -- and therefore one DACL -- between root and
// WinUI3Apps, which lets MSIX sparse-package registration
// propagate the WinUI3Apps parent's rich (Capability/Package SID)
// DACL onto the root path. That trips a kernel "stricter access
// evaluation" path that blocks LOW-IL prevhost.exe from loading
// hostfxr.dll, so File Explorer Monaco preview goes blank on
// Windows 11 23H2. Copying creates a fresh inode in WinUI3Apps,
// so the root copy keeps its simple DACL while the WinUI3Apps
// copy inherits the rich DACL from its parent (matches 0.99.1
// behaviour). See Documents\PR-47233-Handoff.md for full RCA.
fs::copy_file(source, target, fs::copy_options::overwrite_existing, ec);
if (ec)
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Failed to copy: %ls (%hs)", fileName.c_str(), ec.message().c_str());
failed++;
}
else
{
created++;
}
}
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Copied %d files, %d failures", created, failed);
// Catastrophic-case escalation: if every copy failed, the WinUI3Apps tree is
// unusable (Monaco preview / context-menu shells will break). Surface this rather
// than reporting install success. Per-file failures remain tolerated.
if (created == 0 && failed > 0)
{
hr = E_FAIL;
ExitOnFailure(hr, "All WinAppSDK file copies failed; aborting install.");
}
}
LExit:
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}
UINT __stdcall DeleteWinAppSDKHardlinksCA(MSIHANDLE hInstall)
{
HRESULT hr = S_OK;
UINT er = ERROR_SUCCESS;
std::wstring installationFolder;
hr = WcaInitialize(hInstall, "DeleteWinAppSDKHardlinks");
ExitOnFailure(hr, "Failed to initialize");
hr = getInstallFolder(hInstall, installationFolder);
ExitOnFailure(hr, "Failed to get installFolder.");
{
namespace fs = std::filesystem;
const fs::path winui3Dir = fs::path(installationFolder) / L"WinUI3Apps";
const fs::path manifestPath = winui3Dir / L"hardlinks.txt";
if (!fs::exists(manifestPath))
{
goto LExit;
}
std::ifstream manifestFile(manifestPath); // Read as bytes; convert UTF-8 -> wide explicitly.
std::string narrowLine;
// INSTALLFOLDER from MSI typically arrives with a trailing backslash; strip it before
// normalizing so the per-line containment check doesn't false-reject every entry.
auto stripTrailingSep = [](fs::path p) {
auto s = p.native();
while (s.size() > 1 && (s.back() == L'\\' || s.back() == L'/')) s.pop_back();
return fs::path(s);
};
const fs::path winui3DirNorm = stripTrailingSep(winui3Dir).lexically_normal();
while (std::getline(manifestFile, narrowLine))
{
if (narrowLine.empty())
{
continue;
}
if (narrowLine.back() == '\r')
{
narrowLine.pop_back();
if (narrowLine.empty()) continue;
}
const int wideLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, nullptr, 0);
if (wideLen <= 0)
{
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Skipping non-UTF-8 entry: %hs", narrowLine.c_str());
continue;
}
std::wstring fileName(static_cast<size_t>(wideLen) - 1, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, fileName.data(), wideLen);
// Defense-in-depth: reject entries whose resolved target escapes WinUI3Apps.
const fs::path target = (winui3Dir / fileName).lexically_normal();
const auto inWinui3 = std::mismatch(winui3DirNorm.begin(), winui3DirNorm.end(), target.begin(), target.end());
if (inWinui3.first != winui3DirNorm.end())
{
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Rejecting entry outside WinUI3Apps: %ls", fileName.c_str());
continue;
}
std::error_code ec;
fs::remove(target, ec);
}
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Cleaned up deduplicated copy files");
}
LExit:
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}
// DllMain - Initialize and cleanup WiX custom action utils.
extern "C" BOOL WINAPI DllMain(__in HINSTANCE hInst, __in ULONG ulReason, __in LPVOID)
{

View File

@@ -36,3 +36,5 @@ EXPORTS
SetBundleInstallLocationCA
InstallPackageIdentityMSIXCA
UninstallPackageIdentityMSIXCA
CreateWinAppSDKHardlinksCA
DeleteWinAppSDKHardlinksCA

View File

@@ -112,6 +112,8 @@
<Custom Action="SetInstallCmdPalPackageParam" Before="InstallCmdPalPackage" />
<Custom Action="SetUninstallCommandNotFoundParam" Before="UninstallCommandNotFound" />
<Custom Action="SetUpgradeCommandNotFoundParam" Before="UpgradeCommandNotFound" />
<Custom Action="SetCreateWinAppSDKHardlinksParam" Before="CreateWinAppSDKHardlinks" />
<Custom Action="SetDeleteWinAppSDKHardlinksParam" Before="DeleteWinAppSDKHardlinks" />
<Custom Action="SetApplyModulesRegistryChangeSetsParam" Before="ApplyModulesRegistryChangeSets" />
<Custom Action="SetInstallPackageIdentityMSIXParam" Before="InstallPackageIdentityMSIX" />
@@ -124,6 +126,7 @@
<Custom Action="SetBundleInstallLocationData" Before="SetBundleInstallLocation" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
<Custom Action="SetBundleInstallLocation" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
<Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="CreateWinAppSDKHardlinks" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED OR REINSTALL" />
<Custom Action="InstallCmdPalPackage" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed AND WINDOWSBUILDNUMBER &gt;= 22000" />
<Custom Action="override Wix4CloseApplications_$(sys.BUILDARCHSHORT)" Before="RemoveFiles" />
@@ -137,6 +140,7 @@
<?endif?>
<Custom Action="TelemetryLogInstallSuccess" After="InstallFinalize" Condition="NOT Installed" />
<Custom Action="TelemetryLogUninstallSuccess" After="InstallFinalize" Condition="Installed and (NOT UPGRADINGPRODUCTCODE) AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="DeleteWinAppSDKHardlinks" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="UnApplyModulesRegistryChangeSets" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="UnRegisterContextMenuPackages" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="CleanImageResizerRuntimeRegistry" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
@@ -189,8 +193,10 @@
<CustomAction Id="SetUpgradeCommandNotFoundParam" Property="UpgradeCommandNotFound" Value="[INSTALLFOLDER]" />
<CustomAction Id="SetCreateWinAppSDKHardlinksParam" Property="CreateWinAppSDKHardlinks" Value="[INSTALLFOLDER]" />
<CustomAction Id="CreateWinAppSDKHardlinks" Return="check" Impersonate="yes" Execute="deferred" DllEntry="CreateWinAppSDKHardlinksCA" BinaryRef="PTCustomActions" />
<CustomAction Id="SetDeleteWinAppSDKHardlinksParam" Property="DeleteWinAppSDKHardlinks" Value="[INSTALLFOLDER]" />
<CustomAction Id="DeleteWinAppSDKHardlinks" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="DeleteWinAppSDKHardlinksCA" BinaryRef="PTCustomActions" />
<CustomAction Id="SetCreatePTInteropHardlinksParam" Property="CreatePTInteropHardlinks" Value="[INSTALLFOLDER]" />

View File

@@ -7,11 +7,18 @@
<Fragment>
<DirectoryRef Id="WinUI3AppsInstallFolder">
<Component Id="WinUI3Apps_Hardlinks_Manifest" Guid="F7A2C3D1-8E4B-4F6A-9D2E-1B3C5A7F8E90" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="WinUI3Apps_Hardlinks_Manifest" Value="" KeyPath="yes" />
</RegistryKey>
<File Id="WinUI3Apps_hardlinks_txt" Source="$(var.BinDir)\WinUI3Apps\hardlinks.txt" />
</Component>
<!-- Generated by generateFileComponents.ps1 -->
<!--WinUI3ApplicationsFiles_Component_Def-->
</DirectoryRef>
<ComponentGroup Id="WinUI3ApplicationsComponentGroup">
<ComponentRef Id="WinUI3Apps_Hardlinks_Manifest" />
</ComponentGroup>
</Fragment>

View File

@@ -30,6 +30,10 @@ Function Generate-FileList() {
$fileInclusionList = @("*.dll", "*.exe", "*.json", "*.msix", "*.png", "*.gif", "*.ico", "*.cur", "*.svg", "index.html", "reg.js", "gitignore.js", "srt.js", "monacoSpecialLanguages.js", "customTokenThemeRules.js", "*.pri")
# MFC DLLs leak into the output via WindowsAppSDKSelfContained but no PowerToys binary imports them.
# Verified with dumpbin /dependents across all 2176 binaries — zero consumers.
$fileExclusionList += @("mfc140.dll", "mfc140u.dll", "mfcm140.dll", "mfcm140u.dll")
$dllsToIgnore = @("System.CodeDom.dll", "WindowsBase.dll")
if ($fileDepsJson -eq [string]::Empty) {
@@ -85,11 +89,16 @@ Function Generate-FileComponents() {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'fileList',
Justification = 'variable is used in another scope')]
$fileList = $matches[2] -split ';'
$fileList = $matches[2] -split ';' | Where-Object { $_ -ne '' }
return
}
}
if ($null -eq $fileList -or $fileList.Count -eq 0) {
# No files to generate components for — leave placeholder intact
return
}
$componentId = "$($fileListName)_Component"
$componentDefs = "`r`n"
@@ -154,6 +163,67 @@ Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSSc
#WinUI3Applications
Generate-FileList -fileDepsJson "" -fileListName WinUI3ApplicationsFiles -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps"
# Deduplicate: Remove files from WinUI3Apps that are identical to root (same name + same hash).
# These will be re-created as plain file copies at install time by CreateWinAppSDKHardlinksCA.
# (The CA's name is historical: it now uses fs::copy_file rather than CreateHardLinkW to avoid
# DACL contamination across the shared inode -- see CustomAction.cpp for details.)
$rootPath = "$PSScriptRoot..\..\..\$platform\Release"
$winui3Path = "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps"
$winui3WxsPath = "$PSScriptRoot\WinUI3Applications.wxs"
$winui3Wxs = Get-Content $winui3WxsPath -Raw
$manifestPath = Join-Path $winui3Path "hardlinks.txt"
if ($winui3Wxs -match "\<\?define WinUI3ApplicationsFiles=([^?]*)\?\>") {
$winui3FileList = $matches[1] -split ';' | Where-Object { $_ -ne '' }
$hardlinkFiles = @()
# Read the BaseApplications WXS file list so we only deduplicate files that the MSI
# is actually deploying to the install root. If a file was stripped from BaseApplications
# by an earlier step (e.g., the ImageResizer leaked-apphost workaround above), the
# install-time CA's source would be missing and both copies would disappear.
$baseAppsWxs = Get-Content $baseAppWxsPath -Raw
$baseAppsFileList = @()
if ($baseAppsWxs -match "\<\?define BaseApplicationsFiles=([^?]*)\?\>") {
$baseAppsFileList = $matches[1] -split ';' | Where-Object { $_ -ne '' }
}
foreach ($file in $winui3FileList) {
# Skip files that were intentionally not deployed to root by the build
if ($baseAppsFileList -notcontains $file) { continue }
$rootFile = Join-Path $rootPath $file
$winui3File = Join-Path $winui3Path $file
if ((Test-Path $rootFile) -and (Test-Path $winui3File)) {
$rootHash = (Get-FileHash $rootFile -Algorithm SHA256).Hash
$winui3Hash = (Get-FileHash $winui3File -Algorithm SHA256).Hash
if ($rootHash -eq $winui3Hash) {
$hardlinkFiles += $file
}
}
}
if ($hardlinkFiles.Count -gt 0) {
# Remove deduplicated files from WinUI3Apps file list
$remainingFiles = $winui3FileList | Where-Object { $_ -notin $hardlinkFiles }
if ($remainingFiles.Count -eq 0) {
# All files are duplicates — keep at least a dummy entry won't be emitted
# Generate-FileComponents handles empty defines by producing no <File> entries
$winui3Wxs = $winui3Wxs -replace "\<\?define WinUI3ApplicationsFiles=[^?]*\?\>", "<?define WinUI3ApplicationsFiles=?>"
} else {
$winui3Wxs = $winui3Wxs -replace "\<\?define WinUI3ApplicationsFiles=[^?]*\?\>", "<?define WinUI3ApplicationsFiles=$($remainingFiles -join ';')?>"
}
Set-Content -Path $winui3WxsPath -Value $winui3Wxs
Write-Host "Deduplicated $($hardlinkFiles.Count) files from WinUI3Apps (will be copied at install time)"
}
# Always write hardlinks.txt (may be empty — CA handles that gracefully)
# Write as UTF-8 without BOM so the install-time CA can read it via std::ifstream
# + MultiByteToWideChar(CP_UTF8) without dealing with PS-version-dependent default
# encodings or a leading BOM.
[System.IO.File]::WriteAllLines($manifestPath, [string[]]$hardlinkFiles, (New-Object System.Text.UTF8Encoding($false)))
}
Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs
#AdvancedPaste

View File

@@ -8,9 +8,6 @@
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
<!-- Suppress CA1416 for Windows-specific APIs that are used in PowerToys which only runs on Windows 10.0.19041.0+ -->
<!-- IL2104 is the per-assembly roll-up warning emitted by ILLink ("Assembly X produced trim warnings"). The
Windows SDK projection assemblies (Microsoft.Windows.SDK.NET, WinRT.Runtime) ship with known trim warnings
that we can't fix, so we allow IL2104 through as a warning rather than failing the build. -->
<WarningsNotAsErrors>IL2081;IL2104;CsWinRT1028;CA1416;$(WarningsNotAsErrors)</WarningsNotAsErrors>
<WarningsNotAsErrors>IL2081;CsWinRT1028;CA1416;$(WarningsNotAsErrors)</WarningsNotAsErrors>
</PropertyGroup>
</Project>

View File

@@ -5,11 +5,17 @@
#include <sstream>
#include <cmath>
#include <limits>
#include <random>
namespace ExprtkCalculator::internal
{
static double factorial(const double n)
{
if (std::isnan(n) || std::isinf(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
// Only allow non-negative integers
if (n < 0.0 || std::floor(n) != n)
{
@@ -20,13 +26,80 @@ namespace ExprtkCalculator::internal
static double sign(const double n)
{
// The sign of NaN is undefined.
if (std::isnan(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
if (n > 0.0) return 1.0;
if (n < 0.0) return -1.0;
return 0.0;
}
// rand(): returns a uniformly distributed random double in [0, 1)
struct rand_func : public exprtk::ifunction<double>
{
std::mt19937_64 rng;
std::uniform_real_distribution<double> dist;
rand_func() :
exprtk::ifunction<double>(0),
rng(std::random_device{}()),
dist(0.0, 1.0)
{}
inline double operator()() override
{
return dist(rng);
}
};
// randi(n): returns a uniformly distributed random integer in [0, n-1]
struct randi_func : public exprtk::ifunction<double>
{
std::mt19937_64 rng;
randi_func() :
exprtk::ifunction<double>(1),
rng(std::random_device{}())
{}
inline double operator()(const double& n) override
{
if (std::isnan(n) || std::isinf(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
constexpr double maxLongLongAsDouble = static_cast<double>(std::numeric_limits<long long>::max());
if (n < 1.0 || n >= maxLongLongAsDouble)
{
return std::numeric_limits<double>::quiet_NaN();
}
if (std::floor(n) != n)
{
return std::numeric_limits<double>::quiet_NaN();
}
std::uniform_int_distribution<long long> dist(0, static_cast<long long>(n) - 1);
return static_cast<double>(dist(rng));
}
};
std::wstring ToWStringFullPrecision(double value)
{
if (std::isnan(value))
{
return L"NaN";
}
if (std::isinf(value))
{
return value > 0 ? L"inf" : L"-inf";
}
std::wostringstream oss;
oss.imbue(std::locale::classic());
oss << std::fixed << std::setprecision(15) << value;
@@ -47,6 +120,13 @@ namespace ExprtkCalculator::internal
symbol_table.add_function("factorial", factorial);
symbol_table.add_function("sign", sign);
// thread_local ensures each thread has its own RNG instance (seeded once,
// state preserved across calls) without requiring locks.
static thread_local rand_func rand_fn;
static thread_local randi_func randi_fn;
symbol_table.add_function("rand", rand_fn);
symbol_table.add_function("randi", randi_fn);
exprtk::expression<double> expression;
expression.register_symbol_table(symbol_table);
@@ -65,7 +145,7 @@ namespace ExprtkCalculator::internal
parser.settings().disable_all_inequality_ops(); // Disable inequality operators like <, >, <=, >=, !=, etc.
if (!parser.compile(expressionText, expression))
return L"NaN";
return L"ParseError";
return ToWStringFullPrecision(expression.value());
}

View File

@@ -275,6 +275,10 @@ namespace winrt::PowerToys::Interop::implementation
{
return CommonSharedConstants::HOTKEY_UPDATED_POWER_DISPLAY_EVENT;
}
hstring Constants::RescanPowerDisplayMonitorsEvent()
{
return CommonSharedConstants::RESCAN_POWER_DISPLAY_MONITORS_EVENT;
}
hstring Constants::PowerDisplayToggleMessage()
{
return CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE;

View File

@@ -72,6 +72,7 @@ namespace winrt::PowerToys::Interop::implementation
static hstring SettingsUpdatedPowerDisplayEvent();
static hstring PowerDisplaySendSettingsTelemetryEvent();
static hstring HotkeyUpdatedPowerDisplayEvent();
static hstring RescanPowerDisplayMonitorsEvent();
static hstring PowerDisplayToggleMessage();
static hstring PowerDisplayApplyProfileMessage();
static hstring PowerDisplayTerminateAppMessage();

View File

@@ -69,6 +69,7 @@ namespace PowerToys
static String SettingsUpdatedPowerDisplayEvent();
static String PowerDisplaySendSettingsTelemetryEvent();
static String HotkeyUpdatedPowerDisplayEvent();
static String RescanPowerDisplayMonitorsEvent();
static String PowerDisplayToggleMessage();
static String PowerDisplayApplyProfileMessage();
static String PowerDisplayTerminateAppMessage();

View File

@@ -165,6 +165,7 @@ namespace CommonSharedConstants
const wchar_t SETTINGS_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsUpdatedEvent-2e4d6f8a-1c3b-5e7f-9a1d-4c6e8f0b2d3e";
const wchar_t POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsTelemetryEvent-8c4f2a1d-5e3b-7f9c-1a6d-3b8e5f2c9a7d";
const wchar_t HOTKEY_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-HotkeyUpdatedEvent-9d5f3a2b-7e1c-4b8a-6f3d-2a9e5c7b1d4f";
const wchar_t RESCAN_POWER_DISPLAY_MONITORS_EVENT[] = L"Local\\PowerToysPowerDisplay-RescanMonitorsEvent-7f3e8c5a-1d4b-4a9e-bc6f-5d8a2b9e3c4f";
// IPC Messages used in PowerDisplay (Named Pipe communication)
const wchar_t POWER_DISPLAY_TOGGLE_MESSAGE[] = L"Toggle";

View File

@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" />
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
@@ -193,5 +194,18 @@
<ItemGroup>
<Manifest Include="GrabAndMove.manifest" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\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('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" />
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" />
</Target>
</Project>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" />
</packages>

View File

@@ -21,7 +21,6 @@ using System.IO.Pipes;
using System.Linq;
using System.Security.Authentication.ExtendedProtection;
using System.Security.Principal;
using System.ServiceModel.Channels;
using System.ServiceProcess;
using System.Threading;
using System.Threading.Tasks;

View File

@@ -66,7 +66,6 @@
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="Microsoft.Windows.Compatibility" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />

View File

@@ -214,7 +214,6 @@
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="Microsoft.Windows.Compatibility" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />

View File

@@ -71,7 +71,6 @@
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />

View File

@@ -3353,8 +3353,14 @@ void RegisterAllHotkeys(HWND hWnd)
}
if (g_RecordToggleKey) {
registerHotkey( RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
registerHotkey( RECORD_CROP_HOTKEY, ( g_RecordToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
registerHotkey( RECORD_WINDOW_HOTKEY, ( g_RecordToggleMod ^ MOD_ALT ) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
UINT cropMod = g_RecordToggleMod ^ MOD_SHIFT;
UINT windowMod = g_RecordToggleMod ^ MOD_ALT;
if ( cropMod != 0 ) {
registerHotkey( RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
}
if ( windowMod != 0 ) {
registerHotkey( RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
}
}
// Note: COPY_IMAGE_HOTKEY, COPY_CROP_HOTKEY (Ctrl+C, Ctrl+Shift+C) and
@@ -5459,16 +5465,18 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
break;
}
else if( newRecordToggleKey &&
(!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, (newRecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, (newRecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) {
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
else if( newRecordToggleKey ) {
UINT cropMod = newRecordToggleMod ^ MOD_SHIFT;
UINT windowMod = newRecordToggleMod ^ MOD_ALT;
if (!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
(cropMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) {
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
} else {
g_BreakTimeout = newTimeout;
@@ -7502,14 +7510,17 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
else if (g_RecordToggleKey &&
(!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))) {
else if (g_RecordToggleKey) {
UINT cropMod = g_RecordToggleMod ^ MOD_SHIFT;
UINT windowMod = g_RecordToggleMod ^ MOD_ALT;
if (!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
(cropMod != 0 && !RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))) {
MessageBox(hWnd, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
MessageBox(hWnd, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
}
if( showOptions ) {
@@ -10175,9 +10186,11 @@ LRESULT APIENTRY MainWndProc(
}
if (g_RecordToggleKey)
{
UINT cropMod = g_RecordToggleMod ^ MOD_SHIFT;
UINT windowMod = g_RecordToggleMod ^ MOD_ALT;
if (!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))
(cropMod != 0 && !RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF)))
{
if(!g_StartedByPowerToys)
{

View File

@@ -60,7 +60,19 @@ public abstract partial class AppExtensionHost : IExtensionHost
return Task.CompletedTask.AsAsyncAction();
}
CoreLogger.LogDebug(message.Message);
switch (message.State)
{
case MessageState.Error:
CoreLogger.LogError(message.Message);
break;
case MessageState.Warning:
CoreLogger.LogWarning(message.Message);
break;
case MessageState.Info:
default:
CoreLogger.LogInfo(message.Message);
break;
}
_ = Task.Run(() =>
{

View File

@@ -211,7 +211,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
SupportsPinning = true;
// Load pinned commands from saved settings
pinnedCommands = LoadPinnedCommands(four, providerSettings);
pinnedCommands = LoadPinnedCommands(four, settingsService.Settings);
}
Id = model.Id;
@@ -261,12 +261,6 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
}
}
private record TopLevelObjects(
ICommandItem[]? Commands,
IFallbackCommandItem[]? Fallbacks,
ICommandItem[]? PinnedCommands,
ICommandItem[]? DockBands);
private void InitializeCommands(
TopLevelObjects objects,
IServiceProvider serviceProvider,
@@ -295,7 +289,24 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
if (objects.PinnedCommands is not null)
{
topLevelList.AddRange(objects.PinnedCommands.Select(c => make(c, TopLevelType.Normal)));
foreach (var pinnedCommand in objects.PinnedCommands)
{
var pinnedItem = make(pinnedCommand, TopLevelType.Normal);
var alreadyExists = false;
foreach (var existingItem in topLevelList)
{
if (existingItem.Id == pinnedItem.Id)
{
alreadyExists = true;
break;
}
}
if (!alreadyExists)
{
topLevelList.Add(pinnedItem);
}
}
}
TopLevelItems = topLevelList.ToArray();
@@ -398,11 +409,11 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
return null;
}
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings)
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, SettingsModel settings)
{
var pinnedItems = new List<ICommandItem>();
foreach (var pinnedId in providerSettings.PinnedCommandIds)
foreach (var pinnedId in settings.GetPinnedCommandIds(ProviderId))
{
try
{
@@ -441,55 +452,29 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
public void PinCommand(string commandId, IServiceProvider serviceProvider)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var providerSettings = GetProviderSettings(settingsService.Settings);
if (!providerSettings.PinnedCommandIds.Contains(commandId))
if (settingsService.Settings.IsCommandPinned(ProviderId, commandId))
{
settingsService.UpdateSettings(
s =>
{
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
var providerSettings = ps.WithConnection(this);
var newPinned = providerSettings.PinnedCommandIds.Add(commandId);
var newPs = providerSettings with { PinnedCommandIds = newPinned };
return s with
{
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
};
},
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
return;
}
settingsService.UpdateSettings(
s => s.TryPinCommand(ProviderId, commandId),
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
if (!settingsService.Settings.IsCommandPinned(ProviderId, commandId))
{
return;
}
settingsService.UpdateSettings(
s =>
{
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
var providerSettings = ps.WithConnection(this);
var newPinned = providerSettings.PinnedCommandIds.Remove(commandId);
var newPs = providerSettings with { PinnedCommandIds = newPinned };
return s with
{
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
};
},
s => s.TryUnpinCommand(ProviderId, commandId),
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
@@ -673,4 +658,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
this.DockBandItems = bands.ToArray();
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs());
}
private record TopLevelObjects(
ICommandItem[]? Commands,
IFallbackCommandItem[]? Fallbacks,
ICommandItem[]? PinnedCommands,
ICommandItem[]? DockBands);
}

View File

@@ -129,7 +129,7 @@ public partial class CommandViewModel : ExtensionObjectViewModel
Icon = new(iconInfo);
Icon.InitializeProperties();
break;
case nameof(_properties):
case nameof(Properties):
UpdatePropertiesFromExtension(model as IExtendedAttributesProvider);
break;

View File

@@ -51,10 +51,15 @@ public sealed partial class MainListPage : DynamicListPage,
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title);
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private readonly Separator _commandsSeparator = new(Resources.home_sections_commands_title);
private TopLevelViewModel[]? _cachedPinnedViewModels;
private TopLevelViewModel[]? _cachedRegularViewModels;
private bool _defaultViewDirty = true;
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
@@ -100,6 +105,7 @@ public sealed partial class MainListPage : DynamicListPage,
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
_tlcManager.PinnedCommands.CollectionChanged += PinnedCommands_CollectionChanged;
_refreshThrottledDebouncedAction = new ThrottledDebouncedAction(
() =>
@@ -166,8 +172,15 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
private void PinnedCommands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
_defaultViewDirty = true;
RaiseItemsChanged();
}
private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
_defaultViewDirty = true;
_includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
if (_includeApps != _filteredItemsIncludesApps)
{
@@ -238,65 +251,108 @@ public sealed partial class MainListPage : DynamicListPage,
{
lock (_tlcManager.TopLevelCommands)
{
// Either return the top-level commands (no search text), or the merged and
// filtered results.
if (string.IsNullOrWhiteSpace(SearchText))
return string.IsNullOrWhiteSpace(SearchText) ? GetDefaultViewItems() : GetSearchViewItems();
}
}
private IListItem[] GetSearchViewItems()
{
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
_filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
}
private IListItem[] GetDefaultViewItems()
{
if (_defaultViewDirty)
{
RebuildDefaultViewCache();
}
var pinned = _cachedPinnedViewModels!;
var regular = _cachedRegularViewModels!;
var pinnedCount = pinned.Length;
var regularCount = regular.Length;
var sectionCount = (pinnedCount > 0 ? 1 : 0) + (regularCount > 0 ? 1 : 0);
if (sectionCount == 0)
{
return [];
}
var result = new IListItem[pinnedCount + regularCount + sectionCount];
var writeIndex = 0;
if (pinnedCount > 0)
{
result[writeIndex++] = _pinnedSeparator;
Array.Copy(pinned, 0, result, writeIndex, pinnedCount);
writeIndex += pinnedCount;
}
if (regularCount > 0)
{
result[writeIndex++] = _commandsSeparator;
Array.Copy(regular, 0, result, writeIndex, regularCount);
}
return result;
}
private void RebuildDefaultViewCache()
{
var allCommands = _tlcManager.TopLevelCommands;
var pinnedSettings = _tlcManager.PinnedCommands;
// Resolve pinned VMs in settings order
var pinned = new List<TopLevelViewModel>(pinnedSettings.Count);
for (var i = 0; i < pinnedSettings.Count; i++)
{
var s = pinnedSettings[i];
for (var j = 0; j < allCommands.Count; j++)
{
var allCommands = _tlcManager.TopLevelCommands;
// First pass: count eligible commands
var eligibleCount = 0;
for (var i = 0; i < allCommands.Count; i++)
var cmd = allCommands[j];
if (IsEligibleTopLevelCommand(cmd) &&
cmd.CommandProviderId == s.ProviderId &&
cmd.Id == s.CommandId)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
eligibleCount++;
}
pinned.Add(cmd);
break;
}
if (eligibleCount == 0)
{
return [];
}
// +1 for the separator
var result = new IListItem[eligibleCount + 1];
result[0] = _commandsSeparator;
// Second pass: populate
var writeIndex = 1;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
result[writeIndex++] = cmd;
}
}
return result;
}
else
{
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
_filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
}
}
// Single pass for regular items
var regular = new List<TopLevelViewModel>(allCommands.Count);
for (var i = 0; i < allCommands.Count; i++)
{
var candidate = allCommands[i];
if (IsEligibleTopLevelCommand(candidate) && !_tlcManager.IsPinned(candidate.CommandProviderId, candidate.Id))
{
regular.Add(candidate);
}
}
_cachedPinnedViewModels = [.. pinned];
_cachedRegularViewModels = [.. regular];
_defaultViewDirty = false;
}
private static bool IsEligibleTopLevelCommand(TopLevelViewModel command)
{
return !command.IsFallback && !string.IsNullOrEmpty(command.Title);
}
private void ClearResults()
@@ -479,11 +535,9 @@ public sealed partial class MainListPage : DynamicListPage,
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
// Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds.
_settingsService.Settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings);
var pinnedCommandIds = providerSettings?.PinnedCommandIds;
var pinnedCommandIds = _settingsService.Settings.GetPinnedCommandIds(AllAppsCommandProvider.WellKnownId);
if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0)
if (pinnedCommandIds.Count > 0)
{
newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id));
}
@@ -702,6 +756,8 @@ public sealed partial class MainListPage : DynamicListPage,
public void Receive(UpdateFallbackItemsMessage message)
{
_tlcManager.RebuildPinnedCache();
_defaultViewDirty = true;
RequestRefresh(fullRefresh: false);
}
@@ -717,6 +773,7 @@ public sealed partial class MainListPage : DynamicListPage,
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
_tlcManager.PinnedCommands.CollectionChanged -= PinnedCommands_CollectionChanged;
if (_settingsService is not null)
{

View File

@@ -845,6 +845,112 @@ public sealed partial class DockViewModel : IDisposable
Logger.LogDebug($"Unpinned band {bandId} (not saved yet)");
}
/// <summary>
/// Removes a band from this dock by its ID. Used when a band is dragged to
/// another monitor's dock. Does not save — save happens when exiting edit mode.
/// </summary>
public void RemoveBandById(string bandId)
{
if (FindBandById(bandId) == null)
{
return;
}
EnsureMonitorForked();
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
_settings = WithActiveBands(
activeStart.RemoveAll(b => b.CommandId == bandId),
activeCenter.RemoveAll(b => b.CommandId == bandId),
activeEnd.RemoveAll(b => b.CommandId == bandId));
RemoveBandFromCollection(StartItems, bandId);
RemoveBandFromCollection(CenterItems, bandId);
RemoveBandFromCollection(EndItems, bandId);
Logger.LogDebug($"Removed band {bandId} from monitor {_monitorDeviceId} (cross-monitor drag)");
}
/// <summary>
/// Accepts a dock band from another monitor during a cross-monitor drag.
/// Creates the band ViewModel and inserts it at the specified position.
/// Does not save — save happens when exiting edit mode.
/// </summary>
public void AcceptBandFromMonitor(string bandId, DockPinSide targetSide, int targetIndex)
{
if (FindBandById(bandId) != null)
{
Logger.LogWarning($"AcceptBandFromMonitor: band {bandId} already in this dock");
return;
}
EnsureMonitorForked();
var topLevel = _topLevelCommandManager.LookupDockBand(bandId);
if (topLevel is null)
{
Logger.LogWarning($"AcceptBandFromMonitor: band {bandId} not found in DockBandsSnapshot");
return;
}
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId };
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
switch (targetSide)
{
case DockPinSide.Start:
{
var idx = Math.Min(targetIndex, activeStart.Count);
activeStart = activeStart.Insert(idx, bandSettings);
var uiIdx = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIdx, bandVm);
break;
}
case DockPinSide.Center:
{
var idx = Math.Min(targetIndex, activeCenter.Count);
activeCenter = activeCenter.Insert(idx, bandSettings);
var uiIdx = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIdx, bandVm);
break;
}
case DockPinSide.End:
{
var idx = Math.Min(targetIndex, activeEnd.Count);
activeEnd = activeEnd.Insert(idx, bandSettings);
var uiIdx = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIdx, bandVm);
break;
}
}
_settings = WithActiveBands(activeStart, activeCenter, activeEnd);
bandVm.SnapshotShowLabels();
Task.Run(() =>
{
bandVm.SafeInitializePropertiesSynchronous();
});
Logger.LogDebug($"Accepted band {bandId} at {targetSide}[{targetIndex}] on monitor {_monitorDeviceId}");
}
private static void RemoveBandFromCollection(ObservableCollection<DockBandViewModel> collection, string bandId)
{
for (var i = collection.Count - 1; i >= 0; i--)
{
if (collection[i].Id == bandId)
{
collection.RemoveAt(i);
}
}
}
private void DoOnUiThread(Action action)
{
Task.Factory.StartNew(

View File

@@ -48,6 +48,11 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
private readonly IReadOnlyDictionary<string, GalleryInstallSource> _installSourcesByType;
private readonly IReadOnlyDictionary<string, GallerySourceViewModel> _sourcesByKind;
// HTTP uris are set only for sources that can be opened in a browser
private readonly Uri? _homepageHttpUri;
private readonly Uri? _authorPageHttpUri;
private readonly Uri? _installLinkHttpUri;
public ExtensionGalleryItemViewModel(
GalleryExtensionEntry entry,
ILogger<ExtensionGalleryItemViewModel> logger,
@@ -62,6 +67,9 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
_winGetOperationTrackerService = winGetOperationTrackerService;
_installSourcesByType = BuildInstallSourceLookup(entry.InstallSources);
(Sources, _sourcesByKind) = BuildSourceInfos(_installSourcesByType, entry.Homepage);
_homepageHttpUri = TryCreateWebUri(entry.Homepage);
_authorPageHttpUri = TryCreateWebUri(entry.Author?.Url);
_installLinkHttpUri = TryCreateWebUri(GetSource(SourceTypeGitHub)?.Uri ?? GetSource(SourceTypeWebsite)?.Uri);
Screenshots = BuildScreenshots(entry.ScreenshotUrls);
var resolvedIconUri = ResolveIconUri();
@@ -114,11 +122,11 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public bool HasStoreSource => HasSource(SourceTypeStore);
public bool HasUrlSource => _installSourcesByType.ContainsKey(SourceTypeUrl) && !string.IsNullOrWhiteSpace(InstallUrl);
public bool HasUrlSource => _installSourcesByType.ContainsKey(SourceTypeUrl) && InstallUrl is not null;
public bool HasHomepage => !string.IsNullOrWhiteSpace(Homepage);
public bool HasHomepage => _homepageHttpUri is not null;
public bool HasAuthorUrl => !string.IsNullOrWhiteSpace(AuthorUrl);
public bool HasAuthorUrl => _authorPageHttpUri is not null;
public bool HasGitHubSource => HasSource(SourceTypeGitHub);
@@ -167,7 +175,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public string? StoreId => GetSource(SourceTypeStore)?.Id;
public string? InstallUrl => GetSource(SourceTypeGitHub)?.Uri ?? GetSource(SourceTypeWebsite)?.Uri;
public string? InstallUrl => _installLinkHttpUri?.AbsoluteUri;
public string WinGetInstallCommand => !string.IsNullOrWhiteSpace(WinGetId) ? $"winget install --id {WinGetId}" : string.Empty;
@@ -183,7 +191,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public string GitHubTooltip => GetSource(SourceTypeGitHub)?.Uri ?? Resources.gallery_item_github_source;
public string WebsiteTooltip => GetSource(SourceTypeWebsite)?.Uri ?? Homepage ?? Resources.gallery_item_website_source;
public string WebsiteTooltip => GetSource(SourceTypeWebsite)?.Uri ?? _homepageHttpUri?.AbsoluteUri ?? Resources.gallery_item_website_source;
public string WinGetMenuText => !string.IsNullOrWhiteSpace(WinGetId)
? FormatResource(Resources.gallery_item_winget_menu_text_with_id, WinGetId)
@@ -293,21 +301,21 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
ApplySourceDetails(SourceTypeWinGet, CreateSourceDetails(packageInfo.Details));
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(HasHomepage))]
private void OpenHomepage()
{
if (!string.IsNullOrEmpty(Homepage))
if (_homepageHttpUri is not null)
{
ShellHelpers.OpenInShell(Homepage);
ShellHelpers.OpenInShell(_homepageHttpUri.AbsoluteUri);
}
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(HasAuthorUrl))]
private void OpenAuthorPage()
{
if (!string.IsNullOrEmpty(AuthorUrl))
if (_authorPageHttpUri is not null)
{
ShellHelpers.OpenInShell(AuthorUrl);
ShellHelpers.OpenInShell(_authorPageHttpUri.AbsoluteUri);
}
}
@@ -320,12 +328,12 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
}
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(HasUrlSource))]
private void OpenInstallUrl()
{
if (!string.IsNullOrEmpty(InstallUrl))
if (_installLinkHttpUri is not null)
{
ShellHelpers.OpenInShell(InstallUrl);
ShellHelpers.OpenInShell(_installLinkHttpUri.AbsoluteUri);
}
}
@@ -686,15 +694,21 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
};
}
private static GallerySourceViewModel CreateSourceFromUrl(string? url)
private static GallerySourceViewModel? CreateSourceFromUrl(string? url)
{
if (IsGitHubUri(url))
var webUri = TryCreateWebUri(url);
if (webUri is null)
{
return null;
}
if (IsGitHubUri(webUri))
{
return CreateSourceViewModel(
SourceTypeGitHub,
Resources.gallery_item_source_name_github,
id: null,
uri: url,
uri: webUri.AbsoluteUri,
isKnown: true);
}
@@ -702,19 +716,19 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
SourceTypeWebsite,
Resources.gallery_item_source_name_website,
id: null,
uri: url,
uri: webUri.AbsoluteUri,
isKnown: true);
}
private static bool TryCreateSourceFromUri(string? uriValue, out GallerySourceViewModel source)
{
source = default!;
if (string.IsNullOrWhiteSpace(uriValue) || !Uri.TryCreate(uriValue, UriKind.Absolute, out _))
if (CreateSourceFromUrl(uriValue) is not { } webSource)
{
return false;
}
source = CreateSourceFromUrl(uriValue);
source = webSource;
return true;
}
@@ -769,7 +783,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
private static void AddDetail(ICollection<GallerySourceDetailItemViewModel> target, string label, string? value, string? uri)
{
var normalizedValue = ToNullIfWhiteSpace(value);
var normalizedUri = TryCreateUri(uri);
var normalizedUri = TryCreateWebUri(uri);
if (normalizedValue is null && normalizedUri is null)
{
return;
@@ -778,14 +792,14 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
target.Add(new GallerySourceDetailItemViewModel(label, normalizedValue ?? normalizedUri!.AbsoluteUri, normalizedUri));
}
private static Uri? TryCreateUri(string? value)
private static Uri? TryCreateWebUri(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return null;
}
return uri;
return IsWebUri(uri) ? uri : null;
}
private static string? ToNullIfWhiteSpace(string? value)
@@ -824,17 +838,18 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
return builder.ToString();
}
private static bool IsGitHubUri(string? value)
private static bool IsGitHubUri(Uri uri)
{
if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return false;
}
return uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".github.com", StringComparison.OrdinalIgnoreCase);
}
private static bool IsWebUri(Uri uri)
{
return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}
private static bool AreStatusTextsEquivalent(string first, string second)
{
return string.Equals(NormalizeStatusText(first), NormalizeStatusText(second), StringComparison.OrdinalIgnoreCase);

View File

@@ -12,6 +12,12 @@ public static class Icons
public static IconInfo UnpinIcon => new("\uE77A"); // Unpin icon
public static IconInfo MoveUpIcon => new("\uE74A"); // Up icon
public static IconInfo MoveDownIcon => new("\uE74B"); // Down icon
public static IconInfo MoveToTopIcon => new("\uE898"); // Move to top icon
public static IconInfo SettingsIcon => new("\uE713"); // Settings icon
public static IconInfo EditIcon => new("\uE70F"); // Edit icon

View File

@@ -12,6 +12,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListItemViewModel : CommandItemViewModel
{
private const int MaxVisibleTags = 3;
public new ExtensionObject<IListItem> Model { get; }
public List<TagViewModel>? Tags { get; set; }
@@ -20,6 +22,10 @@ public partial class ListItemViewModel : CommandItemViewModel
// cannot be marked [ObservableProperty]
public bool HasTags => (Tags?.Count ?? 0) > 0;
public List<TagViewModel>? VisibleTags { get; private set; }
private TagViewModel? _overflowTag;
public string TextToSuggest { get; private set; } = string.Empty;
public string Section { get; private set; } = string.Empty;
@@ -273,13 +279,45 @@ public partial class ListItemViewModel : CommandItemViewModel
// Tags being an ObservableCollection instead of a List lead to
// many COM exception issues.
Tags = [.. newTags];
UpdateVisibleTags();
// We're already in UI thread, so just raise the events
OnPropertyChanged(nameof(Tags));
OnPropertyChanged(nameof(HasTags));
OnPropertyChanged(nameof(VisibleTags));
});
}
private void UpdateVisibleTags()
{
var allTags = Tags;
if (allTags is null || allTags.Count == 0)
{
VisibleTags = null;
}
else if (allTags.Count <= MaxVisibleTags)
{
VisibleTags = [.. allTags];
}
else
{
_overflowTag?.SafeCleanup();
var visible = allTags.Take(MaxVisibleTags).ToList();
var overflowCount = allTags.Count - MaxVisibleTags;
var hiddenTagNames = allTags.Skip(MaxVisibleTags).Select(t => t.Text);
var overflowTag = new TagViewModel(
new Tag($"+{overflowCount}")
{
ToolTip = string.Join("\n", hiddenTagNames),
},
PageContext);
overflowTag.InitializeProperties();
_overflowTag = overflowTag;
visible.Add(overflowTag);
VisibleTags = visible;
}
}
private void UpdateShowsTitle()
{
var oldShowTitle = ShowTitle;
@@ -306,6 +344,7 @@ public partial class ListItemViewModel : CommandItemViewModel
// Tags don't have event handlers or anything to cleanup
Tags?.ForEach(t => t.SafeCleanup());
_overflowTag?.SafeCleanup();
Details?.SafeCleanup();
var model = Model.Unsafe;

View File

@@ -0,0 +1,27 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Sent when a dock band is dropped onto a different monitor's dock.
/// The source DockControl should remove the band from its ViewModel.
/// </summary>
public sealed class CrossMonitorBandDropMessage
{
public string BandId { get; }
public string SourceMonitorDeviceId { get; }
public CrossMonitorBandDropMessage(string bandId, string sourceMonitorDeviceId)
{
ArgumentNullException.ThrowIfNull(bandId);
ArgumentNullException.ThrowIfNull(sourceMonitorDeviceId);
BandId = bandId;
SourceMonitorDeviceId = sourceMonitorDeviceId;
}
}

View File

@@ -2,7 +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.ComponentModel;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;

View File

@@ -17,7 +17,13 @@ public interface IMonitorService
IReadOnlyList<MonitorInfo> GetMonitors();
/// <summary>
/// Gets a specific monitor by its device identifier.
/// Gets a specific monitor by its stable hardware identifier.
/// </summary>
MonitorInfo? GetMonitorByStableId(string stableId);
/// <summary>
/// Gets a specific monitor by its GDI device name (e.g. <c>\\.\DISPLAY1</c>).
/// Prefer <see cref="GetMonitorByStableId"/> for persistent lookups.
/// </summary>
MonitorInfo? GetMonitorByDeviceId(string deviceId);

View File

@@ -12,10 +12,21 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
public sealed record MonitorInfo
{
/// <summary>
/// Gets the device identifier (e.g. <c>\\.\DISPLAY1</c>).
/// Gets the GDI device name (e.g. <c>\\.\DISPLAY1</c>).
/// This is volatile and may change across reboots or plug/unplug events.
/// Use <see cref="StableId"/> for persistent identification.
/// </summary>
public required string DeviceId { get; init; }
/// <summary>
/// Gets a stable hardware identifier derived from the Display Configuration API
/// device path (e.g. <c>\\?\DISPLAY#GSM1388#4&amp;125707d6&amp;0&amp;UID8388688#{guid}</c>).
/// Unlike <see cref="DeviceId"/>, this value survives reboots, driver updates,
/// and plug/unplug events on the same GPU port. Falls back to <see cref="DeviceId"/>
/// when the Display Configuration API is unavailable.
/// </summary>
public required string StableId { get; init; }
/// <summary>
/// Gets the human-readable display name (e.g. <c>DELL U2723QE</c>).
/// </summary>

View File

@@ -74,6 +74,20 @@ public abstract partial class ParameterRunViewModel : ExtensionObjectViewModel
{
// Override in derived classes
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
// Unsubscribe from the extension model's PropChanged event so we
// don't keep this view model alive for as long as the extension
// object lives.
var model = _model.Unsafe;
if (model is not null)
{
model.PropChanged -= Model_PropChanged;
}
}
}
/// <summary>
@@ -182,12 +196,20 @@ public partial class ParameterValueRunViewModel : ParameterRunViewModel
}
}
public partial class StringParameterRunViewModel : ParameterValueRunViewModel
public partial class StringParameterRunViewModel : ParameterValueRunViewModel, IDisposable
{
// Exclusive scheduler ensures writes to the extension's Text property are
// serialized in the order they were submitted from the UI, so rapid
// typing can't deliver updates out of order.
private readonly TaskFactory _writeTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
private ExtensionObject<IStringParameterRun> _model;
private string _modelText = string.Empty;
// For cancelling in-flight writes when a newer value arrives.
private CancellationTokenSource? _writeCancellationTokenSource;
public string TextForUI { get => _modelText; set => SetTextFromUi(value); }
public StringParameterRunViewModel(IStringParameterRun stringRun, WeakReference<IPageContext> context)
@@ -212,19 +234,74 @@ public partial class StringParameterRunViewModel : ParameterValueRunViewModel
public void SetTextFromUi(string value)
{
if (value != _modelText)
if (value == _modelText)
{
_modelText = value;
_ = Task.Run(() =>
{
var stringRun = _model.Unsafe;
if (stringRun != null)
{
stringRun.Text = value;
}
});
return;
}
_modelText = value;
// Cancel any pending write that hasn't started yet, so we don't push
// stale values to the extension.
CancelAndDisposeTokenSource(ref _writeCancellationTokenSource);
var writeCts = _writeCancellationTokenSource = new CancellationTokenSource();
var writeToken = writeCts.Token;
// Hop off to an exclusive scheduler background thread to update the
// extension. The exclusive scheduler ensures writes are serialized
// and in-order (mirroring ListViewModel.OnSearchTextBoxUpdated).
_ = _writeTaskFactory.StartNew(
() =>
{
if (writeToken.IsCancellationRequested)
{
return;
}
try
{
var stringRun = _model.Unsafe;
if (stringRun != null)
{
stringRun.Text = value;
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
ShowException(ex);
}
},
writeToken,
TaskCreationOptions.None,
_writeTaskFactory.Scheduler!);
}
private static void CancelAndDisposeTokenSource(ref CancellationTokenSource? tokenSource)
{
var tokenSourceToDispose = Interlocked.Exchange(ref tokenSource, null);
if (tokenSourceToDispose is null)
{
return;
}
tokenSourceToDispose.Cancel();
tokenSourceToDispose.Dispose();
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
CancelAndDisposeTokenSource(ref _writeCancellationTokenSource);
}
public void Dispose()
{
GC.SuppressFinalize(this);
SafeCleanup();
}
protected override void FetchProperty(string propertyName)
@@ -356,10 +433,7 @@ public partial class CommandParameterRunViewModel : ParameterValueRunViewModel,
GetHwndMessage msg = new();
WeakReferenceMessenger.Default.Send(msg);
var command = commandRun.GetSelectValueCommand((ulong)msg.Hwnd);
if (command == null)
{
}
else if (command is IListPage list)
if (command is IListPage list)
{
if (PageContext.TryGetTarget(out var pageContext))
{
@@ -443,10 +517,18 @@ public partial class CommandParameterRunViewModel : ParameterValueRunViewModel,
WeakReferenceMessenger.Default.Send(m);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
_listViewModel?.Dispose();
_listViewModel = null;
}
public void Dispose()
{
GC.SuppressFinalize(this);
_listViewModel?.Dispose();
SafeCleanup();
}
}
@@ -588,14 +670,22 @@ public partial class ParametersPageViewModel : PageViewModel, IDisposable
if (removedItem is CommandParameterRunViewModel removedCmdParam)
{
removedCmdParam.ValueChanged -= ListParamValueChanged;
// If the active list param is being removed, clear the
// active references so we don't hold on to a disposed
// ListViewModel.
if (removedCmdParam == _activeListParam)
{
SetActiveListParameter(null);
}
}
removedItem.SafeCleanup();
}
}
catch (Exception)
catch (Exception ex)
{
// Handle exceptions (e.g., log them)
CoreLogger.LogError($"Error fetching parameter items: {ex.Message}");
}
DoOnUiThread(
@@ -638,20 +728,17 @@ public partial class ParametersPageViewModel : PageViewModel, IDisposable
if (e.PropertyName == nameof(ParameterValueRunViewModel.NeedsValue))
{
// Marshal to UI thread — PropChanged events from the extension
// arrive on a background thread, but FocusNextParameter sends a
// message that ultimately touches UI controls.
// arrive on a background thread, but UpdateCommand touches UI
// controls.
//
// Note: For CommandParameterRunViewModel, advancing focus and
// clearing the active list is handled exclusively by
// ListParamValueChanged (subscribed to ValueChanged). That event
// fires for NeedsValue, DisplayText, and Icon changes, so it
// covers both first-pick and re-pick. Handling it here as well
// would risk a double-advance race.
DoOnUiThread(() =>
{
// First-time pick for a list param (NeedsValue true -> false).
if (sender is CommandParameterRunViewModel cmdParam &&
cmdParam == _activeListParam &&
!cmdParam.NeedsValue)
{
CoreLogger.LogDebug($"[ParametersPageVM] First-time list param pick, clearing active list");
SetActiveListParameter(null);
FocusNextParameter(cmdParam);
}
UpdateCommand();
});
}
@@ -748,16 +835,27 @@ public partial class ParametersPageViewModel : PageViewModel, IDisposable
public void Dispose()
{
GC.SuppressFinalize(this);
SafeCleanup();
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
// Drop the active list param reference before disposing items so we
// don't end up pointing at a disposed ListViewModel.
SetActiveListParameter(null);
lock (_listLock)
{
foreach (var item in Items)
{
item.PropertyChanged -= ItemPropertyChanged;
if (item is CommandParameterRunViewModel cmdParam)
{
cmdParam.ValueChanged -= ListParamValueChanged;
}
item.SafeCleanup();
}

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.
namespace Microsoft.CmdPal.UI.ViewModels;
public record PinnedCommandSettings(string ProviderId, string CommandId);

View File

@@ -321,6 +321,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} command.
/// </summary>
public static string builtin_extension_subtext_singular {
get {
return ResourceManager.GetString("builtin_extension_subtext_singular", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1}.
/// </summary>
@@ -339,6 +348,33 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} command, {2} fallback command.
/// </summary>
public static string builtin_extension_subtext_with_fallback_singular_both {
get {
return ResourceManager.GetString("builtin_extension_subtext_with_fallback_singular_both", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} command, {2} fallback commands.
/// </summary>
public static string builtin_extension_subtext_with_fallback_singular_command {
get {
return ResourceManager.GetString("builtin_extension_subtext_with_fallback_singular_command", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} commands, {2} fallback command.
/// </summary>
public static string builtin_extension_subtext_with_fallback_singular_fallback {
get {
return ResourceManager.GetString("builtin_extension_subtext_with_fallback_singular_fallback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Home.
/// </summary>
@@ -1140,6 +1176,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
public static string home_sections_pinned_title {
get {
return ResourceManager.GetString("home_sections_pinned_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>

View File

@@ -133,10 +133,26 @@
<value>{0}, {1} commands</value>
<comment>{0}=extension name, {1}=number of commands</comment>
</data>
<data name="builtin_extension_subtext_singular" xml:space="preserve">
<value>{0}, {1} command</value>
<comment>{0}=extension name, {1}=number of commands (1)</comment>
</data>
<data name="builtin_extension_subtext_with_fallback" xml:space="preserve">
<value>{0}, {1} commands, {2} fallback commands</value>
<comment>{0}=extension name, {1}=number of commands, {2} number of fallback commands</comment>
</data>
<data name="builtin_extension_subtext_with_fallback_singular_command" xml:space="preserve">
<value>{0}, {1} command, {2} fallback commands</value>
<comment>{0}=extension name, {1}=number of commands (1), {2} number of fallback commands</comment>
</data>
<data name="builtin_extension_subtext_with_fallback_singular_fallback" xml:space="preserve">
<value>{0}, {1} commands, {2} fallback command</value>
<comment>{0}=extension name, {1}=number of commands, {2} number of fallback commands (1)</comment>
</data>
<data name="builtin_extension_subtext_with_fallback_singular_both" xml:space="preserve">
<value>{0}, {1} command, {2} fallback command</value>
<comment>{0}=extension name, {1}=number of commands (1), {2} number of fallback commands (1)</comment>
</data>
<data name="builtin_extension_subtext_disabled" xml:space="preserve">
<value>{0}, {1}</value>
<comment>{0}=extension name, {1}=message</comment>
@@ -299,6 +315,9 @@
<value>Results</value>
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
</data>
<data name="home_sections_pinned_title" xml:space="preserve">
<value>Pinned</value>
</data>
<data name="home_sections_commands_title" xml:space="preserve">
<value>Commands</value>
</data>

View File

@@ -18,7 +18,11 @@ public partial class ProviderSettingsViewModel : ObservableObject
{
private static readonly IconInfoViewModel EmptyIcon = new(null);
private static readonly CompositeFormat ExtensionSubtextFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext);
private static readonly CompositeFormat ExtensionSubtextSingularFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_singular);
private static readonly CompositeFormat ExtensionSubtextWithFallbackFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback);
private static readonly CompositeFormat ExtensionSubtextWithFallbackSingularCommandFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback_singular_command);
private static readonly CompositeFormat ExtensionSubtextWithFallbackSingularFallbackFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback_singular_fallback);
private static readonly CompositeFormat ExtensionSubtextWithFallbackSingularBothFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback_singular_both);
private static readonly CompositeFormat ExtensionSubtextDisabledFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_disabled);
private readonly CommandProviderWrapper _provider;
@@ -47,11 +51,40 @@ public partial class ProviderSettingsViewModel : ObservableObject
public string ExtensionName => _provider.Extension?.ExtensionDisplayName ?? Resources.builtin_extension_name;
public string ExtensionSubtext => IsEnabled ?
HasFallbackCommands ?
string.Format(CultureInfo.CurrentCulture, ExtensionSubtextWithFallbackFormat, ExtensionName, TopLevelCommands.Count, _provider.FallbackItems?.Length ?? 0) :
string.Format(CultureInfo.CurrentCulture, ExtensionSubtextFormat, ExtensionName, TopLevelCommands.Count) :
string.Format(CultureInfo.CurrentCulture, ExtensionSubtextDisabledFormat, ExtensionName, Resources.builtin_disabled_extension);
public string ExtensionSubtext
{
get
{
if (!IsEnabled)
{
return string.Format(CultureInfo.CurrentCulture, ExtensionSubtextDisabledFormat, ExtensionName, Resources.builtin_disabled_extension);
}
int commandCount = TopLevelCommands.Count;
if (HasFallbackCommands)
{
int fallbackCount = _provider.FallbackItems?.Length ?? 0;
bool commandSingular = commandCount == 1;
bool fallbackSingular = fallbackCount == 1;
CompositeFormat format = (commandSingular, fallbackSingular) switch
{
(true, true) => ExtensionSubtextWithFallbackSingularBothFormat,
(true, false) => ExtensionSubtextWithFallbackSingularCommandFormat,
(false, true) => ExtensionSubtextWithFallbackSingularFallbackFormat,
(false, false) => ExtensionSubtextWithFallbackFormat,
};
return string.Format(CultureInfo.CurrentCulture, format, ExtensionName, commandCount, fallbackCount);
}
else
{
CompositeFormat format = commandCount == 1 ? ExtensionSubtextSingularFormat : ExtensionSubtextFormat;
return string.Format(CultureInfo.CurrentCulture, format, ExtensionName, commandCount);
}
}
}
[MemberNotNullWhen(true, nameof(Extension))]
public bool IsFromExtension => _provider.Extension is not null;

View File

@@ -96,6 +96,13 @@ public sealed class SettingsService : ISettingsService
Debug.WriteLine($"Migration check failed: {ex}");
}
var normalizedSettings = _settings.NormalizePinnedCommands();
if (!ReferenceEquals(normalizedSettings, _settings))
{
_settings = normalizedSettings;
migratedAny = true;
}
if (migratedAny)
{
Save(hotReload: false);

View File

@@ -109,11 +109,12 @@ public record DockSettings
/// Gets the dock side override for a specific monitor, or <c>null</c> if the
/// monitor has no override (inherits global <see cref="Side"/>).
/// </summary>
public DockSide? GetSideForMonitor(string deviceId)
/// <param name="stableId">The monitor's stable hardware identifier (stored in <see cref="DockMonitorConfig.MonitorDeviceId"/>).</param>
public DockSide? GetSideForMonitor(string stableId)
{
foreach (var cfg in MonitorConfigs)
{
if (string.Equals(cfg.MonitorDeviceId, deviceId, StringComparison.OrdinalIgnoreCase))
if (string.Equals(cfg.MonitorDeviceId, stableId, StringComparison.OrdinalIgnoreCase))
{
return cfg.Side;
}

View File

@@ -11,9 +11,9 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Reconciles persisted <see cref="DockMonitorConfig"/> entries against the
/// set of currently connected monitors. Handles stale device IDs that may change
/// across reboots by using the <see cref="DockMonitorConfig.IsPrimary"/> flag as
/// a secondary matching key.
/// set of currently connected monitors. Uses <see cref="MonitorInfo.StableId"/>
/// (hardware device path) for persistent identification, with automatic
/// migration from legacy GDI device names (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
/// <remarks>
/// All operations are pure — they return new immutable lists rather than
@@ -30,7 +30,8 @@ public static class MonitorConfigReconciler
/// <summary>
/// Reconciles persisted monitor configs against the current set of connected monitors.
/// <para>
/// <b>Phase 1</b>: Exact DeviceId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 1</b>: Exact StableId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 1.5</b>: Legacy migration — match configs with GDI-style IDs by GDI name, then rewrite to StableId.<br/>
/// <b>Phase 2</b>: Fuzzy matching — reassociate unmatched configs by IsPrimary flag.<br/>
/// <b>Phase 3</b>: Create default configs for monitors that have no matching config.<br/>
/// <b>Phase 4</b>: Retain disconnected monitor configs for future reconnection; prune entries not seen for 6+ months.
@@ -61,39 +62,63 @@ public static class MonitorConfigReconciler
return existingConfigs;
}
// Build a DeviceId → index lookup for O(1) matching in Phase 1
var configIndexByDeviceId = new Dictionary<string, int>(existingConfigs.Count, StringComparer.OrdinalIgnoreCase);
// Build a MonitorDeviceId → index lookup for O(1) matching
var configIndexById = new Dictionary<string, int>(existingConfigs.Count, StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < existingConfigs.Count; i++)
{
configIndexByDeviceId.TryAdd(existingConfigs[i].MonitorDeviceId, i);
configIndexById.TryAdd(existingConfigs[i].MonitorDeviceId, i);
}
var matchedMonitorDeviceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedMonitorStableIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedConfigIndices = new HashSet<int>();
var result = new List<DockMonitorConfig>(currentMonitors.Count);
// Phase 1: Exact DeviceId match (O(N) with dictionary lookup)
// Phase 1: Exact match on StableId (configs already migrated to stable paths)
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (configIndexByDeviceId.TryGetValue(monitor.DeviceId, out var ci) && !matchedConfigIndices.Contains(ci))
if (configIndexById.TryGetValue(monitor.StableId, out var ci) && !matchedConfigIndices.Contains(ci))
{
// Update IsPrimary and LastSeen to current state
result.Add(existingConfigs[ci] with { IsPrimary = monitor.IsPrimary, LastSeen = utcNow });
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedMonitorStableIds.Add(monitor.StableId);
matchedConfigIndices.Add(ci);
}
}
// Phase 2: Fuzzy match — recover primary monitor config when its DeviceId changed.
// Windows can reassign DeviceId strings across reboots, driver updates, or cable
// swaps. When the primary monitor's DeviceId no longer matches any saved config,
// Phase 1.5: Legacy migration — match configs that still have GDI-style IDs
// (e.g. "\\.\DISPLAY1") by matching against the monitor's GDI DeviceId,
// then rewrite the MonitorDeviceId to the monitor's stable hardware path.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorStableIds.Contains(monitor.StableId))
{
continue;
}
if (configIndexById.TryGetValue(monitor.DeviceId, out var ci) && !matchedConfigIndices.Contains(ci))
{
// Migrate: rewrite from GDI name to stable path
result.Add(existingConfigs[ci] with
{
MonitorDeviceId = monitor.StableId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorStableIds.Add(monitor.StableId);
matchedConfigIndices.Add(ci);
}
}
// Phase 2: Fuzzy match — recover primary monitor config when its ID changed.
// Windows can reassign device paths across driver updates or cable swaps.
// When the primary monitor's StableId no longer matches any saved config,
// we look for an unmatched config that was previously marked as primary and
// reassociate it. Secondary monitors are not interchangeable, so we skip them.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (!monitor.IsPrimary || matchedMonitorDeviceIds.Contains(monitor.DeviceId))
if (!monitor.IsPrimary || matchedMonitorStableIds.Contains(monitor.StableId))
{
continue;
}
@@ -107,14 +132,13 @@ public static class MonitorConfigReconciler
if (existingConfigs[ci].IsPrimary)
{
// Reassociate: update DeviceId, IsPrimary, and LastSeen
result.Add(existingConfigs[ci] with
{
MonitorDeviceId = monitor.DeviceId,
MonitorDeviceId = monitor.StableId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedMonitorStableIds.Add(monitor.StableId);
matchedConfigIndices.Add(ci);
break;
}
@@ -123,22 +147,21 @@ public static class MonitorConfigReconciler
// Phase 3: Create defaults for new monitors with no matching config.
// Primary monitors inherit global bands (IsCustomized = false) for a seamless
// upgrade path. Secondary monitors start with empty band lists so users don't
// have to manually unpin bands from every new display.
// upgrade path. Secondary monitors start disabled with empty band lists
// users opt-in via Settings when they want the dock on additional displays.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorDeviceIds.Contains(monitor.DeviceId))
if (matchedMonitorStableIds.Contains(monitor.StableId))
{
continue;
}
if (monitor.IsPrimary)
{
// Primary: inherit global bands (IsCustomized = false)
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.DeviceId,
MonitorDeviceId = monitor.StableId,
Enabled = true,
IsPrimary = true,
LastSeen = utcNow,
@@ -146,11 +169,10 @@ public static class MonitorConfigReconciler
}
else
{
// Secondary: start with empty bands so users choose what to pin per-monitor
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.DeviceId,
Enabled = true,
MonitorDeviceId = monitor.StableId,
Enabled = false,
IsPrimary = false,
IsCustomized = true,
StartBands = ImmutableList<DockBandSettings>.Empty,

View File

@@ -37,6 +37,9 @@ public record SettingsModel
public bool AllowBreakthroughShortcut { get; init; }
public ImmutableList<PinnedCommandSettings> PinnedCommands { get; init; }
= ImmutableList<PinnedCommandSettings>.Empty;
public bool AllowExternalReload { get; init; }
private ImmutableDictionary<string, ProviderSettings>? _providerSettings
@@ -137,6 +140,25 @@ public record SettingsModel
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
[JsonConstructor]
public SettingsModel(
ImmutableList<PinnedCommandSettings>? pinnedCommands = null,
ImmutableDictionary<string, ProviderSettings>? providerSettings = null,
string[]? fallbackRanks = null,
ImmutableDictionary<string, CommandAlias>? aliases = null,
ImmutableList<TopLevelHotkey>? commandHotkeys = null)
{
PinnedCommands = pinnedCommands ?? ImmutableList<PinnedCommandSettings>.Empty;
ProviderSettings = providerSettings ?? ImmutableDictionary<string, ProviderSettings>.Empty;
FallbackRanks = fallbackRanks ?? [];
Aliases = aliases ?? ImmutableDictionary<string, CommandAlias>.Empty;
CommandHotkeys = commandHotkeys ?? ImmutableList<TopLevelHotkey>.Empty;
}
public SettingsModel()
{
}
public (SettingsModel Model, ProviderSettings Settings) GetProviderSettings(CommandProviderWrapper provider)
{
if (!ProviderSettings.TryGetValue(provider.ProviderId, out var settings))
@@ -159,6 +181,186 @@ public record SettingsModel
return (newModel, connected);
}
public SettingsModel NormalizePinnedCommands()
{
var pinnedCommands = PinnedCommands;
if (pinnedCommands.Count == 0)
{
var migratedPins = ImmutableList.CreateBuilder<PinnedCommandSettings>();
foreach (var (providerId, providerSettings) in ProviderSettings.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
foreach (var commandId in providerSettings.PinnedCommandIds)
{
migratedPins.Add(new PinnedCommandSettings(providerId, commandId));
}
}
pinnedCommands = migratedPins.ToImmutable();
}
return WithPinnedCommands(pinnedCommands);
}
public SettingsModel WithPinnedCommands(ImmutableList<PinnedCommandSettings> pinnedCommands)
{
var groupedPinnedCommands = pinnedCommands
.GroupBy(static pin => pin.ProviderId, StringComparer.Ordinal)
.ToDictionary(
static group => group.Key,
static group => group.Select(static pin => pin.CommandId).ToImmutableList(),
StringComparer.Ordinal);
var allProviderIds = ProviderSettings.Keys.Union(groupedPinnedCommands.Keys, StringComparer.Ordinal).ToArray();
var providerSettingsAlreadyMatch = allProviderIds.All(providerId =>
{
ProviderSettings.TryGetValue(providerId, out var currentProviderSettings);
groupedPinnedCommands.TryGetValue(providerId, out var desiredPinnedIds);
var currentPinnedIds = currentProviderSettings?.PinnedCommandIds ?? ImmutableList<string>.Empty;
desiredPinnedIds ??= ImmutableList<string>.Empty;
return currentPinnedIds.SequenceEqual(desiredPinnedIds);
});
if (PinnedCommands.SequenceEqual(pinnedCommands) && providerSettingsAlreadyMatch)
{
return this;
}
var providerSettingsBuilder = ProviderSettings.ToBuilder();
foreach (var providerId in allProviderIds)
{
providerSettingsBuilder.TryGetValue(providerId, out var providerSettings);
providerSettings ??= new ProviderSettings();
groupedPinnedCommands.TryGetValue(providerId, out var desiredPinnedIds);
desiredPinnedIds ??= ImmutableList<string>.Empty;
providerSettingsBuilder[providerId] = providerSettings with { PinnedCommandIds = desiredPinnedIds };
}
return this with
{
PinnedCommands = pinnedCommands,
ProviderSettings = providerSettingsBuilder.ToImmutable(),
};
}
public bool IsCommandPinned(string providerId, string commandId)
{
foreach (var pinnedCommand in PinnedCommands)
{
if (pinnedCommand.ProviderId == providerId &&
pinnedCommand.CommandId == commandId)
{
return true;
}
}
return false;
}
public List<string> GetPinnedCommandIds(string providerId)
{
List<string> pinnedCommandIds = [];
foreach (var pinnedCommand in PinnedCommands)
{
if (pinnedCommand.ProviderId == providerId)
{
pinnedCommandIds.Add(pinnedCommand.CommandId);
}
}
return pinnedCommandIds;
}
public SettingsModel TryPinCommand(string providerId, string commandId)
{
if (IsCommandPinned(providerId, commandId))
{
return this;
}
return WithPinnedCommands(PinnedCommands.Add(new PinnedCommandSettings(providerId, commandId)));
}
public SettingsModel TryUnpinCommand(string providerId, string commandId)
{
for (var i = 0; i < PinnedCommands.Count; i++)
{
var pinnedCommand = PinnedCommands[i];
if (pinnedCommand.ProviderId == providerId &&
pinnedCommand.CommandId == commandId)
{
return WithPinnedCommands(PinnedCommands.RemoveAt(i));
}
}
return this;
}
public SettingsModel TryMovePinnedCommand(string providerId, string commandId, bool moveUp, Func<PinnedCommandSettings, bool>? isVisible = null)
{
var index = FindPinnedCommandIndex(providerId, commandId);
if (index < 0)
{
return this;
}
// Find the next visible neighbor in the move direction, skipping
// stale entries (removed/disabled/failed extensions).
var direction = moveUp ? -1 : 1;
var targetIndex = index + direction;
while (targetIndex >= 0 && targetIndex < PinnedCommands.Count &&
isVisible != null && !isVisible(PinnedCommands[targetIndex]))
{
targetIndex += direction;
}
if (targetIndex < 0 || targetIndex >= PinnedCommands.Count)
{
return this;
}
// Remove and re-insert rather than swap so that stale entries
// between index and targetIndex keep their relative positions.
var pinnedCommand = PinnedCommands[index];
var pinnedCommands = PinnedCommands.RemoveAt(index);
pinnedCommands = pinnedCommands.Insert(targetIndex, pinnedCommand);
return WithPinnedCommands(pinnedCommands);
}
public SettingsModel TryMovePinnedCommandToTop(string providerId, string commandId)
{
var index = FindPinnedCommandIndex(providerId, commandId);
if (index <= 0)
{
return this;
}
var pinnedCommand = PinnedCommands[index];
var pinnedCommands = PinnedCommands.RemoveAt(index);
pinnedCommands = pinnedCommands.Insert(0, pinnedCommand);
return WithPinnedCommands(pinnedCommands);
}
private int FindPinnedCommandIndex(string providerId, string commandId)
{
for (var i = 0; i < PinnedCommands.Count; i++)
{
var pinnedCommand = PinnedCommands[i];
if (pinnedCommand.ProviderId == providerId &&
pinnedCommand.CommandId == commandId)
{
return i;
}
}
return -1;
}
public string[] GetGlobalFallbacks()
{
var globalFallbacks = new HashSet<string>();
@@ -208,6 +410,7 @@ public record SettingsModel
[JsonSerializable(typeof(ImmutableList<HistoryItem>), TypeInfoPropertyName = "ImmutableHistoryList")]
[JsonSerializable(typeof(ImmutableDictionary<string, FallbackSettings>), TypeInfoPropertyName = "ImmutableFallbackDictionary")]
[JsonSerializable(typeof(ImmutableList<string>), TypeInfoPropertyName = "ImmutableStringList")]
[JsonSerializable(typeof(ImmutableList<PinnedCommandSettings>), TypeInfoPropertyName = "ImmutablePinnedCommandSettingsList")]
[JsonSerializable(typeof(ImmutableList<DockBandSettings>), TypeInfoPropertyName = "ImmutableDockBandSettingsList")]
[JsonSerializable(typeof(DockMonitorConfig))]
[JsonSerializable(typeof(ImmutableList<DockMonitorConfig>), TypeInfoPropertyName = "ImmutableDockMonitorConfigList")]
@@ -215,6 +418,7 @@ public record SettingsModel
[JsonSerializable(typeof(ImmutableDictionary<string, CommandAlias>), TypeInfoPropertyName = "ImmutableAliasDictionary")]
[JsonSerializable(typeof(ImmutableList<TopLevelHotkey>), TypeInfoPropertyName = "ImmutableTopLevelHotkeyList")]
[JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "Dictionary")]
[JsonSerializable(typeof(PinnedCommandSettings))]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")]
internal sealed partial class JsonSerializationContext : JsonSerializerContext

View File

@@ -371,7 +371,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
foreach (var monitor in monitors)
{
var config = reconciled.FirstOrDefault(c =>
string.Equals(c.MonitorDeviceId, monitor.DeviceId, StringComparison.OrdinalIgnoreCase));
string.Equals(c.MonitorDeviceId, monitor.StableId, StringComparison.OrdinalIgnoreCase));
if (config is not null)
{

View File

@@ -48,6 +48,8 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
private CancellationTokenSource _extensionLoadCts = new();
private CancellationToken _currentExtensionLoadCancellationToken;
private HashSet<(string ProviderId, string CommandId)> _pinnedCommandSet = [];
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
{
_serviceProvider = serviceProvider;
@@ -59,8 +61,11 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
WeakReferenceMessenger.Default.Register<UnpinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<PinToDockMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
RebuildPinnedCache();
}
public ObservableCollection<PinnedCommandSettings> PinnedCommands { get; } = [];
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelViewModel> DockBands { get; set; } = [];
@@ -79,6 +84,18 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
}
}
internal bool IsPinned(string providerId, string commandId)
{
return _pinnedCommandSet.Contains((providerId, commandId));
}
internal void RebuildPinnedCache()
{
var settings = _serviceProvider.GetRequiredService<ISettingsService>().Settings;
_pinnedCommandSet = new(settings.PinnedCommands.Select(p => (p.ProviderId, p.CommandId)));
ListHelpers.InPlaceUpdateList(PinnedCommands, settings.PinnedCommands);
}
public async Task<bool> LoadBuiltinsAsync()
{
var s = new Stopwatch();
@@ -693,12 +710,14 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
var wrapper = LookupProvider(message.ProviderId);
wrapper?.PinCommand(message.CommandId, _serviceProvider);
RebuildPinnedCache();
}
public void Receive(UnpinCommandItemMessage message)
{
var wrapper = LookupProvider(message.ProviderId);
wrapper?.UnpinCommand(message.CommandId, _serviceProvider);
RebuildPinnedCache();
}
public void Receive(PinToDockMessage message)

View File

@@ -95,11 +95,9 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
// Add pin/unpin commands for pinning items to the top-level or to
// the dock.
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper)
{
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId);
var alreadyPinnedToTopLevel = _settingsService.Settings.IsCommandPinned(providerId, itemId);
// Don't add pin/unpin commands for items displayed as
// TopLevelViewModels that aren't already pinned.
@@ -121,7 +119,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
moreCommands.Add(contextItem);
}
TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem);
TryAddPinToDockCommand(itemId, providerId, moreCommands, commandItem);
}
}
@@ -155,33 +153,19 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
List<IContextItem?> contextItems)
{
var itemId = topLevelItem.Id;
var supportsPinning = providerContext.SupportsPinning;
List<IContextItem> moreCommands = [];
var commandItem = topLevelItem.ItemViewModel;
// Add pin/unpin commands for pinning items to the top-level or to
// the dock.
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper)
{
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
TryAddMovePinnedCommands(itemId, providerId, commandItem, moreCommands);
TryAddUnpinFromHomeCommand(itemId, providerId, commandItem, moreCommands);
TryAddPinToHomeCommand(itemId, providerId, commandItem, moreCommands);
var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId);
if (isPinnedSubCommand)
{
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !isPinnedSubCommand,
PinLocation.TopLevel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem);
TryAddPinToDockCommand(itemId, providerId, moreCommands, commandItem);
}
if (moreCommands.Count > 0)
@@ -193,8 +177,73 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
}
}
private void TryAddPinToHomeCommand(
string itemId,
string providerId,
CommandItemViewModel commandItem,
List<IContextItem> moreCommands)
{
if (_settingsService.Settings.IsCommandPinned(providerId, itemId))
{
return;
}
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: true,
PinLocation.TopLevel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
private void TryAddUnpinFromHomeCommand(
string itemId,
string providerId,
CommandItemViewModel commandItem,
List<IContextItem> moreCommands)
{
var isPinnedSubCommand = _settingsService.Settings.IsCommandPinned(providerId, itemId);
if (isPinnedSubCommand)
{
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !isPinnedSubCommand,
PinLocation.TopLevel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
}
private void TryAddMovePinnedCommands(
string itemId,
string providerId,
CommandItemViewModel commandItem,
List<IContextItem> moreCommands)
{
if (!_settingsService.Settings.IsCommandPinned(providerId, itemId))
{
return;
}
var moveToTopCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.ToTop, _settingsService, _topLevelCommandManager);
moreCommands.Add(new MovePinnedContextItem(moveToTopCommand, commandItem));
var moveUpCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.Up, _settingsService, _topLevelCommandManager);
moreCommands.Add(new MovePinnedContextItem(moveUpCommand, commandItem));
var moveDownCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.Down, _settingsService, _topLevelCommandManager);
moreCommands.Add(new MovePinnedContextItem(moveDownCommand, commandItem));
}
private void TryAddPinToDockCommand(
ProviderSettings providerSettings,
string itemId,
string providerId,
List<IContextItem> moreCommands,
@@ -261,6 +310,30 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
}
}
private sealed partial class MovePinnedContextItem : CommandContextItem
{
private readonly MovePinnedCommand _command;
private readonly CommandItemViewModel _commandItem;
public MovePinnedContextItem(MovePinnedCommand command, CommandItemViewModel commandItem)
: base(command)
{
_command = command;
_commandItem = commandItem;
command.MoveStateChanged += this.OnMoveStateChanged;
}
private void OnMoveStateChanged(object? sender, EventArgs e)
{
_commandItem.RefreshMoreCommands();
}
~MovePinnedContextItem()
{
_command.MoveStateChanged -= this.OnMoveStateChanged;
}
}
private sealed partial class PinToCommand : InvokableCommand
{
private readonly string _commandId;
@@ -366,4 +439,85 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
WeakReferenceMessenger.Default.Send(message);
}
}
private sealed partial class MovePinnedCommand : InvokableCommand
{
private readonly string _providerId;
private readonly string _commandId;
private readonly MovePinnedDirection _moveDirection;
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
public override IconInfo Icon => _moveDirection switch
{
MovePinnedDirection.ToTop => Icons.MoveToTopIcon,
MovePinnedDirection.Up => Icons.MoveUpIcon,
_ => Icons.MoveDownIcon,
};
public override string Name => _moveDirection switch
{
MovePinnedDirection.ToTop => RS_.GetString("top_level_move_to_top_command_name"),
MovePinnedDirection.Up => RS_.GetString("top_level_move_up_command_name"),
_ => RS_.GetString("top_level_move_down_command_name"),
};
internal event EventHandler? MoveStateChanged;
public MovePinnedCommand(
string providerId,
string commandId,
MovePinnedDirection moveDirection,
ISettingsService settingsService,
TopLevelCommandManager topLevelCommandManager)
{
_providerId = providerId;
_commandId = commandId;
_moveDirection = moveDirection;
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
}
public override CommandResult Invoke()
{
var moved = false;
_settingsService.UpdateSettings(
s =>
{
var updated = _moveDirection switch
{
MovePinnedDirection.ToTop => s.TryMovePinnedCommandToTop(_providerId, _commandId),
MovePinnedDirection.Up => s.TryMovePinnedCommand(_providerId, _commandId, true, IsLoaded),
_ => s.TryMovePinnedCommand(_providerId, _commandId, false, IsLoaded),
};
moved = !ReferenceEquals(updated, s);
return updated;
},
hotReload: false);
if (moved)
{
WeakReferenceMessenger.Default.Send<UpdateFallbackItemsMessage>();
MoveStateChanged?.Invoke(this, EventArgs.Empty);
}
return CommandResult.KeepOpen();
// Pass a visibility check so moves skip stale pinned entries
// (removed/disabled/failed extensions) that aren't shown on home.
bool IsLoaded(PinnedCommandSettings pin)
{
return _topLevelCommandManager.LookupCommand(pin.CommandId) is TopLevelViewModel cmd &&
cmd.CommandProviderId == pin.ProviderId;
}
}
}
private enum MovePinnedDirection
{
ToTop,
Up,
Down,
}
}

View File

@@ -703,6 +703,22 @@ public sealed partial class SearchBar : UserControl,
e.Handled = true;
}
}
else if (e.Key == VirtualKey.Tab)
{
// Tab away from a list parameter: dismiss the list panel so it
// doesn't linger when the user keyboard-navigates to a different
// control. Don't mark e.Handled — let the default Tab behavior
// move focus to the next/previous control.
if (textBox.DataContext is CommandParameterRunViewModel listParam)
{
if (!listParam.NeedsValue)
{
listParam.CancelEditing();
}
parametersPage.SetActiveListParameter(null);
}
}
else if (e.Key == VirtualKey.Up)
{
WeakReferenceMessenger.Default.Send<NavigatePreviousCommand>();

View File

@@ -15,15 +15,15 @@
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Style="{StaticResource AccentButtonStyle}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasVisibleOperations), Mode=OneWay, FallbackValue=Collapsed}">
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasVisibleOperations), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="10">
<FontIcon FontSize="14" Glyph="&#xE8B7;" />
<ProgressRing
Width="16"
Height="16"
IsActive="{x:Bind HasActiveOperations, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasActiveOperations), Mode=OneWay, FallbackValue=Collapsed}" />
<TextBlock Text="{x:Bind SummaryText, Mode=OneWay}" />
IsActive="{x:Bind ViewModel.HasActiveOperations, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasActiveOperations), Mode=OneWay, FallbackValue=Collapsed}" />
<TextBlock Text="{x:Bind ViewModel.SummaryText, Mode=OneWay}" />
</StackPanel>
<Button.Flyout>
<Flyout Placement="TopEdgeAlignedRight">
@@ -32,7 +32,7 @@
MaxHeight="420"
Padding="16">
<StackPanel Spacing="12">
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind FlyoutHeaderText, Mode=OneWay}" />
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind ViewModel.FlyoutHeaderText, Mode=OneWay}" />
<ScrollViewer MaxHeight="320">
<ItemsControl ItemsSource="{x:Bind ViewModel.Operations, Mode=OneWay}">
<ItemsControl.ItemTemplate>

View File

@@ -2,7 +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.ComponentModel;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.WinGet;
using Microsoft.Extensions.DependencyInjection;
@@ -16,14 +15,6 @@ public sealed partial class WinGetOperationsButton : UserControl, IDisposable
public WinGetOperationsViewModel ViewModel { get; }
public bool HasVisibleOperations => ViewModel.HasVisibleOperations;
public bool HasActiveOperations => ViewModel.HasActiveOperations;
public string SummaryText => ViewModel.SummaryText;
public string FlyoutHeaderText => ViewModel.FlyoutHeaderText;
public WinGetOperationsButton()
{
var trackerService = App.Current.Services.GetRequiredService<IWinGetOperationTrackerService>();
@@ -31,7 +22,6 @@ public sealed partial class WinGetOperationsButton : UserControl, IDisposable
ViewModel = new WinGetOperationsViewModel(trackerService, uiScheduler);
this.InitializeComponent();
ViewModel.PropertyChanged += OnViewModelPropertyChanged;
}
public void Dispose()
@@ -42,12 +32,6 @@ public sealed partial class WinGetOperationsButton : UserControl, IDisposable
}
_disposed = true;
ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
ViewModel.Dispose();
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
Bindings.Update();
}
}

View File

@@ -23,7 +23,7 @@ using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Dock;
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>, IRecipient<CrossMonitorBandDropMessage>
{
private DockViewModel _viewModel;
@@ -96,6 +96,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<EnterDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<ExitDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<CrossMonitorBandDropMessage>(this);
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
@@ -461,12 +462,21 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
_draggedBand = band;
e.Data.RequestedOperation = DataPackageOperation.Move;
// Only advertise cross-monitor data when we have a real monitor ID.
// Without one (single-monitor / global dock) the cross-monitor path
// cannot safely distinguish source from target.
if (ViewModel.MonitorDeviceId is not null)
{
e.Data.Properties["DockBandId"] = band.Id;
e.Data.Properties["SourceMonitorDeviceId"] = ViewModel.MonitorDeviceId;
}
}
}
private void BandListView_DragOver(object sender, DragEventArgs e)
{
if (_draggedBand != null)
if (_draggedBand != null || e.DataView.Properties.ContainsKey("DockBandId"))
{
e.AcceptedOperation = DataPackageOperation.Move;
}
@@ -528,15 +538,27 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void HandleCrossListDrop(DockPinSide targetSide, DragEventArgs e)
{
if (_draggedBand == null)
if (_draggedBand != null)
{
HandleLocalCrossListDrop(targetSide, e);
return;
}
// Cross-monitor drag from another DockControl
if (e.DataView.Properties.TryGetValue("DockBandId", out var bandIdObj) &&
e.DataView.Properties.TryGetValue("SourceMonitorDeviceId", out var sourceMonitorObj) &&
bandIdObj is string bandId &&
sourceMonitorObj is string sourceMonitorDeviceId)
{
HandleCrossMonitorDrop(bandId, sourceMonitorDeviceId, targetSide, e);
}
}
private void HandleLocalCrossListDrop(DockPinSide targetSide, DragEventArgs e)
{
// Check which list the band is currently in
var isInStart = ViewModel.StartItems.Contains(_draggedBand);
var isInCenter = ViewModel.CenterItems.Contains(_draggedBand);
var isInEnd = ViewModel.EndItems.Contains(_draggedBand);
var isInStart = ViewModel.StartItems.Contains(_draggedBand!);
var isInCenter = ViewModel.CenterItems.Contains(_draggedBand!);
DockPinSide sourceSide;
if (isInStart)
@@ -555,7 +577,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
// Only handle cross-list drops here; same-list reorders are handled in DragItemsCompleted
if (sourceSide != targetSide)
{
// Calculate drop index based on drop position
var targetListView = targetSide switch
{
DockPinSide.Start => StartListView,
@@ -572,11 +593,38 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
var dropIndex = GetDropIndex(targetListView, e, targetCollection.Count);
// Move the band to the new side (without saving - save happens on Done)
ViewModel.MoveBandWithoutSaving(_draggedBand, targetSide, dropIndex);
ViewModel.MoveBandWithoutSaving(_draggedBand!, targetSide, dropIndex);
e.Handled = true;
}
}
private void HandleCrossMonitorDrop(string bandId, string sourceMonitorDeviceId, DockPinSide targetSide, DragEventArgs e)
{
var targetListView = targetSide switch
{
DockPinSide.Start => StartListView,
DockPinSide.Center => CenterListView,
_ => EndListView,
};
var targetCollection = targetSide switch
{
DockPinSide.Start => ViewModel.StartItems,
DockPinSide.Center => ViewModel.CenterItems,
_ => ViewModel.EndItems,
};
var dropIndex = GetDropIndex(targetListView, e, targetCollection.Count);
ViewModel.AcceptBandFromMonitor(bandId, targetSide, dropIndex);
if (!string.IsNullOrEmpty(sourceMonitorDeviceId))
{
WeakReferenceMessenger.Default.Send(new CrossMonitorBandDropMessage(bandId, sourceMonitorDeviceId));
}
e.Handled = true;
}
private int GetDropIndex(ListView listView, DragEventArgs e, int itemCount)
{
var position = e.GetPosition(listView);
@@ -656,7 +704,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void BandListView_DragEnter(object sender, DragEventArgs e)
{
if (sender is ListView view)
if (sender is ListView view && (_draggedBand != null || e.DataView.Properties.ContainsKey("DockBandId")))
{
view.Background = Application.Current.Resources["ControlAltFillColorQuarternaryBrush"] as SolidColorBrush;
e.DragUIOverride.IsGlyphVisible = false;
@@ -676,4 +724,23 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
listView.Background = new SolidColorBrush(Colors.Transparent);
}
}
public void Receive(CrossMonitorBandDropMessage message)
{
// Only match if this dock has a real monitor ID that matches the source.
if (ViewModel.MonitorDeviceId is null)
{
return;
}
if (!string.Equals(ViewModel.MonitorDeviceId, message.SourceMonitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return;
}
DispatcherQueue.TryEnqueue(() =>
{
ViewModel.RemoveBandById(message.BandId);
});
}
}

View File

@@ -500,7 +500,7 @@ public sealed partial class DockWindow : WindowEx,
return;
}
var refreshed = _monitorService.GetMonitorByDeviceId(_targetMonitor.DeviceId);
var refreshed = _monitorService.GetMonitorByStableId(_targetMonitor.StableId);
if (refreshed is not null)
{
_targetMonitor = refreshed;
@@ -515,7 +515,7 @@ public sealed partial class DockWindow : WindowEx,
return;
}
_sideOverride = _settings.GetSideForMonitor(_targetMonitor.DeviceId);
_sideOverride = _settings.GetSideForMonitor(_targetMonitor.StableId);
}
/// <summary>

View File

@@ -131,7 +131,7 @@ public sealed partial class DockWindowManager : IDisposable
continue;
}
var monitor = _monitorService.GetMonitorByDeviceId(config.MonitorDeviceId);
var monitor = _monitorService.GetMonitorByStableId(config.MonitorDeviceId);
if (monitor is null)
{
continue;
@@ -184,7 +184,7 @@ public sealed partial class DockWindowManager : IDisposable
{
var viewModel = CreateDockViewModel(monitorDeviceId);
var monitor = _monitorService.GetMonitorByDeviceId(monitorDeviceId);
var monitor = _monitorService.GetMonitorByStableId(monitorDeviceId);
var sideOverride = dockSettings.GetSideForMonitor(monitorDeviceId);
var window = new DockWindow(viewModel, monitor, sideOverride);
@@ -250,7 +250,7 @@ public sealed partial class DockWindowManager : IDisposable
{
new DockMonitorConfig
{
MonitorDeviceId = primary.DeviceId,
MonitorDeviceId = primary.StableId,
Enabled = true,
Side = null,
IsPrimary = true,

View File

@@ -0,0 +1,696 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.ListItemsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
x:Name="ViewRoot"
Background="Transparent"
DataContext="{x:Bind ViewModel, Mode=OneWay}"
mc:Ignorable="d">
<UserControl.Resources>
<!--
GridViewItemCornerRadius is the corner radius defined in GridView template; make
it bigger to match the radii of the gallery
-->
<CornerRadius x:Key="GalleryGridViewItemContainerCornerRadius">6</CornerRadius>
<CornerRadius x:Key="IconGridViewItemContainerCornerRadius">4</CornerRadius>
<CornerRadius x:Key="GalleryGridViewItemRadius">4</CornerRadius>
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
<x:Double x:Key="SmallGridSize">32</x:Double>
<x:Double x:Key="MediumGridSize">48</x:Double>
<x:Double x:Key="MediumGridContainerSize">100</x:Double>
<x:Double x:Key="GalleryGridSize">160</x:Double>
<!--
BEAR LOADING: The list view is virtualized and the item container style is set to a fixed height
to ensure the virtualization works correctly.
-->
<x:Double x:Key="SingleRowListViewItemHeight">44</x:Double>
<x:Double x:Key="ListViewSectionHeight">28</x:Double>
<x:Double x:Key="ListViewSeparatorHeight">28</x:Double>
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource IconGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GalleryGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Margin" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource GalleryGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="GridViewSectionItemStyle"
BasedOn="{StaticResource DefaultGridViewItemStyle}"
TargetType="GridViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="4,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<Style
x:Key="GridViewSeparatorItemStyle"
BasedOn="{StaticResource DefaultGridViewItemStyle}"
TargetType="GridViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="4,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<ControlTemplate x:Key="ListViewItemWithoutVisualIndicatorTemplate" TargetType="ListViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource ListViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource ListViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource ListViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource ListViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource ListViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource ListViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource ListViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource ListViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource ListViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource ListViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource ListViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource ListViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource ListViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource ListViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource ListViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource ListViewItemCheckMode}"
CheckPressedBrush="{ThemeResource ListViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{ThemeResource ListViewItemCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource ListViewItemDragBackground}"
DragForeground="{ThemeResource ListViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource ListViewItemFocusBorderBrush}"
FocusSecondaryBorderBrush="{ThemeResource ListViewItemFocusSecondaryBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource ListViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource ListViewItemBackgroundPointerOver}"
PointerOverForeground="{ThemeResource ListViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource ListViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource ListViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource ListViewItemBackgroundSelected}"
SelectedDisabledBackground="{ThemeResource ListViewItemBackgroundSelectedDisabled}"
SelectedForeground="{ThemeResource ListViewItemForegroundSelected}"
SelectedPointerOverBackground="{ThemeResource ListViewItemBackgroundSelectedPointerOver}"
SelectedPressedBackground="{ThemeResource ListViewItemBackgroundSelectedPressed}"
SelectionCheckMarkVisualEnabled="{ThemeResource ListViewItemSelectionCheckMarkVisualEnabled}"
SelectionIndicatorBrush="{ThemeResource ListViewItemSelectionIndicatorBrush}"
SelectionIndicatorCornerRadius="{ThemeResource ListViewItemSelectionIndicatorCornerRadius}"
SelectionIndicatorDisabledBrush="{ThemeResource ListViewItemSelectionIndicatorDisabledBrush}"
SelectionIndicatorPointerOverBrush="{ThemeResource ListViewItemSelectionIndicatorPointerOverBrush}"
SelectionIndicatorPressedBrush="{ThemeResource ListViewItemSelectionIndicatorPressedBrush}"
SelectionIndicatorVisualEnabled="False" />
</ControlTemplate>
<Style
x:Key="ListSingleRowItemContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template" Value="{StaticResource ListViewItemWithoutVisualIndicatorTemplate}" />
</Style>
<Style
x:Key="ListSectionContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
</Style>
<Style
x:Key="ListSeparatorContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
</Style>
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
<!--
Tags are immutable, so we don't have to worry about binding mode.
-->
<cpcontrols:Tag
AutomationProperties.Name="{x:Bind Text}"
BackgroundColor="{x:Bind Background}"
FontSize="12"
ForegroundColor="{x:Bind Foreground}"
Icon="{x:Bind Icon}"
Text="{x:Bind Text}"
ToolTipService.ToolTip="{x:Bind ToolTip}" />
</DataTemplate>
<cmdpalUI:ListItemTemplateSelector
x:Key="ListItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
ListItem="{StaticResource ListItemSingleRowViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
<cmdpalUI:ListItemContainerStyleSelector
x:Key="ListItemContainerStyleSelector"
Default="{StaticResource ListSingleRowItemContainerStyle}"
Section="{StaticResource ListSectionContainerStyle}"
Separator="{StaticResource ListSeparatorContainerStyle}" />
<cmdpalUI:GridItemTemplateSelector
x:Key="GridItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource MediumGridItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource GridSeparatorViewModelTemplate}"
Small="{StaticResource SmallGridItemViewModelTemplate}" />
<cmdpalUI:GridItemContainerStyleSelector
x:Key="GridItemContainerStyleSelector"
Gallery="{StaticResource GalleryGridViewItemStyle}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource IconGridViewItemStyle}"
Section="{StaticResource GridViewSectionItemStyle}"
Separator="{StaticResource GridViewSeparatorItemStyle}"
Small="{StaticResource IconGridViewItemStyle}" />
<DataTemplate x:Key="ListItemSingleRowViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<cpcontrols:IconBox
x:Name="IconBorder"
Grid.Column="0"
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" />
<!--
Title and subtitle are intentionally in a nested Grid instead in the outer container,
to avoid pushing the following element (tags) out of bounds.
-->
<Grid Grid.Column="1" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</Grid>
<!--
An 8px right margin is added to visually match the spacing between the icon
and the left margin of the list.
Tags are capped at 3 with a [+N] overflow badge to prevent
unbounded growth that pushes the title out of view.
-->
<ItemsRepeater
Grid.Column="2"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsHitTestVisible="False"
IsTabStop="False"
ItemTemplate="{StaticResource TagTemplate}"
ItemsSource="{x:Bind VisibleTags, Mode=OneWay}"
Visibility="{x:Bind HasTags, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Horizontal" Spacing="4" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Rectangle
Grid.Column="1"
Height="1"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Margin="0,8,0,0"
VerticalAlignment="Center"
cpcontrols:WrapPanel.IsFullLine="True"
ColumnSpacing="8"
IsTabStop="False"
IsTapEnabled="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{x:Bind Section}" />
</Grid>
</DataTemplate>
<!-- Grid item templates for visual grid representation -->
<DataTemplate x:Key="SmallGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource SmallGridViewItemCornerRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<cpcontrols:IconBox
x:Name="GridIconBorder"
Width="{StaticResource SmallGridSize}"
Height="{StaticResource SmallGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested32}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="MediumGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Width="{StaticResource MediumGridContainerSize}"
Height="{StaticResource MediumGridContainerSize}"
Padding="8"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
CornerRadius="{StaticResource MediumGridViewItemCornerRadius}"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<cpcontrols:IconBox
x:Name="GridIconBorder"
Grid.Row="0"
Width="{StaticResource MediumGridSize}"
Height="{StaticResource MediumGridSize}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
x:Name="TitleTextBlock"
Grid.Row="1"
Height="32"
Margin="0,8,0,0"
HorizontalAlignment="Center"
CharacterSpacing="12"
FontSize="12"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="Wrap"
Visibility="{x:Bind LayoutShowsTitle, Mode=OneWay}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="GalleryGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
Width="{StaticResource GalleryGridSize}"
Margin="4"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource GalleryGridViewItemRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid
Width="{StaticResource GalleryGridSize}"
Height="{StaticResource GalleryGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="{StaticResource GalleryGridViewItemRadius}">
<Viewbox
HorizontalAlignment="Center"
Stretch="UniformToFill"
StretchDirection="Both">
<cpcontrols:IconBox
CornerRadius="4"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested256}" />
</Viewbox>
</Grid>
<StackPanel
Padding="4"
Orientation="Vertical"
Spacing="4"
Visibility="{x:Bind help:BindTransformers.VisibleWhenAny(ShowTitle, ShowSubtitle)}">
<TextBlock
x:Name="TitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowTitle, Mode=OneWay}" />
<TextBlock
x:Name="SubTitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="11"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="GridSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</DataTemplate>
</UserControl.Resources>
<Grid>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.ShowEmptyContent, Mode=OneWay}">
<controls:Case Value="False">
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.IsGridView, Mode=OneWay}">
<controls:Case Value="False">
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource ListItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</controls:Case>
<controls:Case Value="True">
<GridView
x:Name="ItemsGrid"
Padding="16,16"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource GridItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource GridItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<cpcontrols:WrapPanel
HorizontalSpacing="8"
Orientation="Horizontal"
VerticalSpacing="8" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemContainerTransitions>
<TransitionCollection />
</GridView.ItemContainerTransitions>
</GridView>
</controls:Case>
</controls:SwitchPresenter>
</controls:Case>
<controls:Case Value="True">
<StackPanel
Margin="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="4">
<cpcontrols:IconBox
x:Name="IconBorder"
Width="48"
Height="48"
Margin="8"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind ViewModel.EmptyContent.Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
Margin="0,4,0,0"
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.EmptyContent.Title, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.EmptyContent.Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</controls:Case>
</controls:SwitchPresenter>
</Grid>
</UserControl>

File diff suppressed because it is too large Load Diff

View File

@@ -4,692 +4,10 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
x:Name="PageRoot"
Background="Transparent"
DataContext="{x:Bind ViewModel, Mode=OneWay}"
mc:Ignorable="d">
<Page.Resources>
<!--
GridViewItemCornerRadius is the corner radius defined in GridView template; make
it bigger to match the radii of the gallery
-->
<CornerRadius x:Key="GalleryGridViewItemContainerCornerRadius">6</CornerRadius>
<CornerRadius x:Key="IconGridViewItemContainerCornerRadius">4</CornerRadius>
<CornerRadius x:Key="GalleryGridViewItemRadius">4</CornerRadius>
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
<x:Double x:Key="SmallGridSize">32</x:Double>
<x:Double x:Key="MediumGridSize">48</x:Double>
<x:Double x:Key="MediumGridContainerSize">100</x:Double>
<x:Double x:Key="GalleryGridSize">160</x:Double>
<!--
BEAR LOADING: The list view is virtualized and the item container style is set to a fixed height
to ensure the virtualization works correctly.
-->
<x:Double x:Key="SingleRowListViewItemHeight">44</x:Double>
<x:Double x:Key="ListViewSectionHeight">28</x:Double>
<x:Double x:Key="ListViewSeparatorHeight">28</x:Double>
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource IconGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="GalleryGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Margin" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource GalleryGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="GridViewSectionItemStyle"
BasedOn="{StaticResource DefaultGridViewItemStyle}"
TargetType="GridViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="4,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<Style
x:Key="GridViewSeparatorItemStyle"
BasedOn="{StaticResource DefaultGridViewItemStyle}"
TargetType="GridViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="4,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<ControlTemplate x:Key="ListViewItemWithoutVisualIndicatorTemplate" TargetType="ListViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource ListViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource ListViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource ListViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource ListViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource ListViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource ListViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource ListViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource ListViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource ListViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource ListViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource ListViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource ListViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource ListViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource ListViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource ListViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource ListViewItemCheckMode}"
CheckPressedBrush="{ThemeResource ListViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{ThemeResource ListViewItemCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource ListViewItemDragBackground}"
DragForeground="{ThemeResource ListViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource ListViewItemFocusBorderBrush}"
FocusSecondaryBorderBrush="{ThemeResource ListViewItemFocusSecondaryBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource ListViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource ListViewItemBackgroundPointerOver}"
PointerOverForeground="{ThemeResource ListViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource ListViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource ListViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource ListViewItemBackgroundSelected}"
SelectedDisabledBackground="{ThemeResource ListViewItemBackgroundSelectedDisabled}"
SelectedForeground="{ThemeResource ListViewItemForegroundSelected}"
SelectedPointerOverBackground="{ThemeResource ListViewItemBackgroundSelectedPointerOver}"
SelectedPressedBackground="{ThemeResource ListViewItemBackgroundSelectedPressed}"
SelectionCheckMarkVisualEnabled="{ThemeResource ListViewItemSelectionCheckMarkVisualEnabled}"
SelectionIndicatorBrush="{ThemeResource ListViewItemSelectionIndicatorBrush}"
SelectionIndicatorCornerRadius="{ThemeResource ListViewItemSelectionIndicatorCornerRadius}"
SelectionIndicatorDisabledBrush="{ThemeResource ListViewItemSelectionIndicatorDisabledBrush}"
SelectionIndicatorPointerOverBrush="{ThemeResource ListViewItemSelectionIndicatorPointerOverBrush}"
SelectionIndicatorPressedBrush="{ThemeResource ListViewItemSelectionIndicatorPressedBrush}"
SelectionIndicatorVisualEnabled="False" />
</ControlTemplate>
<Style
x:Key="ListSingleRowItemContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template" Value="{StaticResource ListViewItemWithoutVisualIndicatorTemplate}" />
</Style>
<Style
x:Key="ListSectionContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
</Style>
<Style
x:Key="ListSeparatorContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
</Style>
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
<!--
Tags are immutable, so we don't have to worry about binding mode.
-->
<cpcontrols:Tag
AutomationProperties.Name="{x:Bind Text}"
BackgroundColor="{x:Bind Background}"
FontSize="12"
ForegroundColor="{x:Bind Foreground}"
Icon="{x:Bind Icon}"
Text="{x:Bind Text}"
ToolTipService.ToolTip="{x:Bind ToolTip}" />
</DataTemplate>
<cmdpalUI:ListItemTemplateSelector
x:Key="ListItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
ListItem="{StaticResource ListItemSingleRowViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
<cmdpalUI:ListItemContainerStyleSelector
x:Key="ListItemContainerStyleSelector"
Default="{StaticResource ListSingleRowItemContainerStyle}"
Section="{StaticResource ListSectionContainerStyle}"
Separator="{StaticResource ListSeparatorContainerStyle}" />
<cmdpalUI:GridItemTemplateSelector
x:Key="GridItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource MediumGridItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource GridSeparatorViewModelTemplate}"
Small="{StaticResource SmallGridItemViewModelTemplate}" />
<cmdpalUI:GridItemContainerStyleSelector
x:Key="GridItemContainerStyleSelector"
Gallery="{StaticResource GalleryGridViewItemStyle}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource IconGridViewItemStyle}"
Section="{StaticResource GridViewSectionItemStyle}"
Separator="{StaticResource GridViewSeparatorItemStyle}"
Small="{StaticResource IconGridViewItemStyle}" />
<DataTemplate x:Key="ListItemSingleRowViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<cpcontrols:IconBox
x:Name="IconBorder"
Grid.Column="0"
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" />
<!--
Title and subtitle are intentionally in a nested Grid instead in the outer container,
to avoid pushing the following element (tags) out of bounds.
-->
<Grid Grid.Column="1" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</Grid>
<!--
An 8px right margin is added to visually match the spacing between the icon
and the left margin of the list.
ItemRepeater is a lightweight control (compared to ItemsControl).
-->
<ItemsRepeater
Grid.Column="2"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsHitTestVisible="False"
IsTabStop="False"
ItemTemplate="{StaticResource TagTemplate}"
ItemsSource="{x:Bind Tags, Mode=OneWay}"
Visibility="{x:Bind HasTags, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Horizontal" Spacing="4" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Rectangle
Grid.Column="1"
Height="1"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Margin="0,8,0,0"
VerticalAlignment="Center"
cpcontrols:WrapPanel.IsFullLine="True"
ColumnSpacing="8"
IsTabStop="False"
IsTapEnabled="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{x:Bind Section}" />
</Grid>
</DataTemplate>
<!-- Grid item templates for visual grid representation -->
<DataTemplate x:Key="SmallGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource SmallGridViewItemCornerRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<cpcontrols:IconBox
x:Name="GridIconBorder"
Width="{StaticResource SmallGridSize}"
Height="{StaticResource SmallGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested32}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="MediumGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Width="{StaticResource MediumGridContainerSize}"
Height="{StaticResource MediumGridContainerSize}"
Padding="8"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
CornerRadius="{StaticResource MediumGridViewItemCornerRadius}"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<cpcontrols:IconBox
x:Name="GridIconBorder"
Grid.Row="0"
Width="{StaticResource MediumGridSize}"
Height="{StaticResource MediumGridSize}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
x:Name="TitleTextBlock"
Grid.Row="1"
Height="32"
Margin="0,8,0,0"
HorizontalAlignment="Center"
CharacterSpacing="12"
FontSize="12"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="Wrap"
Visibility="{x:Bind LayoutShowsTitle, Mode=OneWay}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="GalleryGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
Width="{StaticResource GalleryGridSize}"
Margin="4"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource GalleryGridViewItemRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid
Width="{StaticResource GalleryGridSize}"
Height="{StaticResource GalleryGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="{StaticResource GalleryGridViewItemRadius}">
<Viewbox
HorizontalAlignment="Center"
Stretch="UniformToFill"
StretchDirection="Both">
<cpcontrols:IconBox
CornerRadius="4"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested256}" />
</Viewbox>
</Grid>
<StackPanel
Padding="4"
Orientation="Vertical"
Spacing="4"
Visibility="{x:Bind help:BindTransformers.VisibleWhenAny(ShowTitle, ShowSubtitle)}">
<TextBlock
x:Name="TitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowTitle, Mode=OneWay}" />
<TextBlock
x:Name="SubTitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="11"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="GridSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</DataTemplate>
</Page.Resources>
<Grid>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.ShowEmptyContent, Mode=OneWay}">
<controls:Case Value="False">
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.IsGridView, Mode=OneWay}">
<controls:Case Value="False">
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource ListItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</controls:Case>
<controls:Case Value="True">
<GridView
x:Name="ItemsGrid"
Padding="16,16"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource GridItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource GridItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<cpcontrols:WrapPanel
HorizontalSpacing="8"
Orientation="Horizontal"
VerticalSpacing="8" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemContainerTransitions>
<TransitionCollection />
</GridView.ItemContainerTransitions>
</GridView>
</controls:Case>
</controls:SwitchPresenter>
</controls:Case>
<controls:Case Value="True">
<StackPanel
Margin="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="4">
<cpcontrols:IconBox
x:Name="IconBorder"
Width="48"
Height="48"
Margin="8"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind ViewModel.EmptyContent.Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
Margin="0,4,0,0"
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.EmptyContent.Title, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.EmptyContent.Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</controls:Case>
</controls:SwitchPresenter>
</Grid>
<cmdpalUI:ListItemsView x:Name="ListView" ViewModel="{x:Bind ViewModel, Mode=OneWay}" />
</Page>

View File

@@ -3,91 +3,26 @@
x:Class="Microsoft.CmdPal.UI.ParametersPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Interactions="using:Microsoft.Xaml.Interactions.Core"
xmlns:Interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:cmdPalControls="using:Microsoft.CmdPal.UI.Controls"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
mc:Ignorable="d">
<Page.Resources>
<ResourceDictionary>
<DataTemplate x:Key="ParameterListItemTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<cmdPalControls:IconBox
Grid.Column="0"
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" />
<Grid Grid.Column="1" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</Grid>
</Grid>
</DataTemplate>
</ResourceDictionary>
</Page.Resources>
<Grid>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.HasActiveList, Mode=OneWay}">
<controls:Case Value="True">
<!-- Show list items from the active list parameter -->
<ListView
x:Name="ParamItemsList"
Padding="0,2,0,0"
IsItemClickEnabled="True"
ItemClick="ParamItems_ItemClick"
ItemTemplate="{StaticResource ParameterListItemTemplate}"
ItemsSource="{x:Bind ViewModel.ActiveListViewModel.FilteredItems, Mode=OneWay}">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
<!--
Show list items from the active list parameter using the same
list view used by ListPage so behavior stays in one place.
-->
<cmdpalUI:ListItemsView ViewModel="{x:Bind ViewModel.ActiveListViewModel, Mode=OneWay}" />
</controls:Case>
<controls:Case Value="False">
<controls:SwitchPresenter

View File

@@ -2,14 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
@@ -17,17 +12,12 @@ using Microsoft.UI.Xaml.Navigation;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// Hosts a parameter run, optionally embedding a <see cref="ListItemsView"/> when
/// a list parameter is active. List rendering, selection, and keyboard navigation
/// are handled by the embedded <see cref="ListItemsView"/>.
/// </summary>
public sealed partial class ParametersPage : Page,
IRecipient<NavigateNextCommand>,
IRecipient<NavigatePreviousCommand>,
IRecipient<NavigatePageDownCommand>,
IRecipient<NavigatePageUpCommand>,
IRecipient<ActivateSelectedListItemMessage>
public sealed partial class ParametersPage : Page
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
public ParametersPageViewModel? ViewModel
{
get => (ParametersPageViewModel?)GetValue(ViewModelProperty);
@@ -41,20 +31,6 @@ public sealed partial class ParametersPage : Page,
public ParametersPage()
{
this.InitializeComponent();
this.Unloaded += OnUnloaded;
WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
// Unhook from everything to ensure nothing can reach us
// between this point and our complete and utter destruction.
WeakReferenceMessenger.Default.UnregisterAll(this);
}
protected override void OnNavigatedTo(NavigationEventArgs e)
@@ -84,104 +60,9 @@ public sealed partial class ParametersPage : Page,
private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ParametersPage @this)
if (d is ParametersPage && e.NewValue is null)
{
if (e.OldValue is ParametersPageViewModel old)
{
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
}
if (e.NewValue is ParametersPageViewModel page)
{
page.PropertyChanged += @this.ViewModel_PropertyChanged;
}
else if (e.NewValue is null)
{
CoreLogger.LogDebug("cleared view model");
}
CoreLogger.LogDebug("cleared view model");
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var prop = e.PropertyName;
if (prop == nameof(ViewModel.ShowCommand))
{
Debug.WriteLine($"ViewModel.ShowCommand {ViewModel?.ShowCommand}");
}
else if (prop == nameof(ViewModel.ActiveListViewModel))
{
if (ViewModel?.HasActiveList == true)
{
SelectFirstItem();
}
}
}
private void ParamItems_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is ListItemViewModel item)
{
ViewModel?.ActiveListViewModel?.InvokeItemCommand.Execute(item);
}
}
public void Receive(NavigateNextCommand message) => NavigateList(1);
public void Receive(NavigatePreviousCommand message) => NavigateList(-1);
public void Receive(NavigatePageDownCommand message) => NavigateList(10);
public void Receive(NavigatePageUpCommand message) => NavigateList(-10);
public void Receive(ActivateSelectedListItemMessage message)
{
if (ViewModel?.HasActiveList != true)
{
return;
}
if (ParamItemsList.SelectedItem is ListItemViewModel item)
{
ViewModel.ActiveListViewModel?.InvokeItemCommand.Execute(item);
}
else if (ViewModel.ActiveListViewModel?.FilteredItems.Count > 0 &&
ViewModel.ActiveListViewModel.FilteredItems[0] is ListItemViewModel firstItem)
{
ViewModel.ActiveListViewModel.InvokeItemCommand.Execute(firstItem);
}
}
private void NavigateList(int delta)
{
if (ViewModel?.HasActiveList != true)
{
return;
}
var list = ParamItemsList;
var count = list.Items.Count;
if (count == 0)
{
return;
}
var current = list.SelectedIndex;
var target = Math.Clamp(current + delta, 0, count - 1);
list.SelectedIndex = target;
list.ScrollIntoView(list.SelectedItem);
}
public void SelectFirstItem()
{
// Use TryEnqueue so the ListView has had time to populate from the binding
_queue.TryEnqueue(() =>
{
if (ParamItemsList.Items.Count > 0)
{
ParamItemsList.SelectedIndex = 0;
ParamItemsList.ScrollIntoView(ParamItemsList.SelectedItem);
}
});
}
}

View File

@@ -6,7 +6,6 @@ using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI.Xaml;
@@ -137,11 +136,14 @@ internal sealed partial class TrayIconService
{
var exePath = Path.Combine(AppContext.BaseDirectory, "Microsoft.CmdPal.UI.exe");
// DestroyIconSafeHandle largeIcon;
Span<HICON> large = new([default]); // 1 size array to accept icon
PInvoke.ExtractIconEx(exePath, 0, large);
DestroyIconSafeHandle h = new(large[0]);
return h;
var extractedIconCount = PInvoke.ExtractIconEx(exePath, 0, large);
if ((extractedIconCount < 1) || (large[0] == HICON.Null))
{
return new DestroyIconSafeHandle(HICON.Null);
}
return new DestroyIconSafeHandle(large[0]);
}
private LRESULT WindowProc(

View File

@@ -43,6 +43,21 @@ public sealed class MonitorService : IMonitorService
}
}
/// <inheritdoc/>
public MonitorInfo? GetMonitorByStableId(string stableId)
{
var monitors = GetMonitors();
foreach (var monitor in monitors)
{
if (string.Equals(monitor.StableId, stableId, StringComparison.OrdinalIgnoreCase))
{
return monitor;
}
}
return null;
}
/// <inheritdoc/>
public MonitorInfo? GetMonitorByDeviceId(string deviceId)
{
@@ -92,7 +107,7 @@ public sealed class MonitorService : IMonitorService
private static unsafe List<MonitorInfo> EnumerateMonitors()
{
var monitors = new List<MonitorInfo>();
var friendlyNames = BuildFriendlyNameMap();
var displayInfo = BuildDisplayInfoMap();
PInvoke.EnumDisplayMonitors(
HDC.Null,
@@ -115,13 +130,26 @@ public sealed class MonitorService : IMonitorService
var isPrimary = (infoEx.monitorInfo.dwFlags & PrimaryFlag) != 0;
var deviceName = new string(infoEx.szDevice.AsSpan()).TrimEnd('\0');
var displayName = FormatDisplayName(deviceName, isPrimary, friendlyNames);
var friendlyName = string.Empty;
var stableId = deviceName; // Fall back to GDI name
if (displayInfo.TryGetValue(deviceName, out var info))
{
friendlyName = info.FriendlyName;
if (!string.IsNullOrEmpty(info.DevicePath))
{
stableId = info.DevicePath;
}
}
var displayName = FormatDisplayName(deviceName, isPrimary, friendlyName);
var rcMonitor = infoEx.monitorInfo.rcMonitor;
var rcWork = infoEx.monitorInfo.rcWork;
monitors.Add(new MonitorInfo
{
DeviceId = deviceName,
StableId = stableId,
DisplayName = displayName,
Bounds = new ScreenRect(
rcMonitor.left,
@@ -146,13 +174,13 @@ public sealed class MonitorService : IMonitorService
}
/// <summary>
/// Builds a map from GDI device name (e.g. <c>\\.\DISPLAY1</c>) to the hardware
/// friendly name (e.g. <c>DELL U2723QE</c>) using the Display Configuration APIs.
/// Builds a map from GDI device name (e.g. <c>\\.\DISPLAY1</c>) to display metadata
/// (friendly name and stable device path) using the Display Configuration APIs.
/// Returns an empty dictionary on failure so callers can fall back gracefully.
/// </summary>
private static unsafe Dictionary<string, string> BuildFriendlyNameMap()
private static unsafe Dictionary<string, (string FriendlyName, string DevicePath)> BuildDisplayInfoMap()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, (string FriendlyName, string DevicePath)>(StringComparer.OrdinalIgnoreCase);
try
{
@@ -167,14 +195,14 @@ public sealed class MonitorService : IMonitorService
var paths = new DISPLAYCONFIG_PATH_INFO[pathCount];
var modes = new DISPLAYCONFIG_MODE_INFO[modeCount];
DISPLAYCONFIG_TOPOLOGY_ID topologyId = default;
var topologyId = default(DISPLAYCONFIG_TOPOLOGY_ID);
result = PInvoke.QueryDisplayConfig(
QUERY_DISPLAY_CONFIG_FLAGS.QDC_ONLY_ACTIVE_PATHS,
ref pathCount,
paths.AsSpan(),
paths,
ref modeCount,
modes.AsSpan(),
modes,
ref topologyId);
if (result != WIN32_ERROR.NO_ERROR)
{
@@ -216,27 +244,28 @@ public sealed class MonitorService : IMonitorService
}
var friendly = new string(targetName.monitorFriendlyDeviceName.AsSpan()).TrimEnd('\0');
if (!string.IsNullOrEmpty(friendly))
var devicePath = new string(targetName.monitorDevicePath.AsSpan()).TrimEnd('\0');
if (!string.IsNullOrEmpty(friendly) || !string.IsNullOrEmpty(devicePath))
{
map.TryAdd(gdiName, friendly);
map.TryAdd(gdiName, (friendly ?? string.Empty, devicePath ?? string.Empty));
}
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
Logger.LogError($"BuildFriendlyNameMap failed: {ex.Message}");
Logger.LogError($"BuildDisplayInfoMap failed: {ex.Message}");
}
return map;
}
private static string FormatDisplayName(string deviceName, bool isPrimary, Dictionary<string, string> friendlyNames)
private static string FormatDisplayName(string deviceName, bool isPrimary, string friendlyName)
{
string name;
if (friendlyNames.TryGetValue(deviceName, out var friendly))
if (!string.IsNullOrEmpty(friendlyName))
{
name = friendly;
name = friendlyName;
}
else if (deviceName.StartsWith(@"\\.\DISPLAY", StringComparison.OrdinalIgnoreCase))
{

View File

@@ -30,7 +30,7 @@ internal sealed partial class WindowThemeSynchronizer : IDisposable
}
/// <summary>
/// Unsubscribes from theme change events.
/// Unsubscribe from theme change events.
/// </summary>
public void Dispose()
{

View File

@@ -193,12 +193,12 @@
<TextBlock
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind CurrentScreenshotDisplayName, Mode=OneWay}" />
Text="{x:Bind CurrentScreenshotDisplayName, Mode=OneTime}" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind CurrentScreenshotPositionText, Mode=OneWay}" />
Text="{x:Bind CurrentScreenshotPositionText, Mode=OneTime}" />
</StackPanel>
</Border>
</Grid>

View File

@@ -973,6 +973,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Unpin from Dock</value>
<comment>Command name for unpinning an item from the dock</comment>
</data>
<data name="top_level_move_up_command_name" xml:space="preserve">
<value>Move up</value>
<comment>Command name for moving a pinned home item up in the pinned section order</comment>
</data>
<data name="top_level_move_to_top_command_name" xml:space="preserve">
<value>Move to the top</value>
<comment>Command name for moving a pinned home item to the top of the pinned section order</comment>
</data>
<data name="top_level_move_down_command_name" xml:space="preserve">
<value>Move down</value>
<comment>Command name for moving a pinned home item down in the pinned section order</comment>
</data>
<data name="FiltersDropDown.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Filters</value>
</data>

View File

@@ -25,7 +25,10 @@
Color="{ThemeResource SystemAccentColor}" />
<SolidColorBrush x:Key="ParameterBackgroundFocused" Color="{ThemeResource SolidBackgroundFillColorSecondary}" />
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast" />
<ResourceDictionary x:Key="HighContrast">
<SolidColorBrush x:Key="ParameterBackground" Color="{ThemeResource SystemColorButtonFaceColor}" />
<SolidColorBrush x:Key="ParameterBackgroundFocused" Color="{ThemeResource SystemColorHighlightColor}" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<converters:StringVisibilityConverter

View File

@@ -5,6 +5,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Apps.UnitTests;
@@ -73,4 +74,62 @@ public class AllAppsPageTests : AppsTestBase
Assert.IsTrue(items.Any(i => i.Title == "Notepad"));
Assert.IsTrue(items.Any(i => i.Title == "Calculator"));
}
[TestMethod]
public async Task AllAppsPage_GetItems_HidesSubtitlesWhenSettingEnabled()
{
// Arrange
var mockCache = new MockAppCache();
var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe");
mockCache.AddWin32Program(win32App);
try
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"true\"}");
var page = new AllAppsPage(mockCache);
await Task.Delay(100);
// Act
var items = page.GetItems();
// Assert
Assert.AreEqual(1, items.Length);
var appItem = items.OfType<AppListItem>().Single();
Assert.AreEqual(string.Empty, appItem.Subtitle);
}
finally
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"false\"}");
}
}
[TestMethod]
public async Task AllAppsPage_GetItems_ShowsSubtitlesWhenSettingDisabled()
{
// Arrange
var mockCache = new MockAppCache();
var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe");
mockCache.AddWin32Program(win32App);
try
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"false\"}");
var page = new AllAppsPage(mockCache);
await Task.Delay(100);
// Act
var items = page.GetItems();
// Assert
Assert.AreEqual(1, items.Length);
var appItem = items.OfType<AppListItem>().Single();
Assert.IsFalse(string.IsNullOrEmpty(appItem.Subtitle));
}
finally
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"false\"}");
}
}
}

View File

@@ -16,6 +16,8 @@ namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
{
private const int IntMax = int.MaxValue;
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
@@ -64,6 +66,14 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
["log2(3)", 1.58496250072116M],
["log10(3)", 0.47712125471966M],
["ln(e)", 1M],
// Space between function name and '(' must produce the same result
// (regression test for the log-mapping bug).
["ln (3)", 1.09861228866811M],
["log (3)", 0.47712125471966M],
["log2 (3)", 1.58496250072116M],
["log10 (3)", 0.47712125471966M],
["cosh(0)", 1M],
["1*10^(-5)", 0.00001M],
["1*10^(-15)", 0.0000000000000001M],
@@ -401,13 +411,136 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
[DataTestMethod]
[DataRow("171!")]
[DataRow("1000!")]
public void Interpret_ReturnsError_WhenValueOverflowsDecimal(string input)
public void Interpret_ReturnsOutOfBoundsError_WhenValueOverflowsDecimal(string input)
{
var settings = new Settings();
CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.IsFalse(string.IsNullOrEmpty(error));
Assert.AreNotEqual(null, error);
Assert.AreEqual(Properties.Resources.calculator_not_covert_to_decimal, error);
}
[DataTestMethod]
[DataRow("exp(99999)")]
[DataRow("-exp(99999)")]
public void Interpret_ReturnsOutOfBoundsError_WhenResultIsInfinity(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_covert_to_decimal, error);
}
[DataTestMethod]
[DataRow("1 2")]
public void Interpret_ReturnsExpressionNotCompleteError_WhenExpressionIsInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_expression_not_complete, error);
}
[TestMethod]
public void Interpret_ReturnsNotANumberError_WhenResultIsNaN()
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "sqrt(-1)", CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
[DataTestMethod]
[DataRow("factorial(-1)")]
[DataRow("factorial(0.5)")]
[DataRow("factorial(sqrt(-1))")]
[DataRow("sign(sqrt(-1))")]
public void Interpret_ReturnsNotANumberError_WhenCustomFunctionArgumentInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
[TestMethod]
public void Interpret_Rand_ReturnsValueInRange()
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "rand()", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "rand() returned no result");
Assert.IsTrue(result.Result >= 0M && result.Result < 1M, $"rand() result {result.Result} was not in [0, 1)");
}
[TestMethod]
public void Interpret_Randi_ReturnsZero_WhenArgIsOne()
{
// randi(1) has only one valid outcome: 0. This test is fully deterministic.
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "randi(1)", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "randi(1) returned no result");
Assert.AreEqual(0M, result.Result!.Value, "randi(1) must always return 0");
}
[TestMethod]
public void Interpret_Randi_StaysInRange_WhenArgIsTwo()
{
// randi(2) has the smallest non-trivial range [0, 1]. Running many iterations
// gives high confidence both boundary values are reachable.
var settings = new Settings();
for (var i = 0; i < 100; i++)
{
var result = CalculateEngine.Interpret(settings, "randi(2)", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "randi(2) returned no result");
var value = result.Result!.Value;
Assert.AreEqual(value, Math.Floor(value), $"randi(2) result {value} was not an integer");
Assert.IsTrue(value >= 0M && value <= 1M, $"randi(2) result {value} was not in [0, 1]");
}
}
[TestMethod]
public void Interpret_Randi_HandlesIntMaxArgument()
{
// Ensures no integer overflow in the C++ cast when the argument is int.MaxValue.
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, $"randi({IntMax})", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, $"randi({IntMax}) returned no result");
var value = result.Result!.Value;
Assert.AreEqual(value, Math.Floor(value), $"randi({IntMax}) result {value} was not an integer");
Assert.IsTrue(value >= 0M && value < IntMax, $"randi({IntMax}) result {value} was out of range");
}
[DataTestMethod]
[DataRow("randi(0)")]
[DataRow("randi(0.5)")]
[DataRow("randi(-1)")]
[DataRow("randi(exp(10000))")]
public void Interpret_Randi_ReturnsNotANumberError_WhenArgumentInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
}

View File

@@ -88,6 +88,47 @@ public class NumberTranslatorTests
Assert.AreEqual(expectedResult, result);
}
[DataTestMethod]
[DataRow("ceil(123,456.23)", "ceil(123456.23)")]
[DataRow("floor(123,456.23)", "floor(123456.23)")]
[DataRow("round(123,456.23)", "round(123456.23)")]
[DataRow("log(123,456.23)", "log(123456.23)")]
[DataRow("sin(123,456.23)", "sin(123456.23)")]
[DataRow("max(ceil(123,456.23),2)", "max(ceil(123456.23),2)")]
[DataRow("pow(round(1,234.5),2)", "pow(round(1234.5),2)")]
public void Translate_PreservesGroupedNumbers_ForSingleArgumentFunctions_WhenCultureUsesCommaListSeparator(string input, string expectedResult)
{
var translator = NumberTranslator.Create(new CultureInfo("en-US", false), new CultureInfo("en-US", false));
var result = translator.Translate(input);
Assert.IsNotNull(result);
Assert.AreEqual(expectedResult, result);
}
[DataTestMethod]
[DataRow("max(1,2)", "max(1,2)")]
[DataRow("min(1,2)", "min(1,2)")]
[DataRow("pow(2,3)", "pow(2,3)")]
[DataRow("max(1.5,2.5)", "max(1.5,2.5)")]
[DataRow("pow(9,0.5)", "pow(9,0.5)")]
[DataRow("max(123,45)", "max(123,45)")]
[DataRow("max(123,456)", "max(123,456)")]
[DataRow("max(123,min(12,pow(2,6)))", "max(123,min(12,pow(2,6)))")]
[DataRow("max ( 12, 34 )", "max ( 12, 34 )")]
[DataRow("pow(max(2,3),2)", "pow(max(2,3),2)")]
[DataRow("max(1e3,2e3)", "max(1e3,2e3)")]
[DataRow("pow(1.5e2,2)", "pow(1.5e2,2)")]
public void Translate_PreservesFunctionArgumentSeparators_WhenCultureUsesCommaListSeparator(string input, string expectedResult)
{
var translator = NumberTranslator.Create(new CultureInfo("en-US", false), new CultureInfo("en-US", false));
var result = translator.Translate(input);
Assert.IsNotNull(result);
Assert.AreEqual(expectedResult, result);
}
[DataTestMethod]
[DataRow("2.0 * 2", "2,0 * 2")]
[DataRow("4 * 3.6 + 9", "4 * 3,6 + 9")]
@@ -170,7 +211,9 @@ public class NumberTranslatorTests
[DataRow("en-US", "0xF000", "61440")]
[DataRow("en-US", "0xf4572220", "4099351072")]
[DataRow("en-US", "0x12345678", "305419896")]
public void Translate_LargeHexadecimalNumbersToDecimal(string sourceCultureName, string input, string expectedResult)
[DataRow("en-US", "0b101010", "42")]
[DataRow("en-US", "0o377", "255")]
public void Translate_BaseLiteralsToDecimal(string sourceCultureName, string input, string expectedResult)
{
// Arrange
var translator = NumberTranslator.Create(new CultureInfo(sourceCultureName, false), new CultureInfo("en-US", false));

View File

@@ -40,6 +40,21 @@ public class QueryTests : CommandPaletteUnitTestBase
[DataRow("10/2", "5")]
[DataRow("sqrt(16)", "4")]
[DataRow("2^3", "8")]
[DataRow("max(1,2)", "2")]
[DataRow("min(1,2)", "1")]
[DataRow("pow(2,3)", "8")]
[DataRow("max(1.5,2.5)", "2.5")]
[DataRow("pow(9,0.5)", "3")]
[DataRow("max(123,45)", "123")]
[DataRow("max(123,456)", "456")]
[DataRow("max(123,min(12,pow(2,6)))", "123")]
[DataRow("max ( 12, 34 )", "34")]
[DataRow("ceil(123,456.23)", "123457")]
[DataRow("max(ceil(123,456.23),2)", "123457")]
[DataRow("pow(round(1,234.5),2)", "1525225")]
[DataRow("max(1e3,2e3)", "2000")]
[DataRow("pow(1.5e2,2)", "22500")]
[DataRow("max(0b1010,0o12)", "10")]
public void TopLevelPageQueryTest(string input, string expectedResult)
{
var settings = new Settings();

View File

@@ -13,8 +13,6 @@ using System.Threading.Tasks;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.Run;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -92,6 +90,13 @@ public class RunPageTests : CommandPaletteUnitTestBase
[TestMethod]
public async Task TestSimple()
{
// Note to future self: are the tests hanging mysteriously? Running
// forever, but not actually doing anything, seemingly just spinning on
// this case?
//
// If they are, then you forgot to add a string resource to our .resx
// somewhere. Fix that, and the tests will run again.
// Setup
var nativeService = CreateMockHistoryService().Object;
using var page = new RunListPage(nativeService, telemetryService: null);
@@ -632,7 +637,7 @@ public class RunPageTests : CommandPaletteUnitTestBase
// Filtered items should be less than or equal to all items
Assert.IsTrue(filteredItems.Length <= allItems.Length);
// All filtered items should contain 'D' (case insensitive)
// All filtered items should contain 'D' (case-insensitive)
foreach (var item in filteredItems)
{
StringAssert.Contains(item.Title, "D", StringComparison.OrdinalIgnoreCase);

View File

@@ -21,6 +21,7 @@ public class DockMultiMonitorTests
private static readonly MonitorInfo PrimaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY1",
StableId = @"\\?\DISPLAY#PRI1234#4&aaa&0&UID111#{guid1}",
DisplayName = "Display 1 (Primary)",
Bounds = new ScreenRect(0, 0, 1920, 1080),
WorkArea = new ScreenRect(0, 0, 1920, 1040),
@@ -31,6 +32,7 @@ public class DockMultiMonitorTests
private static readonly MonitorInfo SecondaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY2",
StableId = @"\\?\DISPLAY#SEC5678#4&bbb&0&UID222#{guid2}",
DisplayName = "Display 2",
Bounds = new ScreenRect(1920, 0, 3840, 1080),
WorkArea = new ScreenRect(1920, 0, 3840, 1040),
@@ -167,8 +169,8 @@ public class DockMultiMonitorTests
public void Reconciler_ExactMatch_PreservesExistingConfigs()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.StableId, Enabled = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
@@ -181,7 +183,7 @@ public class DockMultiMonitorTests
public void Reconciler_NewMonitor_CreatesDefaultConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
@@ -189,25 +191,24 @@ public class DockMultiMonitorTests
Assert.AreEqual(2, result.Count);
var newConfig = result[1];
Assert.AreEqual(@"\\.\DISPLAY2", newConfig.MonitorDeviceId);
Assert.IsTrue(newConfig.Enabled);
Assert.AreEqual(SecondaryMonitor.StableId, newConfig.MonitorDeviceId);
Assert.IsFalse(newConfig.Enabled, "New secondary monitor should be disabled by default");
}
[TestMethod]
public void Reconciler_DisconnectedMonitor_PreservesConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, LastSeen = DateTime.UtcNow },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY99", Enabled = true, IsCustomized = true, LastSeen = DateTime.UtcNow });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, LastSeen = DateTime.UtcNow },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#GONE#4&ccc&0&UID999#{guid99}", Enabled = true, IsCustomized = true, LastSeen = DateTime.UtcNow });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Connected monitor is first, disconnected monitor is retained at end
Assert.AreEqual(2, result.Count);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY99", result[1].MonitorDeviceId);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\?\DISPLAY#GONE#4&ccc&0&UID999#{guid99}", result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "Disconnected monitor should preserve its customizations.");
}
@@ -256,26 +257,26 @@ public class DockMultiMonitorTests
[TestMethod]
public void Reconciler_FuzzyMatch_UpdatesPrimaryFlag()
{
// Config has old device ID but marked as primary
// Config has old stable ID but marked as primary
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_OLD", Enabled = true, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#OLD#4&ddd&0&UID000#{guidOld}", Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.IsTrue(result[0].IsPrimary);
}
[TestMethod]
public void Reconciler_FuzzyMatch_DoesNotMatchNonPrimaryMonitors()
{
// Config has stale device ID for a non-primary monitor
// Config has stale stable ID for a non-primary monitor
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_STALE", Enabled = true, IsPrimary = false, IsCustomized = true, LastSeen = DateTime.UtcNow });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#STALE#4&eee&0&UID333#{guidStale}", Enabled = true, IsPrimary = false, IsCustomized = true, LastSeen = DateTime.UtcNow });
// Current monitors have primary + a different secondary
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
@@ -285,11 +286,11 @@ public class DockMultiMonitorTests
// Primary keeps its config, new secondary gets a fresh customized config,
// stale secondary is retained at end for future reconnection
Assert.AreEqual(3, result.Count);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY2", result[1].MonitorDeviceId);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "New secondary should get an empty-bands customized config.");
Assert.AreEqual(0, result[1].StartBands?.Count ?? 0, "New secondary should start with empty bands.");
Assert.AreEqual(@"\\.\DISPLAY_STALE", result[2].MonitorDeviceId, "Stale config should be preserved.");
Assert.AreEqual(@"\\?\DISPLAY#STALE#4&eee&0&UID333#{guidStale}", result[2].MonitorDeviceId, "Stale config should be preserved.");
Assert.IsTrue(result[2].IsCustomized, "Stale config should retain its customizations.");
}
@@ -358,8 +359,8 @@ public class DockMultiMonitorTests
var now = DateTime.UtcNow;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.DeviceId, Enabled = true, IsPrimary = false, LastSeen = now });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.StableId, Enabled = true, IsPrimary = false, LastSeen = now });
var reconciled = MonitorConfigReconciler.Reconcile(configs, monitors, now);
@@ -375,7 +376,7 @@ public class DockMultiMonitorTests
var monitorOneConfig = new DockMonitorConfig
{
MonitorDeviceId = PrimaryMonitor.DeviceId,
MonitorDeviceId = PrimaryMonitor.StableId,
Enabled = true,
IsPrimary = true,
IsCustomized = true,
@@ -385,7 +386,7 @@ public class DockMultiMonitorTests
};
var monitorTwoConfig = new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.DeviceId,
MonitorDeviceId = SecondaryMonitor.StableId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
@@ -478,7 +479,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_IsEnabled_ReadsFromConfig()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = false, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = false, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -491,7 +492,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_IsEnabled_PersistsChange()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -506,7 +507,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_SideOverrideIndex_ReturnsZeroWhenNull()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Side = null });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Side = null });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -520,7 +521,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_SideOverrideIndex_MapsCorrectly()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Side = DockSide.Right });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Side = DockSide.Right });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -534,7 +535,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_DisplayInfo_ExposesMonitorProperties()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -553,6 +554,7 @@ public class DockMultiMonitorTests
var tertiary = new MonitorInfo
{
DeviceId = @"\\.\DISPLAY3",
StableId = @"\\?\DISPLAY#TER9012#4&fff&0&UID333#{guid3}",
DisplayName = "Display 3",
Bounds = new ScreenRect(3840, 0, 5760, 1080),
WorkArea = new ScreenRect(3840, 0, 5760, 1040),
@@ -566,10 +568,18 @@ public class DockMultiMonitorTests
Assert.AreEqual(3, reconciled.Count, "Should create configs for all 3 monitors");
// All monitors should be enabled by default
// Only the primary monitor should be enabled by default
foreach (var config in reconciled)
{
Assert.IsTrue(config.Enabled, $"Monitor {config.MonitorDeviceId} should be enabled");
if (config.IsPrimary)
{
Assert.IsTrue(config.Enabled, $"Primary monitor {config.MonitorDeviceId} should be enabled");
}
else
{
Assert.IsFalse(config.Enabled, $"Secondary monitor {config.MonitorDeviceId} should be disabled by default");
}
Assert.IsNull(config.Side, $"Monitor {config.MonitorDeviceId} should inherit global side");
}
@@ -586,11 +596,11 @@ public class DockMultiMonitorTests
}
// Primary should be flagged correctly
var primaryConfig = reconciled.Find(c => c.MonitorDeviceId == PrimaryMonitor.DeviceId);
var primaryConfig = reconciled.Find(c => c.MonitorDeviceId == PrimaryMonitor.StableId);
Assert.IsNotNull(primaryConfig, "Primary monitor config should exist");
Assert.IsTrue(primaryConfig.IsPrimary, "Primary config should be marked as primary");
var secondaryConfig = reconciled.Find(c => c.MonitorDeviceId == SecondaryMonitor.DeviceId);
var secondaryConfig = reconciled.Find(c => c.MonitorDeviceId == SecondaryMonitor.StableId);
Assert.IsNotNull(secondaryConfig, "Secondary monitor config should exist");
Assert.IsFalse(secondaryConfig.IsPrimary, "Secondary config should not be marked as primary");
}
@@ -613,7 +623,7 @@ public class DockMultiMonitorTests
var secondaryConfig = reconciled.Find(c => !c.IsPrimary);
Assert.IsNotNull(secondaryConfig, "Secondary config should be created");
Assert.IsTrue(secondaryConfig.Enabled, "Secondary should be enabled by default");
Assert.IsFalse(secondaryConfig.Enabled, "Secondary should be disabled by default");
Assert.IsTrue(secondaryConfig.IsCustomized, "Secondary should start with custom (empty) bands");
}
@@ -624,10 +634,10 @@ public class DockMultiMonitorTests
var customBands = ImmutableList.Create(new DockBandSettings { ProviderId = "custom", CommandId = "cmd1" });
var now = DateTime.UtcNow;
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.DeviceId,
MonitorDeviceId = SecondaryMonitor.StableId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
@@ -650,7 +660,7 @@ public class DockMultiMonitorTests
// Verify customizations survived the round-trip
var secondaryConfig = afterReconnect.Find(c =>
string.Equals(c.MonitorDeviceId, SecondaryMonitor.DeviceId, StringComparison.OrdinalIgnoreCase));
string.Equals(c.MonitorDeviceId, SecondaryMonitor.StableId, StringComparison.OrdinalIgnoreCase));
Assert.IsNotNull(secondaryConfig, "Secondary config should be found after reconnection");
Assert.IsTrue(secondaryConfig.IsCustomized, "Customization flag should survive");
Assert.AreEqual(DockSide.Left, secondaryConfig.Side, "Side override should survive");
@@ -666,9 +676,9 @@ public class DockMultiMonitorTests
var fiveMonthsAgo = now - TimeSpan.FromDays(150);
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_STALE", Enabled = true, IsPrimary = false, LastSeen = sevenMonthsAgo },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_RECENT", Enabled = true, IsPrimary = false, LastSeen = fiveMonthsAgo });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#STALE#4&sss&0&UID444#{guidStale}", Enabled = true, IsPrimary = false, LastSeen = sevenMonthsAgo },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#RECENT#4&rrr&0&UID555#{guidRecent}", Enabled = true, IsPrimary = false, LastSeen = fiveMonthsAgo });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
@@ -676,8 +686,8 @@ public class DockMultiMonitorTests
// Primary is matched, RECENT is retained (< 6 months), STALE is pruned (> 6 months)
Assert.AreEqual(2, result.Count, "Should have matched primary + recently-seen disconnected config");
Assert.AreEqual(PrimaryMonitor.DeviceId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY_RECENT", result[1].MonitorDeviceId, "Recently-seen config should be retained");
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\?\DISPLAY#RECENT#4&rrr&0&UID555#{guidRecent}", result[1].MonitorDeviceId, "Recently-seen config should be retained");
}
[TestMethod]
@@ -685,8 +695,8 @@ public class DockMultiMonitorTests
{
// Configs from before LastSeen was added (LastSeen is null)
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_LEGACY", Enabled = true, IsPrimary = false, IsCustomized = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#LEGACY#4&lll&0&UID666#{guidLegacy}", Enabled = true, IsPrimary = false, IsCustomized = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
@@ -694,7 +704,27 @@ public class DockMultiMonitorTests
// Legacy config (null LastSeen) should be treated as fresh and retained
Assert.AreEqual(2, result.Count, "Legacy config without LastSeen should be retained");
Assert.AreEqual(@"\\.\DISPLAY_LEGACY", result[1].MonitorDeviceId);
Assert.AreEqual(@"\\?\DISPLAY#LEGACY#4&lll&0&UID666#{guidLegacy}", result[1].MonitorDeviceId);
}
[TestMethod]
public void Reconciler_LegacyGdiName_MigratedToStableId()
{
// Simulate upgrade from pre-stable-ID settings: configs use GDI device names
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true, Side = DockSide.Left },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true, IsPrimary = false, IsCustomized = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Phase 1.5 should detect GDI-style names and rewrite to stable IDs
Assert.AreEqual(2, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId, "Primary should be migrated to stable ID");
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId, "Secondary should be migrated to stable ID");
Assert.AreEqual(DockSide.Left, result[0].Side, "Side override should survive migration");
Assert.IsTrue(result[1].IsCustomized, "Customization flag should survive migration");
}
private static SettingsModel CreateSettingsModelWithConfigs(params DockMonitorConfig[] configs)

View File

@@ -96,6 +96,41 @@ public class ExtensionGalleryItemViewModelTests
Assert.IsTrue(viewModel.Sources.Count >= 4);
}
[TestMethod]
public void Constructor_IgnoresNonWebGalleryLinks()
{
var entry = new GalleryExtensionEntry
{
Id = "unsafe-links-extension",
Title = "Unsafe links extension",
Description = "Unsafe links extension description",
Homepage = "ms-settings:appsfeatures",
Author = new GalleryAuthor
{
Name = "Author",
Url = "file:///C:/Windows/System32/calc.exe",
},
InstallSources =
[
new GalleryInstallSource { Type = "url", Uri = "file:///C:/Windows/System32/notepad.exe" },
],
};
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsFalse(viewModel.HasAuthorUrl);
Assert.IsFalse(viewModel.HasUrlSource);
Assert.IsFalse(viewModel.HasActionableSourceDetails);
Assert.IsNull(viewModel.InstallUrl);
Assert.IsFalse(viewModel.Sources.Any(source =>
string.Equals(source.Kind, "github", StringComparison.OrdinalIgnoreCase)
|| string.Equals(source.Kind, "website", StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(viewModel.OpenHomepageCommand.CanExecute(null));
Assert.IsFalse(viewModel.OpenAuthorPageCommand.CanExecute(null));
Assert.IsFalse(viewModel.OpenInstallUrlCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_EnablesCopyCommand_WhenWinGetIdIsAvailable()
{
@@ -347,6 +382,68 @@ public class ExtensionGalleryItemViewModelTests
CollectionAssert.Contains(sourceDetails.Select(item => item.Value).ToList(), "utility, productivity");
}
[TestMethod]
public void ApplyWinGetPackageInfo_IgnoresNonWebMetadataLinks()
{
var entry = new GalleryExtensionEntry
{
Id = "winget-details-unsafe-links-extension",
Title = "WinGet details unsafe links extension",
Description = "WinGet details unsafe links extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var details = new WinGetPackageDetails(
Name: "Contoso Extension",
Version: "1.2.3",
Summary: "Summary",
Description: "Description",
Publisher: "Contoso",
PublisherUrl: "file:///C:/Windows/System32/calc.exe",
PublisherSupportUrl: "ms-settings:appsfeatures",
Author: "Contoso Team",
License: "MIT",
LicenseUrl: "https://contoso.example/license",
PackageUrl: "https://contoso.example/package",
ReleaseNotes: "Release notes",
ReleaseNotesUrl: "mailto:support@contoso.example",
IconUrl: "https://contoso.example/iconUrl.png",
DocumentationLinks:
[
new WinGetNamedLink("Unsafe docs", "search-ms:query=contoso"),
new WinGetNamedLink("Safe docs", "https://contoso.example/docs"),
],
Tags:
[
"utility",
"productivity",
]);
var viewModel = CreateViewModel(entry);
viewModel.ApplyWinGetPackageInfo(
new WinGetPackageInfo(
new WinGetPackageStatus(
IsInstalled: true,
IsInstalledStateKnown: true,
IsUpdateAvailable: false,
IsUpdateStateKnown: true),
details));
var wingetSource = viewModel.Sources.First(source => string.Equals(source.Kind, "winget", StringComparison.OrdinalIgnoreCase));
var sourceDetails = wingetSource.Details;
Assert.IsTrue(sourceDetails.Any(item => string.Equals(item.LinkUri?.AbsoluteUri, "https://contoso.example/license", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(sourceDetails.Any(item => string.Equals(item.LinkUri?.AbsoluteUri, "https://contoso.example/package", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(sourceDetails.Any(item => string.Equals(item.LinkUri?.AbsoluteUri, "https://contoso.example/docs", StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(sourceDetails.Any(item => item.LinkUri is not null && !string.Equals(item.LinkUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(item.LinkUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(sourceDetails.Any(item => string.Equals(item.Value, "ms-settings:appsfeatures", StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(sourceDetails.Any(item => string.Equals(item.Value, "search-ms:query=contoso", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public void ApplyWinGetPackageInfo_RaisesHasDetailsChanged_WhenMetadataIsAdded()
{

View File

@@ -484,7 +484,7 @@ follow - these are not part of the current SDK spec.
> [!NOTE]
>
> A thought: what if a action returns a `CommandResult.Entity`, then that takes
> A thought: what if an action returns a `CommandResult.Entity`, then that takes
> devpal back home, but leaves the entity in the query box. This would allow for
> a Quicksilver-like "thing, do" flow. That command would prepopulate the
> parameters. So we would then filter top-level commands based on things that can

View File

@@ -38,6 +38,7 @@ internal sealed partial class ActionsTestPage : ListPage
var items = new List<ListItem>();
#if DEBUG
var actionsDebug = string.Empty;
foreach (var action in actions)
@@ -48,6 +49,7 @@ internal sealed partial class ActionsTestPage : ListPage
}
Logger.LogDebug(actionsDebug);
#endif
foreach (var action in actions)
{

View File

@@ -98,6 +98,14 @@ public sealed partial class AllAppsPage : ListPage
items.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal));
if (AllAppsSettings.Instance.HideAppDescriptions)
{
foreach (var item in items)
{
item.Subtitle = string.Empty;
}
}
return [.. items];
}
}

View File

@@ -66,6 +66,8 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface
public bool IncludeNonAppsInStartMenu => _includeNonAppsInStartMenu.Value;
public bool HideAppDescriptions => _hideAppDescriptions.Value;
private readonly ChoiceSetSetting _searchResultLimitSource = new(
Namespaced(nameof(SearchResultLimit)),
Resources.limit_fallback_results_source,
@@ -137,6 +139,12 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface
string.Empty,
true);
private readonly ToggleSetting _hideAppDescriptions = new(
Namespaced(nameof(HideAppDescriptions)),
Resources.hide_app_descriptions,
Resources.hide_app_descriptions_description,
false);
public double MinScoreThreshold { get; set; } = 0.75;
internal const char SuffixSeparator = ';';
@@ -160,6 +168,7 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface
Settings.Add(_enableRegistrySource);
Settings.Add(_enablePathEnvironmentVariableSource);
Settings.Add(_searchResultLimitSource);
Settings.Add(_hideAppDescriptions);
LoadSettings();

View File

@@ -141,6 +141,24 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Hide app description in search results.
/// </summary>
internal static string hide_app_descriptions {
get {
return ResourceManager.GetString("hide_app_descriptions", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hide description text next to app results for a cleaner look.
/// </summary>
internal static string hide_app_descriptions_description {
get {
return ResourceManager.GetString("hide_app_descriptions_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Also include non-app shortcuts from the Start menu.
/// </summary>

View File

@@ -244,4 +244,10 @@
<data name="include_non_apps_in_start_menu" xml:space="preserve">
<value>Also include non-app shortcuts from the Start menu</value>
</data>
<data name="hide_app_descriptions" xml:space="preserve">
<value>Hide app description in search results</value>
</data>
<data name="hide_app_descriptions_description" xml:space="preserve">
<value>Hide description text next to app results for a cleaner look</value>
</data>
</root>

View File

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

View File

@@ -10,24 +10,48 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable
internal sealed partial class BookmarkPlaceholderPage : ParametersPage, IDisposable
{
private readonly FormContent _bookmarkPlaceholder;
private readonly BookmarkData _bookmarkData;
private readonly IBookmarkResolver _resolver;
private readonly Classification _bookmarkClassification;
private readonly IParameterRun[] _parameters;
private readonly Dictionary<string, StringParameterRun> _placeholderRuns;
private readonly ListItem _commandItem;
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
public BookmarkPlaceholderPage(BookmarkData bookmarkData, IBookmarkIconLocator iconLocator, IBookmarkResolver resolver, IPlaceholderParser placeholderParser)
{
ArgumentNullException.ThrowIfNull(bookmarkData);
ArgumentNullException.ThrowIfNull(resolver);
ArgumentNullException.ThrowIfNull(placeholderParser);
_bookmarkData = bookmarkData;
_resolver = resolver;
// Cache the original bookmark's classification — it doesn't depend on
// placeholder values, and we need it on every keystroke to know how to
// encode the preview/launched URL.
_bookmarkClassification = resolver.ClassifyOrUnknown(bookmarkData.Bookmark);
Name = Resources.bookmarks_command_name_open;
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
_bookmarkPlaceholder = new BookmarkPlaceholderForm(bookmarkData, resolver, placeholderParser);
placeholderParser.ParsePlaceholders(bookmarkData.Bookmark, out _, out var placeholders);
(_parameters, _placeholderRuns) = BuildParameterRuns(bookmarkData.Bookmark, placeholders);
var submitCommand = new LaunchPlaceholderCommand(this);
_commandItem = new ListItem(submitCommand);
foreach (var run in _placeholderRuns.Values)
{
run.PropChanged += OnPlaceholderChanged;
}
UpdateSubtitle();
_iconReloadGate = new(
async ct =>
{
var c = resolver.ClassifyOrUnknown(bookmarkData.Bookmark);
return await iconLocator.GetIconForPath(c, ct);
},
async ct => await iconLocator.GetIconForPath(_bookmarkClassification, ct),
icon =>
{
Icon = icon as IconInfo ?? Icons.PinIcon;
@@ -35,7 +59,115 @@ internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable
RequestIconReloadAsync();
}
public override IContent[] GetContent() => [_bookmarkPlaceholder];
public override IParameterRun[] Parameters => _parameters;
public override IListItem Command => _commandItem;
private static (IParameterRun[] Runs, Dictionary<string, StringParameterRun> RunsByName) BuildParameterRuns(string bookmark, List<PlaceholderInfo> placeholders)
{
var runs = new List<IParameterRun>();
var byName = new Dictionary<string, StringParameterRun>(StringComparer.OrdinalIgnoreCase);
var cursor = 0;
// PlaceholderParser emits placeholders in source order, but be defensive
// in case that ever changes — slicing relies on monotonic indices.
placeholders.Sort((a, b) => a.Index.CompareTo(b.Index));
foreach (var placeholder in placeholders)
{
if (placeholder.Index > cursor)
{
runs.Add(new LabelRun(bookmark.Substring(cursor, placeholder.Index - cursor)));
}
if (!byName.TryGetValue(placeholder.Name, out var run))
{
run = new StringParameterRun
{
PlaceholderText = placeholder.Name,
};
byName[placeholder.Name] = run;
}
runs.Add(run);
// Advance past "{Name}" — name length plus the two braces.
cursor = placeholder.Index + placeholder.Name.Length + 2;
}
if (cursor < bookmark.Length)
{
runs.Add(new LabelRun(bookmark.Substring(cursor)));
}
return (runs.ToArray(), byName);
}
private CommandResult LaunchWithCurrentValues()
{
var target = BuildEvaluatedBookmark();
// Re-classify the final target — adding placeholder values may change
// what kind of command this is (e.g. a path that needs different launch).
var classification = _resolver.ClassifyOrUnknown(target);
var success = CommandLauncher.Launch(classification);
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
private string BuildEvaluatedBookmark()
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, run) in _placeholderRuns)
{
values[name] = run.Text ?? string.Empty;
}
return ReplacePlaceholders(_bookmarkData.Bookmark, values, _bookmarkClassification);
}
private bool AllPlaceholdersHaveValues()
{
foreach (var run in _placeholderRuns.Values)
{
if (string.IsNullOrEmpty(run.Text))
{
return false;
}
}
return true;
}
private void OnPlaceholderChanged(object sender, IPropChangedEventArgs args)
{
if (args.PropertyName == nameof(StringParameterRun.Text))
{
UpdateSubtitle();
}
}
private void UpdateSubtitle()
{
_commandItem.Subtitle = AllPlaceholdersHaveValues() ? BuildEvaluatedBookmark() : string.Empty;
}
private static string ReplacePlaceholders(string input, Dictionary<string, string> placeholders, Classification classification)
{
var result = input;
foreach (var (key, value) in placeholders)
{
var placeholderString = $"{{{key}}}";
var encodedValue = value;
if (classification.Kind is CommandKind.Protocol or CommandKind.WebUrl)
{
encodedValue = Uri.EscapeDataString(value);
}
result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase);
}
return result;
}
private void RequestIconReloadAsync()
{
@@ -44,5 +176,27 @@ internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable
_ = _iconReloadGate.ExecuteAsync();
}
public void Dispose() => _iconReloadGate.Dispose();
public void Dispose()
{
foreach (var run in _placeholderRuns.Values)
{
run.PropChanged -= OnPlaceholderChanged;
}
_iconReloadGate.Dispose();
}
private sealed partial class LaunchPlaceholderCommand : InvokableCommand
{
private readonly BookmarkPlaceholderPage _page;
public LaunchPlaceholderCommand(BookmarkPlaceholderPage page)
{
_page = page;
Name = Resources.bookmarks_form_open;
Icon = Icons.PinIcon;
}
public override ICommandResult Invoke() => _page.LaunchWithCurrentValues();
}
}

View File

@@ -51,11 +51,13 @@ public static partial class CalculateEngine
return default;
}
// mages has quirky log representation
// mage has log == ln vs log10
input = input.
Replace("log(", "log10(", true, CultureInfo.CurrentCulture).
Replace("ln(", "log(", true, CultureInfo.CurrentCulture);
// ExprTK uses log == ln and log10 for base-10, so we remap here
// to match the user-facing names (log → log10, ln log).
// Use regex replacements so optional whitespace between the function name and
// '(' is handled correctly - "log (100)" must map to log10 just like "log(100)"
// does. The negative lookahead prevents "log10" / "log2" from being touched.
input = LogRegex().Replace(input, "log10(");
input = LnRegex().Replace(input, "log(");
input = CalculateHelper.FixHumanMultiplicationExpressions(input);
@@ -73,12 +75,18 @@ public static partial class CalculateEngine
var result = _calculator.EvaluateExpression(input);
// This could happen for some incorrect queries, like pi(2)
if (result == "NaN")
if (result == "ParseError")
{
error = Properties.Resources.calculator_expression_not_complete;
return default;
}
if (result == "NaN")
{
error = Properties.Resources.calculator_not_a_number;
return default;
}
// If we're out of bounds
if (result is "inf" or "-inf")
{
@@ -88,6 +96,7 @@ public static partial class CalculateEngine
if (string.IsNullOrEmpty(result))
{
error = Properties.Resources.calculator_not_a_number;
return default;
}
@@ -135,6 +144,15 @@ public static partial class CalculateEngine
return rounded / 1.000000000000000000000000000000000m;
}
[GeneratedRegex("\\/\\s*0(?!(?:[,\\.0-9]|[box]0*[1-9a-f]))", RegexOptions.IgnoreCase, "en-US")]
[GeneratedRegex("\\/\\s*0(?!(?:[,\\.0-9]|[box]0*[1-9a-f]))", RegexOptions.IgnoreCase)]
private static partial Regex DivisionByZeroRegex();
// Case-insensitive match for "log" not followed by a digit, then optional whitespace,
// then '('. The negative lookahead protects "log2" and "log10". A new log variant
// like "logb" must be handled explicitly.
[GeneratedRegex("log(?![0-9])\\s*\\(", RegexOptions.IgnoreCase)]
private static partial Regex LogRegex();
[GeneratedRegex("ln\\s*\\(", RegexOptions.IgnoreCase)]
private static partial Regex LnRegex();
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -14,7 +14,7 @@ public static partial class CalculateHelper
@"^(" +
@"%|" +
@"ceil\s*\(|floor\s*\(|exp\s*\(|max\s*\(|min\s*\(|abs\s*\(|log(?:2|10)?\s*\(|ln\s*\(|sqrt\s*\(|pow\s*\(|" +
@"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\([^\)]|" +
@"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\((?=[^\)])|" +
@"sin\s*\(|cos\s*\(|tan\s*\(|arcsin\s*\(|arccos\s*\(|arctan\s*\(|" +
@"sinh\s*\(|cosh\s*\(|tanh\s*\(|arsinh\s*\(|arcosh\s*\(|artanh\s*\(|" +
@"rad\s*\(|deg\s*\(|grad\s*\(|" + /* trigonometry unit conversion macros */
@@ -385,7 +385,7 @@ public static partial class CalculateHelper
public static string UpdateFactorialFunctions(string input)
{
// Handle n! -> factorial(n)
int startSearch = 0;
var startSearch = 0;
while (true)
{
var index = input.IndexOf('!', startSearch);

View File

@@ -15,6 +15,50 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
/// </summary>
public class NumberTranslator
{
private const string ProtectedListSeparatorToken = "\uE000";
/// <summary>
/// ExprTK does not expose a public API that lets us enumerate the built-in functions accepted by
/// this calculator together with their arity. Keep this table in sync with the functions allowed
/// by <see cref="CalculateHelper.InputValid"/> and extend the translator/query tests when adding
/// new functions.
/// </summary>
private static readonly Dictionary<string, int> SupportedFunctionArgumentCounts = new(StringComparer.OrdinalIgnoreCase)
{
{ "ceil", 1 },
{ "floor", 1 },
{ "exp", 1 },
{ "max", 2 },
{ "min", 2 },
{ "abs", 1 },
{ "log", 1 },
{ "log2", 1 },
{ "log10", 1 },
{ "ln", 1 },
{ "sqrt", 1 },
{ "pow", 2 },
{ "factorial", 1 },
{ "sign", 1 },
{ "round", 1 },
{ "rand", 0 },
{ "randi", 1 },
{ "sin", 1 },
{ "cos", 1 },
{ "tan", 1 },
{ "arcsin", 1 },
{ "arccos", 1 },
{ "arctan", 1 },
{ "sinh", 1 },
{ "cosh", 1 },
{ "tanh", 1 },
{ "arsinh", 1 },
{ "arcosh", 1 },
{ "artanh", 1 },
{ "rad", 1 },
{ "deg", 1 },
{ "grad", 1 },
};
private readonly CultureInfo sourceCulture;
private readonly CultureInfo targetCulture;
private readonly Regex splitRegexForSource;
@@ -94,13 +138,23 @@ public class NumberTranslator
private static string Translate(string input, CultureInfo cultureFrom, CultureInfo cultureTo, Regex splitRegex)
{
var protectFunctionArgumentSeparators = cultureFrom.NumberFormat.NumberGroupSeparator == cultureFrom.TextInfo.ListSeparator;
// In cultures such as en-US, ',' can mean either digit grouping or a function argument
// separator. Preserve separators only inside the supported multi-argument functions before
// the regex-based number pass so expressions like max(123,456) are not collapsed to 123456,
// while single-argument calls such as ceil(123,456.23) still keep their grouped number.
var workingInput = protectFunctionArgumentSeparators
? ProtectFunctionArgumentSeparators(input, cultureFrom.TextInfo.ListSeparator, ProtectedListSeparatorToken)
: input;
var outputBuilder = new StringBuilder();
// Match numbers in hexadecimal (0x..), binary (0b..), or octal (0o..) format,
// and convert them to decimal form for compatibility with ExprTk (which only supports decimal input).
var baseNumberRegex = new Regex(@"(0[xX][\da-fA-F]+|0[bB][0-9]+|0[oO][0-9]+)");
var tokens = baseNumberRegex.Split(input);
var tokens = baseNumberRegex.Split(workingInput);
foreach (var token in tokens)
{
@@ -138,26 +192,125 @@ public class NumberTranslator
outputBuilder.Append(
decimal.TryParse(inner, NumberStyles.Number, cultureFrom, out number)
? (new string('0', leadingZeroCount) + number.ToString(cultureTo))
? ((inner.Contains(cultureFrom.NumberFormat.NumberDecimalSeparator, StringComparison.Ordinal) ? string.Empty : new string('0', leadingZeroCount)) + number.ToString(cultureTo))
: inner.Replace(cultureFrom.TextInfo.ListSeparator, cultureTo.TextInfo.ListSeparator));
}
}
var translated = outputBuilder.ToString();
// Restore protected argument separators after numeric translation has finished.
return protectFunctionArgumentSeparators
? translated.Replace(ProtectedListSeparatorToken, cultureTo.TextInfo.ListSeparator, StringComparison.Ordinal)
: translated;
}
private static string ProtectFunctionArgumentSeparators(string input, string listSeparator, string placeholder)
{
if (string.IsNullOrEmpty(listSeparator))
{
return input;
}
var outputBuilder = new StringBuilder();
var parenthesisProtection = new Stack<bool>();
for (var i = 0; i < input.Length; i++)
{
if (parenthesisProtection.Count > 0 && parenthesisProtection.Peek() && MatchesAt(input, listSeparator, i))
{
// Protect separators only for the current multi-argument function call. Nested
// single-argument functions such as max(ceil(123,456.23), 2) must still be able to
// treat ',' as a digit-group separator inside their own argument.
outputBuilder.Append(placeholder);
i += listSeparator.Length - 1;
continue;
}
var current = input[i];
outputBuilder.Append(current);
if (current == '(')
{
parenthesisProtection.Push(ShouldProtectFunctionArgumentSeparators(input, i));
}
else if (current == ')' && parenthesisProtection.Count > 0)
{
parenthesisProtection.Pop();
}
}
return outputBuilder.ToString();
}
private static bool ShouldProtectFunctionArgumentSeparators(string input, int openParenIndex)
{
var end = openParenIndex - 1;
// Allow whitespace between a function name and its opening parenthesis.
while (end >= 0 && char.IsWhiteSpace(input[end]))
{
end--;
}
if (end < 0 || !char.IsLetterOrDigit(input[end]))
{
return false;
}
var start = end;
while (start >= 0 && char.IsLetterOrDigit(input[start]))
{
start--;
}
start++;
// Treat identifier-like text such as max ( as a function call, but avoid marking plain
// grouping parentheses like (1 + 2) as function syntax. Only supported functions with more
// than one argument need protection; single-argument functions must still allow grouping
// separators inside their numeric inputs.
if (start > end || !char.IsLetter(input[start]))
{
return false;
}
var functionName = input.Substring(start, end - start + 1);
return SupportedFunctionArgumentCounts.TryGetValue(functionName, out var argumentCount) && argumentCount > 1;
}
private static bool MatchesAt(string input, string value, int index)
{
return index + value.Length <= input.Length &&
string.Compare(input, index, value, 0, value.Length, StringComparison.Ordinal) == 0;
}
private static Regex GetSplitRegex(CultureInfo culture)
{
var listSeparator = culture.TextInfo.ListSeparator;
var groupSeparator = culture.NumberFormat.NumberGroupSeparator;
var hasAmbiguousNumericSeparators = groupSeparator == listSeparator;
// if the group separator is a no-break space, we also add a normal space to the regex
// Some cultures use a non-breaking space for digit grouping, but users may type a
// normal space instead. Expand the group separator to allow for either character.
if (groupSeparator == "\u00a0")
{
groupSeparator = "\u0020\u00a0";
}
var splitPattern = $"([0-9{Regex.Escape(culture.NumberFormat.NumberDecimalSeparator)}" +
$"{Regex.Escape(groupSeparator)}]+)";
return new Regex(splitPattern);
var decimalSeparator = Regex.Escape(culture.NumberFormat.NumberDecimalSeparator);
// Strictly match only culture-valid numbers when the group separator is also the
// function argument separator. In cultures like en-US, a looser pattern would
// swallow max(1,2) as if "1,2" were a single number instead of two arguments.
var strictNumberTokenPattern =
$@"((?:\d{{1,3}}(?:[{Regex.Escape(groupSeparator)}]\d{{3}})+|\d+)(?:{decimalSeparator}\d+)?|{decimalSeparator}\d+)";
// Preserve the legacy looser matching in cultures where numeric grouping cannot be
// confused with function argument separators. This keeps existing behavior for cases
// like de-DE, where '.' is a group separator but ';' separates function arguments.
var looseNumberTokenPattern = $"([0-9{decimalSeparator}{Regex.Escape(groupSeparator)}]+)";
return new Regex(hasAmbiguousNumericSeparators ? strictNumberTokenPattern : looseNumberTokenPattern);
}
}

View File

@@ -0,0 +1,61 @@
// 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 Windows.Win32;
using Windows.Win32.System.Power;
namespace CoreWidgetProvider.Helpers;
internal sealed partial class BatteryStats
{
// GetSystemPowerStatus returns 255 (0xFF) for percent / time when unknown.
private const byte BatteryPercentUnknown = 255;
private const uint BatteryLifeTimeUnknown = 0xFFFFFFFF;
// BatteryFlag bits.
private const byte BatteryFlagCharging = 0x08;
private const byte BatteryFlagNoBattery = 0x80;
private const byte BatteryFlagUnknown = 0xFF;
public bool HasBattery { get; set; }
public bool IsCharging { get; set; }
public bool IsOnAcPower { get; set; }
/// <summary>
/// Charge level in [0, 1], or -1 when unknown.
/// </summary>
public float ChargePercent { get; set; } = -1f;
/// <summary>
/// Estimated seconds of battery life remaining, or -1 when unknown / charging / on AC.
/// </summary>
public int SecondsRemaining { get; set; } = -1;
public void GetData()
{
if (!PInvoke.GetSystemPowerStatus(out SYSTEM_POWER_STATUS status))
{
HasBattery = false;
IsCharging = false;
IsOnAcPower = false;
ChargePercent = -1f;
SecondsRemaining = -1;
return;
}
HasBattery = status.BatteryFlag != BatteryFlagUnknown && (status.BatteryFlag & BatteryFlagNoBattery) == 0;
IsCharging = HasBattery && (status.BatteryFlag & BatteryFlagCharging) != 0;
IsOnAcPower = status.ACLineStatus == 1;
ChargePercent = status.BatteryLifePercent != BatteryPercentUnknown
? status.BatteryLifePercent / 100f
: -1f;
SecondsRemaining = status.BatteryLifeTime != BatteryLifeTimeUnknown
? (int)status.BatteryLifeTime
: -1;
}
}

View File

@@ -46,7 +46,11 @@ internal sealed partial class CPUStats : PerformanceCounterSourceBase, IDisposab
new ProcessStats()
];
_procPerf = CreatePerformanceCounter("Processor Information", "% Processor Utility", "_Total");
// Use "% Processor Time" instead of "% Processor Utility": the latter is unbounded above 100%
// when cores boost above their nominal base frequency (it is scaled by % Processor Performance),
// which produced values like 144% in the dock under heavy load. % Processor Time is the same
// counter Task Manager renders and is naturally bounded to 0-100%. See issue #46381.
_procPerf = CreatePerformanceCounter("Processor Information", "% Processor Time", "_Total");
_procPerformance = CreatePerformanceCounter("Processor Information", "% Processor Performance", "_Total");
_procFrequency = CreatePerformanceCounter("Processor Information", "Processor Frequency", "_Total");
}

View File

@@ -61,6 +61,14 @@ internal sealed partial class DataManager : IDisposable
}
}
private void GetBatteryData()
{
lock (_systemData.BatteryStats)
{
_systemData.BatteryStats.GetData();
}
}
private void UpdateTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
var firstUpdateBlockSuffix = GetFirstUpdateBlockSuffix();
@@ -98,6 +106,12 @@ internal sealed partial class DataManager : IDisposable
GetNetworkData();
break;
}
case DataType.Battery:
{
GetBatteryData();
break;
}
}
if (isTracked)
@@ -132,6 +146,7 @@ internal sealed partial class DataManager : IDisposable
DataType.GPU => "GPU.FirstUpdate",
DataType.Memory => "Memory.FirstUpdate",
DataType.Network => "Network.FirstUpdate",
DataType.Battery => "Battery.FirstUpdate",
_ => null,
};
}
@@ -168,6 +183,14 @@ internal sealed partial class DataManager : IDisposable
}
}
internal BatteryStats GetBatteryStats()
{
lock (_systemData.BatteryStats)
{
return _systemData.BatteryStats;
}
}
public void Start()
{
_updateTimer.Start();

View File

@@ -32,4 +32,9 @@ public enum DataType
/// Network related data.
/// </summary>
Network,
/// <summary>
/// Battery related data.
/// </summary>
Battery,
}

View File

@@ -15,6 +15,7 @@ internal sealed partial class SystemData
private readonly Lazy<NetworkStats> _networkStats = new(() => CreateGuarded("Network.Initialize", static () => new NetworkStats()));
private readonly Lazy<GPUStats> _gpuStats = new(() => CreateGuarded("GPU.Initialize", static () => new GPUStats()));
private readonly Lazy<CPUStats> _cpuStats = new(() => CreateGuarded("CPU.Initialize", static () => new CPUStats()));
private readonly Lazy<BatteryStats> _batteryStats = new(() => CreateGuarded("Battery.Initialize", static () => new BatteryStats()));
public MemoryStats MemoryStats => _memoryStats.Value;
@@ -24,6 +25,8 @@ internal sealed partial class SystemData
public CPUStats CpuStats => _cpuStats.Value;
public BatteryStats BatteryStats => _batteryStats.Value;
private SystemData()
{
}

View File

@@ -0,0 +1,90 @@
{
"type": "AdaptiveCard",
"body": [
{
"type": "Container",
"$when": "${errorMessage != null}",
"items": [
{
"type": "TextBlock",
"text": "${errorMessage}",
"wrap": true,
"size": "small"
}
],
"style": "warning"
},
{
"type": "Container",
"$when": "${errorMessage == null}",
"items": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"items": [
{
"text": "%Battery_Widget_Template/Charge%",
"type": "TextBlock",
"size": "small",
"isSubtle": true
},
{
"text": "${batteryCharge}",
"type": "TextBlock",
"size": "${if($host.widgetSize == \"small\", \"medium\", \"extraLarge\")}",
"weight": "bolder"
}
]
},
{
"type": "Column",
"items": [
{
"text": "%Battery_Widget_Template/Status%",
"type": "TextBlock",
"size": "small",
"isSubtle": true,
"horizontalAlignment": "right"
},
{
"text": "${batteryStatus}",
"type": "TextBlock",
"size": "${if($host.widgetSize == \"small\", \"medium\", \"large\")}",
"weight": "bolder",
"horizontalAlignment": "right"
}
]
}
]
},
{
"type": "ColumnSet",
"$when": "${$host.widgetSize != \"small\"}",
"columns": [
{
"type": "Column",
"items": [
{
"text": "%Battery_Widget_Template/TimeRemaining%",
"type": "TextBlock",
"size": "small",
"isSubtle": true
},
{
"text": "${batteryTimeRemaining}",
"type": "TextBlock",
"size": "medium",
"wrap": true
}
]
}
]
}
]
}
],
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.5"
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.PerformanceMonitor;
@@ -24,6 +25,40 @@ internal static class Icons
internal static IconInfo GpuIcon => new("\uE950"); // Component icon
internal static IconInfo BatteryIcon => BatteryIcons[10]; // MobBattery10 (page identity)
// Pre-built cache so the 1 Hz dock-band update reuses IconInfo instances
// instead of allocating one per tick. 11 discharging (MobBattery0..10), 11
// charging (MobBatteryCharging0..10), 1 unknown (MobBatteryUnknown).
private static readonly IconInfo[] BatteryIcons = BuildBatteryGlyphs(0xEBA0);
private static readonly IconInfo[] BatteryChargingIcons = BuildBatteryGlyphs(0xEBAB);
private static readonly IconInfo BatteryUnknownIcon = new("\uEC02");
private static IconInfo[] BuildBatteryGlyphs(int baseCodepoint)
{
var icons = new IconInfo[11];
for (var i = 0; i <= 10; i++)
{
icons[i] = new IconInfo(char.ConvertFromUtf32(baseCodepoint + i));
}
return icons;
}
// Returns a MobBattery glyph that reflects the actual charge level so the dock-band icon
// is never misread as "100%". Range maps 0-100% to MobBattery0..MobBattery10 (\uEBA0..\uEBAA);
// charging swaps to MobBatteryCharging0..10 (\uEBAB..\uEBB5); unknown/no battery uses MobBatteryUnknown.
internal static IconInfo BatteryGlyph(double percent01, bool isCharging, bool hasBattery)
{
if (!hasBattery || percent01 < 0)
{
return BatteryUnknownIcon;
}
var level = (int)Math.Round(Math.Clamp(percent01, 0, 1) * 10);
return isCharging ? BatteryChargingIcons[level] : BatteryIcons[level];
}
internal static IconInfo NavigateBackwardIcon => new("\uE72B"); // Previous icon
internal static IconInfo NavigateForwardIcon => new("\uE72A"); // Next icon

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