Compare commits

..

7 Commits

Author SHA1 Message Date
Leilei Zhang
2e25722300 update 2025-07-11 08:58:18 +08:00
Leilei Zhang
617c6e00f2 Merge branch 'main' of https://github.com/microsoft/PowerToys into leilzh/timeout 2025-07-10 09:41:34 +08:00
leileizhang
6d29c3a2c9 [pipeline] feat: Implement flexible UI test pipeline with configurable build and execution modes (#40490)
<!-- 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
**Root Cause:**
The current pipeline builds the entire solution and runs all UI tests
every time, which takes more than 2 hours to complete.

**Fix**
Make the PowerToys UI test pipeline provides flexible options for
building and testing:

### Pipeline Options

- **useLatestOfficialBuild**: When checked, downloads the latest
official PowerToys build and installs it for testing. This skips the
full solution build and only builds UI test projects.

- **useCurrentBranchBuild**: When checked along with
`useLatestOfficialBuild`, downloads the official build from the current
branch instead of main.

- **uiTestModules**: Specify which UI test modules to build and run.
Examples:
  - `UITests-FancyZones` - Only FancyZones UI tests
  - `MouseUtils.UITests` - Only MouseUtils UI tests
- `['UITests-FancyZones', 'MouseUtils.UITests']` - Multiple specific
modules
  - Leave empty to build and run all UI test modules

### Build Modes

1. **Official Build + Selective Testing** (`useLatestOfficialBuild =
true`)
   - Downloads and installs official PowerToys build
   - Builds only specified UI test projects
   - Runs specified UI tests against installed PowerToys
   - Controlled by `uiTestModules` parameter

2. **Full Build + Testing** (`useLatestOfficialBuild = false`)
   - Builds entire PowerToys solution
   - Builds UI test projects (all or specific based on `uiTestModules`)
   - Runs UI tests (all or specific based on `uiTestModules`)
   - Uses freshly built PowerToys for testing
  
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-07-10 09:26:26 +08:00
Mike Griese
608eb1e034 CmdPal: Pull out VM bits from ShellPage.xaml.cs (#40479)
ref #40113

Moves a lot of the "model" logic out of `ShellPage.xaml.cs` into
`ShellViewModel`.

The LARGE majority of this code is copy-paste moving code. We're now
using a couple more messages to pass navigation between the VM and the
page. And a couple new messages for passing ETW events.
2025-07-09 18:49:21 -05:00
Niels Laute
f341aeb627 [UX] Improved formatting for release notes (#40320)
## Summary of the Pull Request

Before vs after:
![Screenshot 2025-07-01
130138](https://github.com/user-attachments/assets/f850298f-fe0b-43c1-9bae-eee712dec5e8)

## PR Checklist

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-07-09 16:19:15 -05:00
Mike Griese
ee764d5f56 cmdpal: Re-re-enable the clipboard history (#40471)
_⚠️ targets #40445_

This time, for real

This really really re-enables the clipboard history command. With the
foreground fixes from #40445, we can properly dismiss ourself to give FG
to the next app window. This actually lets us paste correctly.


I took the liberty of localizing the strings and fixing up the icons
while I was at it.

Closes #38344
2025-07-09 15:42:46 -05:00
Leilei Zhang
40a1729462 update the timeout for pipeline 2025-07-09 21:40:17 +08:00
72 changed files with 1244 additions and 2148 deletions

View File

@@ -76,6 +76,7 @@ ARPINSTALLLOCATION
ARPPRODUCTICON
ARRAYSIZE
ARROWKEYS
ARTIFACTSTAGINGDIRECTORY
asf
Ashcraft
AShortcut
@@ -1722,6 +1723,8 @@ UFlags
UHash
UIA
UIEx
uild
uitests
ULONGLONG
ums
uncompilable
@@ -1748,6 +1751,7 @@ Uptool
urld
Usb
USEDEFAULT
USEINSTALLERFORTEST
USEFILEATTRIBUTES
USESHOWWINDOW
USESTDHANDLES

View File

@@ -0,0 +1,46 @@
param(
[Parameter()]
[ValidateSet("Machine", "PerUser")]
[string]$InstallMode = "Machine"
)
$ProgressPreference = 'SilentlyContinue'
# Get artifact path
$ArtifactPath = $ENV:BUILD_ARTIFACTSTAGINGDIRECTORY
if (-not $ArtifactPath) {
throw "BUILD_ARTIFACTSTAGINGDIRECTORY environment variable not set"
}
# Since we only download PowerToysSetup-*.exe files, we can directly find it
$Installer = Get-ChildItem -Path $ArtifactPath -Filter 'PowerToys*.exe' | Select-Object -First 1
if (-not $Installer) {
throw "PowerToys installer not found"
}
Write-Host "Installing PowerToys: $($Installer.Name)"
# Install PowerToys
$Process = Start-Process -Wait -FilePath $Installer.FullName -ArgumentList "/passive", "/norestart" -PassThru -NoNewWindow
if ($Process.ExitCode -eq 0 -or $Process.ExitCode -eq 3010) {
Write-Host "✅ PowerToys installation completed successfully"
} else {
throw "PowerToys installation failed with exit code: $($Process.ExitCode)"
}
# Verify installation
if ($InstallMode -eq "PerUser") {
if (Test-Path "${env:LOCALAPPDATA}\PowerToys\PowerToys.exe") {
Write-Host "✅ PowerToys verified at: ${env:LOCALAPPDATA}\PowerToys\PowerToys.exe"
} else {
throw "PowerToys installation verification failed"
}
} else {
if (Test-Path "${env:ProgramFiles}\PowerToys\PowerToys.exe") {
Write-Host "✅ PowerToys verified at: ${env:ProgramFiles}\PowerToys\PowerToys.exe"
} else {
throw "PowerToys installation verification failed"
}
}

View File

@@ -43,6 +43,7 @@ stages:
enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }}
runTests: true
useVSPreview: ${{ parameters.useVSPreview }}
timeoutInMinutes: 90
- stage: OneFuzz
displayName: Fuzz ${{ parameters.platform }}

View File

@@ -81,6 +81,12 @@ parameters:
- 'src/modules/previewpane/SvgPreviewHandler/SvgPreviewHandler.csproj'
- 'src/modules/previewpane/SvgThumbnailProvider/SvgThumbnailProvider.csproj'
- 'src/modules/FileLocksmith/FileLocksmithUI/FileLocksmithUI.csproj'
- name: timeoutInMinutes
type: number
default: 240
- name: cancelTimeoutInMinutes
type: number
default: 1
jobs:
- job: ${{ parameters.jobName }}
@@ -123,8 +129,8 @@ jobs:
${{ else }}:
RestoreAdditionalProjectSourcesArg: ''
displayName: Build
timeoutInMinutes: 240
cancelTimeoutInMinutes: 1
timeoutInMinutes: ${{ parameters.timeoutInMinutes }}
cancelTimeoutInMinutes: ${{ parameters.cancelTimeoutInMinutes }}
templateContext: # Required when this template is hosted in 1ES PT
outputs:
- output: pipelineArtifact

View File

@@ -0,0 +1,132 @@
# Minimal UI Tests Build Template
# This template only builds UI test projects and stages their test DLLs for consumption by test pipelines
parameters:
- name: buildConfigurations
type: object
default:
- Release
- name: buildPlatforms
type: object
default:
- x64
- name: condition
type: string
default: ''
- name: dependsOn
type: object
default: []
- name: pool
type: object
default: []
- name: variables
type: object
default: {}
- name: uiTestModules
type: object
default: []
jobs:
- job: BuildUITests
${{ if ne(length(parameters.pool), 0) }}:
pool: ${{ parameters.pool }}
dependsOn: ${{ parameters.dependsOn }}
condition: ${{ parameters.condition }}
strategy:
matrix:
${{ each config in parameters.buildConfigurations }}:
${{ each platform in parameters.buildPlatforms }}:
${{ config }}_${{ platform }}:
BuildConfiguration: ${{ config }}
BuildPlatform: ${{ platform }}
variables:
JobOutputDirectory: $(Build.ArtifactStagingDirectory)
LogOutputDirectory: $(Build.ArtifactStagingDirectory)\logs
JobOutputArtifactName: build-$(BuildPlatform)-$(BuildConfiguration)
NUGET_RESTORE_MSBUILD_ARGS: /p:Platform=$(BuildPlatform)
${{ insert }}: ${{ parameters.variables }}
displayName: Build UI Tests Only
timeoutInMinutes: 60
cancelTimeoutInMinutes: 1
templateContext:
outputs:
- output: pipelineArtifact
artifactName: $(JobOutputArtifactName)
targetPath: $(Build.ArtifactStagingDirectory)
steps:
- checkout: self
clean: true
submodules: true
persistCredentials: True
fetchTags: false
fetchDepth: 1
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '9.0'
- template: .\steps-restore-nuget.yml
- task: NuGetCommand@2
displayName: Restore solution-level NuGet packages
inputs:
command: restore
feedsToUse: config
configPath: nuget.config
restoreSolution: PowerToys.sln
restoreDirectory: '$(Build.SourcesDirectory)\packages'
# Build all UI test projects if no specific modules are specified
- ${{ if eq(length(parameters.uiTestModules), 0) }}:
- task: VSBuild@1
displayName: Build UI Test Projects
inputs:
solution: '**/*UITest*.csproj'
vsVersion: 17.0
msbuildArgs: >-
-restore
-graph
/p:RestorePackagesConfig=true
/p:BuildProjectReferences=true
/p:CIBuild=true
/bl:$(LogOutputDirectory)\build-all-uitests.binlog
$(NUGET_RESTORE_MSBUILD_ARGS)
platform: $(BuildPlatform)
configuration: $(BuildConfiguration)
msbuildArchitecture: x64
maximumCpuCount: true
# Build specific UI test modules
- ${{ if ne(length(parameters.uiTestModules), 0) }}:
- ${{ each module in parameters.uiTestModules }}:
- task: VSBuild@1
displayName: 'Build UI Test Module: ${{ module }}'
inputs:
solution: '**/*${{ module }}*.csproj'
vsVersion: 17.0
msbuildArgs: >-
-restore
-graph
/p:RestorePackagesConfig=true
/p:BuildProjectReferences=true
/p:CIBuild=true
/bl:$(LogOutputDirectory)\build-${{ module }}.binlog
$(NUGET_RESTORE_MSBUILD_ARGS)
platform: $(BuildPlatform)
configuration: $(BuildConfiguration)
msbuildArchitecture: x64
maximumCpuCount: true
# Stage test project outputs with directory structure
- task: CopyFiles@2
displayName: Stage UI Test Build Outputs
inputs:
sourceFolder: '$(Build.SourcesDirectory)'
contents: '$(BuildPlatform)/$(BuildConfiguration)/**/*'
targetFolder: '$(JobOutputDirectory)\$(BuildPlatform)\$(BuildConfiguration)'
- publish: $(JobOutputDirectory)
artifact: $(JobOutputArtifactName)
displayName: Publish UI Test artifacts
condition: always()

View File

@@ -11,10 +11,28 @@ parameters:
- name: useLatestWebView2
type: boolean
default: false
- name: useLatestOfficialBuild
type: boolean
default: true
- name: useCurrentBranchBuild
type: boolean
default: false
- name: uiTestModules
type: object
default: []
- name: installMode
type: string
default: 'machine'
values:
- 'machine'
- 'peruser'
- name: jobSuffix
type: string
default: ''
jobs:
- job: Test${{ parameters.platform }}${{ parameters.configuration }}
displayName: Test ${{ parameters.platform }} ${{ parameters.configuration }}
- job: Test${{ parameters.platform }}${{ parameters.configuration }}${{ parameters.jobSuffix }}
displayName: Test ${{ parameters.platform }} ${{ parameters.configuration }}${{ parameters.jobSuffix }}
timeoutInMinutes: 300
variables:
${{ if or(eq(parameters.platform, 'x64Win10'), eq(parameters.platform, 'x64Win11')) }}:
@@ -95,28 +113,80 @@ jobs:
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppDriver.ps1'
displayName: Download and install WinAppDriver
- ${{ if eq(parameters.useLatestOfficialBuild, true) }}:
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'specific'
project: 'Dart'
definition: '76541'
buildVersionToDownload: 'latestFromBranch'
${{ if eq(parameters.useCurrentBranchBuild, true) }}:
branchName: '$(Build.SourceBranch)'
${{ else }}:
branchName: 'refs/heads/main'
artifactName: 'build-$(BuildPlatform)-Release'
targetPath: '$(Build.ArtifactStagingDirectory)'
${{ if eq(parameters.installMode, 'peruser') }}:
patterns: |
**/PowerToysUserSetup*.exe
${{ else }}:
patterns: |
**/PowerToysSetup*.exe
- ${{ if eq(parameters.useLatestOfficialBuild, true) }}:
- ${{ if eq(parameters.installMode, 'peruser') }}:
- pwsh: |-
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "PerUser"
displayName: Install PowerToys (Per-User)
- ${{ if eq(parameters.installMode, 'machine') }}:
- pwsh: |-
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine"
displayName: Install PowerToys (Machine-Level)
- ${{ if ne(parameters.platform, 'arm64') }}:
- task: ScreenResolutionUtility@1
inputs:
displaySettings: 'optimal'
- task: VSTest@3
displayName: Run UI Tests
inputs:
platform: '$(BuildPlatform)'
configuration: '$(BuildConfiguration)'
testSelector: 'testAssemblies'
searchFolder: '$(Pipeline.Workspace)\$(TestArtifactsName)'
vsTestVersion: 'toolsInstaller'
uiTests: true
rerunFailedTests: true
# Since UITests-FancyZonesEditor.dll is generated in both UITests-FancyZonesEditor and UITests-FancyZones, removed one to avoid duplicate test runs
testAssemblyVer2: |
**\*UITest*.dll
!**\obj\**
!**\ref\**
!**\UITests-FancyZones\**\UITests-FancyZonesEditor.dll
- ${{ if eq(length(parameters.uiTestModules), 0) }}:
- task: VSTest@3
displayName: Run UI Tests
inputs:
platform: '$(BuildPlatform)'
configuration: '$(BuildConfiguration)'
testSelector: 'testAssemblies'
searchFolder: '$(Pipeline.Workspace)\$(TestArtifactsName)'
vsTestVersion: 'toolsInstaller'
uiTests: true
rerunFailedTests: true
# Since UITests-FancyZonesEditor.dll is generated in both UITests-FancyZonesEditor and UITests-FancyZones, removed one to avoid duplicate test runs
testAssemblyVer2: |
**\*UITest*.dll
!**\obj\**
!**\ref\**
!**\UITests-FancyZones\**\UITests-FancyZonesEditor.dll
env:
platform: '$(TestPlatform)'
useInstallerForTest: ${{ parameters.useLatestOfficialBuild }}
env:
platform: '$(TestPlatform)'
- ${{ if ne(length(parameters.uiTestModules), 0) }}:
- ${{ each module in parameters.uiTestModules }}:
- task: VSTest@3
displayName: Run UI Test - ${{ module }}
inputs:
platform: '$(BuildPlatform)'
configuration: '$(BuildConfiguration)'
testSelector: 'testAssemblies'
searchFolder: '$(Pipeline.Workspace)\$(TestArtifactsName)'
vsTestVersion: 'toolsInstaller'
uiTests: true
rerunFailedTests: true
testAssemblyVer2: |
**\*${{ module }}*.dll
!**\obj\**
!**\ref\**
!**\UITests-FancyZones\**\UITests-FancyZonesEditor.dll
env:
platform: '$(TestPlatform)'
useInstallerForTest: ${{ parameters.useLatestOfficialBuild }}

View File

@@ -60,4 +60,5 @@ stages:
useLatestWinAppSDK: ${{ parameters.useLatestWinAppSDK }}
${{ if eq(parameters.useLatestWinAppSDK, true) }}:
winAppSDKVersionNumber: ${{ parameters.winAppSDKVersionNumber }}
useExperimentalVersion: ${{ parameters.useExperimentalVersion }}
useExperimentalVersion: ${{ parameters.useExperimentalVersion }}
timeoutInMinutes: 90

View File

@@ -22,63 +22,155 @@ parameters:
- name: useLatestWebView2
type: boolean
default: false
- name: useLatestOfficialBuild
type: boolean
default: true
- name: testBothInstallModes
type: boolean
default: true
- name: useCurrentBranchBuild
type: boolean
default: false
- name: uiTestModules
type: object
default: []
stages:
- ${{ each platform in parameters.buildPlatforms }}:
- stage: Build_${{ platform }}
displayName: Build ${{ platform }}
dependsOn: []
jobs:
- template: job-build-project.yml
parameters:
pool:
${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}:
name: SHINE-INT-L
${{ else }}:
name: SHINE-OSS-L
${{ if eq(parameters.useVSPreview, true) }}:
demands: ImageOverride -equals SHINE-VS17-Preview
buildPlatforms:
- ${{ platform }}
buildConfigurations: [Release]
enablePackageCaching: true
enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }}
runTests: false
buildTests: true
useVSPreview: ${{ parameters.useVSPreview }}
- ${{ if eq(parameters.useLatestOfficialBuild, false) }}:
- stage: Build_${{ platform }}
displayName: Build ${{ platform }}
dependsOn: []
jobs:
- template: job-build-project.yml
parameters:
pool:
${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}:
name: SHINE-INT-L
${{ else }}:
name: SHINE-OSS-L
${{ if eq(parameters.useVSPreview, true) }}:
demands: ImageOverride -equals SHINE-VS17-Preview
buildPlatforms:
- ${{ platform }}
buildConfigurations: [Release]
enablePackageCaching: true
enableMsBuildCaching: ${{ parameters.enableMsBuildCaching }}
runTests: false
buildTests: true
useVSPreview: ${{ parameters.useVSPreview }}
timeoutInMinutes: 90
- ${{ if eq(parameters.useLatestOfficialBuild, true) }}:
- stage: BuildUITests_${{ platform }}
displayName: Build UI Tests Only
dependsOn: []
jobs:
- template: job-build-ui-tests.yml
parameters:
pool:
${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}:
name: SHINE-INT-L
${{ else }}:
name: SHINE-OSS-L
${{ if eq(parameters.useVSPreview, true) }}:
demands: ImageOverride -equals SHINE-VS17-Preview
buildPlatforms:
- ${{ platform }}
uiTestModules: ${{ parameters.uiTestModules }}
- ${{ if eq(platform, 'x64') }}:
- stage: Test_x64Win10
displayName: Test x64Win10
dependsOn:
- Build_${{platform}}
${{ if eq(parameters.useLatestOfficialBuild, true) }}:
dependsOn:
- BuildUITests_${{ platform }}
${{ else }}:
dependsOn:
- Build_${{ platform }}
jobs:
- template: job-test-project.yml
parameters:
platform: x64Win10
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
useLatestOfficialBuild: ${{ parameters.useLatestOfficialBuild }}
useCurrentBranchBuild: ${{ parameters.useCurrentBranchBuild }}
uiTestModules: ${{ parameters.uiTestModules }}
# Additional per-user installation test (when both modes are enabled)
- ${{ if and(eq(parameters.useLatestOfficialBuild, true), eq(parameters.testBothInstallModes, true)) }}:
- template: job-test-project.yml
parameters:
platform: x64Win10
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
useLatestOfficialBuild: ${{ parameters.useLatestOfficialBuild }}
useCurrentBranchBuild: ${{ parameters.useCurrentBranchBuild }}
uiTestModules: ${{ parameters.uiTestModules }}
installMode: 'peruser'
jobSuffix: '_PerUser'
- ${{ if eq(platform, 'x64') }}:
- stage: Test_x64Win11
displayName: Test x64Win11
dependsOn:
- Build_${{platform}}
${{ if eq(parameters.useLatestOfficialBuild, true) }}:
dependsOn:
- BuildUITests_${{ platform }}
${{ else }}:
dependsOn:
- Build_${{ platform }}
jobs:
- template: job-test-project.yml
parameters:
platform: x64Win11
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
useLatestOfficialBuild: ${{ parameters.useLatestOfficialBuild }}
useCurrentBranchBuild: ${{ parameters.useCurrentBranchBuild }}
uiTestModules: ${{ parameters.uiTestModules }}
# Additional per-user installation test (when both modes are enabled)
- ${{ if and(eq(parameters.useLatestOfficialBuild, true), eq(parameters.testBothInstallModes, true)) }}:
- template: job-test-project.yml
parameters:
platform: x64Win11
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
useLatestOfficialBuild: ${{ parameters.useLatestOfficialBuild }}
useCurrentBranchBuild: ${{ parameters.useCurrentBranchBuild }}
uiTestModules: ${{ parameters.uiTestModules }}
installMode: 'peruser'
jobSuffix: '_PerUser'
- ${{ if ne(platform, 'x64') }}:
- stage: Test_${{ platform }}
displayName: Test ${{ platform }}
dependsOn:
- Build_${{platform}}
${{ if eq(parameters.useLatestOfficialBuild, true) }}:
dependsOn:
- BuildUITests_${{ platform }}
${{ else }}:
dependsOn:
- Build_${{ platform }}
jobs:
- template: job-test-project.yml
parameters:
platform: ${{ platform }}
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
useLatestOfficialBuild: ${{ parameters.useLatestOfficialBuild }}
useCurrentBranchBuild: ${{ parameters.useCurrentBranchBuild }}
uiTestModules: ${{ parameters.uiTestModules }}
# Additional per-user installation test (when both modes are enabled)
- ${{ if and(eq(parameters.useLatestOfficialBuild, true), eq(parameters.testBothInstallModes, true)) }}:
- template: job-test-project.yml
parameters:
platform: ${{ platform }}
configuration: Release
useLatestWebView2: ${{ parameters.useLatestWebView2 }}
useLatestOfficialBuild: ${{ parameters.useLatestOfficialBuild }}
useCurrentBranchBuild: ${{ parameters.useCurrentBranchBuild }}
uiTestModules: ${{ parameters.uiTestModules }}
installMode: 'peruser'
jobSuffix: '_PerUser'

View File

@@ -16,6 +16,56 @@
- Run tests in the Test Explorer (`Test > Test Explorer` or `Ctrl+E, T`).
## Running tests in pipeline
The PowerToys UI test pipeline provides flexible options for building and testing:
### Pipeline Options
- **useLatestOfficialBuild**: When checked, downloads the latest official PowerToys build and installs it for testing. This skips the full solution build and only builds UI test projects.
- **useCurrentBranchBuild**: When checked along with `useLatestOfficialBuild`, downloads the official build from the current branch instead of main.
**Default value**: `false` (downloads from main branch)
**When to use this**:
- **Default scenario**: The pipeline tests against the latest signed PowerToys build from the `main` branch, regardless of which branch your test code changes are from
- **Custom branch testing**: Only specify `true` when:
- Your branch has produced its own signed PowerToys build via the official build pipeline
- You want to test against that specific branch's PowerToys build instead of main
- You are testing PowerToys functionality changes that are only available in your branch's build
**Important notes**:
- The test pipeline itself runs from your specified branch, but by default tests against the main branch's PowerToys build
- Not all branches have signed builds available - only use this if you're certain your branch has a signed build
- If enabled but no build exists for your branch, the pipeline may fail or fall back to main
- **uiTestModules**: Specify which UI test modules to build and run. This parameter controls both the `.csproj` projects to build and the `.dll` test assemblies to execute. Examples:
- `['UITests-FancyZones']` - Only FancyZones UI tests
- `['MouseUtils.UITests']` - Only MouseUtils UI tests
- `['UITests-FancyZones', 'MouseUtils.UITests']` - Multiple specific modules
- Leave empty to build and run all UI test modules
**Important**: The `uiTestModules` parameter values must match both the test project names (for `.csproj` selection during build) and the test assembly names (for `.dll` execution during testing).
### Build Modes
1. **Official Build + Selective Testing** (`useLatestOfficialBuild = true`)
- Downloads and installs official PowerToys build
- Builds only specified UI test projects
- Runs specified UI tests against installed PowerToys
- Controlled by `uiTestModules` parameter
2. **Full Build + Testing** (`useLatestOfficialBuild = false`)
- Builds entire PowerToys solution
- Builds UI test projects (all or specific based on `uiTestModules`)
- Runs UI tests (all or specific based on `uiTestModules`)
- Uses freshly built PowerToys for testing
> **Note**: Both modes support the `uiTestModules` parameter to control which specific UI test modules to build and run.
### Pipeline Access
- Pipeline: https://microsoft.visualstudio.com/Dart/_build?definitionId=161438&_a=summary
## How to add the first UI tests for your modules

View File

@@ -77,7 +77,7 @@ namespace Microsoft.PowerToys.UITest
internal class ModuleConfigData
{
private Dictionary<PowerToysModule, string> ModulePath { get; }
private Dictionary<PowerToysModule, ModuleInfo> ModuleInfo { get; }
// Singleton instance of ModuleConfigData.
private static readonly Lazy<ModuleConfigData> SingletonInstance = new Lazy<ModuleConfigData>(() => new ModuleConfigData());
@@ -86,37 +86,74 @@ namespace Microsoft.PowerToys.UITest
public const string WindowsApplicationDriverUrl = "http://127.0.0.1:4723";
public Dictionary<PowerToysModule, string> ModuleWindowName { get; }
private bool UseInstallerForTest { get; }
private ModuleConfigData()
{
// The exe window name for each module.
ModuleWindowName = new Dictionary<PowerToysModule, string>
{
[PowerToysModule.PowerToysSettings] = "PowerToys Settings",
[PowerToysModule.FancyZone] = "FancyZones Layout",
[PowerToysModule.Hosts] = "Hosts File Editor",
[PowerToysModule.Runner] = "PowerToys",
[PowerToysModule.Workspaces] = "Workspaces Editor",
[PowerToysModule.PowerRename] = "PowerRename",
};
// Check if we should use installer paths from environment variable
string? useInstallerForTestEnv =
Environment.GetEnvironmentVariable("useInstallerForTest") ?? Environment.GetEnvironmentVariable("USEINSTALLERFORTEST");
UseInstallerForTest = !string.IsNullOrEmpty(useInstallerForTestEnv) && bool.TryParse(useInstallerForTestEnv, out bool result) && result;
// Exe start path for the module if it exists.
ModulePath = new Dictionary<PowerToysModule, string>
// Module information including executable name, window name, and optional subdirectory
ModuleInfo = new Dictionary<PowerToysModule, ModuleInfo>
{
[PowerToysModule.PowerToysSettings] = @"\..\..\..\WinUI3Apps\PowerToys.Settings.exe",
[PowerToysModule.FancyZone] = @"\..\..\..\PowerToys.FancyZonesEditor.exe",
[PowerToysModule.Hosts] = @"\..\..\..\WinUI3Apps\PowerToys.Hosts.exe",
[PowerToysModule.Runner] = @"\..\..\..\PowerToys.exe",
[PowerToysModule.Workspaces] = @"\..\..\..\PowerToys.WorkspacesEditor.exe",
[PowerToysModule.PowerRename] = @"\..\..\..\WinUI3Apps\PowerToys.PowerRename.exe",
[PowerToysModule.PowerToysSettings] = new ModuleInfo("PowerToys.Settings.exe", "PowerToys Settings", "WinUI3Apps"),
[PowerToysModule.FancyZone] = new ModuleInfo("PowerToys.FancyZonesEditor.exe", "FancyZones Layout"),
[PowerToysModule.Hosts] = new ModuleInfo("PowerToys.Hosts.exe", "Hosts File Editor", "WinUI3Apps"),
[PowerToysModule.Runner] = new ModuleInfo("PowerToys.exe", "PowerToys"),
[PowerToysModule.Workspaces] = new ModuleInfo("PowerToys.WorkspacesEditor.exe", "Workspaces Editor"),
[PowerToysModule.PowerRename] = new ModuleInfo("PowerToys.PowerRename.exe", "PowerRename", "WinUI3Apps"),
};
}
public string GetModulePath(PowerToysModule scope) => ModulePath[scope];
private string GetPowerToysInstallPath()
{
// Try common installation paths
string[] possiblePaths =
{
@"C:\Program Files\PowerToys",
@"C:\Program Files (x86)\PowerToys",
Environment.ExpandEnvironmentVariables(@"%LocalAppData%\PowerToys"),
Environment.ExpandEnvironmentVariables(@"%ProgramFiles%\PowerToys"),
};
foreach (string path in possiblePaths)
{
if (Directory.Exists(path) && File.Exists(Path.Combine(path, "PowerToys.exe")))
{
return path;
}
}
// Fallback to Program Files if not found
return @"C:\Program Files\PowerToys";
}
public string GetModulePath(PowerToysModule scope)
{
var moduleInfo = ModuleInfo[scope];
if (UseInstallerForTest)
{
string powerToysInstallPath = GetPowerToysInstallPath();
string installedPath = moduleInfo.GetInstalledPath(powerToysInstallPath);
if (File.Exists(installedPath))
{
return installedPath;
}
else
{
Console.WriteLine($"Warning: Installed module not found at {installedPath}, using development path");
}
}
return moduleInfo.GetDevelopmentPath();
}
public string GetWindowsApplicationDriverUrl() => WindowsApplicationDriverUrl;
public string GetModuleWindowName(PowerToysModule scope) => ModuleWindowName[scope];
public string GetModuleWindowName(PowerToysModule scope) => ModuleInfo[scope].WindowName;
}
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Microsoft.PowerToys.UITest
{
internal class ModuleInfo
{
public string ExecutableName { get; }
public string? SubDirectory { get; }
public string WindowName { get; }
public ModuleInfo(string executableName, string windowName, string? subDirectory = null)
{
ExecutableName = executableName;
WindowName = windowName;
SubDirectory = subDirectory;
}
/// <summary>
/// Gets the relative development path for this module
/// </summary>
public string GetDevelopmentPath()
{
if (string.IsNullOrEmpty(SubDirectory))
{
return $@"\..\..\..\{ExecutableName}";
}
return $@"\..\..\..\{SubDirectory}\{ExecutableName}";
}
/// <summary>
/// Gets the installed path for this module based on the PowerToys install directory
/// </summary>
public string GetInstalledPath(string powerToysInstallPath)
{
if (string.IsNullOrEmpty(SubDirectory))
{
return Path.Combine(powerToysInstallPath, ExecutableName);
}
return Path.Combine(powerToysInstallPath, SubDirectory, ExecutableName);
}
}
}

View File

@@ -37,13 +37,18 @@ namespace Microsoft.PowerToys.UITest
private PowerToysModule scope;
private string[]? commandLineArgs;
private bool UseInstallerForTest { get; }
[UnconditionalSuppressMessage("SingleFile", "IL3000:Avoid accessing Assembly file path when publishing as a single file", Justification = "<Pending>")]
public SessionHelper(PowerToysModule scope, string[]? commandLineArgs = null)
{
this.scope = scope;
this.commandLineArgs = commandLineArgs;
this.sessionPath = ModuleConfigData.Instance.GetModulePath(scope);
this.locationPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
string? useInstallerForTestEnv =
Environment.GetEnvironmentVariable("useInstallerForTest") ?? Environment.GetEnvironmentVariable("USEINSTALLERFORTEST");
UseInstallerForTest = !string.IsNullOrEmpty(useInstallerForTestEnv) && bool.TryParse(useInstallerForTestEnv, out bool result) && result;
this.locationPath = UseInstallerForTest ? string.Empty : Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
CheckWinAppDriverAndRoot();
}
@@ -156,7 +161,7 @@ namespace Microsoft.PowerToys.UITest
if (root != null)
{
const int maxRetries = 3;
const int maxRetries = 5;
const int delayMs = 5000;
var windowName = "PowerToys Settings";

View File

@@ -15,7 +15,7 @@
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<PropertyGroup>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Hosts.UITests\</OutputPath>
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)\tests\Hosts.UITests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Baseline\HostModuleTests_TestAddingEntry_arm64.png" />

View File

@@ -1,27 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace Microsoft.CmdPal.Common.Services;
public interface IRunHistoryService
{
/// <summary>
/// Gets the run history.
/// </summary>
/// <returns>A list of run history items.</returns>
IReadOnlyList<string> GetRunHistory();
/// <summary>
/// Clears the run history.
/// </summary>
void ClearRunHistory();
/// <summary>
/// Adds a run history item.
/// </summary>
/// <param name="item">The run history item to add.</param>
void AddRunHistoryItem(string item);
}

View File

@@ -21,12 +21,8 @@ public partial class AppStateModel : ObservableObject
///////////////////////////////////////////////////////////////////////////
// STATE HERE
// Make sure that you make the setters public (JsonSerializer.Deserialize will fail silently otherwise)!
// Make sure that any new types you add are added to JsonSerializationContext!
public RecentCommandsManager RecentCommands { get; set; } = new();
public List<string> RunHistory { get; set; } = [];
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
@@ -90,7 +86,7 @@ public partial class AppStateModel : ObservableObject
{
foreach (var item in newSettings)
{
savedSettings[item.Key] = item.Value?.DeepClone();
savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null;
}
var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.AppStateModel.Options);
@@ -125,4 +121,20 @@ public partial class AppStateModel : ObservableObject
// now, the settings is just next to the exe
return Path.Combine(directory, "state.json");
}
// [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "<Pending>")]
// private static readonly JsonSerializerOptions _serializerOptions = new()
// {
// WriteIndented = true,
// Converters = { new JsonStringEnumConverter() },
// };
// private static readonly JsonSerializerOptions _deserializerOptions = new()
// {
// PropertyNameCaseInsensitive = true,
// IncludeFields = true,
// AllowTrailingCommas = true,
// PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate,
// ReadCommentHandling = JsonCommentHandling.Skip,
// };
}

View File

@@ -222,7 +222,7 @@ public partial class MainListPage : DynamicListPage,
{
nameMatch,
descriptionMatch,
isFallback ? 1 : 0, // Always give fallbacks a chance
isFallback ? 1 : 0, // Always give fallbacks a chance...
};
var max = scores.Max();
@@ -232,7 +232,8 @@ public partial class MainListPage : DynamicListPage,
// above "git" from "whatever"
max = max + extensionTitleMatch;
var matchSomething = max
// ... but downweight them
var matchSomething = (max / (isFallback ? 3 : 1))
+ (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0));
// If we matched title, subtitle, or alias (something real), then

View File

@@ -56,8 +56,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
public CommandItemViewModel EmptyContent { get; private set; }
public bool IsMainPage { get; init; }
private bool _isDynamic;
private Task? _initializeItemsTask;
@@ -160,7 +158,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
// Cancel any ongoing search
_cancellationTokenSource?.Cancel();
if (_cancellationTokenSource != null)
{
_cancellationTokenSource.Cancel();
}
lock (_listLock)
{
@@ -372,7 +373,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
TextToSuggest = item.TextToSuggest;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
});
_lastSelectedItem = item;
@@ -426,8 +426,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
TextToSuggest = string.Empty;
});
}

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.Messages;
public record BeginInvokeMessage;

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.Messages;
public record CmdPalInvokeResultMessage(Microsoft.CommandPalette.Extensions.CommandResultKind Kind);

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record GoBackMessage(bool WithAnimation = true, bool FocusSearch = true)
{
// TODO! sticking these properties here feels like leaking the UI into the models
}

View File

@@ -4,6 +4,7 @@
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record GoHomeMessage()
// TODO! sticking these properties here feels like leaking the UI into the models
public record GoHomeMessage(bool WithAnimation = true, bool FocusSearch = true)
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation)
{
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record ShowConfirmationMessage(Microsoft.CommandPalette.Extensions.IConfirmationArgs? Args)
{
}

View File

@@ -4,6 +4,6 @@
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record UpdateSuggestionMessage(string TextToSuggest)
public record ShowToastMessage(string Message)
{
}

View File

@@ -18,8 +18,14 @@ using WinRT;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskScheduler _scheduler) : ObservableObject
public partial class ShellViewModel : ObservableObject,
IRecipient<PerformCommandMessage>
{
private readonly IServiceProvider _serviceProvider;
private readonly TaskScheduler _scheduler;
private readonly Lock _invokeLock = new();
private Task? _handleInvokeTask;
[ObservableProperty]
public partial bool IsLoaded { get; set; } = false;
@@ -29,7 +35,7 @@ public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskSched
[ObservableProperty]
public partial bool IsDetailsVisible { get; set; }
private PageViewModel _currentPage = new LoadingPageViewModel(null, _scheduler);
private PageViewModel _currentPage;
public PageViewModel CurrentPage
{
@@ -57,6 +63,19 @@ public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskSched
private MainListPage? _mainListPage;
private IExtensionWrapper? _activeExtension;
private bool _isNested;
public bool IsNested { get => _isNested; }
public ShellViewModel(IServiceProvider serviceProvider, TaskScheduler scheduler)
{
_serviceProvider = serviceProvider;
_scheduler = scheduler;
_currentPage = new LoadingPageViewModel(null, _scheduler);
// Register to receive messages
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
}
[RelayCommand]
public async Task<bool> LoadAsync()
@@ -164,6 +183,241 @@ public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskSched
}
}
public void Receive(PerformCommandMessage message)
{
PerformCommand(message);
}
private void PerformCommand(PerformCommandMessage message)
{
var command = message.Command.Unsafe;
if (command == null)
{
return;
}
if (!CurrentPage.IsNested)
{
// on the main page here
PerformTopLevelCommand(message);
}
IExtensionWrapper? extension = null;
try
{
// In the case that we're coming from a top-level command, the
// current page's host is the global instance. We only really want
// to use that as the host of last resort.
var pageHost = CurrentPage?.ExtensionHost;
if (pageHost == CommandPaletteHost.Instance)
{
pageHost = null;
}
var messageHost = message.ExtensionHost;
// Use the host from the current page if it has one, else use the
// one specified in the PerformMessage for a top-level command,
// else just use the global one.
CommandPaletteHost host;
// Top level items can come through without a Extension set on the
// message. In that case, the `Context` is actually the
// TopLevelViewModel itself, and we can use that to get at the
// extension object.
extension = pageHost?.Extension ?? messageHost?.Extension ?? null;
if (extension == null && message.Context is TopLevelViewModel topLevelViewModel)
{
extension = topLevelViewModel.ExtensionHost?.Extension;
host = pageHost ?? messageHost ?? topLevelViewModel?.ExtensionHost ?? CommandPaletteHost.Instance;
}
else
{
host = pageHost ?? messageHost ?? CommandPaletteHost.Instance;
}
if (extension != null)
{
if (messageHost != null)
{
Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}");
}
else
{
Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}");
}
}
SetActiveExtension(extension);
if (command is IPage page)
{
Logger.LogDebug($"Navigating to page");
var isMainPage = command is MainListPage;
// Construct our ViewModel of the appropriate type and pass it the UI Thread context.
var pageViewModel = GetViewModelForPage(page, !isMainPage, host);
if (pageViewModel == null)
{
Logger.LogError($"Failed to create ViewModel for page {page.GetType().Name}");
throw new NotSupportedException();
}
// Kick off async loading of our ViewModel
LoadPageViewModel(pageViewModel);
_isNested = !isMainPage;
OnUIThread(() => { WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)); });
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation));
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
// See RootFrame_Navigated event handler.
}
else if (command is IInvokableCommand invokable)
{
Logger.LogDebug($"Invoking command");
WeakReferenceMessenger.Default.Send<BeginInvokeMessage>();
StartInvoke(message, invokable);
}
}
catch (Exception ex)
{
// TODO: It would be better to do this as a page exception, rather
// than a silent log message.
CommandPaletteHost.Instance.Log(ex.Message);
}
}
private void StartInvoke(PerformCommandMessage message, IInvokableCommand invokable)
{
// TODO GH #525 This needs more better locking.
lock (_invokeLock)
{
if (_handleInvokeTask != null)
{
// do nothing - a command is already doing a thing
}
else
{
_handleInvokeTask = Task.Run(() =>
{
SafeHandleInvokeCommandSynchronous(message, invokable);
});
}
}
}
private void SafeHandleInvokeCommandSynchronous(PerformCommandMessage message, IInvokableCommand invokable)
{
try
{
// Call out to extension process.
// * May fail!
// * May never return!
var result = invokable.Invoke(message.Context);
// But if it did succeed, we need to handle the result.
UnsafeHandleCommandResult(result);
_handleInvokeTask = null;
}
catch (Exception ex)
{
_handleInvokeTask = null;
// TODO: It would be better to do this as a page exception, rather
// than a silent log message.
CommandPaletteHost.Instance.Log(ex.Message);
}
}
private void UnsafeHandleCommandResult(ICommandResult? result)
{
if (result == null)
{
// No result, nothing to do.
return;
}
var kind = result.Kind;
Logger.LogDebug($"handling {kind.ToString()}");
WeakReferenceMessenger.Default.Send<CmdPalInvokeResultMessage>(new(kind));
switch (kind)
{
case CommandResultKind.Dismiss:
{
// Reset the palette to the main page and dismiss
GoHome(withAnimation: false, focusSearch: false);
WeakReferenceMessenger.Default.Send<DismissMessage>();
break;
}
case CommandResultKind.GoHome:
{
// Go back to the main page, but keep it open
GoHome();
break;
}
case CommandResultKind.GoBack:
{
GoBack();
break;
}
case CommandResultKind.Hide:
{
// Keep this page open, but hide the palette.
WeakReferenceMessenger.Default.Send<DismissMessage>();
break;
}
case CommandResultKind.KeepOpen:
{
// Do nothing.
break;
}
case CommandResultKind.Confirm:
{
if (result.Args is IConfirmationArgs a)
{
WeakReferenceMessenger.Default.Send<ShowConfirmationMessage>(new(a));
}
break;
}
case CommandResultKind.ShowToast:
{
if (result.Args is IToastArgs a)
{
WeakReferenceMessenger.Default.Send<ShowToastMessage>(new(a.Message));
UnsafeHandleCommandResult(a.Result);
}
break;
}
}
}
private PageViewModel? GetViewModelForPage(IPage page, bool nested, CommandPaletteHost host)
{
return page switch
{
IListPage listPage => new ListViewModel(listPage, _scheduler, host)
{
IsNested = nested,
},
IContentPage contentPage => new ContentPageViewModel(contentPage, _scheduler, host),
_ => null,
};
}
public void SetActiveExtension(IExtensionWrapper? extension)
{
if (extension != _activeExtension)
@@ -196,9 +450,15 @@ public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskSched
}
}
public void GoHome()
public void GoHome(bool withAnimation = true, bool focusSearch = true)
{
SetActiveExtension(null);
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(withAnimation, focusSearch));
}
public void GoBack(bool withAnimation = true, bool focusSearch = true)
{
WeakReferenceMessenger.Default.Send<GoBackMessage>(new(withAnimation, focusSearch));
}
// You may ask yourself, why aren't we using CsWin32 for this?
@@ -214,4 +474,13 @@ public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskSched
[SupportedOSPlatform("windows5.0")]
internal static extern unsafe global::Windows.Win32.Foundation.HRESULT CoAllowSetForegroundWindow(nint pUnk, [Optional] void* lpvReserved);
}
private void OnUIThread(Action action)
{
_ = Task.Factory.StartNew(
action,
CancellationToken.None,
TaskCreationOptions.None,
_scheduler);
}
}

View File

@@ -97,13 +97,10 @@ public partial class App : Application
// Built-in Commands. Order matters - this is the order they'll be presented by default.
var allApps = new AllAppsCommandProvider();
var files = new IndexerCommandsProvider();
files.SuppressFallbackWhen(ShellCommandsProvider.SuppressFileFallbackIf);
services.AddSingleton<ICommandProvider>(allApps);
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
services.AddSingleton<ICommandProvider>(files);
services.AddSingleton<ICommandProvider, IndexerCommandsProvider>();
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>();
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
@@ -146,7 +143,8 @@ public partial class App : Application
services.AddSingleton(state);
services.AddSingleton<IExtensionService, ExtensionService>();
services.AddSingleton<TrayIconService>();
services.AddSingleton<IRunHistoryService, RunHistoryService>();
services.AddSingleton(new TelemetryForwarder());
// ViewModels
services.AddSingleton<ShellViewModel>();

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.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.ViewModels;
@@ -20,7 +21,6 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class SearchBar : UserControl,
IRecipient<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateSuggestionMessage>,
ICurrentPageAware
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
@@ -31,10 +31,6 @@ public sealed partial class SearchBar : UserControl,
private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private bool _isBackspaceHeld;
private bool _inSuggestion;
private string? _lastText;
private string? _deletedSuggestion;
public PageViewModel? CurrentPageViewModel
{
get => (PageViewModel?)GetValue(CurrentPageViewModelProperty);
@@ -73,7 +69,6 @@ public sealed partial class SearchBar : UserControl,
this.InitializeComponent();
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this);
}
public void ClearSearch()
@@ -130,6 +125,15 @@ public sealed partial class SearchBar : UserControl,
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));
e.Handled = true;
}
else if (e.Key == VirtualKey.Right)
{
if (CurrentPageViewModel != null && !string.IsNullOrEmpty(CurrentPageViewModel.TextToSuggest))
{
FilterBox.Text = CurrentPageViewModel.TextToSuggest;
FilterBox.Select(FilterBox.Text.Length, 0);
e.Handled = true;
}
}
else if (e.Key == VirtualKey.Escape)
{
if (string.IsNullOrEmpty(FilterBox.Text))
@@ -200,65 +204,12 @@ public sealed partial class SearchBar : UserControl,
e.Handled = true;
}
else if (e.Key == VirtualKey.Right)
{
if (_inSuggestion)
{
_inSuggestion = false;
_lastText = null;
DoFilterBoxUpdate();
}
}
else if (e.Key == VirtualKey.Down)
{
WeakReferenceMessenger.Default.Send<NavigateNextCommand>();
e.Handled = true;
}
if (_inSuggestion)
{
if (
e.Key == VirtualKey.Back ||
e.Key == VirtualKey.Delete
)
{
_deletedSuggestion = FilterBox.Text;
FilterBox.Text = _lastText ?? string.Empty;
FilterBox.Select(FilterBox.Text.Length, 0);
// Logger.LogInfo("deleting suggestion");
_inSuggestion = false;
_lastText = null;
e.Handled = true;
return;
}
var ignoreLeave =
e.Key == VirtualKey.Up ||
e.Key == VirtualKey.Down ||
e.Key == VirtualKey.RightMenu ||
e.Key == VirtualKey.LeftMenu ||
e.Key == VirtualKey.Menu ||
e.Key == VirtualKey.Shift ||
e.Key == VirtualKey.RightShift ||
e.Key == VirtualKey.LeftShift ||
e.Key == VirtualKey.RightControl ||
e.Key == VirtualKey.LeftControl ||
e.Key == VirtualKey.Control;
if (ignoreLeave)
{
return;
}
// Logger.LogInfo("leaving suggestion");
_inSuggestion = false;
_lastText = null;
}
}
private void FilterBox_PreviewKeyUp(object sender, KeyRoutedEventArgs e)
@@ -272,7 +223,7 @@ public sealed partial class SearchBar : UserControl,
private void FilterBox_TextChanged(object sender, TextChangedEventArgs e)
{
// Logger.LogInfo($"FilterBox_TextChanged: {FilterBox.Text}");
Debug.WriteLine($"FilterBox_TextChanged: {FilterBox.Text}");
// TERRIBLE HACK TODO GH #245
// There's weird wacky bugs with debounce currently. We're trying
@@ -281,22 +232,23 @@ public sealed partial class SearchBar : UserControl,
// (otherwise aliases just stop working)
if (FilterBox.Text.Length == 1)
{
DoFilterBoxUpdate();
if (CurrentPageViewModel != null)
{
CurrentPageViewModel.Filter = FilterBox.Text;
}
return;
}
if (_inSuggestion)
{
// Logger.LogInfo($"-- skipping, in suggestion --");
return;
}
// TODO: We could encapsulate this in a Behavior if we wanted to bind to the Filter property.
_debounceTimer.Debounce(
() =>
{
DoFilterBoxUpdate();
// Actually plumb Filtering to the view model
if (CurrentPageViewModel != null)
{
CurrentPageViewModel.Filter = FilterBox.Text;
}
},
//// Couldn't find a good recommendation/resource for value here. PT uses 50ms as default, so that is a reasonable default
//// This seems like a useful testing site for typing times: https://keyboardtester.info/keyboard-latency-test/
@@ -306,21 +258,6 @@ public sealed partial class SearchBar : UserControl,
immediate: FilterBox.Text.Length <= 1);
}
private void DoFilterBoxUpdate()
{
if (_inSuggestion)
{
// Logger.LogInfo($"--- skipping ---");
return;
}
// Actually plumb Filtering to the view model
if (CurrentPageViewModel != null)
{
CurrentPageViewModel.Filter = FilterBox.Text;
}
}
// Used to handle the case when a ListPage's `SearchText` may have changed
private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
@@ -340,8 +277,6 @@ public sealed partial class SearchBar : UserControl,
// ... Move the cursor to the end of the input
FilterBox.Select(FilterBox.Text.Length, 0);
}
// TODO! deal with suggestion
}
else if (property == nameof(ListViewModel.InitialSearchText))
{
@@ -359,96 +294,4 @@ public sealed partial class SearchBar : UserControl,
public void Receive(GoHomeMessage message) => ClearSearch();
public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
public void Receive(UpdateSuggestionMessage message)
{
var suggestion = message.TextToSuggest;
_queue.TryEnqueue(new(() =>
{
var clearSuggestion = string.IsNullOrEmpty(suggestion);
if (clearSuggestion && _inSuggestion)
{
// Logger.LogInfo($"Cleared suggestion \"{_lastText}\" to {suggestion}");
_inSuggestion = false;
FilterBox.Text = _lastText ?? string.Empty;
_lastText = null;
return;
}
if (clearSuggestion)
{
_deletedSuggestion = null;
return;
}
if (suggestion == _deletedSuggestion)
{
return;
}
else
{
_deletedSuggestion = null;
}
var currentText = _lastText ?? FilterBox.Text;
_lastText = currentText;
// if (_inSuggestion)
// {
// Logger.LogInfo($"Suggestion from \"{_lastText}\" to {suggestion}");
// }
// else
// {
// Logger.LogInfo($"Entering suggestion from \"{_lastText}\" to {suggestion}");
// }
_inSuggestion = true;
var matchedChars = 0;
var suggestionStartsWithQuote = suggestion.Length > 0 && suggestion[0] == '"';
var currentStartsWithQuote = currentText.Length > 0 && currentText[0] == '"';
var skipCheckingFirst = suggestionStartsWithQuote && !currentStartsWithQuote;
for (int i = skipCheckingFirst ? 1 : 0, j = 0;
i < suggestion.Length && j < currentText.Length;
i++, j++)
{
if (string.Equals(
suggestion[i].ToString(),
currentText[j].ToString(),
StringComparison.OrdinalIgnoreCase))
{
matchedChars++;
}
else
{
break;
}
}
var first = skipCheckingFirst ? "\"" : string.Empty;
var second = currentText.AsSpan(0, matchedChars);
var third = suggestion.AsSpan(matchedChars + (skipCheckingFirst ? 1 : 0));
var newText = string.Concat(
first,
second,
third);
FilterBox.Text = newText;
var wrappedInQuotes = suggestionStartsWithQuote && suggestion.Last() == '"';
if (wrappedInQuotes)
{
FilterBox.Select(
(skipCheckingFirst ? 1 : 0) + matchedChars,
Math.Max(0, suggestion.Length - matchedChars - 1 + (skipCheckingFirst ? -1 : 0)));
}
else
{
FilterBox.Select(matchedChars, suggestion.Length - matchedChars);
}
}));
}
}

View File

@@ -264,13 +264,6 @@ public sealed partial class ListPage : Page,
{
ItemsList.SelectedIndex = 0;
}
// Always reset the selected item when the top-level list page changes
// its items
if (!sender.IsNested)
{
ItemsList.SelectedIndex = 0;
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)

View File

@@ -0,0 +1,40 @@
// 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 CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.PowerToys.Telemetry;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// TelemetryForwarder is responsible for forwarding telemetry events from the
/// command palette core to PowerToys Telemetry.
/// This allows us to emit telemetry events as messages from the core,
/// and then handle them by logging to our PT telemetry provider.
///
/// We may in the future want to replace this with a more generic "ITelemetryService"
/// or something similar, but this works for now.
/// </summary>
internal sealed class TelemetryForwarder :
IRecipient<BeginInvokeMessage>,
IRecipient<CmdPalInvokeResultMessage>
{
public TelemetryForwarder()
{
WeakReferenceMessenger.Default.Register<BeginInvokeMessage>(this);
WeakReferenceMessenger.Default.Register<CmdPalInvokeResultMessage>(this);
}
public void Receive(CmdPalInvokeResultMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new CmdPalInvokeResult(message.Kind));
}
public void Receive(BeginInvokeMessage message)
{
PowerToysTelemetry.Log.WriteEvent(new BeginInvoke());
}
}

View File

@@ -6,11 +6,9 @@ using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.Settings;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
@@ -28,15 +26,18 @@ namespace Microsoft.CmdPal.UI.Pages;
/// </summary>
public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<NavigateBackMessage>,
IRecipient<PerformCommandMessage>,
IRecipient<OpenSettingsMessage>,
IRecipient<HotkeySummonMessage>,
IRecipient<ShowDetailsMessage>,
IRecipient<HideDetailsMessage>,
IRecipient<ClearSearchMessage>,
IRecipient<HandleCommandResultMessage>,
IRecipient<LaunchUriMessage>,
IRecipient<SettingsWindowClosedMessage>,
IRecipient<GoHomeMessage>,
IRecipient<GoBackMessage>,
IRecipient<ShowConfirmationMessage>,
IRecipient<ShowToastMessage>,
IRecipient<NavigateToPageMessage>,
INotifyPropertyChanged
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
@@ -50,8 +51,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private readonly ToastWindow _toast = new();
private readonly Lock _invokeLock = new();
private Task? _handleInvokeTask;
private SettingsWindow? _settingsWindow;
public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!;
@@ -64,8 +63,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// how we are doing navigation around
WeakReferenceMessenger.Default.Register<NavigateBackMessage>(this);
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
WeakReferenceMessenger.Default.Register<OpenSettingsMessage>(this);
WeakReferenceMessenger.Default.Register<HotkeySummonMessage>(this);
WeakReferenceMessenger.Default.Register<SettingsWindowClosedMessage>(this);
@@ -76,6 +73,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
WeakReferenceMessenger.Default.Register<LaunchUriMessage>(this);
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
WeakReferenceMessenger.Default.Register<GoBackMessage>(this);
WeakReferenceMessenger.Default.Register<ShowConfirmationMessage>(this);
WeakReferenceMessenger.Default.Register<ShowToastMessage>(this);
WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this);
RootFrame.Navigate(typeof(LoadingPage), ViewModel);
}
@@ -103,195 +106,72 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
public void Receive(PerformCommandMessage message)
public void Receive(NavigateToPageMessage message)
{
PerformCommand(message);
// TODO GH #526 This needs more better locking too
_ = _queue.TryEnqueue(() =>
{
// Also hide our details pane about here, if we had one
HideDetails();
// Navigate to the appropriate host page for that VM
RootFrame.Navigate(
message.Page switch
{
ListViewModel => typeof(ListPage),
ContentPageViewModel => typeof(ContentPage),
_ => throw new NotSupportedException(),
},
message.Page,
message.WithAnimation ? _slideRightTransition : _noAnimation);
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
// Refocus on the Search for continual typing on the next search request
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
if (!ViewModel.IsNested)
{
// todo BODGY
RootFrame.BackStack.Clear();
}
});
}
private void PerformCommand(PerformCommandMessage message)
public void Receive(ShowConfirmationMessage message)
{
var command = message.Command.Unsafe;
if (command == null)
DispatcherQueue.TryEnqueue(async () =>
{
try
{
await HandleConfirmArgsOnUiThread(message.Args);
}
catch (Exception ex)
{
Logger.LogError(ex.ToString());
}
});
}
public void Receive(ShowToastMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
_toast.ShowToast(message.Message);
});
}
// This gets called from the UI thread
private async Task HandleConfirmArgsOnUiThread(IConfirmationArgs? args)
{
if (args == null)
{
return;
}
if (!ViewModel.CurrentPage.IsNested)
{
// on the main page here
ViewModel.PerformTopLevelCommand(message);
}
IExtensionWrapper? extension = null;
// TODO: Actually loading up the page, or invoking the command -
// that might belong in the model, not the view?
// Especially considering the try/catch concerns around the fact that the
// COM call might just fail.
// Or the command may be a stub. Future us problem.
try
{
// In the case that we're coming from a top-level command, the
// current page's host is the global instance. We only really want
// to use that as the host of last resort.
var pageHost = ViewModel.CurrentPage?.ExtensionHost;
if (pageHost == CommandPaletteHost.Instance)
{
pageHost = null;
}
var messageHost = message.ExtensionHost;
// Use the host from the current page if it has one, else use the
// one specified in the PerformMessage for a top-level command,
// else just use the global one.
CommandPaletteHost host;
// Top level items can come through without a Extension set on the
// message. In that case, the `Context` is actually the
// TopLevelViewModel itself, and we can use that to get at the
// extension object.
extension = pageHost?.Extension ?? messageHost?.Extension ?? null;
if (extension == null && message.Context is TopLevelViewModel topLevelViewModel)
{
extension = topLevelViewModel.ExtensionHost?.Extension;
host = pageHost ?? messageHost ?? topLevelViewModel?.ExtensionHost ?? CommandPaletteHost.Instance;
}
else
{
host = pageHost ?? messageHost ?? CommandPaletteHost.Instance;
}
if (extension != null)
{
if (messageHost != null)
{
Logger.LogDebug($"Activated top-level command from {extension.ExtensionDisplayName}");
}
else
{
Logger.LogDebug($"Activated command from {extension.ExtensionDisplayName}");
}
}
ViewModel.SetActiveExtension(extension);
if (command is IPage page)
{
Logger.LogDebug($"Navigating to page");
// TODO GH #526 This needs more better locking too
_ = _queue.TryEnqueue(() =>
{
// Also hide our details pane about here, if we had one
HideDetails();
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
var isMainPage = command is MainListPage;
// Construct our ViewModel of the appropriate type and pass it the UI Thread context.
PageViewModel pageViewModel = page switch
{
IListPage listPage => new ListViewModel(listPage, _mainTaskScheduler, host)
{
IsNested = !isMainPage,
},
IContentPage contentPage => new ContentPageViewModel(contentPage, _mainTaskScheduler, host),
_ => throw new NotSupportedException(),
};
// Kick off async loading of our ViewModel
ViewModel.LoadPageViewModel(pageViewModel);
// Navigate to the appropriate host page for that VM
RootFrame.Navigate(
page switch
{
IListPage => typeof(ListPage),
IContentPage => typeof(ContentPage),
_ => throw new NotSupportedException(),
},
pageViewModel,
message.WithAnimation ? _slideRightTransition : _noAnimation);
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
// Refocus on the Search for continual typing on the next search request
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
if (isMainPage)
{
// todo BODGY
RootFrame.BackStack.Clear();
}
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
// See RootFrame_Navigated event handler.
});
}
else if (command is IInvokableCommand invokable)
{
Logger.LogDebug($"Invoking command");
PowerToysTelemetry.Log.WriteEvent(new BeginInvoke());
HandleInvokeCommand(message, invokable);
}
}
catch (Exception ex)
{
// TODO: It would be better to do this as a page exception, rather
// than a silent log message.
CommandPaletteHost.Instance.Log(ex.Message);
}
}
private void HandleInvokeCommand(PerformCommandMessage message, IInvokableCommand invokable)
{
// TODO GH #525 This needs more better locking.
lock (_invokeLock)
{
if (_handleInvokeTask != null)
{
// do nothing - a command is already doing a thing
}
else
{
_handleInvokeTask = Task.Run(() =>
{
try
{
var result = invokable.Invoke(message.Context);
DispatcherQueue.TryEnqueue(() =>
{
try
{
HandleCommandResultOnUiThread(result);
}
finally
{
_handleInvokeTask = null;
}
});
}
catch (Exception ex)
{
_handleInvokeTask = null;
// TODO: It would be better to do this as a page exception, rather
// than a silent log message.
CommandPaletteHost.Instance.Log(ex.Message);
}
});
}
}
}
// This gets called from the UI thread
private void HandleConfirmArgs(IConfirmationArgs args)
{
ConfirmResultViewModel vm = new(args, new(ViewModel.CurrentPage));
var initializeDialogTask = Task.Run(() => { InitializeConfirmationDialog(vm); });
initializeDialogTask.Wait();
await initializeDialogTask;
var resourceLoader = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance.ResourceLoader;
var confirmText = resourceLoader.GetString("ConfirmationDialog_ConfirmButtonText");
@@ -322,19 +202,16 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
// };
}
DispatcherQueue.TryEnqueue(async () =>
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var performMessage = new PerformCommandMessage(vm);
PerformCommand(performMessage);
}
else
{
// cancel
}
});
var performMessage = new PerformCommandMessage(vm);
WeakReferenceMessenger.Default.Send(performMessage);
}
else
{
// cancel
}
}
private void InitializeConfirmationDialog(ConfirmResultViewModel vm)
@@ -342,79 +219,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
vm.SafeInitializePropertiesSynchronous();
}
private void HandleCommandResultOnUiThread(ICommandResult? result)
{
try
{
if (result != null)
{
var kind = result.Kind;
Logger.LogDebug($"handling {kind.ToString()}");
PowerToysTelemetry.Log.WriteEvent(new CmdPalInvokeResult(kind));
switch (kind)
{
case CommandResultKind.Dismiss:
{
// Reset the palette to the main page and dismiss
GoHome(withAnimation: false, focusSearch: false);
WeakReferenceMessenger.Default.Send<DismissMessage>();
break;
}
case CommandResultKind.GoHome:
{
// Go back to the main page, but keep it open
GoHome();
break;
}
case CommandResultKind.GoBack:
{
GoBack();
break;
}
case CommandResultKind.Hide:
{
// Keep this page open, but hide the palette.
WeakReferenceMessenger.Default.Send<DismissMessage>();
break;
}
case CommandResultKind.KeepOpen:
{
// Do nothing.
break;
}
case CommandResultKind.Confirm:
{
if (result.Args is IConfirmationArgs a)
{
HandleConfirmArgs(a);
}
break;
}
case CommandResultKind.ShowToast:
{
if (result.Args is IToastArgs a)
{
_toast.ShowToast(a.Message);
HandleCommandResultOnUiThread(a.Result);
}
break;
}
}
}
}
catch
{
}
}
public void Receive(OpenSettingsMessage message)
{
_ = DispatcherQueue.TryEnqueue(() =>
@@ -467,14 +271,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public void Receive(LaunchUriMessage message) => _ = global::Windows.System.Launcher.LaunchUriAsync(message.Uri);
public void Receive(HandleCommandResultMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
HandleCommandResultOnUiThread(message.Result.Unsafe);
});
}
private void HideDetails()
{
ViewModel.Details = null;
@@ -554,6 +350,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Send<FocusSearchBoxMessage>();
}
public void Receive(GoBackMessage message)
{
_ = DispatcherQueue.TryEnqueue(() => GoBack(message.WithAnimation, message.FocusSearch));
}
private void GoBack(bool withAnimation = true, bool focusSearch = true)
{
HideDetails();
@@ -591,14 +392,17 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
}
}
public void Receive(GoHomeMessage message)
{
_ = DispatcherQueue.TryEnqueue(() => GoHome(withAnimation: message.WithAnimation, focusSearch: message.FocusSearch));
}
private void GoHome(bool withAnimation = true, bool focusSearch = true)
{
while (RootFrame.CanGoBack)
{
GoBack(withAnimation, focusSearch);
}
WeakReferenceMessenger.Default.Send<GoHomeMessage>();
}
private void BackButton_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());

View File

@@ -1,50 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels;
namespace Microsoft.CmdPal.UI;
internal sealed class RunHistoryService : IRunHistoryService
{
private readonly AppStateModel _appStateModel;
public RunHistoryService(AppStateModel appStateModel)
{
_appStateModel = appStateModel;
}
public IReadOnlyList<string> GetRunHistory()
{
if (_appStateModel.RunHistory.Count == 0)
{
var history = Microsoft.Terminal.UI.RunHistory.CreateRunHistory();
_appStateModel.RunHistory.AddRange(history);
}
return _appStateModel.RunHistory;
}
public void ClearRunHistory()
{
_appStateModel.RunHistory.Clear();
}
public void AddRunHistoryItem(string item)
{
// insert at the beginning of the list
if (string.IsNullOrWhiteSpace(item))
{
return; // Do not add empty or whitespace items
}
_appStateModel.RunHistory.Remove(item);
// Add the item to the front of the history
_appStateModel.RunHistory.Insert(0, item);
AppStateModel.SaveState(_appStateModel);
}
}

View File

@@ -186,8 +186,6 @@
x:Load="False"
AutomationProperties.AccessibilityView="Raw"
CharacterSpacing="15"
FontFamily="{TemplateBinding FontFamily}"
FontSize="{TemplateBinding FontSize}"
Foreground="{Binding PlaceholderForeground, RelativeSource={RelativeSource TemplatedParent}, TargetNullValue={ThemeResource TextControlPlaceholderForeground}}"
Text="{TemplateBinding Description}"
TextWrapping="{TemplateBinding TextWrapping}" />

View File

@@ -383,5 +383,4 @@ namespace winrt::Microsoft::Terminal::UI::implementation
icon.Height(targetSize);
return icon;
}
}

View File

@@ -13,7 +13,6 @@ namespace winrt::Microsoft::Terminal::UI::implementation
static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, bool convertToGrayscale, const int targetSize=24);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath, const int targetSize);
};
}

View File

@@ -153,9 +153,6 @@
<ClInclude Include="IconPathConverter.h">
<DependentUpon>IconPathConverter.idl</DependentUpon>
</ClInclude>
<ClInclude Include="RunHistory.h">
<DependentUpon>RunHistory.idl</DependentUpon>
</ClInclude>
<ClInclude Include="ResourceString.h">
<DependentUpon>ResourceString.idl</DependentUpon>
</ClInclude>
@@ -171,9 +168,6 @@
<ClCompile Include="IconPathConverter.cpp">
<DependentUpon>IconPathConverter.idl</DependentUpon>
</ClCompile>
<ClCompile Include="RunHistory.cpp">
<DependentUpon>RunHistory.idl</DependentUpon>
</ClCompile>
<ClCompile Include="ResourceString.cpp">
<DependentUpon>ResourceString.idl</DependentUpon>
</ClCompile>
@@ -182,7 +176,6 @@
<ItemGroup>
<Midl Include="Converters.idl" />
<Midl Include="IconPathConverter.idl" />
<Midl Include="RunHistory.idl" />
<Midl Include="IDirectKeyListener.idl" />
<Midl Include="ResourceString.idl" />
</ItemGroup>

View File

@@ -1,87 +0,0 @@
#include "pch.h"
#include "RunHistory.h"
#include "RunHistory.g.cpp"
using namespace winrt::Windows;
namespace winrt::Microsoft::Terminal::UI::implementation
{
// Run history
// Largely copied from the Run work circa 2022.
winrt::Windows::Foundation::Collections::IVector<hstring> RunHistory::CreateRunHistory()
{
// Load MRU history
std::vector<hstring> history;
wil::unique_hmodule _comctl;
HANDLE(WINAPI* _createMRUList)(MRUINFO* lpmi);
int(WINAPI* _enumMRUList)(HANDLE hMRU,int nItem,void* lpData,UINT uLen);
void(WINAPI *_freeMRUList)(HANDLE hMRU);
int(WINAPI *_addMRUString)(HANDLE hMRU, LPCWSTR szString);
// Lazy load comctl32.dll
// Theoretically, we could cache this into a magic static, but we shouldn't need to actually do this more than once in CmdPal
_comctl.reset(LoadLibraryExW(L"comctl32.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32));
_createMRUList = reinterpret_cast<decltype(_createMRUList)>(GetProcAddress(_comctl.get(), "CreateMRUListW"));
FAIL_FAST_LAST_ERROR_IF(!_createMRUList);
_enumMRUList = reinterpret_cast<decltype(_enumMRUList)>(GetProcAddress(_comctl.get(), "EnumMRUListW"));
FAIL_FAST_LAST_ERROR_IF(!_enumMRUList);
_freeMRUList = reinterpret_cast<decltype(_freeMRUList)>(GetProcAddress(_comctl.get(), "FreeMRUList"));
FAIL_FAST_LAST_ERROR_IF(!_freeMRUList);
_addMRUString = reinterpret_cast<decltype(_addMRUString)>(GetProcAddress(_comctl.get(), "AddMRUStringW"));
FAIL_FAST_LAST_ERROR_IF(!_addMRUString);
static const WCHAR c_szRunMRU[] = REGSTR_PATH_EXPLORER L"\\RunMRU";
MRUINFO mi = {
sizeof(mi),
26,
MRU_CACHEWRITE,
HKEY_CURRENT_USER,
c_szRunMRU,
NULL // NOTE: use default string compare
// since this is a GLOBAL MRU
};
if (const auto hmru = _createMRUList(&mi))
{
auto freeMRUList = wil::scope_exit([=]() {
_freeMRUList(hmru);
});
for (int nMax = _enumMRUList(hmru, -1, NULL, 0), i = 0; i < nMax; ++i)
{
WCHAR szCommand[MAX_PATH + 2];
const auto length = _enumMRUList(hmru, i, szCommand, ARRAYSIZE(szCommand));
if (length > 1)
{
// clip off the null-terminator
std::wstring_view text{ szCommand, wil::safe_cast<size_t>(length - 1) };
//#pragma disable warning(C26493)
#pragma warning( push )
#pragma warning( disable : 26493 )
if (text.back() == L'\\')
{
// old MRU format has a slash at the end with the show cmd
text = { szCommand, wil::safe_cast<size_t>(length - 2) };
#pragma warning( pop )
if (text.empty())
{
continue;
}
}
history.emplace_back(text);
}
}
}
// Update dropdown & initial value
return winrt::single_threaded_observable_vector<winrt::hstring>(std::move(history));
}
}

View File

@@ -1,21 +0,0 @@
#pragma once
#include "RunHistory.g.h"
#include "types.h"
namespace winrt::Microsoft::Terminal::UI::implementation
{
struct RunHistory
{
RunHistory() = default;
static winrt::Windows::Foundation::Collections::IVector<hstring> CreateRunHistory();
private:
winrt::Windows::Foundation::Collections::IVector<hstring> _mruHistory;
};
}
namespace winrt::Microsoft::Terminal::UI::factory_implementation
{
BASIC_FACTORY(RunHistory);
}

View File

@@ -1,11 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
namespace Microsoft.Terminal.UI
{
static runtimeclass RunHistory
{
static Windows.Foundation.Collections.IVector<String> CreateRunHistory();
};
}

View File

@@ -64,8 +64,6 @@
// WIL
#include <wil/com.h>
#include <wil/resource.h>
#include <wil/safecast.h>
#include <wil/stl.h>
#include <wil/filesystem.h>
// Due to the use of RESOURCE_SUPPRESS_STL in result.h, we need to include resource.h first, which happens
@@ -92,7 +90,6 @@
#include <winrt/Windows.ApplicationModel.Resources.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Graphics.Imaging.h>
#include <Windows.Graphics.Imaging.Interop.h>

View File

@@ -1,23 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#pragma once
#define MRU_CACHEWRITE 0x0002
#define REGSTR_PATH_EXPLORER TEXT("Software\\Microsoft\\Windows\\CurrentVersion\\Explorer")
// https://learn.microsoft.com/en-us/windows/win32/shell/mrucmpproc
typedef int(CALLBACK* MRUCMPPROC)(
LPCTSTR pString1,
LPCTSTR pString2);
// https://learn.microsoft.com/en-us/windows/win32/shell/mruinfo
struct MRUINFO
{
DWORD cbSize;
UINT uMax;
UINT fFlags;
HKEY hKey;
LPCTSTR lpszSubKey;
MRUCMPPROC lpfnCompare;
};

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
@@ -73,9 +75,31 @@ internal sealed partial class AddBookmarkForm : FormContent
var formBookmark = formInput["bookmark"] ?? string.Empty;
var hasPlaceholder = formBookmark.ToString().Contains('{') && formBookmark.ToString().Contains('}');
// Determine the type of the bookmark
string bookmarkType;
if (formBookmark.ToString().StartsWith("http://", StringComparison.OrdinalIgnoreCase) || formBookmark.ToString().StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
bookmarkType = "web";
}
else if (File.Exists(formBookmark.ToString()))
{
bookmarkType = "file";
}
else if (Directory.Exists(formBookmark.ToString()))
{
bookmarkType = "folder";
}
else
{
// Default to web if we can't determine the type
bookmarkType = "web";
}
var updated = _bookmark ?? new BookmarkData();
updated.Name = formName.ToString();
updated.Bookmark = formBookmark.ToString();
updated.Type = bookmarkType;
AddedCommand?.Invoke(this, updated);
return CommandResult.GoHome();

View File

@@ -2,9 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.Json.Serialization;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Bookmarks;
@@ -14,38 +12,8 @@ public class BookmarkData
public string Bookmark { get; set; } = string.Empty;
// public string Type { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
[JsonIgnore]
public bool IsPlaceholder => Bookmark.Contains('{') && Bookmark.Contains('}');
internal void GetExeAndArgs(out string exe, out string args)
{
ShellHelpers.ParseExecutableAndArgs(Bookmark, out exe, out args);
}
internal bool IsWebUrl()
{
GetExeAndArgs(out var exe, out var args);
if (string.IsNullOrEmpty(exe))
{
return false;
}
if (Uri.TryCreate(exe, UriKind.Absolute, out var uri))
{
if (uri.Scheme == Uri.UriSchemeFile)
{
return false;
}
// return true if the scheme is http or https, or if there's no scheme (e.g., "www.example.com") but there is a dot in the host
return
uri.Scheme == Uri.UriSchemeHttp ||
uri.Scheme == Uri.UriSchemeHttps ||
(string.IsNullOrEmpty(uri.Scheme) && uri.Host.Contains('.'));
}
// If we can't parse it as a URI, we assume it's not a web URL
return false;
}
}

View File

@@ -2,14 +2,17 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Bookmarks;
@@ -22,7 +25,7 @@ internal sealed partial class BookmarkPlaceholderForm : FormContent
private readonly string _bookmark = string.Empty;
// TODO pass in an array of placeholders
public BookmarkPlaceholderForm(string name, string url)
public BookmarkPlaceholderForm(string name, string url, string type)
{
_bookmark = url;
var r = new Regex(Regex.Escape("{") + "(.*?)" + Regex.Escape("}"));
@@ -85,8 +88,23 @@ internal sealed partial class BookmarkPlaceholderForm : FormContent
target = target.Replace(placeholderString, placeholderData);
}
var success = UrlCommand.LaunchCommand(target);
try
{
var uri = UrlCommand.GetUri(target);
if (uri != null)
{
_ = Launcher.LaunchUriAsync(uri);
}
else
{
// throw new UriFormatException("The provided URL is not valid.");
}
}
catch (Exception ex)
{
Logger.LogError(ex.Message);
}
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
return CommandResult.GoHome();
}
}

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;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -10,30 +9,19 @@ namespace Microsoft.CmdPal.Ext.Bookmarks;
internal sealed partial class BookmarkPlaceholderPage : ContentPage
{
private readonly Lazy<IconInfo> _icon;
private readonly FormContent _bookmarkPlaceholder;
public override IContent[] GetContent() => [_bookmarkPlaceholder];
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
public BookmarkPlaceholderPage(BookmarkData data)
: this(data.Name, data.Bookmark)
: this(data.Name, data.Bookmark, data.Type)
{
}
public BookmarkPlaceholderPage(string name, string url)
public BookmarkPlaceholderPage(string name, string url, string type)
{
Name = Properties.Resources.bookmarks_command_name_open;
_bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url);
_icon = new Lazy<IconInfo>(() =>
{
ShellHelpers.ParseExecutableAndArgs(url, out var exe, out var args);
var t = UrlCommand.GetIconForPath(exe);
t.Wait();
return t.Result;
});
Name = name;
Icon = new IconInfo(UrlCommand.IconFromUrl(url, type));
_bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url, type);
}
}

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using ManagedCommon;
@@ -38,7 +39,10 @@ public partial class BookmarksCommandProvider : CommandProvider
private void AddNewCommand_AddedCommand(object sender, BookmarkData args)
{
ExtensionHost.LogMessage($"Adding bookmark ({args.Name},{args.Bookmark})");
_bookmarks?.Data.Add(args);
if (_bookmarks != null)
{
_bookmarks.Data.Add(args);
}
SaveAndUpdateCommands();
}
@@ -116,7 +120,7 @@ public partial class BookmarksCommandProvider : CommandProvider
// Add commands for folder types
if (command is UrlCommand urlCommand)
{
if (!bookmark.IsWebUrl())
if (urlCommand.Type == "folder")
{
contextMenu.Add(
new CommandContextItem(new DirectoryPage(urlCommand.Url)));
@@ -124,10 +128,9 @@ public partial class BookmarksCommandProvider : CommandProvider
contextMenu.Add(
new CommandContextItem(new OpenInTerminalCommand(urlCommand.Url)));
}
}
listItem.Title = bookmark.Name;
listItem.Subtitle = bookmark.Bookmark;
listItem.Subtitle = urlCommand.Url;
}
var edit = new AddBookmarkPage(bookmark) { Icon = EditIcon };
edit.AddedCommand += Edit_AddedCommand;

View File

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

View File

@@ -148,9 +148,6 @@
<data name="bookmarks_form_open" xml:space="preserve">
<value>Open</value>
</data>
<data name="bookmarks_command_name_open" xml:space="preserve">
<value>Open</value>
</data>
<data name="bookmarks_form_name_required" xml:space="preserve">
<value>Name is required</value>
</data>

View File

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

View File

@@ -30,14 +30,13 @@ public static class ResultHelper
var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query);
// No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is,
// as the user is typing it.
return new ListItem(saveCommand)
{
// Using CurrentCulture since this is user facing
Icon = CalculatorIcons.ResultIcon,
Title = result,
Subtitle = query,
TextToSuggest = result,
MoreCommands = [
new CommandContextItem(copyCommandItem.Command)
{

View File

@@ -21,8 +21,6 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System
private uint _queryCookie = 10;
private Func<string, bool> _suppressCallback;
public FallbackOpenFileItem()
: base(new NoOpCommand(), Resources.Indexer_Find_Path_fallback_display_title)
{
@@ -43,17 +41,6 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System
return;
}
if (_suppressCallback != null && _suppressCallback(query))
{
Command = new NoOpCommand();
Title = string.Empty;
Subtitle = string.Empty;
Icon = null;
MoreCommands = null;
return;
}
if (Path.Exists(query))
{
// Exit 1: The query is a direct path to a file. Great! Return it.
@@ -138,9 +125,4 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, System
_searchEngine.Dispose();
GC.SuppressFinalize(this);
}
public void SuppressFallbackWhen(Func<string, bool> callback)
{
_suppressCallback = callback;
}
}

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;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -42,9 +41,4 @@ public partial class IndexerCommandsProvider : CommandProvider
[
_fallbackFileItem
];
public void SuppressFallbackWhen(Func<string, bool> callback)
{
_fallbackFileItem.SuppressFallbackWhen(callback);
}
}

View File

@@ -26,17 +26,17 @@ internal sealed partial class ExecuteItem : InvokableCommand
if (type == RunAsType.Administrator)
{
Name = Properties.Resources.cmd_run_as_administrator;
Icon = Icons.RunAsAdmin;
Icon = new IconInfo("\xE7EF"); // Admin Icon
}
else if (type == RunAsType.OtherUser)
{
Name = Properties.Resources.cmd_run_as_user;
Icon = Icons.RunAsUser;
Icon = new IconInfo("\xE7EE"); // User Icon
}
else
{
Name = Properties.Resources.generic_run_command;
Icon = Icons.RunV2;
Icon = new IconInfo("\uE751"); // Return Key Icon
}
Cmd = cmd;
@@ -44,6 +44,36 @@ internal sealed partial class ExecuteItem : InvokableCommand
_runas = type;
}
private static bool ExistInPath(string filename)
{
if (File.Exists(filename))
{
return true;
}
else
{
var values = Environment.GetEnvironmentVariable("PATH");
if (values != null)
{
foreach (var path in values.Split(';'))
{
var path1 = Path.Combine(path, filename);
var path2 = Path.Combine(path, filename + ".exe");
if (File.Exists(path1) || File.Exists(path2))
{
return true;
}
}
return false;
}
else
{
return false;
}
}
}
private void Execute(Func<ProcessStartInfo, Process?> startProcess, ProcessStartInfo info)
{
if (startProcess == null)
@@ -154,7 +184,7 @@ internal sealed partial class ExecuteItem : InvokableCommand
if (parts.Length == 2)
{
var filename = parts[0];
if (ShellListPageHelpers.FileExistInPath(filename))
if (ExistInPath(filename))
{
var arguments = parts[1];
if (_settings.LeaveShellOpen)

View File

@@ -2,196 +2,37 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Shell.Commands;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell;
internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDisposable
internal sealed partial class FallbackExecuteItem : FallbackCommandItem
{
private readonly Action<string>? _addToHistory;
private CancellationTokenSource? _cancellationTokenSource;
private Task? _currentUpdateTask;
private readonly ExecuteItem _executeItem;
private readonly SettingsManager _settings;
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory)
: base(new NoOpCommand(), Resources.shell_command_display_title)
public FallbackExecuteItem(SettingsManager settings)
: base(new ExecuteItem(string.Empty, settings), Resources.shell_command_display_title)
{
_settings = settings;
_executeItem = (ExecuteItem)this.Command!;
Title = string.Empty;
Icon = Icons.RunV2;
_addToHistory = addToHistory;
_executeItem.Name = string.Empty;
Subtitle = Properties.Resources.generic_run_command;
Icon = Icons.RunV2; // Defined in Icons.cs and contains the execute command icon.
}
public override void UpdateQuery(string query)
{
// Cancel any ongoing query processing
_cancellationTokenSource?.Cancel();
_cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = _cancellationTokenSource.Token;
try
{
// Save the latest update task
_currentUpdateTask = DoUpdateQueryAsync(query, cancellationToken);
}
catch (OperationCanceledException)
{
// DO NOTHING HERE
return;
}
catch (Exception)
{
// Handle other exceptions
return;
}
// Await the task to ensure only the latest one gets processed
_ = ProcessUpdateResultsAsync(_currentUpdateTask);
}
private async Task ProcessUpdateResultsAsync(Task updateTask)
{
try
{
await updateTask;
}
catch (OperationCanceledException)
{
// Handle cancellation gracefully
}
catch (Exception)
{
// Handle other exceptions
}
}
private async Task DoUpdateQueryAsync(string query, CancellationToken cancellationToken)
{
// Check for cancellation at the start
cancellationToken.ThrowIfCancellationRequested();
var searchText = query.Trim();
var expanded = Environment.ExpandEnvironmentVariables(searchText);
searchText = expanded;
if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText))
{
Command = null;
Title = string.Empty;
return;
}
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
// Check for cancellation before file system operations
cancellationToken.ThrowIfCancellationRequested();
var exeExists = false;
var fullExePath = string.Empty;
var pathIsDir = false;
try
{
// Create a timeout for file system operations (200ms)
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var timeoutToken = combinedCts.Token;
// Use Task.Run with timeout for file system operations
var fileSystemTask = Task.Run(
() =>
{
exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath);
pathIsDir = Directory.Exists(exe);
},
CancellationToken.None);
// Wait for either completion or timeout
await fileSystemTask.WaitAsync(timeoutToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Main cancellation token was cancelled, re-throw
throw;
}
catch (TimeoutException)
{
// Timeout occurred - use defaults
return;
}
catch (OperationCanceledException)
{
// Timeout occurred (from WaitAsync) - use defaults
return;
}
catch (Exception)
{
// Handle any other exceptions that might bubble up
return;
}
// Check for cancellation before updating UI properties
cancellationToken.ThrowIfCancellationRequested();
if (exeExists)
{
// TODO we need to probably get rid of the settings for this provider entirely
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory);
Title = exeItem.Title;
Subtitle = exeItem.Subtitle;
Icon = exeItem.Icon;
Command = exeItem.Command;
MoreCommands = exeItem.MoreCommands;
}
else if (pathIsDir)
{
var pathItem = new PathListItem(exe, query, _addToHistory);
Title = pathItem.Title;
Subtitle = pathItem.Subtitle;
Icon = pathItem.Icon;
Command = pathItem.Command;
MoreCommands = pathItem.MoreCommands;
}
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
{
Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory) { Result = CommandResult.Dismiss() };
Title = searchText;
}
else
{
Command = null;
Title = string.Empty;
}
// Final cancellation check
cancellationToken.ThrowIfCancellationRequested();
}
public void Dispose()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
}
internal static bool SuppressFileFallbackIf(string query)
{
var searchText = query.Trim();
var expanded = Environment.ExpandEnvironmentVariables(searchText);
searchText = expanded;
if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText))
{
return false;
}
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
var exeExists = ShellListPageHelpers.FileExistInPath(exe, out var fullExePath);
var pathIsDir = Directory.Exists(exe);
return exeExists || pathIsDir;
_executeItem.Cmd = query;
_executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.generic_run_command;
Title = query;
MoreCommands = [
new CommandContextItem(new ExecuteItem(query, _settings, RunAsType.Administrator)),
new CommandContextItem(new ExecuteItem(query, _settings, RunAsType.OtherUser)),
];
}
}

View File

@@ -4,15 +4,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Shell.Commands;
using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell.Helpers;
@@ -29,7 +26,7 @@ public class ShellListPageHelpers
private ListItem GetCurrentCmd(string cmd)
{
var result = new ListItem(new ExecuteItem(cmd, _settings))
ListItem result = new ListItem(new ExecuteItem(cmd, _settings))
{
Title = cmd,
Subtitle = Properties.Resources.cmd_plugin_name + ": " + Properties.Resources.cmd_execute_through_shell,
@@ -41,7 +38,7 @@ public class ShellListPageHelpers
private List<ListItem> GetHistoryCmds(string cmd, ListItem result)
{
var history = _settings.Count.Where(o => o.Key.Contains(cmd, StringComparison.CurrentCultureIgnoreCase))
IEnumerable<ListItem?> history = _settings.Count.Where(o => o.Key.Contains(cmd, StringComparison.CurrentCultureIgnoreCase))
.OrderByDescending(o => o.Value)
.Select(m =>
{
@@ -69,7 +66,7 @@ public class ShellListPageHelpers
{
ArgumentNullException.ThrowIfNull(query);
var results = new List<ListItem>();
List<ListItem> results = new List<ListItem>();
var cmd = query;
if (string.IsNullOrEmpty(cmd))
{
@@ -104,7 +101,7 @@ public class ShellListPageHelpers
private List<ListItem> ResultsFromHistory()
{
var history = _settings.Count.OrderByDescending(o => o.Value)
IEnumerable<ListItem> history = _settings.Count.OrderByDescending(o => o.Value)
.Select(m => new ListItem(new ExecuteItem(m.Key, _settings))
{
Title = m.Key,
@@ -116,89 +113,4 @@ public class ShellListPageHelpers
return history.ToList();
}
internal static bool FileExistInPath(string filename)
{
return FileExistInPath(filename, out var _);
}
internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
{
// TODO! remove this method and just use ShellHelpers.FileExistInPath directly
return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None);
}
internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory)
{
var li = new ListItem();
var searchText = query.Trim();
var expanded = Environment.ExpandEnvironmentVariables(searchText);
searchText = expanded;
if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText))
{
return null;
}
ShellHelpers.ParseExecutableAndArgs(searchText, out var exe, out var args);
var exeExists = false;
var pathIsDir = false;
var fullExePath = string.Empty;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
// Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation
var pathResolutionTask = Task.Run(
() =>
{
// Don't check cancellation token here - let the Task timeout handle it
exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath);
pathIsDir = Directory.Exists(expanded);
},
CancellationToken.None); // Use None here since we're handling timeout differently
// Wait for either completion or timeout
pathResolutionTask.Wait(cts.Token);
}
catch (OperationCanceledException)
{
Debug.WriteLine("Operation was canceled.");
}
Debug.WriteLine($"Run: exeExists={exeExists}, pathIsDir={pathIsDir}");
if (exeExists)
{
// TODO we need to probably get rid of the settings for this provider entirely
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory);
li.Command = exeItem.Command;
li.Title = exeItem.Title;
li.Subtitle = exeItem.Subtitle;
li.Icon = exeItem.Icon;
li.MoreCommands = exeItem.MoreCommands;
}
else if (pathIsDir)
{
var pathItem = new PathListItem(exe, query, addToHistory);
li.Command = pathItem.Command;
li.Title = pathItem.Title;
li.Subtitle = pathItem.Subtitle;
li.Icon = pathItem.Icon;
li.MoreCommands = pathItem.MoreCommands;
}
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
{
li.Command = new OpenUrlWithHistoryCommand(searchText) { Result = CommandResult.Dismiss() };
li.Title = searchText;
}
else
{
return null;
}
return li;
}
}

View File

@@ -9,10 +9,4 @@ namespace Microsoft.CmdPal.Ext.Shell;
internal sealed class Icons
{
internal static IconInfo RunV2 { get; } = IconHelpers.FromRelativePath("Assets\\Run.svg");
internal static IconInfo Folder { get; } = new("📁");
internal static IconInfo RunAsAdmin { get; } = new("\xE7EF"); // admin
internal static IconInfo RunAsUser { get; } = new("\uE7EE"); // user
}

View File

@@ -14,12 +14,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DependentUpon>Resources.resx</DependentUpon>

View File

@@ -1,30 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell;
internal sealed partial class OpenUrlWithHistoryCommand : OpenUrlCommand
{
private readonly Action<string>? _addToHistory;
private readonly string _url;
public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null)
: base(url)
{
_addToHistory = addToHistory;
_url = url;
}
public override CommandResult Invoke()
{
_addToHistory?.Invoke(_url);
var result = base.Invoke();
return result;
}
}

View File

@@ -1,115 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Storage.Streams;
namespace Microsoft.CmdPal.Ext.Shell.Pages;
internal sealed partial class RunExeItem : ListItem
{
private readonly Lazy<IconInfo> _icon;
private readonly Action<string>? _addToHistory;
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
internal string FullExePath { get; private set; }
internal string Exe { get; private set; }
private string _args = string.Empty;
private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}";
public RunExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
{
FullExePath = fullExePath;
Exe = exe;
var command = new AnonymousCommand(Run)
{
Name = Properties.Resources.generic_run_command,
Result = CommandResult.Dismiss(),
};
Command = command;
Subtitle = FullExePath;
_icon = new Lazy<IconInfo>(() =>
{
var t = FetchIcon();
t.Wait();
return t.Result;
});
_addToHistory = addToHistory;
UpdateArgs(args);
MoreCommands = [
new CommandContextItem(
new AnonymousCommand(RunAsAdmin)
{
Name = Properties.Resources.cmd_run_as_administrator,
Icon = Icons.RunAsAdmin,
}),
new CommandContextItem(
new AnonymousCommand(RunAsOther)
{
Name = Properties.Resources.cmd_run_as_user,
Icon = Icons.RunAsUser,
}),
];
}
internal void UpdateArgs(string args)
{
_args = args;
Title = string.IsNullOrEmpty(_args) ? Exe : Exe + " " + _args; // todo! you're smarter than this
}
public async Task<IconInfo> FetchIcon()
{
IconInfo? icon = null;
try
{
var stream = await ThumbnailHelper.GetThumbnail(FullExePath);
if (stream != null)
{
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
icon = new IconInfo(data, data);
((AnonymousCommand?)Command)!.Icon = icon;
}
}
catch
{
}
icon = icon ?? new IconInfo(FullExePath);
return icon;
}
public void Run()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args);
}
public void RunAsAdmin()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator);
}
public void RunAsOther()
{
_addToHistory?.Invoke(FullString);
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser);
}
}

View File

@@ -2,13 +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;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -16,480 +9,20 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell.Pages;
internal sealed partial class ShellListPage : DynamicListPage, IDisposable
internal sealed partial class ShellListPage : DynamicListPage
{
private readonly ShellListPageHelpers _helper;
private readonly List<ListItem> _topLevelItems = [];
private readonly Dictionary<string, ListItem> _historyItems = [];
private readonly List<ListItem> _currentHistoryItems = [];
private readonly IRunHistoryService _historyService;
private RunExeItem? _exeItem;
private List<ListItem> _pathItems = [];
private ListItem? _uriItem;
private CancellationTokenSource? _cancellationTokenSource;
private Task? _currentSearchTask;
private bool _loadedInitialHistory;
public ShellListPage(SettingsManager settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false)
public ShellListPage(SettingsManager settingsManager)
{
Icon = Icons.RunV2;
Id = "com.microsoft.cmdpal.shell";
Name = Resources.cmd_plugin_name;
PlaceholderText = Resources.list_placeholder_text;
_helper = new(settingsManager);
_historyService = runHistoryService;
EmptyContent = new CommandItem()
{
Title = Resources.cmd_plugin_name,
Icon = Icons.RunV2,
Subtitle = Resources.list_placeholder_text,
};
if (addBuiltins)
{
// here, we _could_ add built-in providers if we wanted. links to apps, calc, etc.
// That would be a truly run-first experience
}
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
if (newSearch == oldSearch)
{
return;
}
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0);
DoUpdateSearchText(newSearch);
}
private void DoUpdateSearchText(string newSearch)
{
// Cancel any ongoing search
_cancellationTokenSource?.Cancel();
_cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = _cancellationTokenSource.Token;
try
{
// Save the latest search task
_currentSearchTask = BuildListItemsForSearchAsync(newSearch, cancellationToken);
}
catch (OperationCanceledException)
{
// DO NOTHING HERE
return;
}
catch (Exception)
{
// Handle other exceptions
return;
}
// Await the task to ensure only the latest one gets processed
_ = ProcessSearchResultsAsync(_currentSearchTask, newSearch);
}
private async Task ProcessSearchResultsAsync(Task searchTask, string newSearch)
{
try
{
await searchTask;
// Ensure this is still the latest task
if (_currentSearchTask == searchTask)
{
// The search results have already been updated in BuildListItemsForSearchAsync
IsLoading = false;
RaiseItemsChanged();
}
}
catch (OperationCanceledException)
{
// Handle cancellation gracefully
}
catch (Exception)
{
// Handle other exceptions
IsLoading = false;
}
}
private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken)
{
// Check for cancellation at the start
cancellationToken.ThrowIfCancellationRequested();
// If the search text is the start of a path to a file (it might be a
// UNC path), then we want to list all the files that start with that text:
// 1. Check if the search text is a valid path
// 2. If it is, then list all the files that start with that text
var searchText = newSearch.Trim();
var expanded = Environment.ExpandEnvironmentVariables(searchText);
// Check for cancellation after environment expansion
cancellationToken.ThrowIfCancellationRequested();
// TODO we can be smarter about only re-reading the filesystem if the
// new search is just the oldSearch+some chars
if (string.IsNullOrEmpty(searchText) || string.IsNullOrWhiteSpace(searchText))
{
_pathItems.Clear();
_exeItem = null;
_uriItem = null;
_currentHistoryItems.Clear();
_currentHistoryItems.AddRange(_historyItems.Values);
return;
}
ShellHelpers.ParseExecutableAndArgs(expanded, out var exe, out var args);
// Check for cancellation before file system operations
cancellationToken.ThrowIfCancellationRequested();
// Reset the path resolution flag
var couldResolvePath = false;
var exeExists = false;
var fullExePath = string.Empty;
var pathIsDir = false;
try
{
// Create a timeout for file system operations (200ms)
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var timeoutToken = combinedCts.Token;
// Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation
var pathResolutionTask = Task.Run(
() =>
{
// Don't check cancellation token here - let the Task timeout handle it
exeExists = ShellListPageHelpers.FileExistInPath(exe, out fullExePath);
pathIsDir = Directory.Exists(expanded);
couldResolvePath = true;
},
CancellationToken.None); // Use None here since we're handling timeout differently
// Wait for either completion or timeout
await pathResolutionTask.WaitAsync(timeoutToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Main cancellation token was cancelled, re-throw
throw;
}
catch (TimeoutException)
{
// Timeout occurred
couldResolvePath = false;
}
catch (OperationCanceledException)
{
// Timeout occurred (from WaitAsync)
couldResolvePath = false;
}
catch (Exception)
{
// Handle any other exceptions that might bubble up
couldResolvePath = false;
}
cancellationToken.ThrowIfCancellationRequested();
_pathItems.Clear();
// We want to show path items:
// * If there's no args, AND (the path doesn't exist OR the path is a dir)
if (string.IsNullOrEmpty(args)
&& (!exeExists || pathIsDir)
&& couldResolvePath)
{
IsLoading = true;
await CreatePathItemsAsync(expanded, searchText, cancellationToken);
}
// Check for cancellation before creating exe items
cancellationToken.ThrowIfCancellationRequested();
if (couldResolvePath && exeExists)
{
CreateAndAddExeItems(exe, args, fullExePath);
}
else
{
_exeItem = null;
}
// Only create the URI item if we didn't make a file or exe item for it.
if (!exeExists && !pathIsDir)
{
CreateUriItems(searchText);
}
else
{
_uriItem = null;
}
var histItemsNotInSearch =
_historyItems
.Where(kv => !kv.Key.Equals(newSearch, StringComparison.OrdinalIgnoreCase));
if (_exeItem != null)
{
// If we have an exe item, we want to remove it from the history items
histItemsNotInSearch = histItemsNotInSearch
.Where(kv => !kv.Value.Title.Equals(_exeItem.Title, StringComparison.OrdinalIgnoreCase));
}
if (_uriItem != null)
{
// If we have an uri item, we want to remove it from the history items
histItemsNotInSearch = histItemsNotInSearch
.Where(kv => !kv.Value.Title.Equals(_uriItem.Title, StringComparison.OrdinalIgnoreCase));
}
// Filter the history items based on the search text
var filterHistory = (string query, KeyValuePair<string, ListItem> pair) =>
{
// Fuzzy search on the key (command string)
var score = StringMatcher.FuzzySearch(query, pair.Key).Score;
return score;
};
var filteredHistory =
ListHelpers.FilterList<KeyValuePair<string, ListItem>>(
histItemsNotInSearch,
searchText,
filterHistory)
.Select(p => p.Value);
_currentHistoryItems.Clear();
_currentHistoryItems.AddRange(filteredHistory);
// Final cancellation check
cancellationToken.ThrowIfCancellationRequested();
}
private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? addToHistory = null)
{
var pathItem = new PathListItem(path, originalPath, addToHistory);
// Is this path an executable? If so, then make a RunExeItem
if (IsExecutable(path))
{
var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory);
exeItem.MoreCommands = [
.. exeItem.MoreCommands,
.. pathItem.MoreCommands];
return exeItem;
}
return pathItem;
}
public override IListItem[] GetItems()
{
if (!_loadedInitialHistory)
{
LoadInitialHistory();
}
var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText);
List<ListItem> uriItems = _uriItem != null ? [_uriItem] : [];
List<ListItem> exeItems = _exeItem != null ? [_exeItem] : [];
return
exeItems
.Concat(filteredTopLevel)
.Concat(_currentHistoryItems)
.Concat(_pathItems)
.Concat(uriItems)
.ToArray();
}
internal static RunExeItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
{
// PathToListItem will return a RunExeItem if it can find a executable.
// It will ALSO add the file search commands to the RunExeItem.
return PathToListItem(fullExePath, exe, args, addToHistory) as RunExeItem ??
new RunExeItem(exe, args, fullExePath, addToHistory);
}
private void CreateAndAddExeItems(string exe, string args, string fullExePath)
{
// If we already have an exe item, and the exe is the same, we can just update it
if (_exeItem != null && _exeItem.FullExePath.Equals(fullExePath, StringComparison.OrdinalIgnoreCase))
{
_exeItem.UpdateArgs(args);
}
else
{
_exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory);
}
}
private static bool IsExecutable(string path)
{
// Is this path an executable?
// check all the extensions in PATHEXT
var extensions = Environment.GetEnvironmentVariable("PATHEXT")?.Split(';') ?? Array.Empty<string>();
return extensions.Any(ext => string.Equals(Path.GetExtension(path), ext, StringComparison.OrdinalIgnoreCase));
}
private async Task CreatePathItemsAsync(string searchPath, string originalPath, CancellationToken cancellationToken)
{
var directoryPath = string.Empty;
var searchPattern = string.Empty;
var startsWithQuote = searchPath.Length > 0 && searchPath[0] == '"';
var endsWithQuote = searchPath.Last() == '"';
var trimmed = (startsWithQuote && endsWithQuote) ? searchPath.Substring(1, searchPath.Length - 2) : searchPath;
var isDriveRoot = trimmed.Length == 2 && trimmed[1] == ':';
// we should also handle just drive roots, ala c:\ or d:\
// we need to handle this case first, because "C:" does exist, but we need to append the "\" in that case
if (isDriveRoot)
{
directoryPath = trimmed + "\\";
searchPattern = $"*";
}
// Easiest case: text is literally already a full directory
else if (Directory.Exists(trimmed))
{
directoryPath = trimmed;
searchPattern = $"*";
}
// Check if the search text is a valid path
else if (Path.IsPathRooted(trimmed) && Path.GetDirectoryName(trimmed) is string directoryName)
{
directoryPath = directoryName;
searchPattern = $"{Path.GetFileName(trimmed)}*";
}
// Check if the search text is a valid UNC path
else if (trimmed.StartsWith(@"\\", System.StringComparison.CurrentCultureIgnoreCase) &&
trimmed.Contains(@"\\"))
{
directoryPath = trimmed;
searchPattern = $"*";
}
// Check for cancellation before directory operations
cancellationToken.ThrowIfCancellationRequested();
var dirExists = Directory.Exists(directoryPath);
// searchPath is fully expanded, and originalPath is not. We might get:
// * original: X%Y%Z\partial
// * search: X_foo_Z\partial
// and we want the result `X_foo_Z\partialOne` to use the suggestion `X%Y%Z\partialOne`
//
// To do this:
// * Get the directoryPath
// * trim that out of the beginning of searchPath -> searchPathTrailer
// * everything left from searchPath? remove searchPathTrailer from the end of originalPath
// that gets us the expanded original dir
// Check if the directory exists
if (dirExists)
{
// Check for cancellation before file system enumeration
cancellationToken.ThrowIfCancellationRequested();
// Get all the files in the directory that start with the search text
// Run this on a background thread to avoid blocking
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken);
// Check for cancellation after file enumeration
cancellationToken.ThrowIfCancellationRequested();
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length));
var originalBeginning = originalPath.Remove(originalPath.Length - searchPathTrailer.Length);
if (isDriveRoot)
{
originalBeginning = string.Concat(originalBeginning, '\\');
}
// Create a list of commands for each file
var commands = files.Select(f => PathToListItem(f, originalBeginning)).ToList();
// Final cancellation check before updating results
cancellationToken.ThrowIfCancellationRequested();
// Add the commands to the list
_pathItems = commands;
}
else
{
_pathItems.Clear();
}
}
internal void CreateUriItems(string searchText)
{
if (!System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
{
_uriItem = null;
return;
}
var command = new OpenUrlCommand(searchText) { Result = CommandResult.Dismiss() };
_uriItem = new ListItem(command)
{
Title = searchText,
};
}
private void LoadInitialHistory()
{
var hist = _historyService.GetRunHistory();
var histItems = hist
.Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory)))
.Where(tuple => tuple.Item2 != null)
.Select(tuple => (tuple.h, tuple.Item2!))
.ToList();
// Add all the history items to the _historyItems dictionary
foreach (var (h, item) in histItems)
{
_historyItems[h] = item;
}
_currentHistoryItems.Clear();
_currentHistoryItems.AddRange(histItems.Select(tuple => tuple.Item2));
_loadedInitialHistory = true;
}
internal void AddToHistory(string commandString)
{
if (string.IsNullOrWhiteSpace(commandString))
{
return; // Do not add empty or whitespace items
}
_historyService.AddRunHistoryItem(commandString);
LoadInitialHistory();
DoUpdateSearchText(SearchText);
}
public void Dispose()
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
}
public override IListItem[] GetItems() => [.. _helper.Query(SearchText)];
}

View File

@@ -1,69 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Shell;
internal sealed partial class PathListItem : ListItem
{
private readonly Lazy<IconInfo> _icon;
private readonly bool _isDirectory;
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
public PathListItem(string path, string originalDir, Action<string>? addToHistory)
: base(new OpenUrlWithHistoryCommand(path, addToHistory))
{
var fileName = Path.GetFileName(path);
_isDirectory = Directory.Exists(path);
if (_isDirectory)
{
path = path + "\\";
fileName = fileName + "\\";
}
Title = fileName;
Subtitle = path;
// NOTE ME:
// If there are spaces on originalDir, trim them off, BEFORE combining originalDir and fileName.
// THEN add quotes at the end
// Trim off leading & trailing quote, if there is one
var trimmed = originalDir.Trim('"');
var originalPath = Path.Combine(trimmed, fileName);
var suggestion = originalPath;
var hasSpace = originalPath.Contains(' ');
if (hasSpace)
{
// wrap it in quotes
suggestion = string.Concat("\"", suggestion, "\"");
}
TextToSuggest = suggestion;
MoreCommands = [
new CommandContextItem(new CopyTextCommand(path) { Name = Properties.Resources.copy_path_command_name }) { }
];
// TODO: Follow-up during 0.4. Add the indexer commands here.
// MoreCommands = [
// new CommandContextItem(new OpenWithCommand(indexerItem)),
// new CommandContextItem(new ShowFileInFolderCommand(indexerItem.FullPath) { Name = Resources.Indexer_Command_ShowInFolder }),
// new CommandContextItem(new CopyPathCommand(indexerItem)),
// new CommandContextItem(new OpenInConsoleCommand(indexerItem)),
// new CommandContextItem(new OpenPropertiesCommand(indexerItem)),
// ];
_icon = new Lazy<IconInfo>(() =>
{
var iconStream = ThumbnailHelper.GetThumbnail(path).Result;
var icon = iconStream != null ? IconInfo.FromStream(iconStream) :
_isDirectory ? Icons.Folder : Icons.RunV2;
return icon;
});
}
}

View File

@@ -132,15 +132,6 @@ namespace Microsoft.CmdPal.Ext.Shell.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copy path.
/// </summary>
public static string copy_path_command_name {
get {
return ResourceManager.GetString("copy_path_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Find and run the executable file.
/// </summary>

View File

@@ -190,7 +190,4 @@
<data name="shell_command_display_title" xml:space="preserve">
<value>Run commands</value>
</data>
<data name="copy_path_command_name" xml:space="preserve">
<value>Copy path</value>
</data>
</root>

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 Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.Shell.Helpers;
using Microsoft.CmdPal.Ext.Shell.Pages;
using Microsoft.CmdPal.Ext.Shell.Properties;
@@ -14,26 +13,19 @@ namespace Microsoft.CmdPal.Ext.Shell;
public partial class ShellCommandsProvider : CommandProvider
{
private readonly CommandItem _shellPageItem;
private readonly SettingsManager _settingsManager = new();
private readonly ShellListPage _shellListPage;
private readonly FallbackCommandItem _fallbackItem;
private readonly IRunHistoryService _historyService;
public ShellCommandsProvider(IRunHistoryService runHistoryService)
public ShellCommandsProvider()
{
_historyService = runHistoryService;
Id = "Run";
DisplayName = Resources.cmd_plugin_name;
Icon = Icons.RunV2;
Settings = _settingsManager.Settings;
_shellListPage = new ShellListPage(_settingsManager, _historyService);
_fallbackItem = new FallbackExecuteItem(_settingsManager);
_fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory);
_shellPageItem = new CommandItem(_shellListPage)
_shellPageItem = new CommandItem(new ShellListPage(_settingsManager))
{
Icon = Icons.RunV2,
Title = Resources.shell_command_name,
@@ -47,6 +39,4 @@ public partial class ShellCommandsProvider : CommandProvider
public override ICommandItem[] TopLevelCommands() => [_shellPageItem];
public override IFallbackCommandItem[]? FallbackCommands() => [_fallbackItem];
public static bool SuppressFileFallbackIf(string query) => FallbackExecuteItem.SuppressFileFallbackIf(query);
}

View File

@@ -15,26 +15,21 @@ internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem
{
private readonly SearchWebCommand _executeItem;
private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open);
private static readonly CompositeFormat SubtitleText = System.Text.CompositeFormat.Parse(Properties.Resources.web_search_fallback_subtitle);
private string _title;
public FallbackExecuteSearchItem(SettingsManager settings)
: base(new SearchWebCommand(string.Empty, settings), Resources.command_item_title)
{
_executeItem = (SearchWebCommand)this.Command!;
Title = string.Empty;
Subtitle = string.Empty;
_executeItem.Name = string.Empty;
_title = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName);
Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName);
Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png");
}
public override void UpdateQuery(string query)
{
_executeItem.Arguments = query;
var isEmpty = string.IsNullOrEmpty(query);
_executeItem.Name = isEmpty ? string.Empty : Properties.Resources.open_in_default_browser;
Title = isEmpty ? string.Empty : _title;
Subtitle = string.Format(CultureInfo.CurrentCulture, SubtitleText, query);
_executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.open_in_default_browser;
Title = query;
}
}

View File

@@ -7,6 +7,7 @@ using System.Globalization;
using System.Text;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo;
@@ -32,7 +33,6 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem
{
if (!IsValidUrl(query))
{
_executeItem.Name = string.Empty;
Title = string.Empty;
Subtitle = string.Empty;
return;

View File

@@ -239,14 +239,5 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties {
return ResourceManager.GetString("settings_page_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Search for &quot;{0}&quot;.
/// </summary>
public static string web_search_fallback_subtitle {
get {
return ResourceManager.GetString("web_search_fallback_subtitle", resourceCulture);
}
}
}
}

View File

@@ -178,7 +178,4 @@
<data name="settings_page_name" xml:space="preserve">
<value>Settings</value>
</data>
<data name="web_search_fallback_subtitle" xml:space="preserve">
<value>Search for "{0}"</value>
</data>
</root>

View File

@@ -43,6 +43,7 @@ public partial class ListHelpers
}
public static IEnumerable<T> FilterList<T>(IEnumerable<T> items, string query, Func<string, T, int> scoreFunction)
where T : class
{
var scores = items
.Select(li => new Scored<T>() { Item = li, Score = scoreFunction(query, li) })

View File

@@ -4,7 +4,7 @@
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class OpenUrlCommand : InvokableCommand
public sealed partial class OpenUrlCommand : InvokableCommand
{
private readonly string _target;

View File

@@ -59,101 +59,4 @@ public static class ShellHelpers
Administrator,
OtherUser,
}
/// <summary>
/// Parses the input string to extract the executable and its arguments.
/// </summary>
public static void ParseExecutableAndArgs(string input, out string executable, out string arguments)
{
input = input.Trim();
executable = string.Empty;
arguments = string.Empty;
if (string.IsNullOrEmpty(input))
{
return;
}
if (input.StartsWith("\"", System.StringComparison.InvariantCultureIgnoreCase))
{
// Find the closing quote
var closingQuoteIndex = input.IndexOf('\"', 1);
if (closingQuoteIndex > 0)
{
executable = input.Substring(1, closingQuoteIndex - 1);
if (closingQuoteIndex + 1 < input.Length)
{
arguments = input.Substring(closingQuoteIndex + 1).TrimStart();
}
}
}
else
{
// Executable ends at first space
var firstSpaceIndex = input.IndexOf(' ');
if (firstSpaceIndex > 0)
{
executable = input.Substring(0, firstSpaceIndex);
arguments = input[(firstSpaceIndex + 1)..].TrimStart();
}
else
{
executable = input;
}
}
}
/// <summary>
/// Checks if a file exists somewhere in the PATH.
/// If it exists, returns the full path to the file in the out parameter.
/// If it does not exist, returns false and the out parameter is set to an empty string.
/// <param name="filename">The name of the file to check.</param>
/// <param name="fullPath">The full path to the file if it exists, otherwise an empty string.</param>
/// <param name="token">An optional cancellation token to cancel the operation.</param>
/// <returns>True if the file exists in the PATH, otherwise false.</returns>
/// </summary>
public static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
{
fullPath = string.Empty;
if (File.Exists(filename))
{
token?.ThrowIfCancellationRequested();
fullPath = Path.GetFullPath(filename);
return true;
}
else
{
var values = Environment.GetEnvironmentVariable("PATH");
if (values != null)
{
foreach (var path in values.Split(';'))
{
var path1 = Path.Combine(path, filename);
if (File.Exists(path1))
{
fullPath = Path.GetFullPath(path1);
return true;
}
token?.ThrowIfCancellationRequested();
var path2 = Path.Combine(path, filename + ".exe");
if (File.Exists(path2))
{
fullPath = Path.GetFullPath(path2);
return true;
}
token?.ThrowIfCancellationRequested();
}
return false;
}
else
{
return false;
}
}
}
}

View File

@@ -133,13 +133,15 @@
VerticalAlignment="Top"
Background="Transparent"
Header1FontSize="20"
Header1Margin="0,16,0,0"
Header2FontSize="17"
Header2FontWeight="SemiBold"
Header4FontSize="14"
Header1FontWeight="SemiBold"
Header1Margin="0,0,0,4"
Header3FontSize="16"
Header3FontWeight="SemiBold"
Header4FontSize="16"
Header4FontWeight="SemiBold"
HorizontalRuleMargin="24"
LinkClicked="ReleaseNotesMarkdown_LinkClicked"
ListMargin="-18,4,0,12"
ParagraphMargin="0,0,0,0"
TableMargin="24"
Visibility="Collapsed" />

View File

@@ -112,7 +112,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
foreach (var release in latestReleases)
{
releaseNotesHtmlBuilder.AppendLine("# " + release.Name);
var notes = removeHashRegex.Replace(release.ReleaseNotes, "\r\n## Highlights");
var notes = removeHashRegex.Replace(release.ReleaseNotes, "\r\n### Highlights");
// Add a unique counter to [github-current-release-work] to distinguish each release,
// since this variable is used for all latest releases when they are merged.