Compare commits

..

1 Commits

Author SHA1 Message Date
Niels Laute
232766b147 Create xaml-styler.yml 2026-02-25 11:01:16 +01:00
192 changed files with 1238 additions and 14019 deletions

View File

@@ -315,7 +315,6 @@ xef
xes
PACKAGEVERSIONNUMBER
APPXMANIFESTVERSION
PROGMAN
# MRU lists
CACHEWRITE
@@ -326,14 +325,6 @@ REGSTR
# Misc Win32 APIs and PInvokes
INVOKEIDLIST
MEMORYSTATUSEX
ABE
HTCAPTION
POSCHANGED
QUERYPOS
SETAUTOHIDEBAR
WINDOWPOS
WINEVENTPROC
WORKERW
# PowerRename metadata pattern abbreviations (used in tests and regex patterns)
DDDD
@@ -358,6 +349,3 @@ nostdin
# Performance counter keys
engtype
Nonpaged
# XAML
Untargeted

117
.github/workflows/xaml-styler.yml vendored Normal file
View File

@@ -0,0 +1,117 @@
name: XAML Styler
on:
issue_comment:
types:
- "created"
permissions:
contents: read
jobs:
xaml-styler:
name: Apply XAML Styling
runs-on: windows-latest
permissions:
contents: write
pull-requests: write
if: >-
github.event.issue.pull_request &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) &&
trim(github.event.comment.body) == '/xamlstyler'
concurrency:
group: xaml-styler-${{ github.event.issue.number }}
cancel-in-progress: true
steps:
- name: Add eyes reaction
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'eyes'
});
- name: Get PR details
id: pr
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
core.setOutput('head_ref', pr.data.head.ref);
core.setOutput('head_repo', pr.data.head.repo.full_name);
- name: Checkout PR branch
uses: actions/checkout@v4
with:
repository: ${{ steps.pr.outputs.head_repo }}
ref: ${{ steps.pr.outputs.head_ref }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Restore dotnet tools
run: dotnet tool restore
- name: Run XAML Styler
run: |
& '.pipelines/applyXamlStyling.ps1' -Main
shell: pwsh
- name: Commit and push changes
id: commit
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
if (git diff --cached --quiet) {
echo "has_changes=false" >> $env:GITHUB_OUTPUT
} else {
git commit -m "Apply XAML styling"
git push
echo "has_changes=true" >> $env:GITHUB_OUTPUT
}
shell: pwsh
- name: Add success reaction
if: always() && steps.commit.outcome == 'success'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const hasChanges = '${{ steps.commit.outputs.has_changes }}' === 'true';
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: hasChanges ? 'rocket' : '+1'
});
if (!hasChanges) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '✅ No XAML styling changes needed — all files are already formatted correctly.'
});
}
- name: Add failure reaction
if: failure()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'confused'
});

View File

@@ -211,6 +211,9 @@
"PowerToys.PowerAccentModuleInterface.dll",
"PowerToys.PowerAccentKeyboardService.dll",
"PowerToys.PowerDisplayModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
"PowerDisplay.Lib.dll",
"WinUI3Apps\\PowerToys.PowerRenameExt.dll",

View File

@@ -688,13 +688,11 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<!-- TEMPORARILY_DISABLED: PowerDisplay
<Project Path="src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj" Id="d1234567-8901-2345-6789-abcdef012345" />
-->
</Folder>
<Folder Name="/modules/PowerDisplay/Tests/">
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj">

View File

@@ -1,203 +0,0 @@
# Advanced Paste Python Scripts
Advanced Paste supports user-defined Python scripts that transform clipboard content. Scripts are
discovered automatically from a configurable folder and appear as actions in the Advanced Paste UI.
## Quick start
1. Open the scripts folder — by default `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts`.
You can change this in **Settings → Advanced Paste → Python scripts → Scripts folder**.
2. Drop a `.py` file into the folder.
3. Add the required header comments at the top (see [Header format](#header-format)).
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
## Header format
Every script must start with one or more **header comment lines**. Each line follows the pattern:
```
# @advancedpaste:<tag> <value>
```
The parser reads the first 50 lines of each file; only lines beginning with `#` are inspected.
### Supported tags
| Tag | Required | Description |
|-----|----------|-------------|
| `name` | **Yes** | Display name shown in the Advanced Paste UI. |
| `desc` | No | Short description / tooltip. |
| `formats` | No | Comma-separated list of supported clipboard formats. Defaults to **all** formats when omitted. |
| `platform` | No | `windows` (default) or `linux`. Determines the execution mode (see below). |
| `version` | No | Free-form version string (reserved for future use). |
| `requires` | No | Space-separated Python package requirements. See [Declaring dependencies](#declaring-dependencies). |
### Formats
| Value | Clipboard content |
|-------|--------------------|
| `text` | Plain or Unicode text (`CF_UNICODETEXT`) |
| `html` | HTML fragment (`CF_HTML`) |
| `image` | Bitmap / PNG image |
| `audio` | Audio file(s) |
| `video` | Video file(s) |
| `files` or `file` | File paths (`CF_HDROP` / `StorageItems`) |
| `any` | All of the above |
Multiple values can be combined with commas:
```python
# @advancedpaste:formats text,html
```
## Execution modes
### Windows mode (`platform windows`)
The script runs directly on Windows via the configured Python interpreter.
It **owns the clipboard** — use a library like `pywin32` (`win32clipboard`) to read
and write clipboard data.
**Invocation:**
```
python.exe "<script.py>" --format <detected_format> --work-dir "<temp_dir>"
```
**Minimal example — reverse text:**
```python
# @advancedpaste:name Reverse text
# @advancedpaste:formats text
# @advancedpaste:platform windows
import win32clipboard
win32clipboard.OpenClipboard()
text = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT)
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_UNICODETEXT, text[::-1])
win32clipboard.CloseClipboard()
```
After the script exits with code 0, Advanced Paste re-reads the clipboard and pastes
the result. A non-zero exit code signals failure; stderr is shown in the error UI.
### WSL / Linux mode (`platform linux`)
The script runs inside WSL via `wsl.exe bash -l -c "python3 -X utf8 <script>"`.
Instead of direct clipboard access, data is exchanged via **JSON**:
| Direction | Channel | Schema |
|-----------|---------|--------|
| **Input** (C# → Python) | `stdin` (JSON) | See [Input payload](#input-payload) |
| **Output** (Python → C#) | `stdout` (JSON) | See [Output payload](#output-payload) |
#### Input payload
```jsonc
{
"version": 2,
"format": ["text"], // array of detected clipboard format names
"work_dir": "/mnt/c/...", // writable temp directory (WSL path)
"text": "Hello, world!", // present when clipboard has text
"html": "<b>Hello</b>", // present when clipboard has HTML
"image_path": "/mnt/c/.../input.png", // present when clipboard has an image
"file_paths": ["/mnt/c/.../file.txt"] // present when clipboard has files
}
```
#### Output payload
```jsonc
{
"result_type": "text", // "text" | "html" | "image" | "file" | "files"
"text": "HELLO, WORLD!", // for result_type "text"
"html": "<b>HELLO</b>", // for result_type "html"
"image_path": "/mnt/c/.../output.png", // for result_type "image"
"file_paths": ["/mnt/c/.../out.txt"] // for result_type "file"/"files"
}
```
> **Note:** File paths in the output must use `/mnt/<drive>/...` format so that
> Advanced Paste can map them back to Windows paths.
**Minimal example — uppercase text (WSL):**
```python
# @advancedpaste:name WSL Upper Case
# @advancedpaste:formats text
# @advancedpaste:platform linux
import sys, json
data = json.load(sys.stdin)
text = data.get("text", "")
json.dump({"result_type": "text", "text": text.upper()}, sys.stdout)
```
## Declaring dependencies
Use `requires` to declare Python packages the script needs:
```python
# @advancedpaste:requires markitdown='markitdown[all]'
# @advancedpaste:requires cv2=opencv-python-headless numpy requests
```
Each token is either:
- **`import_name`** — the pip package is assumed to have the same name (e.g. `requests`).
- **`import_name=pip_package`** — when the import name differs from the pip package
(e.g. `cv2=opencv-python-headless`, `PIL=Pillow`).
Multiple tokens on one line are space-separated. You can also use multiple `requires` lines.
### Automatic import detection
Advanced Paste also scans the script body for `import` and `from ... import` statements
and cross-references them against the Python standard library. Any non-stdlib import
that is not already installed triggers a prompt to install it automatically.
A built-in mapping table handles common mismatches (e.g. `win32clipboard``pywin32`,
`cv2``opencv-python`, `PIL``Pillow`). For uncommon packages where the import name
differs from the pip name, add an explicit `requires` entry.
## Security — script trust
The first time a script is executed (or after it has been modified), Advanced Paste
shows a confirmation dialog. Upon approval the SHA-256 hash of the script is stored.
Subsequent runs of the unchanged file skip the dialog.
## Error handling
When a script fails, Advanced Paste extracts the Python traceback from stderr and
displays a user-friendly summary in the UI:
- **ModuleNotFoundError** — identifies the missing module and suggests installing it.
- **SyntaxError** — shows the file and line number.
- **Timeout** — shows the configured timeout value (default 30 s; configurable in Settings).
- **Other errors** — shows the last line of the traceback as a summary, with the full
traceback available in the expandable *Details* section.
## Settings
The following settings are available under **Settings → Advanced Paste → Python scripts**:
| Setting | Description | Default |
|---------|-------------|---------|
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
## Tips
- Put reusable helper functions in a separate `.py` file without a `# @advancedpaste:name`
header — it will be ignored by the script discovery and can be imported by other scripts.
- For complex WSL scripts that need packages not available via `apt`, consider using
a virtual environment. The script can re-exec itself with the venv interpreter:
```python
import os, sys
venv = os.path.expanduser("~/my_env/bin/python3")
if os.path.exists(venv) and sys.executable != venv:
os.execv(venv, [venv] + sys.argv)
```
- The `--work-dir` argument (Windows mode) and `work_dir` JSON field (WSL mode) point to
a temporary directory that is cleaned up after execution. Use it for intermediate files.

View File

@@ -1565,6 +1565,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
L"PowerToys.PowerRename.exe",
L"PowerToys.ImageResizer.exe",
L"PowerToys.LightSwitchService.exe",
L"PowerToys.PowerDisplay.exe",
L"PowerToys.GcodeThumbnailProvider.exe",
L"PowerToys.BgcodeThumbnailProvider.exe",
L"PowerToys.PdfThumbnailProvider.exe",

View File

@@ -47,6 +47,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
call move /Y ..\..\..\NewPlus.wxs.bk ..\..\..\NewPlus.wxs
call move /Y ..\..\..\Peek.wxs.bk ..\..\..\Peek.wxs
call move /Y ..\..\..\PowerRename.wxs.bk ..\..\..\PowerRename.wxs
call move /Y ..\..\..\PowerDisplay.wxs.bk ..\..\..\PowerDisplay.wxs
call move /Y ..\..\..\Product.wxs.bk ..\..\..\Product.wxs
call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs
call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs
@@ -123,6 +124,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
<Compile Include="KeyboardManager.wxs" />
<Compile Include="Peek.wxs" />
<Compile Include="PowerRename.wxs" />
<Compile Include="PowerDisplay.wxs" />
<Compile Include="DscResources.wxs" />
<Compile Include="RegistryPreview.wxs" />
<Compile Include="Run.wxs" />

View File

@@ -53,6 +53,7 @@
<ComponentGroupRef Id="LightSwitchComponentGroup" />
<ComponentGroupRef Id="PeekComponentGroup" />
<ComponentGroupRef Id="PowerRenameComponentGroup" />
<ComponentGroupRef Id="PowerDisplayComponentGroup" />
<ComponentGroupRef Id="RegistryPreviewComponentGroup" />
<ComponentGroupRef Id="RunComponentGroup" />
<ComponentGroupRef Id="SettingsComponentGroup" />

View File

@@ -176,6 +176,10 @@ Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PS
Generate-FileList -fileDepsJson "" -fileListName LightSwitchFiles -wxsFilePath $PSScriptRoot\LightSwitch.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\LightSwitchService"
Generate-FileComponents -fileListName "LightSwitchFiles" -wxsFilePath $PSScriptRoot\LightSwitch.wxs
#PowerDisplay
Generate-FileList -fileDepsJson "" -fileListName PowerDisplayAssetsFiles -wxsFilePath $PSScriptRoot\PowerDisplay.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerDisplay"
Generate-FileComponents -fileListName "PowerDisplayAssetsFiles" -wxsFilePath $PSScriptRoot\PowerDisplay.wxs
#New+
Generate-FileList -fileDepsJson "" -fileListName NewPlusAssetsFiles -wxsFilePath $PSScriptRoot\NewPlus.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\NewPlus"
Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs

View File

@@ -117,4 +117,4 @@
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>
</Project>

View File

@@ -515,7 +515,8 @@ namespace ManagedCommon
return lightnessL.ToString(CultureInfo.InvariantCulture);
case "Lc":
var (lightnessC, _, _) = ConvertToCIELABColor(color);
return ColorPercentFormatted(lightnessC, paramFormat, 2);
lightnessC = Math.Round(lightnessC, 2);
return lightnessC.ToString(CultureInfo.InvariantCulture);
case "Lo":
var (lightnessO, _, _) = ConvertToOklabColor(color);
lightnessO = Math.Round(lightnessO, 2);
@@ -530,10 +531,12 @@ namespace ManagedCommon
return blackness.ToString(CultureInfo.InvariantCulture);
case "Ca":
var (_, chromaticityA, _) = ConvertToCIELABColor(color);
return ColorPercentFormatted(chromaticityA, paramFormat, 2);
chromaticityA = Math.Round(chromaticityA, 2);
return chromaticityA.ToString(CultureInfo.InvariantCulture);
case "Cb":
var (_, _, chromaticityB) = ConvertToCIELABColor(color);
return ColorPercentFormatted(chromaticityB, paramFormat, 2);
chromaticityB = Math.Round(chromaticityB, 2);
return chromaticityB.ToString(CultureInfo.InvariantCulture);
case "Oa":
var (_, chromaticityAOklab, _) = ConvertToOklabColor(color);
chromaticityAOklab = Math.Round(chromaticityAOklab, 2);
@@ -592,24 +595,6 @@ namespace ManagedCommon
}
}
private static string ColorPercentFormatted(double colorPercentValue, char paramFormat, int defaultDecimalDigits)
{
switch (paramFormat)
{
case 'i':
double roundedColorPercentValue = Math.Round(colorPercentValue);
if (roundedColorPercentValue == 0)
{
// convert -0 to 0
roundedColorPercentValue = 0.0;
}
return roundedColorPercentValue.ToString(CultureInfo.InvariantCulture);
default:
return Math.Round(colorPercentValue, defaultDecimalDigits).ToString(CultureInfo.InvariantCulture);
}
}
public static string GetDefaultFormat(string formatName)
{
switch (formatName)

View File

@@ -149,7 +149,6 @@
<decimal value="0" />
</disabledValue>
</policy>
<!-- TEMPORARILY_DISABLED: PowerDisplay
<policy name="ConfigureEnabledUtilityPowerDisplay" class="Both" displayName="$(string.ConfigureEnabledUtilityPowerDisplay)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityPowerDisplay">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_95_0" />
@@ -160,7 +159,6 @@
<decimal value="0" />
</disabledValue>
</policy>
-->
<policy name="ConfigureEnabledUtilityEnvironmentVariables" class="Both" displayName="$(string.ConfigureEnabledUtilityEnvironmentVariables)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityEnvironmentVariables">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_75_0" />

View File

@@ -248,7 +248,7 @@ If you don't configure this policy, the user will be able to control the setting
<string id="ConfigureEnabledUtilityCmdPal">CmdPal: Configure enabled state</string>
<string id="ConfigureEnabledUtilityCropAndLock">Crop And Lock: Configure enabled state</string>
<string id="ConfigureEnabledUtilityLightSwitch">Light Switch: Configure enabled state</string>
<!-- <string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string> --><!-- TEMPORARILY_DISABLED: PowerDisplay -->
<string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string>
<string id="ConfigureEnabledUtilityEnvironmentVariables">Environment Variables: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFancyZones">FancyZones: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileLocksmith">File Locksmith: Configure enabled state</string>

View File

@@ -57,16 +57,6 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public PasteAIConfiguration PasteAIConfiguration => _configuration;
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => Array.Empty<AdvancedPastePythonScriptAction>();
public string PythonScriptsFolder => string.Empty;
public string PythonExecutablePath => string.Empty;
public int PythonScriptTimeoutSeconds => 30;
public IReadOnlyDictionary<string, string> TrustedScriptHashes => new Dictionary<string, string>();
public event EventHandler Changed;
public Task SetActiveAIProviderAsync(string providerId)
@@ -75,8 +65,4 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
Changed?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
}
}

View File

@@ -1,378 +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.Collections.Generic;
using System.Linq;
using AdvancedPaste.Services.PythonScripts;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AdvancedPaste.UnitTests.ServicesTests;
[TestClass]
public sealed class PythonScriptServiceTests
{
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsSimpleImports()
{
var lines = new[]
{
"# @advancedpaste:name test",
"import requests",
"import numpy",
"import os",
"import sys",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count); // requests + numpy; os and sys are stdlib
Assert.IsTrue(result.Any(r => r.ImportName == "requests" && r.PipPackage == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy" && r.PipPackage == "numpy"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsFromImports()
{
var lines = new[]
{
"from PIL import Image",
"from markitdown import MarkItDown",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "PIL" && r.PipPackage == "Pillow"));
Assert.IsTrue(result.Any(r => r.ImportName == "markitdown" && r.PipPackage == "markitdown"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_WellKnownMappings()
{
var lines = new[]
{
"import cv2",
"import win32clipboard",
"import yaml",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "cv2" && r.PipPackage == "opencv-python"));
Assert.IsTrue(result.Any(r => r.ImportName == "win32clipboard" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "yaml" && r.PipPackage == "PyYAML"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_ExplicitRequirementsTakePrecedence()
{
var lines = new[]
{
"import cv2",
"import requests",
};
var explicitReqs = new List<PythonRequirement>
{
new("cv2", "opencv-python-headless"),
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, explicitReqs);
Assert.AreEqual(2, result.Count);
// cv2 should use the explicit pip package name, not the well-known mapping
var cv2Req = result.First(r => r.ImportName == "cv2");
Assert.AreEqual("opencv-python-headless", cv2Req.PipPackage);
// requests should be auto-detected
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsStdlib()
{
var lines = new[]
{
"import os",
"import sys",
"import json",
"import io",
"import pathlib",
"import tempfile",
"import subprocess",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsComments()
{
var lines = new[]
{
"# import requests",
"# from PIL import Image",
"import json",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesMultipleImportsOnOneLine()
{
var lines = new[]
{
"import requests, numpy, pandas",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy"));
Assert.IsTrue(result.Any(r => r.ImportName == "pandas"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesSubmoduleImport()
{
var lines = new[]
{
"import win32com.client",
"from llama_cpp import Llama",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "win32com" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "llama_cpp" && r.PipPackage == "llama-cpp-python"));
}
[TestMethod]
public void ParsePythonError_ModuleNotFoundError()
{
var stderr = """
Traceback (most recent call last):
File "C:\scripts\reverse.py", line 4, in <module>
import win32clipboard
ModuleNotFoundError: No module named 'win32clipboard'
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("reverse.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 4"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("win32clipboard"), $"Summary should mention the module: {summary}");
Assert.IsTrue(summary.Contains("pywin32"), $"Summary should suggest pip package: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxError()
{
var stderr = """
File "test.py", line 5
def foo(
^
SyntaxError: unexpected EOF while parsing
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 5"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxErrorWithColumn()
{
var stderr = " File \"script.py\", line 3\n x = (1 +\n ^\nSyntaxError: '(' was never closed\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("script.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 3"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("col"), $"Summary should mention the column: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_GenericError()
{
var stderr = """
Traceback (most recent call last):
File "test.py", line 10, in <module>
result = 1 / 0
ZeroDivisionError: division by zero
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 10"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("ZeroDivisionError"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_NestedTraceback_ShowsLastFrame()
{
var stderr = """
Traceback (most recent call last):
File "main.py", line 5, in <module>
helper()
File "helper.py", line 12, in helper
do_work()
File "worker.py", line 8, in do_work
raise RuntimeError("bad state")
RuntimeError: bad state
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("worker.py"), $"Summary should mention the last script in the chain: {summary}");
Assert.IsTrue(summary.Contains("line 8"), $"Summary should mention the line of the last frame: {summary}");
Assert.IsTrue(summary.Contains("bad state"), $"Summary should contain the error message: {summary}");
}
[TestMethod]
public void ParsePythonError_EmptyStderr()
{
var (summary, details) = PythonScriptService.ParsePythonError(string.Empty);
Assert.IsTrue(!string.IsNullOrEmpty(summary));
Assert.AreEqual(string.Empty, details);
}
[TestMethod]
public void ParsePythonError_NoTraceback_PlainStderr()
{
var stderr = "Something went wrong in the script\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
// No File "..." reference, so no location — just the message
Assert.IsTrue(summary.Contains("Something went wrong"), $"Summary: {summary}");
Assert.IsFalse(summary.Contains("line"), $"Summary should not contain 'line' without a traceback: {summary}");
}
[TestMethod]
public void ExtractLastTracebackLocation_BasicTraceback()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"script.py\", line 10, in <module>",
" result = 1 / 0",
"ZeroDivisionError: division by zero",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("script.py", location.Value.FileName);
Assert.AreEqual(10, location.Value.Line);
Assert.IsNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_WithCaret()
{
var lines = new[]
{
" File \"test.py\", line 5",
" def foo(",
" ^",
"SyntaxError: unexpected EOF while parsing",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("test.py", location.Value.FileName);
Assert.AreEqual(5, location.Value.Line);
Assert.IsNotNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_FullPath_ReturnsBasename()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"C:\\Users\\user\\scripts\\my_script.py\", line 42, in <module>",
" some_call()",
"ValueError: invalid value",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("my_script.py", location.Value.FileName);
Assert.AreEqual(42, location.Value.Line);
}
[TestMethod]
public void ExtractLastTracebackLocation_NoFileLine_ReturnsNull()
{
var lines = new[]
{
"Some random error output",
"No traceback here",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNull(location);
}
[TestMethod]
public void ParsePipInstallError_ExtractsErrorLine()
{
var stderr = """
Collecting some-package
Downloading some-package-1.0.tar.gz (15 kB)
ERROR: Could not find a version that satisfies the requirement some-package (from versions: none)
ERROR: No matching distribution found for some-package
""";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("No matching distribution"), $"Summary should contain the last ERROR line: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_NoErrorPrefix_UsesLastLine()
{
var stderr = "permission denied: /usr/lib/python3/dist-packages\n";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("permission denied"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_EmptyStderr()
{
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(string.Empty);
Assert.AreEqual("unknown error", summary);
Assert.AreEqual(string.Empty, fullStderr);
}
}

View File

@@ -14,7 +14,6 @@ using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using AdvancedPaste.ViewModels;
using ManagedCommon;
@@ -84,8 +83,6 @@ namespace AdvancedPaste
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
services.AddSingleton<IPythonScriptService, PythonScriptService>();
services.AddSingleton<IPythonScriptTrustService, PythonScriptTrustService>();
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
services.AddSingleton<OptionsViewModel>();
}).Build();

View File

@@ -755,7 +755,63 @@
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</TextBlock>
<!-- Error message grid moved to MainPage.xaml so it remains enabled when PromptBox is disabled -->
<Grid
x:Name="ErrorMessageGrid"
x:Uid="ErrorMessageGrid"
Grid.Row="1"
Margin="8,8,0,0"
ColumnSpacing="8"
Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<ToolTipService.ToolTip>
<ToolTip VerticalOffset="-105" Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel
MinWidth="300"
HorizontalAlignment="Stretch"
Orientation="Vertical">
<TextBox
x:Name="AIErrorMessage"
x:Uid="AIErrorMessage"
FontSize="12"
IsReadOnly="True"
Text="{x:Bind ViewModel.PasteActionError.Details, Mode=OneWay}"
TextWrapping="Wrap" />
</StackPanel>
</ToolTip>
</ToolTipService.ToolTip>
<FontIcon
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
</StackPanel>
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="1"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation To="1.0" Duration="0:0:0.6" />
</animations:Implicit.ShowAnimations>
<animations:Implicit.HideAnimations>
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="DefaultState" />
@@ -776,6 +832,7 @@
<VisualState.Setters>
<Setter Target="InputTxtBox.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
<Setter Target="DisclaimerPresenter.Visibility" Value="Collapsed" />
<Setter Target="ErrorMessageGrid.Visibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>

View File

@@ -43,8 +43,7 @@ namespace AdvancedPaste
double GetHeight(int maxCustomActionCount) =>
baseHeight +
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.PythonScriptPasteFormats.Count);
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
MinHeight = GetHeight(1);
Height = GetHeight(5);
@@ -60,7 +59,6 @@ namespace AdvancedPaste
UpdateHeight();
}
};
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
this.ExtendsContentIntoTitleBar = true;

View File

@@ -144,7 +144,6 @@
</Page.KeyboardAccelerators>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -300,65 +299,13 @@
</StackPanel>
</controls:PromptBox.Footer>
</controls:PromptBox>
<Grid
x:Name="ErrorMessageGrid"
Grid.Row="2"
Margin="20,4,20,0"
ColumnSpacing="8"
Visibility="{x:Bind ViewModel.PasteActionError.HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
Grid.Column="1"
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
MaxLines="2"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
<HyperlinkButton
x:Name="ShowErrorDetailsBtn"
x:Uid="ShowErrorDetailsBtn"
Grid.Column="2"
Margin="4,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Click="ShowErrorDetailsBtn_Click"
FontSize="12"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="3"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
</Grid>
<ScrollViewer Grid.Row="3">
<ScrollViewer Grid.Row="2">
<Grid RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListView
@@ -394,27 +341,6 @@
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="2" />
<Rectangle
Grid.Row="3"
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
<ListView
x:Name="PythonScriptsListView"
Grid.Row="4"
VerticalAlignment="Top"
IsItemClickEnabled="True"
ItemClick="PasteFormat_ItemClick"
ItemContainerTransitions="{x:Null}"
ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}"
ItemsSource="{x:Bind ViewModel.PythonScriptPasteFormats, Mode=OneWay}"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="3" />
</Grid>
</ScrollViewer>
</Grid>

View File

@@ -208,43 +208,5 @@ namespace AdvancedPaste.Pages
Clipboard.SetHistoryItemAsContent(item.Item);
}
}
private async void ShowErrorDetailsBtn_Click(object sender, RoutedEventArgs e)
{
var details = ViewModel.PasteActionError?.Details;
if (string.IsNullOrEmpty(details))
{
return;
}
var scrollViewer = new ScrollViewer
{
MaxHeight = 400,
MinWidth = 400,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
};
var textBlock = new TextBlock
{
Text = details,
TextWrapping = TextWrapping.Wrap,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
IsTextSelectionEnabled = true,
};
scrollViewer.Content = textBlock;
var dialog = new ContentDialog
{
Title = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogTitle"),
CloseButtonText = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogClose"),
Content = scrollViewer,
XamlRoot = this.XamlRoot,
};
await dialog.ShowAsync();
}
}
}

View File

@@ -27,22 +27,8 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
public bool IsPythonScriptsEnabled { get; }
public string PythonScriptsFolder { get; }
public string PythonExecutablePath { get; }
public int PythonScriptTimeoutSeconds { get; }
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; }
public event EventHandler Changed;
Task SetActiveAIProviderAsync(string providerId);
void StoreTrustedScriptHash(string scriptPath, string hash);
}
}

View File

@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading;
@@ -26,10 +25,6 @@ namespace AdvancedPaste.Settings
private readonly Lock _loadingSettingsLock = new();
private readonly List<PasteFormats> _additionalActions;
private readonly List<AdvancedPasteCustomAction> _customActions;
private readonly List<AdvancedPastePythonScriptAction> _pythonScriptActions;
private FileSystemWatcher _scriptFolderWatcher;
private CancellationTokenSource _scriptFolderDebounce;
private string _watchedScriptsFolder = string.Empty;
private const string AdvancedPasteModuleName = "AdvancedPaste";
private const int MaxNumberOfRetry = 5;
@@ -53,18 +48,6 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => _pythonScriptActions;
public string PythonScriptsFolder { get; private set; }
public bool IsPythonScriptsEnabled { get; private set; }
public string PythonExecutablePath { get; private set; }
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; private set; } = new Dictionary<string, string>();
public UserSettings(IFileSystem fileSystem)
{
_settingsUtils = new SettingsUtils(fileSystem);
@@ -74,12 +57,8 @@ namespace AdvancedPaste.Settings
CloseAfterLosingFocus = false;
EnableClipboardPreview = true;
PasteAIConfiguration = new PasteAIConfiguration();
PythonScriptsFolder = GetDefaultScriptsFolder();
PythonExecutablePath = string.Empty;
PythonScriptTimeoutSeconds = 30;
_additionalActions = [];
_customActions = [];
_pythonScriptActions = [];
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
LoadSettingsFromJson();
@@ -87,14 +66,6 @@ namespace AdvancedPaste.Settings
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
}
private static string GetDefaultScriptsFolder() =>
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"AdvancedPaste",
"Scripts");
private void OnSettingsFileChanged()
{
lock (_loadingSettingsLock)
@@ -160,22 +131,6 @@ namespace AdvancedPaste.Settings
_customActions.Clear();
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
IsPythonScriptsEnabled = pythonScripts.IsEnabled;
PythonScriptsFolder = string.IsNullOrWhiteSpace(pythonScripts.ScriptsFolder)
? GetDefaultScriptsFolder()
: pythonScripts.ScriptsFolder;
PythonExecutablePath = pythonScripts.PythonExecutablePath ?? string.Empty;
PythonScriptTimeoutSeconds = pythonScripts.TimeoutSeconds > 0 ? pythonScripts.TimeoutSeconds : 30;
TrustedScriptHashes = new Dictionary<string, string>(
pythonScripts.TrustedScriptHashes ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
_pythonScriptActions.Clear();
_pythonScriptActions.AddRange(pythonScripts.Value.Where(a => a.IsShown));
UpdateScriptFolderWatcher(PythonScriptsFolder);
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -340,102 +295,6 @@ namespace AdvancedPaste.Settings
return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant();
}
private void UpdateScriptFolderWatcher(string folderPath)
{
if (string.Equals(_watchedScriptsFolder, folderPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
_scriptFolderWatcher?.Dispose();
_scriptFolderWatcher = null;
_watchedScriptsFolder = folderPath;
if (string.IsNullOrWhiteSpace(folderPath))
{
return;
}
try
{
if (!System.IO.Directory.Exists(folderPath))
{
System.IO.Directory.CreateDirectory(folderPath);
}
_scriptFolderWatcher = new FileSystemWatcher(folderPath, "*.py")
{
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime,
EnableRaisingEvents = true,
IncludeSubdirectories = false,
};
_scriptFolderWatcher.Changed += OnScriptFolderChanged;
_scriptFolderWatcher.Created += OnScriptFolderChanged;
_scriptFolderWatcher.Deleted += OnScriptFolderChanged;
_scriptFolderWatcher.Renamed += OnScriptFolderChanged;
}
catch (Exception ex)
{
Logger.LogError($"Failed to set up script folder watcher for {folderPath}", ex);
}
}
private void OnScriptFolderChanged(object sender, FileSystemEventArgs e)
{
lock (_loadingSettingsLock)
{
_scriptFolderDebounce?.Cancel();
_scriptFolderDebounce = new CancellationTokenSource();
Task.Delay(TimeSpan.FromMilliseconds(500))
.ContinueWith(
_ =>
{
Task.Factory
.StartNew(
() => Changed?.Invoke(this, EventArgs.Empty),
CancellationToken.None,
TaskCreationOptions.None,
_taskScheduler)
.Wait();
},
_scriptFolderDebounce.Token,
TaskContinuationOptions.NotOnCanceled,
TaskScheduler.Default);
}
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
lock (_loadingSettingsLock)
{
try
{
var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName);
if (settings?.Properties?.PythonScripts is null)
{
return;
}
settings.Properties.PythonScripts.TrustedScriptHashes ??= new Dictionary<string, string>();
settings.Properties.PythonScripts.TrustedScriptHashes[scriptPath] = hash;
settings.Save(_settingsUtils);
// Update in-memory cache.
var updated = new Dictionary<string, string>(TrustedScriptHashes, StringComparer.OrdinalIgnoreCase)
{
[scriptPath] = hash,
};
TrustedScriptHashes = updated;
}
catch (Exception ex)
{
Logger.LogError("Failed to store trusted script hash", ex);
}
}
}
public async Task SetActiveAIProviderAsync(string providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
@@ -528,8 +387,6 @@ namespace AdvancedPaste.Settings
if (disposing)
{
_cancellationTokenSource?.Dispose();
_scriptFolderDebounce?.Dispose();
_scriptFolderWatcher?.Dispose();
_watcher?.Dispose();
}

View File

@@ -40,14 +40,6 @@ public sealed class PasteFormat
IsSavedQuery = isSavedQuery,
};
public static PasteFormat CreatePythonScriptFormat(string name, string scriptPath, ClipboardFormat availableFormats) =>
new(PasteFormats.PythonScript, availableFormats, isAIServiceEnabled: false)
{
Name = name,
Prompt = scriptPath,
IsSavedQuery = true,
};
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
public string IconGlyph => Metadata.IconGlyph;

View File

@@ -122,13 +122,4 @@ public enum PasteFormats
KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.",
RequiresPrompt = true)]
CustomTextTransformation,
[PasteFormatMetadata(
IsCoreAction = false,
IconGlyph = "\uE943",
RequiresAIService = false,
CanPreview = true,
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Image | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.File,
KernelFunctionDescription = "Runs a user-provided Python script on clipboard content.")]
PythonScript,
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -9,23 +9,15 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services;
public sealed class PasteFormatExecutor(
IKernelService kernelService,
ICustomActionTransformService customActionTransformService,
IPythonScriptService pythonScriptService,
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
{
private readonly IKernelService _kernelService = kernelService;
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
private readonly IPythonScriptService _pythonScriptService = pythonScriptService;
private readonly IPythonScriptTrustService _pythonScriptTrustService = pythonScriptTrustService;
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
{
@@ -40,15 +32,6 @@ public sealed class PasteFormatExecutor(
var clipboardData = Clipboard.GetContent();
// PythonScript must NOT run inside Task.Run: the trust confirmation (ContentDialog)
// requires the UI (XAML) thread and will throw if called from a thread-pool thread.
// Python script execution is fully async (process.WaitForExitAsync), so it is safe
// to await it directly without wrapping in Task.Run.
if (format == PasteFormats.PythonScript)
{
return await ExecutePythonScriptAsync(pasteFormat.Prompt, clipboardData, cancellationToken, progress);
}
// Run on thread-pool; although we use Async routines consistently, some actions still occasionally take a long time without yielding.
return await Task.Run(async () =>
pasteFormat.Format switch
@@ -59,85 +42,6 @@ public sealed class PasteFormatExecutor(
});
}
private async Task<DataPackage> ExecutePythonScriptAsync(
string scriptPath,
DataPackageView clipboardData,
CancellationToken cancellationToken,
IProgress<double> progress)
{
// Security: ensure the script is trusted before executing.
if (!_pythonScriptTrustService.IsTrusted(scriptPath))
{
var hash = _pythonScriptTrustService.ComputeHash(scriptPath);
var approved = await _pythonScriptTrustService.RequestTrustAsync(scriptPath, hash);
if (!approved)
{
throw new OperationCanceledException("User declined to trust the Python script.");
}
_pythonScriptTrustService.StoreTrust(scriptPath, hash);
}
var metadata = _pythonScriptService.ReadMetadata(scriptPath);
// Pre-flight: check for missing packages and offer to install them.
var missingPackages = await _pythonScriptService.GetMissingRequirementsAsync(metadata, cancellationToken);
if (missingPackages.Count > 0)
{
var approved = await _pythonScriptTrustService.RequestInstallAsync(metadata.Name, missingPackages);
if (!approved)
{
throw new OperationCanceledException("User declined to install missing Python packages.");
}
await _pythonScriptService.InstallRequirementsAsync(missingPackages, metadata.Platform, cancellationToken);
}
var detectedFormat = await clipboardData.GetAvailableFormatsAsync();
if (string.Equals(metadata.Platform, "linux", StringComparison.OrdinalIgnoreCase))
{
return await _pythonScriptService.ExecuteWslScriptAsync(scriptPath, clipboardData, detectedFormat, cancellationToken, progress);
}
else
{
// Windows mode: script modifies the clipboard in-process; we return the updated clipboard.
await _pythonScriptService.ExecuteWindowsScriptAsync(scriptPath, detectedFormat, cancellationToken, progress);
// Re-read clipboard after script has run.
return Clipboard.GetContent() is { } updatedView
? await DataPackageFromViewAsync(updatedView)
: new DataPackage();
}
}
private static async Task<DataPackage> DataPackageFromViewAsync(DataPackageView view)
{
var pkg = new DataPackage();
if (view.Contains(StandardDataFormats.Text))
{
pkg.SetText(await view.GetTextAsync());
}
else if (view.Contains(StandardDataFormats.Html))
{
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
}
else if (view.Contains(StandardDataFormats.StorageItems))
{
var items = await view.GetStorageItemsAsync();
pkg.SetStorageItems(items);
}
else if (view.Contains(StandardDataFormats.Bitmap))
{
var bitmap = await view.GetBitmapAsync();
pkg.SetBitmap(bitmap);
}
return pkg;
}
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
{
switch (source)

View File

@@ -1,62 +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.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptService
{
/// <summary>
/// Windows mode: the script directly manipulates the clipboard. C# waits for the process to exit.
/// </summary>
Task ExecuteWindowsScriptAsync(string scriptPath, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// WSL mode: C# passes data via JSON stdin, receives a DataPackage from JSON stdout.
/// </summary>
Task<DataPackage> ExecuteWslScriptAsync(string scriptPath, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Parses the @advancedpaste: header comments from a Python script file.
/// </summary>
PythonScriptMetadata ReadMetadata(string scriptPath);
/// <summary>
/// Discovers all .py scripts in <paramref name="folderPath"/> and returns their metadata.
/// </summary>
IReadOnlyList<PythonScriptMetadata> DiscoverScripts(string folderPath);
/// <summary>
/// Finds the Python executable to use. Returns null if none is found.
/// </summary>
string TryFindPythonExecutable(string overridePath = null);
/// <summary>
/// Returns true if wsl.exe is available on this machine.
/// </summary>
bool IsWslAvailable();
/// <summary>
/// Checks which of the declared requirements are not yet importable.
/// Returns an empty list if all packages are installed.
/// </summary>
Task<IReadOnlyList<PythonRequirement>> GetMissingRequirementsAsync(
PythonScriptMetadata metadata,
CancellationToken cancellationToken);
/// <summary>
/// Installs the given packages via pip / pip3.
/// </summary>
Task InstallRequirementsAsync(
IReadOnlyList<PythonRequirement> requirements,
string platform,
CancellationToken cancellationToken);
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptTrustService
{
/// <summary>
/// Returns true if the script at <paramref name="scriptPath"/> is currently trusted (hash matches stored value).
/// </summary>
bool IsTrusted(string scriptPath);
/// <summary>
/// Shows a UI confirmation dialog for the script. Returns true if the user approved execution.
/// </summary>
Task<bool> RequestTrustAsync(string scriptPath, string hash);
/// <summary>
/// Persists the trust entry for <paramref name="scriptPath"/> with the given <paramref name="hash"/>.
/// </summary>
void StoreTrust(string scriptPath, string hash);
/// <summary>
/// Computes the SHA-256 hash of the script file and returns the hex string.
/// </summary>
string ComputeHash(string scriptPath);
/// <summary>
/// Shows a confirmation dialog listing the missing packages and asking the user
/// whether to install them. Returns true if the user approved installation.
/// </summary>
Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages);
}

View File

@@ -1,13 +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.
namespace AdvancedPaste.Services.PythonScripts;
/// <summary>
/// Represents a single Python package requirement declared via
/// <c># @advancedpaste:requires import_name=pip_package</c>.
/// </summary>
/// <param name="ImportName">The Python import name used in the script (e.g. "cv2").</param>
/// <param name="PipPackage">The pip install name (e.g. "opencv-python-headless"). Equals <see cref="ImportName"/> when not explicitly specified.</param>
public sealed record PythonRequirement(string ImportName, string PipPackage);

View File

@@ -1,19 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using AdvancedPaste.Models;
namespace AdvancedPaste.Services.PythonScripts;
public sealed record PythonScriptMetadata(
string ScriptPath,
string Name,
string Description,
ClipboardFormat SupportedFormats,
string Platform,
string Version,
bool IsEnabled,
IReadOnlyList<PythonRequirement> Requirements);

View File

@@ -1,126 +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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Settings;
using ManagedCommon;
using Microsoft.UI.Xaml.Controls;
namespace AdvancedPaste.Services.PythonScripts;
public sealed class PythonScriptTrustService(IUserSettings userSettings) : IPythonScriptTrustService
{
private readonly IUserSettings _userSettings = userSettings;
public bool IsTrusted(string scriptPath)
{
var hashes = _userSettings.TrustedScriptHashes;
if (hashes is null || !hashes.TryGetValue(scriptPath, out var storedHash))
{
return false;
}
try
{
var currentHash = ComputeHash(scriptPath);
return string.Equals(currentHash, storedHash, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
Logger.LogError($"Failed to compute hash for {scriptPath}", ex);
return false;
}
}
public async Task<bool> RequestTrustAsync(string scriptPath, string hash)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonScriptTrustTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonScriptTrustContent"),
scriptPath),
PrimaryButtonText = resourceLoader.GetString("PythonScriptTrustConfirm"),
CloseButtonText = resourceLoader.GetString("PythonScriptTrustCancel"),
};
// XamlRoot must be set for ContentDialog to function.
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show trust dialog", ex);
return false;
}
}
public void StoreTrust(string scriptPath, string hash)
{
_userSettings.StoreTrustedScriptHash(scriptPath, hash);
}
public string ComputeHash(string scriptPath)
{
using var stream = File.OpenRead(scriptPath);
var hashBytes = SHA256.HashData(stream);
return Convert.ToHexStringLower(hashBytes);
}
public async Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var packageList = string.Join("\n", missingPackages.Select(r =>
string.Equals(r.ImportName, r.PipPackage, StringComparison.Ordinal)
? $" • {r.PipPackage}"
: $" • {r.PipPackage} (import: {r.ImportName})"));
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonPackageInstallTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonPackageInstallContent"),
scriptName,
packageList),
PrimaryButtonText = resourceLoader.GetString("PythonPackageInstallConfirm"),
CloseButtonText = resourceLoader.GetString("PythonPackageInstallCancel"),
};
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show package install dialog", ex);
return false;
}
}
}

View File

@@ -372,73 +372,4 @@
<value>Unable to load Foundry Local model: {0}</value>
<comment>{0} is the model identifier. Do not translate {0}.</comment>
</data>
<data name="PythonNotFound" xml:space="preserve">
<value>Python was not found. Please install Python or configure the path in Settings.</value>
</data>
<data name="WslNotAvailable" xml:space="preserve">
<value>WSL is not installed or not available. Cannot run Linux scripts.</value>
</data>
<data name="PythonScriptFailed" xml:space="preserve">
<value>The Python script failed to execute.</value>
</data>
<data name="PythonScriptTimeout" xml:space="preserve">
<value>Script execution timed out ({0} seconds).</value>
<comment>{0} is the configured timeout in seconds. Do not translate {0}.</comment>
</data>
<data name="PythonScriptNotFound" xml:space="preserve">
<value>Script file not found: {0}</value>
<comment>{0} is the script file path. Do not translate {0}.</comment>
</data>
<data name="PythonScriptInvalidJson" xml:space="preserve">
<value>The script output is not valid JSON.</value>
</data>
<data name="PythonScriptTrustTitle" xml:space="preserve">
<value>Run Python Script?</value>
</data>
<data name="PythonScriptTrustContent" xml:space="preserve">
<value>This script has not been verified. Running untrusted scripts can be a security risk. Do you want to run the following script?
{0}</value>
<comment>{0} is the script file path. Do not translate {0}.</comment>
</data>
<data name="PythonScriptTrustConfirm" xml:space="preserve">
<value>Run</value>
</data>
<data name="PythonScriptTrustCancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="PythonPackageInstallTitle" xml:space="preserve">
<value>Install Missing Packages?</value>
</data>
<data name="PythonPackageInstallContent" xml:space="preserve">
<value>The script "{0}" requires the following Python packages that are not installed:
{1}
Install them now?</value>
<comment>{0} = script display name, {1} = bullet list of package names. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallConfirm" xml:space="preserve">
<value>Install</value>
</data>
<data name="PythonPackageInstallCancel" xml:space="preserve">
<value>Skip</value>
</data>
<data name="PythonPackageInstallFailed" xml:space="preserve">
<value>Failed to install package(s) "{0}": {1}</value>
<comment>{0} = pip package names, {1} = error detail. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallTimeout" xml:space="preserve">
<value>Package installation for "{0}" timed out ({1} seconds).</value>
<comment>{0} = pip package names, {1} = timeout in seconds. Do not translate {0} or {1}.</comment>
</data>
<data name="ShowErrorDetailsBtn.Content" xml:space="preserve">
<value>Show details</value>
</data>
<data name="ErrorDetailsDialogTitle" xml:space="preserve">
<value>Error Details</value>
</data>
<data name="ErrorDetailsDialogClose" xml:space="preserve">
<value>Close</value>
</data>
</root>

View File

@@ -16,7 +16,6 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -42,7 +41,6 @@ namespace AdvancedPaste.ViewModels
private readonly IUserSettings _userSettings;
private readonly IPasteFormatExecutor _pasteFormatExecutor;
private readonly IAICredentialsProvider _credentialsProvider;
private readonly IPythonScriptService _pythonScriptService;
private CancellationTokenSource _pasteActionCancellationTokenSource;
@@ -102,8 +100,6 @@ namespace AdvancedPaste.ViewModels
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
public bool IsCustomAIServiceEnabled
{
get
@@ -262,12 +258,11 @@ namespace AdvancedPaste.ViewModels
public event EventHandler PreviewRequested;
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
{
_credentialsProvider = credentialsProvider;
_userSettings = userSettings;
_pasteFormatExecutor = pasteFormatExecutor;
_pythonScriptService = pythonScriptService;
GeneratedResponses = [];
GeneratedResponses.CollectionChanged += (s, e) =>
@@ -418,51 +413,12 @@ namespace AdvancedPaste.ViewModels
}
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
.Where(format => format != PasteFormats.PythonScript &&
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
.Select(CreateStandardPasteFormat));
UpdateFormats(
CustomActionPasteFormats,
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
UpdateFormats(
PythonScriptPasteFormats,
BuildPythonScriptFormats());
}
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
{
if (!_userSettings.IsPythonScriptsEnabled)
{
yield break;
}
var folder = _userSettings.PythonScriptsFolder;
if (string.IsNullOrWhiteSpace(folder))
{
yield break;
}
var discoveredScripts = _pythonScriptService.DiscoverScripts(folder);
var scriptActions = _userSettings.PythonScriptActions;
// Use metadata from discovered scripts, but apply IsShown from saved settings.
var hiddenPaths = new System.Collections.Generic.HashSet<string>(
scriptActions.Where(a => !a.IsShown).Select(a => a.ScriptPath),
StringComparer.OrdinalIgnoreCase);
foreach (var meta in discoveredScripts)
{
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
{
continue;
}
// Filter by intersection: only pass clipboard formats the script supports.
var filteredFormats = AvailableClipboardFormats & meta.SupportedFormats;
yield return PasteFormat.CreatePythonScriptFormat(meta.Name, meta.ScriptPath, filteredFormats);
}
}
public void Dispose()
@@ -736,10 +692,7 @@ namespace AdvancedPaste.ViewModels
_pasteActionCancellationTokenSource = new();
TransformProgress = double.NaN;
PasteActionError = PasteActionError.None;
// For Python scripts the Prompt field holds the file path, not a user-visible query.
// Setting Query to the path would show it in the AI prompt box, which is misleading.
Query = pasteFormat.Format == PasteFormats.PythonScript ? string.Empty : pasteFormat.Query;
Query = pasteFormat.Query;
try
{
@@ -779,7 +732,7 @@ namespace AdvancedPaste.ViewModels
internal async Task ExecutePasteFormatAsync(VirtualKey key)
{
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
.Where(pasteFormat => pasteFormat.IsEnabled)
.ElementAtOrDefault(key - VirtualKey.Number1);

View File

@@ -940,12 +940,10 @@ VideoRecordingSession::VideoRecordingSession(
video.PixelAspectRatio().Denominator(1);
m_encodingProfile.Video(video);
if (captureAudio || captureSystemAudio)
{
auto audio = m_encodingProfile.Audio();
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
m_encodingProfile.Audio(audio);
}
// Always set up audio profile for loopback capture (stereo AAC)
auto audio = m_encodingProfile.Audio();
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
m_encodingProfile.Audio(audio);
// Describe our input: uncompressed BGRA8 buffers
auto properties = winrt::VideoEncodingProperties::CreateUncompressed(
@@ -966,14 +964,8 @@ VideoRecordingSession::VideoRecordingSession(
winrt::check_hresult(m_previewSwapChain->GetBuffer(0, winrt::guid_of<ID3D11Texture2D>(), backBuffer.put_void()));
winrt::check_hresult(m_d3dDevice->CreateRenderTargetView(backBuffer.get(), nullptr, m_renderTargetView.put()));
if (captureAudio || captureSystemAudio)
{
m_audioGenerator = std::make_unique<AudioSampleGenerator>(captureAudio, captureSystemAudio, micMonoMix);
}
else
{
m_audioGenerator = nullptr;
}
// Always create audio generator for loopback capture; captureAudio controls microphone
m_audioGenerator = std::make_unique<AudioSampleGenerator>(captureAudio, captureSystemAudio, micMonoMix);
}
@@ -1215,8 +1207,14 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
{
try
{
auto sample = m_audioGenerator ? m_audioGenerator->TryGetNextSample() : std::optional<winrt::MediaStreamSample>{};
request.Sample(sample.has_value() ? sample.value() : nullptr);
if (auto sample = m_audioGenerator->TryGetNextSample())
{
request.Sample(sample.value());
}
else
{
request.Sample(nullptr);
}
}
catch (winrt::hresult_error const& error)
{

View File

@@ -21,25 +21,6 @@ namespace NonLocalizable
{
const static wchar_t* TOOL_WINDOW_CLASS_NAME = L"AlwaysOnTopWindow";
const static wchar_t* WINDOW_IS_PINNED_PROP = L"AlwaysOnTop_Pinned";
constexpr UINT SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND = 0xEFE0;
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_START = 0x0006;
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_END = 0x0007;
}
namespace
{
void UnsubscribeEvents(std::vector<HWINEVENTHOOK>& hooks) noexcept
{
for (const auto hook : hooks)
{
if (hook)
{
UnhookWinEvent(hook);
}
}
hooks.clear();
}
}
bool isExcluded(HWND window)
@@ -51,7 +32,7 @@ bool isExcluded(HWND window)
}
AlwaysOnTop::AlwaysOnTop(bool useLLKH, DWORD mainThreadId) :
SettingsObserver({SettingId::FrameEnabled, SettingId::Hotkey, SettingId::ExcludeApps, SettingId::ShowInSystemMenu}),
SettingsObserver({SettingId::FrameEnabled, SettingId::Hotkey, SettingId::ExcludeApps}),
m_hinstance(reinterpret_cast<HINSTANCE>(&__ImageBase)),
m_useCentralizedLLKH(useLLKH),
m_mainThreadId(mainThreadId),
@@ -72,11 +53,6 @@ AlwaysOnTop::AlwaysOnTop(bool useLLKH, DWORD mainThreadId) :
SubscribeToEvents();
StartTrackingTopmostWindows();
if (HWND foregroundWindow = GetForegroundWindow())
{
UpdateSystemMenuItem(foregroundWindow);
}
}
else
{
@@ -168,13 +144,6 @@ void AlwaysOnTop::SettingsUpdate(SettingId id)
}
}
break;
case SettingId::ShowInSystemMenu:
{
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings().showInSystemMenu);
m_lastSystemMenuWindow = nullptr;
UpdateSystemMenuItem(GetForegroundWindow());
}
break;
default:
break;
}
@@ -256,8 +225,6 @@ void AlwaysOnTop::ProcessCommand(HWND window)
{
m_sound.Play(soundType);
}
UpdateSystemMenuItem(window);
}
void AlwaysOnTop::StartTrackingTopmostWindows()
@@ -447,86 +414,6 @@ void AlwaysOnTop::SubscribeToEvents()
Logger::error(L"Failed to set win event hook");
}
}
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings().showInSystemMenu);
}
void AlwaysOnTop::UpdateSystemMenuEventHooks(bool enable)
{
constexpr std::array<DWORD, 3> menu_events_to_subscribe = {
NonLocalizable::SYSTEM_EVENT_MENU_POPUP_START,
NonLocalizable::SYSTEM_EVENT_MENU_POPUP_END,
EVENT_OBJECT_INVOKED,
};
if (enable)
{
if (m_systemMenuWinEventHooks.size() == menu_events_to_subscribe.size())
{
return;
}
// Recover from any partial hook registration before re-registering.
UnsubscribeEvents(m_systemMenuWinEventHooks);
for (const auto event : menu_events_to_subscribe)
{
auto hook = SetWinEventHook(event, event, nullptr, WinHookProc, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
if (hook)
{
m_systemMenuWinEventHooks.emplace_back(hook);
}
else
{
Logger::error(L"Failed to set system menu win event hook");
}
}
}
else
{
UnsubscribeEvents(m_systemMenuWinEventHooks);
}
}
void AlwaysOnTop::UpdateSystemMenuItem(HWND window) const noexcept
{
if (!window || !IsWindow(window))
{
return;
}
const auto systemMenu = GetSystemMenu(window, false);
if (!systemMenu)
{
return;
}
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
{
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1))
{
RemoveMenu(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND);
}
return;
}
auto text = GET_RESOURCE_STRING(IDS_SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP);
MENUITEMINFOW menuItemInfo{};
menuItemInfo.cbSize = sizeof(menuItemInfo);
menuItemInfo.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING;
menuItemInfo.wID = NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND;
menuItemInfo.fState = IsPinned(window) ? MFS_CHECKED : MFS_UNCHECKED;
menuItemInfo.dwTypeData = text.data();
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) == static_cast<UINT>(-1))
{
InsertMenuItemW(systemMenu, SC_CLOSE, FALSE, &menuItemInfo);
}
else
{
menuItemInfo.fMask = MIIM_STATE | MIIM_STRING;
SetMenuItemInfoW(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, FALSE, &menuItemInfo);
}
}
void AlwaysOnTop::UnpinAll()
@@ -547,9 +434,6 @@ void AlwaysOnTop::UnpinAll()
void AlwaysOnTop::CleanUp()
{
UnsubscribeEvents(m_systemMenuWinEventHooks);
UnsubscribeEvents(m_staticWinEventHooks);
UnpinAll();
if (m_window)
{
@@ -608,79 +492,6 @@ bool AlwaysOnTop::IsTracked(HWND window) const noexcept
void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
{
switch (data->event)
{
case NonLocalizable::SYSTEM_EVENT_MENU_POPUP_START:
{
if (data->idObject == OBJID_SYSMENU && data->hwnd)
{
m_lastSystemMenuWindow = AlwaysOnTopSettings::settings().showInSystemMenu ? data->hwnd : nullptr;
UpdateSystemMenuItem(data->hwnd);
}
}
return;
case NonLocalizable::SYSTEM_EVENT_MENU_POPUP_END:
{
if (data->idObject == OBJID_SYSMENU && data->hwnd == m_lastSystemMenuWindow)
{
m_lastSystemMenuWindow = nullptr;
}
}
return;
case EVENT_OBJECT_INVOKED:
{
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
{
return;
}
if (data->idChild != static_cast<LONG>(NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND))
{
return;
}
const bool isMenuRelatedObject = (data->idObject == OBJID_SYSMENU || data->idObject == OBJID_MENU || data->idObject == OBJID_CLIENT);
if (!isMenuRelatedObject && (!m_lastSystemMenuWindow || !IsWindow(m_lastSystemMenuWindow)))
{
return;
}
const auto hasToggleMenuItem = [](HWND window) -> bool {
if (!window || !IsWindow(window))
{
return false;
}
const auto systemMenu = GetSystemMenu(window, false);
return systemMenu &&
GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1);
};
HWND commandWindow = nullptr;
const auto trySetCommandWindow = [&](HWND candidate) noexcept {
if (!commandWindow && hasToggleMenuItem(candidate))
{
commandWindow = candidate;
}
};
if (m_lastSystemMenuWindow && IsWindow(m_lastSystemMenuWindow))
{
trySetCommandWindow(m_lastSystemMenuWindow);
}
trySetCommandWindow(data->hwnd);
trySetCommandWindow(GetForegroundWindow());
if (commandWindow)
{
ProcessCommand(commandWindow);
}
}
return;
default:
break;
}
if (!AlwaysOnTopSettings::settings().enableFrame || !data->hwnd)
{
return;
@@ -755,8 +566,6 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
break;
case EVENT_SYSTEM_FOREGROUND:
{
UpdateSystemMenuItem(data->hwnd);
if (!is_process_elevated() && IsProcessOfWindowElevated(data->hwnd))
{
m_notificationUtil->WarnIfElevationIsRequired(GET_RESOURCE_STRING(IDS_ALWAYSONTOP),
@@ -967,4 +776,4 @@ void AlwaysOnTop::RestoreWindowAlpha(HWND window)
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
}
}
}
}

View File

@@ -45,7 +45,6 @@ private:
static inline AlwaysOnTop* s_instance = nullptr;
std::vector<HWINEVENTHOOK> m_staticWinEventHooks{};
std::vector<HWINEVENTHOOK> m_systemMenuWinEventHooks{};
Sound m_sound;
VirtualDesktopUtils m_virtualDesktopUtils;
@@ -70,18 +69,15 @@ private:
std::thread m_thread;
const bool m_useCentralizedLLKH;
bool m_running = true;
HWND m_lastSystemMenuWindow{ nullptr };
std::unique_ptr<notifications::NotificationUtil> m_notificationUtil;
LRESULT WndProc(HWND, UINT, WPARAM, LPARAM) noexcept;
void HandleWinHookEvent(WinHookEvent* data) noexcept;
void UpdateSystemMenuItem(HWND window) const noexcept;
bool InitMainWindow();
void RegisterHotkey() const;
void RegisterLLKH();
void SubscribeToEvents();
void UpdateSystemMenuEventHooks(bool enable);
void ProcessCommand(HWND window);
void StartTrackingTopmostWindows();

View File

@@ -131,7 +131,4 @@
<data name="System_Foreground_Elevated_Dialog_Dont_Show_Again" xml:space="preserve">
<value>Don't show again</value>
</data>
<data name="System_Menu_Toggle_Always_On_Top" xml:space="preserve">
<value>Always on top</value>
</data>
</root>
</root>

View File

@@ -14,7 +14,6 @@ namespace NonLocalizable
const static wchar_t* HotkeyID = L"hotkey";
const static wchar_t* SoundEnabledID = L"sound-enabled";
const static wchar_t* ShowInSystemMenuID = L"show-in-system-menu";
const static wchar_t* FrameEnabledID = L"frame-enabled";
const static wchar_t* FrameThicknessID = L"frame-thickness";
const static wchar_t* FrameColorID = L"frame-color";
@@ -116,16 +115,6 @@ void AlwaysOnTopSettings::LoadSettings()
}
}
if (const auto jsonVal = values.get_bool_value(NonLocalizable::ShowInSystemMenuID))
{
auto val = *jsonVal;
if (m_settings.showInSystemMenu != val)
{
m_settings.showInSystemMenu = val;
NotifyObservers(SettingId::ShowInSystemMenu);
}
}
if (const auto jsonVal = values.get_int_value(NonLocalizable::FrameThicknessID))
{
auto val = *jsonVal;

View File

@@ -18,7 +18,6 @@ struct Settings
static constexpr int minTransparencyPercentage = 20; // minimum transparency (can't go below 20%)
static constexpr int maxTransparencyPercentage = 100; // maximum (fully opaque)
static constexpr int transparencyStep = 10; // step size for +/- adjustment
bool showInSystemMenu = false;
bool enableFrame = true;
bool enableSound = true;
bool roundCornersEnabled = true;
@@ -57,4 +56,4 @@ private:
std::unordered_set<SettingsObserver*> m_observers;
void NotifyObservers(SettingId id) const;
};
};

View File

@@ -4,7 +4,6 @@ enum class SettingId
{
Hotkey = 0,
SoundEnabled,
ShowInSystemMenu,
FrameEnabled,
FrameThickness,
FrameColor,
@@ -13,4 +12,4 @@ enum class SettingId
ExcludeApps,
FrameAccentColor,
RoundCornersEnabled
};
};

View File

@@ -26,11 +26,6 @@
"input": "pushd .\\ExtensionTemplate\\ ; git archive -o ..\\Microsoft.CmdPal.UI.ViewModels\\Assets\\template.zip HEAD -- .\\TemplateCmdPalExtension\\ ; popd",
"name": "Update template project",
"description": "zips up the ExtensionTemplate into our assets. Run this in the cmdpal/ directory."
},
{
"input": " .\\extensionsdk\\nuget\\BuildSDKHelper.ps1 -VersionOfSDK 0.0.1",
"name": "Build SDK",
"description": "Builds the SDK nuget package with the specified version."
}
]
}

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;
namespace Microsoft.CmdPal.Common;
public static class CoreLogger
@@ -13,8 +15,6 @@ public static class CoreLogger
private static ILogger? _logger;
public static ILogger? Instance => _logger;
public static void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0)
{
_logger?.LogError(message, ex, memberName, sourceFilePath, sourceLineNumber);

View File

@@ -1,24 +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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Common.Helpers;
public partial class PinnedDockItem : WrappedDockItem
{
public override string Title => $"{base.Title} ({Properties.Resources.PinnedItemSuffix})";
public PinnedDockItem(ICommand command)
: base(command, command.Name)
{
}
public PinnedDockItem(IListItem item, string id)
: base([item], id, item.Title)
{
Icon = item.Icon;
}
}

View File

@@ -72,14 +72,5 @@ namespace Microsoft.CmdPal.Common.Properties {
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
internal static string PinnedItemSuffix {
get {
return ResourceManager.GetString("PinnedItemSuffix", resourceCulture);
}
}
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
@@ -117,10 +117,6 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PinnedItemSuffix" xml:space="preserve">
<value>Pinned</value>
<comment>Suffix shown for pinned items in the dock</comment>
</data>
<data name="ErrorReport_Global_Preamble" xml:space="preserve">
<value>This is an error report generated by Windows Command Palette.
If you are seeing this, it means something went a little sideways in the app.
@@ -128,4 +124,4 @@ You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
(While youre at it, give the details below a quick skim — just to make sure theres nothing personal youd prefer not to share. Its rare, but sometimes little surprises sneak in.)</value>
</data>
</root>
</root>

View File

@@ -166,5 +166,5 @@ public interface IAppHostService
AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost);
ICommandProviderContext GetProviderContextForCommand(object? command, ICommandProviderContext? currentContext);
CommandProviderContext GetProviderContextForCommand(object? command, CommandProviderContext? currentContext);
}

View File

@@ -18,9 +18,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable
{
internal static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212);
private static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212);
internal static readonly ObservableCollection<Color> WindowsColorSwatches = [
private static readonly ObservableCollection<Color> WindowsColorSwatches = [
// row 0
Color.FromArgb(255, 255, 185, 0), // #ffb900

View File

@@ -96,10 +96,9 @@ public partial class CommandBarViewModel : ObservableObject,
SecondaryCommand = SelectedItem.SecondaryCommand;
var hasMoreThanOneContextItem = SelectedItem.MoreCommands.Count() > 1;
var hasMoreThanOneCommand = SelectedItem.MoreCommands.OfType<CommandContextItemViewModel>().Any();
ShouldShowContextMenu = hasMoreThanOneContextItem && hasMoreThanOneCommand;
ShouldShowContextMenu = SelectedItem.MoreCommands
.OfType<CommandContextItemViewModel>()
.Count() > 1;
OnPropertyChanged(nameof(HasSecondaryCommand));
OnPropertyChanged(nameof(SecondaryCommand));

View File

@@ -22,7 +22,7 @@ public partial class CommandContextItemViewModel : CommandItemViewModel, IContex
public bool HasRequestedShortcut => RequestedShortcut is not null && (RequestedShortcut.Value != nullKeyChord);
public CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference<IPageContext> context)
: base(new(contextItem), context, contextMenuFactory: null)
: base(new(contextItem), context)
{
Model = new(contextItem);
IsContextMenuItem = true;

View File

@@ -51,11 +51,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
private string _itemTitle = string.Empty;
protected string ItemTitle => _itemTitle;
public string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
public virtual string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
public virtual string Subtitle { get; private set; } = string.Empty;
public string Subtitle { get; private set; } = string.Empty;
private IconInfoViewModel _icon = new(null);
@@ -75,30 +73,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel? PrimaryCommand => this;
public CommandItemViewModel? SecondaryCommand
{
get
{
if (HasMoreCommands)
{
if (MoreCommands[0] is CommandContextItemViewModel command)
{
return command;
}
}
return null;
}
}
public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[0] : null;
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
public bool HasTitle => !string.IsNullOrEmpty(Title);
public bool HasSubtitle => !string.IsNullOrEmpty(Subtitle);
public virtual bool HasText => HasTitle || HasSubtitle;
public DataPackageView? DataPackage { get; private set; }
public List<IContextItemViewModel> AllCommands
@@ -125,7 +103,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel(
ExtensionObject<ICommandItem> item,
WeakReference<IPageContext> errorContext,
IContextMenuFactory? contextMenuFactory)
IContextMenuFactory? contextMenuFactory = null)
: base(errorContext)
{
_commandItemModel = item;
@@ -353,13 +331,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Icon));
UpdateProperty(nameof(HasText));
break;
case nameof(Title):
_itemTitle = model.Title;
_titleCache.Invalidate();
UpdateProperty(nameof(HasText));
break;
case nameof(Subtitle):
@@ -367,7 +343,6 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
this.Subtitle = modelSubtitle;
_defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
_subtitleCache.Invalidate();
UpdateProperty(nameof(HasText));
break;
case nameof(Icon):
@@ -426,10 +401,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
}
private void UpdateDefaultContextItemIcon() =>
private void UpdateDefaultContextItemIcon()
{
// Command icon takes precedence over our icon on the primary command
_defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon);
}
private void UpdateTitle(string? title)
{
@@ -488,30 +464,6 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
.ForEach(c => c.SafeCleanup());
}
public void RefreshMoreCommands()
{
Task.Run(RefreshMoreCommandsSynchronous);
}
private void RefreshMoreCommandsSynchronous()
{
try
{
BuildAndInitMoreCommands();
UpdateProperty(nameof(MoreCommands));
UpdateProperty(nameof(AllCommands));
UpdateProperty(nameof(SecondaryCommand));
UpdateProperty(nameof(SecondaryCommandName));
UpdateProperty(nameof(HasMoreCommands));
}
catch (Exception ex)
{
// Handle any exceptions that might occur during the refresh process
CoreLogger.LogError("Error refreshing MoreCommands in CommandItemViewModel", ex);
ShowException(ex, _commandItemModel?.Unsafe?.Title);
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();

View File

@@ -8,7 +8,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandPaletteContentPageViewModel : ContentPageViewModel
{
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext)
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext)
: base(model, scheduler, host, providerContext)
{
}

View File

@@ -10,19 +10,19 @@ public class CommandPalettePageViewModelFactory
: IPageViewModelFactoryService
{
private readonly TaskScheduler _scheduler;
private readonly IContextMenuFactory _contextMenuFactory;
private readonly IContextMenuFactory? _contextMenuFactory;
public CommandPalettePageViewModelFactory(TaskScheduler scheduler, IContextMenuFactory contextMenuFactory)
public CommandPalettePageViewModelFactory(TaskScheduler scheduler, IContextMenuFactory? contextMenuFactory)
{
_scheduler = scheduler;
_contextMenuFactory = contextMenuFactory;
}
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, ICommandProviderContext providerContext)
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext)
{
return page switch
{
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested },
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsNested = nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext),
_ => null,
};

View File

@@ -4,21 +4,9 @@
namespace Microsoft.CmdPal.UI.ViewModels;
public static class CommandProviderContext
public sealed class CommandProviderContext
{
public static ICommandProviderContext Empty { get; } = new EmptyCommandProviderContext();
public required string ProviderId { get; init; }
private sealed class EmptyCommandProviderContext : ICommandProviderContext
{
public string ProviderId => "<EMPTY>";
public bool SupportsPinning => false;
}
}
public interface ICommandProviderContext
{
string ProviderId { get; }
bool SupportsPinning { get; }
public static CommandProviderContext Empty { get; } = new() { ProviderId = "<EMPTY>" };
}

View File

@@ -6,7 +6,6 @@ using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
@@ -14,7 +13,7 @@ using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed class CommandProviderWrapper : ICommandProviderContext
public sealed class CommandProviderWrapper
{
public bool IsExtension => Extension is not null;
@@ -30,8 +29,6 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
public TopLevelViewModel[] DockBandItems { get; private set; } = [];
public string DisplayName { get; private set; } = string.Empty;
public IExtensionWrapper? Extension { get; }
@@ -50,17 +47,12 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
public bool SupportsPinning { get; private set; }
public TopLevelItemPageContext TopLevelPageContext { get; }
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
{
// This ctor is only used for in-proc builtin commands. So the Unsafe!
// calls are pretty dang safe actually.
_commandProvider = new(provider);
_taskScheduler = mainThread;
TopLevelPageContext = new(this, _taskScheduler);
// Hook the extension back into us
ExtensionHost = new CommandPaletteHost(provider);
@@ -85,7 +77,6 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
{
_taskScheduler = mainThread;
_commandProviderCache = commandProviderCache;
TopLevelPageContext = new(this, _taskScheduler);
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
@@ -130,7 +121,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
return settings.GetProviderSettings(this);
}
public async Task LoadTopLevelCommands(IServiceProvider serviceProvider)
public async Task LoadTopLevelCommands(IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{
if (!isValid)
{
@@ -149,47 +140,25 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
return;
}
ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null;
ICommandItem[] dockBands = []; // do not initialize me to null
var displayInfoInitialized = false;
try
{
var model = _commandProvider.Unsafe!;
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
loadTopLevelCommandsTask.Start();
commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
// On a BG thread here
fallbacks = model.FallbackCommands();
var fallbacks = model.FallbackCommands();
if (model is ICommandProvider2 two)
{
UnsafePreCacheApiAdditions(two);
}
if (model is ICommandProvider3 supportsDockBands)
{
var bands = supportsDockBands.GetDockBands();
if (bands is not null)
{
Logger.LogDebug($"Found {bands.Length} bands on {DisplayName} ({ProviderId}) ");
dockBands = bands;
}
}
ICommandItem[] pinnedCommands = [];
ICommandProvider4? four = null;
if (model is ICommandProvider4 definitelyFour)
{
four = definitelyFour; // stash this away so we don't need to QI again
SupportsPinning = true;
// Load pinned commands from saved settings
pinnedCommands = LoadPinnedCommands(four, providerSettings);
}
// Load pinned commands from saved settings
var pinnedCommands = LoadPinnedCommands(model, providerSettings);
Id = model.Id;
DisplayName = model.DisplayName;
@@ -208,8 +177,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
Settings = new(model.Settings, this, _taskScheduler);
// We do need to explicitly initialize commands though
var objects = new TopLevelObjects(commands, fallbacks, pinnedCommands, dockBands);
InitializeCommands(objects, serviceProvider, four);
InitializeCommands(commands, fallbacks, pinnedCommands, serviceProvider, pageContext);
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
}
@@ -240,27 +208,15 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
}
}
private record TopLevelObjects(
ICommandItem[]? Commands,
IFallbackCommandItem[]? Fallbacks,
ICommandItem[]? PinnedCommands,
ICommandItem[]? DockBands);
private void InitializeCommands(
TopLevelObjects objects,
IServiceProvider serviceProvider,
ICommandProvider4? four)
private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, ICommandItem[] pinnedCommands, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var contextMenuFactory = serviceProvider.GetService<IContextMenuFactory>()!;
var state = serviceProvider.GetService<AppStateModel>()!;
var providerSettings = GetProviderSettings(settings);
var ourContext = GetProviderContext();
WeakReference<IPageContext> pageContext = new(this.TopLevelPageContext);
var make = (ICommandItem? i, TopLevelType t) =>
var makeAndAdd = (ICommandItem? i, bool fallback) =>
{
CommandItemViewModel commandItemViewModel = new(new(i), pageContext, contextMenuFactory: contextMenuFactory);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, t, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i, contextMenuFactory: contextMenuFactory);
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i);
topLevelViewModel.InitializeProperties();
return topLevelViewModel;
@@ -268,123 +224,47 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
var topLevelList = new List<TopLevelViewModel>();
if (objects.Commands is not null)
if (commands is not null)
{
topLevelList.AddRange(objects.Commands.Select(c => make(c, TopLevelType.Normal)));
topLevelList.AddRange(commands.Select(c => makeAndAdd(c, false)));
}
if (objects.PinnedCommands is not null)
if (pinnedCommands is not null)
{
topLevelList.AddRange(objects.PinnedCommands.Select(c => make(c, TopLevelType.Normal)));
topLevelList.AddRange(pinnedCommands.Select(c => makeAndAdd(c, false)));
}
TopLevelItems = topLevelList.ToArray();
if (objects.Fallbacks is not null)
if (fallbacks is not null)
{
FallbackItems = objects.Fallbacks
.Select(c => make(c, TopLevelType.Fallback))
FallbackItems = fallbacks
.Select(c => makeAndAdd(c, true))
.ToArray();
}
}
List<TopLevelViewModel> bands = new();
if (objects.DockBands is not null)
private ICommandItem[] LoadPinnedCommands(ICommandProvider model, ProviderSettings providerSettings)
{
var pinnedItems = new List<ICommandItem>();
if (model is ICommandProvider4 provider4)
{
// Start by adding TopLevelViewModels for all the dock bands which
// are explicitly provided by the provider through the GetDockBands
// API.
foreach (var b in objects.DockBands)
{
var bandVm = make(b, TopLevelType.DockBand);
bands.Add(bandVm);
}
}
var dockSettings = settings.DockSettings;
var allPinnedCommands = dockSettings.AllPinnedCommands;
var pinnedBandsForThisProvider = allPinnedCommands.Where(c => c.ProviderId == ProviderId);
foreach (var (providerId, commandId) in pinnedBandsForThisProvider)
{
Logger.LogDebug($"Looking for pinned dock band command {commandId} for provider {providerId}");
// First, try to lookup the command as one of this provider's
// top-level commands. If it's there, then we can skip a lot of
// work and just clone it as a band.
if (LookupTopLevelCommand(commandId) is TopLevelViewModel topLevelCommand)
{
Logger.LogDebug($"Found pinned dock band command {commandId} for provider {providerId} as a top-level command");
var bandModel = topLevelCommand.ToPinnedDockBandItem();
var bandVm = make(bandModel, TopLevelType.DockBand);
bands.Add(bandVm);
continue;
}
// If we didn't find it as a top-level command, then we need to
// try to get it directly from the provider and hope it supports
// being a dock band. This is the fallback for providers that
// don't explicitly support dock bands through GetDockBands, but
// do support pinning commands (ICommandProvider4)
if (four is not null)
foreach (var pinnedId in providerSettings.PinnedCommandIds)
{
try
{
var commandItem = four.GetCommandItem(commandId);
var commandItem = provider4.GetCommandItem(pinnedId);
if (commandItem is not null)
{
Logger.LogDebug($"Found pinned dock band command {commandId} for provider {providerId} through ICommandProvider4 API");
var bandVm = make(commandItem, TopLevelType.DockBand);
bands.Add(bandVm);
}
else
{
Logger.LogWarning($"Couldn't find pinned dock band command {commandId} for provider {providerId} through ICommandProvider4 API. This command won't be shown as a dock band.");
pinnedItems.Add(commandItem);
}
}
catch (Exception e)
{
Logger.LogError($"Failed to load pinned dock band command {commandId} for provider {providerId}: {e.Message}");
Logger.LogError($"Failed to load pinned command {pinnedId}: {e.Message}");
}
}
else
{
Logger.LogWarning($"Couldn't find pinned dock band command {commandId} for provider {providerId} as a top-level command, and provider doesn't support ICommandProvider4 API to get it directly. This command won't be shown as a dock band.");
}
}
DockBandItems = bands.ToArray();
}
private TopLevelViewModel? LookupTopLevelCommand(string commandId)
{
foreach (var c in TopLevelItems)
{
if (c.Id == commandId)
{
return c;
}
}
return null;
}
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings)
{
var pinnedItems = new List<ICommandItem>();
foreach (var pinnedId in providerSettings.PinnedCommandIds)
{
try
{
var commandItem = model.GetCommandItem(pinnedId);
if (commandItem is not null)
{
pinnedItems.Add(commandItem);
}
}
catch (Exception e)
{
Logger.LogError($"Failed to load pinned command {pinnedId}: {e.Message}");
}
}
return pinnedItems.ToArray();
@@ -400,10 +280,6 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
{
Logger.LogDebug($"{ProviderId}: Found an IExtendedAttributesProvider");
}
else if (a is ICommandItem[] commands)
{
Logger.LogDebug($"{ProviderId}: Found an ICommandItem[]");
}
}
}
@@ -415,57 +291,18 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
if (!providerSettings.PinnedCommandIds.Contains(commandId))
{
providerSettings.PinnedCommandIds.Add(commandId);
SettingsModel.SaveSettings(settings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
}
public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
public CommandProviderContext GetProviderContext()
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
if (providerSettings.PinnedCommandIds.Remove(commandId))
{
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
return new() { ProviderId = ProviderId };
}
public void PinDockBand(string commandId, IServiceProvider serviceProvider)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var bandSettings = new DockBandSettings
{
CommandId = commandId,
ProviderId = this.ProviderId,
};
settings.DockSettings.StartBands.Add(bandSettings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
settings.DockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
settings.DockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
settings.DockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
public ICommandProviderContext GetProviderContext() => this;
public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;
public override int GetHashCode() => _commandProvider.GetHashCode();
@@ -479,14 +316,4 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
// In handling this, a call will be made to `LoadTopLevelCommands` to
// retrieve the new items.
this.CommandsChanged?.Invoke(this, args);
internal void PinDockBand(TopLevelViewModel bandVm)
{
Logger.LogDebug($"CommandProviderWrapper.PinDockBand: {ProviderId} - {bandVm.Id}");
var bands = this.DockBandItems.ToList();
bands.Add(bandVm);
this.DockBandItems = bands.ToArray();
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs());
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -18,8 +18,6 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
private readonly FallbackLogItem _fallbackLogItem = new();
private readonly NewExtensionPage _newExtension = new();
private readonly IRootPageService _rootPageService;
public override ICommandItem[] TopLevelCommands() =>
[
new CommandItem(openSettings) { },
@@ -39,22 +37,11 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
_fallbackLogItem,
];
public BuiltInsCommandProvider(IRootPageService rootPageService)
public BuiltInsCommandProvider()
{
Id = "com.microsoft.cmdpal.builtin.core";
DisplayName = Properties.Resources.builtin_display_name;
Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png");
_rootPageService = rootPageService;
}
public override ICommandItem[]? GetDockBands()
{
var rootPage = _rootPageService.GetRootPage();
List<ICommandItem> bandItems = new();
bandItems.Add(new WrappedDockItem(rootPage, Properties.Resources.builtin_command_palette_title));
return bandItems.ToArray();
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
}
public override void InitializeWithHost(IExtensionHost host) => BuiltinsExtensionHost.Instance.Initialize(host);

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -11,6 +11,7 @@ using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.Ext.Apps.State;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
@@ -35,11 +36,6 @@ public sealed partial class MainListPage : DynamicListPage,
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
@@ -65,7 +61,6 @@ public sealed partial class MainListPage : DynamicListPage,
AppStateModel appStateModel,
IFuzzyMatcherProvider fuzzyMatcherProvider)
{
Id = "com.microsoft.cmdpal.home";
Title = Resources.builtin_home_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder;
@@ -176,40 +171,9 @@ public sealed partial class MainListPage : DynamicListPage,
// filtered results.
if (string.IsNullOrWhiteSpace(SearchText))
{
var allCommands = _tlcManager.TopLevelCommands;
// First pass: count eligible commands
var eligibleCount = 0;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
eligibleCount++;
}
}
if (eligibleCount == 0)
{
return [];
}
// +1 for the separator
var result = new IListItem[eligibleCount + 1];
result[0] = _resultsSeparator;
// Second pass: populate
var writeIndex = 1;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
result[writeIndex++] = cmd;
}
}
return result;
return _tlcManager.TopLevelCommands
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
.ToArray();
}
else
{
@@ -226,8 +190,6 @@ public sealed partial class MainListPage : DynamicListPage,
validScoredFallbacks,
_filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
}
}
@@ -409,13 +371,11 @@ public sealed partial class MainListPage : DynamicListPage,
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
// Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds.
_settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings);
var pinnedCommandIds = providerSettings?.PinnedCommandIds;
var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0)
if (pinnedApps.Length > 0)
{
newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id));
newApps = allNewApps.Where(w => pinnedApps.IndexOf(w.AppIdentifier) < 0);
}
else
{

View File

@@ -21,8 +21,6 @@ internal static class MainListPageResultFactory
IList<RoScored<IListItem>>? scoredFallbackItems,
IList<RoScored<IListItem>>? filteredApps,
IList<RoScored<IListItem>>? fallbackItems,
IListItem resultsSeparator,
IListItem fallbacksSeparator,
int appResultLimit)
{
if (appResultLimit < 0)
@@ -42,13 +40,8 @@ internal static class MainListPageResultFactory
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
// Allocate the exact size of the result array.
// We'll add an extra slot for the fallbacks section header if needed,
// and another for the "Results" section header when merged results exist.
int mergedCount = len1 + len2 + len3;
bool needsResultsHeader = mergedCount > 0;
int totalCount = mergedCount + nonEmptyFallbackCount
+ (needsResultsHeader ? 1 : 0)
+ (nonEmptyFallbackCount > 0 ? 1 : 0);
// We'll add an extra slot for the fallbacks section header if needed.
int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0);
var result = new IListItem[totalCount];
@@ -56,12 +49,6 @@ internal static class MainListPageResultFactory
int idx1 = 0, idx2 = 0, idx3 = 0;
int writePos = 0;
// Add "Results" section header when merged results will precede the fallbacks.
if (needsResultsHeader)
{
result[writePos++] = resultsSeparator;
}
// Merge while all three lists have items. To maintain a stable sort, the
// priority is: list1 > list2 > list3 when scores are equal.
while (idx1 < len1 && idx2 < len2 && idx3 < len3)
@@ -145,7 +132,7 @@ internal static class MainListPageResultFactory
// Create the fallbacks section header
if (fallbackItems.Count > 0)
{
result[writePos++] = fallbacksSeparator;
result[writePos++] = new Separator(Properties.Resources.fallbacks);
}
for (int i = 0; i < fallbackItems.Count; i++)

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -70,15 +70,6 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPag
StateJson = model.StateJson;
DataJson = model.DataJson;
RenderCard();
UpdateProperty(nameof(Card));
model.PropChanged += Model_PropChanged;
}
private void RenderCard()
{
if (TryBuildCard(TemplateJson, DataJson, out var builtCard, out var renderingError))
{
Card = builtCard;
@@ -102,41 +93,8 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPag
UpdateProperty(nameof(Card));
return;
}
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected virtual void FetchProperty(string propertyName)
{
var model = this._formModel.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(DataJson):
DataJson = model.DataJson;
RenderCard();
break;
case nameof(TemplateJson):
TemplateJson = model.TemplateJson;
RenderCard();
break;
}
UpdateProperty(propertyName);
UpdateProperty(nameof(Card));
}
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AdaptiveOpenUrlAction))]

View File

@@ -47,7 +47,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext)
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext)
: base(model, scheduler, host, providerContext)
{
_model = new(model);

View File

@@ -59,8 +59,11 @@ public partial class ContextMenuViewModel : ObservableObject,
{
if (SelectedItem is not null)
{
ContextMenuStack.Clear();
PushContextStack(SelectedItem.AllCommands);
if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands)
{
ContextMenuStack.Clear();
PushContextStack(SelectedItem.AllCommands);
}
}
}

View File

@@ -40,12 +40,4 @@ public partial class DefaultContextMenuFactory : IContextMenuFactory
return results;
}
public void AddMoreCommandsToTopLevel(
TopLevelViewModel topLevelItem,
ICommandProviderContext providerContext,
List<IContextItem?> contextItems)
{
// do nothing
}
}

View File

@@ -1,251 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public partial class DockBandSettingsViewModel : ObservableObject
{
private static readonly CompositeFormat PluralItemsFormatString = CompositeFormat.Parse(Properties.Resources.dock_item_count_plural);
private readonly SettingsModel _settingsModel;
private readonly DockBandSettings _dockSettingsModel;
private readonly TopLevelViewModel _adapter;
private readonly DockBandViewModel? _bandViewModel;
public string Title => _adapter.Title;
public string Description
{
get
{
List<string> parts = [_adapter.ExtensionName];
// Add the number of items in the band
var itemCount = NumItemsInBand();
if (itemCount > 0)
{
var itemsString = itemCount == 1 ?
Properties.Resources.dock_item_count_singular :
string.Format(CultureInfo.CurrentCulture, PluralItemsFormatString, itemCount);
parts.Add(itemsString);
}
return string.Join(" - ", parts);
}
}
public string ProviderId => _adapter.CommandProviderId;
public IconInfoViewModel Icon => _adapter.IconViewModel;
private ShowLabelsOption _showLabels;
public ShowLabelsOption ShowLabels
{
get => _showLabels;
set
{
if (value != _showLabels)
{
_showLabels = value;
_dockSettingsModel.ShowLabels = value switch
{
ShowLabelsOption.Default => null,
ShowLabelsOption.ShowLabels => true,
ShowLabelsOption.HideLabels => false,
_ => null,
};
Save();
}
}
}
private ShowLabelsOption FetchShowLabels()
{
if (_dockSettingsModel.ShowLabels == null)
{
return ShowLabelsOption.Default;
}
return _dockSettingsModel.ShowLabels.Value ? ShowLabelsOption.ShowLabels : ShowLabelsOption.HideLabels;
}
// used to map to ComboBox selection
public int ShowLabelsIndex
{
get => (int)ShowLabels;
set => ShowLabels = (ShowLabelsOption)value;
}
private DockPinSide PinSide
{
get => _pinSide;
set
{
if (value != _pinSide)
{
UpdatePinSide(value);
}
}
}
private DockPinSide _pinSide;
public int PinSideIndex
{
get => (int)PinSide;
set => PinSide = (DockPinSide)value;
}
/// <summary>
/// Gets or sets a value indicating whether the band is pinned to the dock.
/// When enabled, pins to Center. When disabled, removes from all sides.
/// </summary>
public bool IsPinned
{
get => PinSide != DockPinSide.None;
set
{
if (value && PinSide == DockPinSide.None)
{
// Pin to Center by default when enabling
PinSide = DockPinSide.Center;
}
else if (!value && PinSide != DockPinSide.None)
{
// Remove from dock when disabling
PinSide = DockPinSide.None;
}
}
}
public DockBandSettingsViewModel(
DockBandSettings dockSettingsModel,
TopLevelViewModel topLevelAdapter,
DockBandViewModel? bandViewModel,
SettingsModel settingsModel)
{
_dockSettingsModel = dockSettingsModel;
_adapter = topLevelAdapter;
_bandViewModel = bandViewModel;
_settingsModel = settingsModel;
_pinSide = FetchPinSide();
_showLabels = FetchShowLabels();
}
private DockPinSide FetchPinSide()
{
var dockSettings = _settingsModel.DockSettings;
var inStart = dockSettings.StartBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inStart)
{
return DockPinSide.Start;
}
var inCenter = dockSettings.CenterBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inCenter)
{
return DockPinSide.Center;
}
var inEnd = dockSettings.EndBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inEnd)
{
return DockPinSide.End;
}
return DockPinSide.None;
}
private int NumItemsInBand()
{
var bandVm = _bandViewModel;
if (bandVm is null)
{
return 0;
}
return bandVm.Items.Count;
}
private void Save()
{
SettingsModel.SaveSettings(_settingsModel);
}
private void UpdatePinSide(DockPinSide value)
{
OnPinSideChanged(value);
OnPropertyChanged(nameof(PinSideIndex));
OnPropertyChanged(nameof(PinSide));
OnPropertyChanged(nameof(IsPinned));
}
public void SetBandPosition(DockPinSide side, int? index)
{
var dockSettings = _settingsModel.DockSettings;
// Remove from all sides first
dockSettings.StartBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
// Add to the selected side
switch (side)
{
case DockPinSide.Start:
{
var insertIndex = index ?? dockSettings.StartBands.Count;
dockSettings.StartBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.Center:
{
var insertIndex = index ?? dockSettings.CenterBands.Count;
dockSettings.CenterBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.End:
{
var insertIndex = index ?? dockSettings.EndBands.Count;
dockSettings.EndBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.None:
default:
// Do nothing
break;
}
Save();
}
private void OnPinSideChanged(DockPinSide value)
{
SetBandPosition(value, null);
_pinSide = value;
}
}
public enum DockPinSide
{
None,
Start,
Center,
End,
}
public enum ShowLabelsOption
{
Default,
ShowLabels,
HideLabels,
}

View File

@@ -1,300 +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.ObjectModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
#pragma warning disable SA1402 // File may only contain a single type
public sealed partial class DockBandViewModel : ExtensionObjectViewModel
{
private readonly CommandItemViewModel _rootItem;
private readonly DockBandSettings _bandSettings;
private readonly DockSettings _dockSettings;
private readonly Action _saveSettings;
private readonly IContextMenuFactory _contextMenuFactory;
public ObservableCollection<DockItemViewModel> Items { get; } = new();
private bool _showTitles = true;
private bool _showSubtitles = true;
private bool? _showTitlesSnapshot;
private bool? _showSubtitlesSnapshot;
public string Id => _rootItem.Command.Id;
/// <summary>
/// Gets or sets a value indicating whether titles are shown for items in this band.
/// This is a preview value - call <see cref="SaveLabelSettings"/> to persist or
/// <see cref="RestoreLabelSettings"/> to discard changes.
/// </summary>
public bool ShowTitles
{
get => _showTitles;
set
{
if (_showTitles != value)
{
_showTitles = value;
foreach (var item in Items)
{
item.ShowTitle = value;
}
UpdateProperty(nameof(ShowTitles));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether subtitles are shown for items in this band.
/// This is a preview value - call <see cref="SaveLabelSettings"/> to persist or
/// <see cref="RestoreLabelSettings"/> to discard changes.
/// </summary>
public bool ShowSubtitles
{
get => _showSubtitles;
set
{
if (_showSubtitles != value)
{
_showSubtitles = value;
foreach (var item in Items)
{
item.ShowSubtitle = value;
}
UpdateProperty(nameof(ShowSubtitles));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether labels (both titles and subtitles) are shown.
/// Provided for backward compatibility - setting this sets both ShowTitles and ShowSubtitles.
/// </summary>
public bool ShowLabels
{
get => _showTitles && _showSubtitles;
set
{
ShowTitles = value;
ShowSubtitles = value;
}
}
/// <summary>
/// Takes a snapshot of the current label settings before editing.
/// </summary>
internal void SnapshotShowLabels()
{
_showTitlesSnapshot = _showTitles;
_showSubtitlesSnapshot = _showSubtitles;
}
/// <summary>
/// Saves the current label settings to settings.
/// </summary>
internal void SaveShowLabels()
{
_bandSettings.ShowTitles = _showTitles;
_bandSettings.ShowSubtitles = _showSubtitles;
_showTitlesSnapshot = null;
_showSubtitlesSnapshot = null;
}
/// <summary>
/// Restores the label settings from the snapshot.
/// </summary>
internal void RestoreShowLabels()
{
if (_showTitlesSnapshot.HasValue)
{
ShowTitles = _showTitlesSnapshot.Value;
_showTitlesSnapshot = null;
}
if (_showSubtitlesSnapshot.HasValue)
{
ShowSubtitles = _showSubtitlesSnapshot.Value;
_showSubtitlesSnapshot = null;
}
}
internal DockBandViewModel(
CommandItemViewModel commandItemViewModel,
WeakReference<IPageContext> errorContext,
DockBandSettings settings,
DockSettings dockSettings,
Action saveSettings,
IContextMenuFactory contextMenuFactory)
: base(errorContext)
{
_rootItem = commandItemViewModel;
_bandSettings = settings;
_dockSettings = dockSettings;
_saveSettings = saveSettings;
_contextMenuFactory = contextMenuFactory;
_showTitles = settings.ResolveShowTitles(dockSettings.ShowLabels);
_showSubtitles = settings.ResolveShowSubtitles(dockSettings.ShowLabels);
}
private void InitializeFromList(IListPage list)
{
var items = list.GetItems();
var newViewModels = new List<DockItemViewModel>();
foreach (var item in items)
{
var newItemVm = new DockItemViewModel(new(item), this.PageContext, _showTitles, _showSubtitles, _contextMenuFactory);
newItemVm.SlowInitializeProperties();
newViewModels.Add(newItemVm);
}
List<DockItemViewModel> removed = new();
DoOnUiThread(() =>
{
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removed);
});
foreach (var removedItem in removed)
{
removedItem.SafeCleanup();
}
}
public override void InitializeProperties()
{
var command = _rootItem.Command;
var list = command.Model.Unsafe as IListPage;
if (list is not null)
{
InitializeFromList(list);
list.ItemsChanged += HandleItemsChanged;
}
else
{
var dockItem = new DockItemViewModel(_rootItem, _showTitles, _showSubtitles, _contextMenuFactory);
dockItem.SlowInitializeProperties();
DoOnUiThread(() =>
{
Items.Add(dockItem);
});
}
}
private void HandleItemsChanged(object sender, IItemsChangedEventArgs args)
{
if (_rootItem.Command.Model.Unsafe is IListPage p)
{
InitializeFromList(p);
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
var command = _rootItem.Command;
if (command.Model.Unsafe is IListPage list)
{
list.ItemsChanged -= HandleItemsChanged;
}
foreach (var item in Items)
{
item.SafeCleanup();
}
}
}
public partial class DockItemViewModel : CommandItemViewModel
{
private bool _showTitle = true;
private bool _showSubtitle = true;
public bool ShowTitle
{
get => _showTitle;
internal set
{
if (_showTitle != value)
{
_showTitle = value;
UpdateProperty(nameof(ShowTitle));
UpdateProperty(nameof(ShowLabel));
UpdateProperty(nameof(HasText));
UpdateProperty(nameof(Title));
}
}
}
public bool ShowSubtitle
{
get => _showSubtitle;
internal set
{
if (_showSubtitle != value)
{
_showSubtitle = value;
UpdateProperty(nameof(ShowSubtitle));
UpdateProperty(nameof(ShowLabel));
UpdateProperty(nameof(Subtitle));
}
}
}
/// <summary>
/// Gets a value indicating whether labels are shown (either titles or subtitles).
/// Setting this sets both ShowTitle and ShowSubtitle.
/// </summary>
public bool ShowLabel
{
get => _showTitle || _showSubtitle;
internal set
{
ShowTitle = value;
ShowSubtitle = value;
}
}
public override string Title => _showTitle ? ItemTitle : string.Empty;
public override string Subtitle => _showSubtitle ? base.Subtitle : string.Empty;
public override bool HasText => (_showTitle && !string.IsNullOrEmpty(ItemTitle)) || (_showSubtitle && !string.IsNullOrEmpty(base.Subtitle));
/// <summary>
/// Gets the tooltip for the dock item, which includes the title and
/// subtitle. If it doesn't have one part, it just returns the other.
/// </summary>
/// <remarks>
/// Trickery: in the case one is empty, we can just concatenate, and it will
/// always only be the one that's non-empty
/// </remarks>
public string Tooltip =>
!string.IsNullOrEmpty(ItemTitle) && !string.IsNullOrEmpty(base.Subtitle) ?
$"{ItemTitle}\n{base.Subtitle}" :
ItemTitle + base.Subtitle;
public DockItemViewModel(CommandItemViewModel root, bool showTitle, bool showSubtitle, IContextMenuFactory contextMenuFactory)
: this(root.Model, root.PageContext, showTitle, showSubtitle, contextMenuFactory)
{
}
public DockItemViewModel(ExtensionObject<ICommandItem> item, WeakReference<IPageContext> errorContext, bool showTitle, bool showSubtitle, IContextMenuFactory contextMenuFactory)
: base(item, errorContext, contextMenuFactory)
{
_showTitle = showTitle;
_showSubtitle = showSubtitle;
}
}
#pragma warning restore SA1402 // File may only contain a single type

View File

@@ -1,651 +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.ObjectModel;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public sealed partial class DockViewModel
{
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly SettingsModel _settingsModel;
private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves
private readonly IContextMenuFactory _contextMenuFactory;
private DockSettings _settings;
public TaskScheduler Scheduler { get; }
public ObservableCollection<DockBandViewModel> StartItems { get; } = new();
public ObservableCollection<DockBandViewModel> CenterItems { get; } = new();
public ObservableCollection<DockBandViewModel> EndItems { get; } = new();
public ObservableCollection<TopLevelViewModel> AllItems => _topLevelCommandManager.DockBands;
public DockViewModel(
TopLevelCommandManager tlcManager,
IContextMenuFactory contextMenuFactory,
SettingsModel settings,
TaskScheduler scheduler)
{
_topLevelCommandManager = tlcManager;
_contextMenuFactory = contextMenuFactory;
_settingsModel = settings;
_settings = settings.DockSettings;
Scheduler = scheduler;
_pageContext = new(this);
_topLevelCommandManager.DockBands.CollectionChanged += DockBands_CollectionChanged;
EmitDockConfiguration();
}
private void DockBands_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
Logger.LogDebug("Starting DockBands_CollectionChanged");
SetupBands();
Logger.LogDebug("Ended DockBands_CollectionChanged");
}
public void UpdateSettings(DockSettings settings)
{
Logger.LogDebug($"DockViewModel.UpdateSettings");
_settings = settings;
SetupBands();
}
private void SetupBands()
{
Logger.LogDebug($"Setting up dock bands");
SetupBands(_settings.StartBands, StartItems);
SetupBands(_settings.CenterBands, CenterItems);
SetupBands(_settings.EndBands, EndItems);
}
private void SetupBands(
List<DockBandSettings> bands,
ObservableCollection<DockBandViewModel> target)
{
List<DockBandViewModel> newBands = new();
foreach (var band in bands)
{
var commandId = band.CommandId;
var topLevelCommand = _topLevelCommandManager.LookupDockBand(commandId);
if (topLevelCommand is null)
{
Logger.LogWarning($"Failed to find band {commandId}");
}
if (topLevelCommand is not null)
{
// note: CreateBandItem doesn't actually initialize the band, it
// just creates the VM. Callers need to make sure to call
// InitializeProperties() on a BG thread elsewhere
var bandVm = CreateBandItem(band, topLevelCommand.ItemViewModel);
newBands.Add(bandVm);
}
}
var beforeCount = target.Count;
var afterCount = newBands.Count;
DoOnUiThread(() =>
{
List<DockBandViewModel> removed = new();
ListHelpers.InPlaceUpdateList(target, newBands, out removed);
var isStartBand = target == StartItems;
var label = isStartBand ? "Start bands:" : "End bands:";
Logger.LogDebug($"{label} ({beforeCount}) -> ({afterCount}), Removed {removed?.Count ?? 0} items");
// then, back to a BG thread:
Task.Run(() =>
{
if (removed is not null)
{
foreach (var removedItem in removed)
{
removedItem.SafeCleanup();
}
}
});
});
// Initialize properties on BG thread
Task.Run(() =>
{
foreach (var band in newBands)
{
band.SafeInitializePropertiesSynchronous();
}
});
}
/// <summary>
/// Instantiate a new band view model for this CommandItem, given the
/// settings. The DockBandViewModel will _not_ be initialized - callers
/// will need to make sure to initialize it somewhere else (off the UI
/// thread)
/// </summary>
private DockBandViewModel CreateBandItem(
DockBandSettings bandSettings,
CommandItemViewModel commandItem)
{
DockBandViewModel band = new(commandItem, commandItem.PageContext, bandSettings, _settings, SaveSettings, _contextMenuFactory);
// the band is NOT initialized here!
return band;
}
private void SaveSettings()
{
SettingsModel.SaveSettings(_settingsModel);
}
public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc)
{
var id = tlc.Id;
return FindBandById(id);
}
public DockBandViewModel? FindBandById(string id)
{
foreach (var band in StartItems)
{
if (band.Id == id)
{
return band;
}
}
foreach (var band in CenterItems)
{
if (band.Id == id)
{
return band;
}
}
foreach (var band in EndItems)
{
if (band.Id == id)
{
return band;
}
}
return null;
}
/// <summary>
/// Syncs the band position in settings after a same-list reorder.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
return;
}
// Remove from all settings lists
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
// Add to target settings list at the correct index
var targetSettings = targetSide switch
{
DockPinSide.Start => dockSettings.StartBands,
DockPinSide.Center => dockSettings.CenterBands,
DockPinSide.End => dockSettings.EndBands,
_ => dockSettings.StartBands,
};
var insertIndex = Math.Min(targetIndex, targetSettings.Count);
targetSettings.Insert(insertIndex, bandSettings);
}
/// <summary>
/// Moves a dock band to a new position (cross-list drop).
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
Logger.LogWarning($"Could not find band settings for band {bandId}");
return;
}
// Remove from all sides (settings and UI)
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
// Add to the target side at the specified index
switch (targetSide)
{
case DockPinSide.Start:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.StartBands.Count);
dockSettings.StartBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIndex, band);
break;
}
case DockPinSide.Center:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.CenterBands.Count);
dockSettings.CenterBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIndex, band);
break;
}
case DockPinSide.End:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.EndBands.Count);
dockSettings.EndBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIndex, band);
break;
}
}
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
}
/// <summary>
/// Saves the current band order and label settings to settings.
/// Call this when exiting edit mode.
/// </summary>
public void SaveBandOrder()
{
// Save ShowLabels for all bands
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
{
band.SaveShowLabels();
}
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotBandViewModels = null;
SettingsModel.SaveSettings(_settingsModel);
Logger.LogDebug("Saved band order to settings");
}
private List<DockBandSettings>? _snapshotStartBands;
private List<DockBandSettings>? _snapshotCenterBands;
private List<DockBandSettings>? _snapshotEndBands;
private Dictionary<string, DockBandViewModel>? _snapshotBandViewModels;
/// <summary>
/// Takes a snapshot of the current band order and label settings before editing.
/// Call this when entering edit mode.
/// </summary>
public void SnapshotBandOrder()
{
var dockSettings = _settingsModel.DockSettings;
_snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList();
_snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList();
_snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList();
// Snapshot band ViewModels so we can restore unpinned bands
// Use a dictionary but handle potential duplicates gracefully
_snapshotBandViewModels = new Dictionary<string, DockBandViewModel>();
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
{
_snapshotBandViewModels.TryAdd(band.Id, band);
}
// Snapshot ShowLabels for all bands
foreach (var band in _snapshotBandViewModels.Values)
{
band.SnapshotShowLabels();
}
Logger.LogDebug($"Snapshot taken: {_snapshotStartBands.Count} start bands, {_snapshotCenterBands.Count} center bands, {_snapshotEndBands.Count} end bands");
}
/// <summary>
/// Restores the band order and label settings from the snapshot taken when entering edit mode.
/// Call this when discarding edit mode changes.
/// </summary>
public void RestoreBandOrder()
{
if (_snapshotStartBands == null ||
_snapshotCenterBands == null ||
_snapshotEndBands == null || _snapshotBandViewModels == null)
{
Logger.LogWarning("No snapshot to restore from");
return;
}
// Restore ShowLabels for all snapshotted bands
foreach (var band in _snapshotBandViewModels.Values)
{
band.RestoreShowLabels();
}
var dockSettings = _settingsModel.DockSettings;
// Restore settings from snapshot
dockSettings.StartBands.Clear();
dockSettings.CenterBands.Clear();
dockSettings.EndBands.Clear();
foreach (var bandSnapshot in _snapshotStartBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.StartBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotCenterBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.CenterBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotEndBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.EndBands.Add(bandSettings);
}
// Rebuild UI collections from restored settings using the snapshotted ViewModels
RebuildUICollectionsFromSnapshot();
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotBandViewModels = null;
Logger.LogDebug("Restored band order from snapshot");
}
private void RebuildUICollectionsFromSnapshot()
{
if (_snapshotBandViewModels == null)
{
return;
}
var dockSettings = _settingsModel.DockSettings;
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
StartItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.CenterBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
CenterItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.EndBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
EndItems.Add(bandVM);
}
}
}
private void RebuildUICollections()
{
var dockSettings = _settingsModel.DockSettings;
// Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
StartItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.CenterBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
CenterItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.EndBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
EndItems.Add(bandVM);
}
}
}
/// <summary>
/// Gets the list of dock bands that are not currently pinned to any section.
/// </summary>
public IEnumerable<TopLevelViewModel> GetAvailableBandsToAdd()
{
// Get IDs of all bands currently in the dock
var pinnedBandIds = new HashSet<string>();
foreach (var band in StartItems)
{
pinnedBandIds.Add(band.Id);
}
foreach (var band in CenterItems)
{
pinnedBandIds.Add(band.Id);
}
foreach (var band in EndItems)
{
pinnedBandIds.Add(band.Id);
}
// Return all dock bands that are not already pinned
return AllItems.Where(tlc => !pinnedBandIds.Contains(tlc.Id));
}
/// <summary>
/// Adds a band to the specified dock section.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void AddBandToSection(TopLevelViewModel topLevel, DockPinSide targetSide)
{
var bandId = topLevel.Id;
// Check if already in the dock
if (FindBandById(bandId) != null)
{
Logger.LogWarning($"Band {bandId} is already in the dock");
return;
}
// Create settings for the new band
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null };
var dockSettings = _settingsModel.DockSettings;
// Create the band view model
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
// Add to the appropriate section
switch (targetSide)
{
case DockPinSide.Start:
dockSettings.StartBands.Add(bandSettings);
StartItems.Add(bandVm);
break;
case DockPinSide.Center:
dockSettings.CenterBands.Add(bandSettings);
CenterItems.Add(bandVm);
break;
case DockPinSide.End:
dockSettings.EndBands.Add(bandSettings);
EndItems.Add(bandVm);
break;
}
// Snapshot the new band so it can be removed on discard
bandVm.SnapshotShowLabels();
Task.Run(() =>
{
bandVm.SafeInitializePropertiesSynchronous();
});
Logger.LogDebug($"Added band {bandId} to {targetSide} (not saved yet)");
}
/// <summary>
/// Unpins a band from the dock, removing it from whichever section it's in.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void UnpinBand(DockBandViewModel band)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
// Remove from settings
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
// Remove from UI collections
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
Logger.LogDebug($"Unpinned band {bandId} (not saved yet)");
}
private void DoOnUiThread(Action action)
{
Task.Factory.StartNew(
action,
CancellationToken.None,
TaskCreationOptions.None,
Scheduler);
}
public CommandItemViewModel GetContextMenuForDock()
{
var model = new DockContextMenuItem();
var vm = new CommandItemViewModel(new(model), new(_pageContext), contextMenuFactory: null);
vm.SlowInitializeProperties();
return vm;
}
private sealed partial class DockContextMenuItem : CommandItem
{
public DockContextMenuItem()
{
var editDockCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new EnterDockEditModeMessage());
})
{
Name = Properties.Resources.dock_edit_dock_name,
Icon = Icons.EditIcon,
};
var openSettingsCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Dock"));
})
{
Name = Properties.Resources.dock_settings_name,
Icon = Icons.SettingsIcon,
};
MoreCommands = new CommandContextItem[]
{
new CommandContextItem(editDockCommand),
new CommandContextItem(openSettingsCommand),
};
}
}
private void EmitDockConfiguration()
{
var isDockEnabled = _settingsModel.EnableDock;
var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none";
static string FormatBands(List<DockBandSettings> bands) =>
string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}"));
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(_settings.CenterBands) : string.Empty;
var endBands = isDockEnabled ? FormatBands(_settings.EndBands) : string.Empty;
WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage(
isDockEnabled, dockSide, startBands, centerBands, endBands));
}
/// <summary>
/// Provides an empty page context, for the dock's own context menu. We're
/// building the context menu for the dock using literally our own cmdpal
/// types, but that means we need a page context for the VM we will
/// generate.
/// </summary>
private sealed partial class DockPageContext(DockViewModel dockViewModel) : IPageContext
{
public TaskScheduler Scheduler => dockViewModel.Scheduler;
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint)
{
var extensionText = extensionHint ?? "<unknown>";
Logger.LogError($"Error in dock context {extensionText}", ex);
}
}
}

View File

@@ -1,90 +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 CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public partial class DockWindowViewModel : ObservableObject, IDisposable
{
private readonly IThemeService _themeService;
private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!;
[ObservableProperty]
public partial ImageSource? BackgroundImageSource { get; private set; }
[ObservableProperty]
public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill;
[ObservableProperty]
public partial double BackgroundImageOpacity { get; private set; }
[ObservableProperty]
public partial Color BackgroundImageTint { get; private set; }
[ObservableProperty]
public partial double BackgroundImageTintIntensity { get; private set; }
[ObservableProperty]
public partial int BackgroundImageBlurAmount { get; private set; }
[ObservableProperty]
public partial double BackgroundImageBrightness { get; private set; }
[ObservableProperty]
public partial bool ShowBackgroundImage { get; private set; }
[ObservableProperty]
public partial bool ShowColorizationOverlay { get; private set; }
[ObservableProperty]
public partial Color ColorizationColor { get; private set; }
[ObservableProperty]
public partial double ColorizationOpacity { get; private set; }
public DockWindowViewModel(IThemeService themeService)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeService_ThemeChanged;
UpdateFromThemeSnapshot();
}
private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_uiDispatcherQueue.TryEnqueue(UpdateFromThemeSnapshot);
}
private void UpdateFromThemeSnapshot()
{
var snapshot = _themeService.CurrentDockTheme;
BackgroundImageSource = snapshot.BackgroundImageSource;
BackgroundImageStretch = snapshot.BackgroundImageStretch;
BackgroundImageOpacity = snapshot.BackgroundImageOpacity;
BackgroundImageBrightness = snapshot.BackgroundBrightness;
BackgroundImageTint = snapshot.Tint;
BackgroundImageTintIntensity = snapshot.TintIntensity;
BackgroundImageBlurAmount = snapshot.BlurAmount;
ShowBackgroundImage = BackgroundImageSource != null;
// Colorization overlay for transparent backdrop
ShowColorizationOverlay = snapshot.Backdrop == DockBackdrop.Transparent && snapshot.TintIntensity > 0;
ColorizationColor = snapshot.Tint;
ColorizationOpacity = snapshot.TintIntensity;
}
public void Dispose()
{
_themeService.ThemeChanged -= ThemeService_ThemeChanged;
GC.SuppressFinalize(this);
}
}

View File

@@ -1,341 +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.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
using Windows.UI.ViewManagement;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// View model for dock appearance settings, controlling theme, backdrop, colorization,
/// and background image settings for the dock.
/// </summary>
public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, IDisposable
{
private readonly SettingsModel _settings;
private readonly DockSettings _dockSettings;
private readonly UISettings _uiSettings;
private readonly IThemeService _themeService;
private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread();
private ElementTheme? _elementThemeOverride;
private Color _currentSystemAccentColor;
public ObservableCollection<Color> Swatches => AppearanceSettingsViewModel.WindowsColorSwatches;
public int ThemeIndex
{
get => (int)_dockSettings.Theme;
set => Theme = (UserTheme)value;
}
public UserTheme Theme
{
get => _dockSettings.Theme;
set
{
if (_dockSettings.Theme != value)
{
_dockSettings.Theme = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ThemeIndex));
Save();
}
}
}
public int BackdropIndex
{
get => (int)_dockSettings.Backdrop;
set => Backdrop = (DockBackdrop)value;
}
public DockBackdrop Backdrop
{
get => _dockSettings.Backdrop;
set
{
if (_dockSettings.Backdrop != value)
{
_dockSettings.Backdrop = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BackdropIndex));
Save();
}
}
}
public ColorizationMode ColorizationMode
{
get => _dockSettings.ColorizationMode;
set
{
if (_dockSettings.ColorizationMode != value)
{
_dockSettings.ColorizationMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ColorizationModeIndex));
OnPropertyChanged(nameof(IsCustomTintVisible));
OnPropertyChanged(nameof(IsCustomTintIntensityVisible));
OnPropertyChanged(nameof(IsBackgroundControlsVisible));
OnPropertyChanged(nameof(IsNoBackgroundVisible));
OnPropertyChanged(nameof(IsAccentColorControlsVisible));
if (value == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
IsColorizationDetailsExpanded = value != ColorizationMode.None;
Save();
}
}
}
public int ColorizationModeIndex
{
get => (int)_dockSettings.ColorizationMode;
set => ColorizationMode = (ColorizationMode)value;
}
public Color ThemeColor
{
get => _dockSettings.CustomThemeColor;
set
{
if (_dockSettings.CustomThemeColor != value)
{
_dockSettings.CustomThemeColor = value;
OnPropertyChanged();
if (ColorIntensity == 0)
{
ColorIntensity = 100;
}
Save();
}
}
}
public int ColorIntensity
{
get => _dockSettings.CustomThemeColorIntensity;
set
{
_dockSettings.CustomThemeColorIntensity = value;
OnPropertyChanged();
Save();
}
}
public string BackgroundImagePath
{
get => _dockSettings.BackgroundImagePath ?? string.Empty;
set
{
if (_dockSettings.BackgroundImagePath != value)
{
_dockSettings.BackgroundImagePath = value;
OnPropertyChanged();
if (BackgroundImageOpacity == 0)
{
BackgroundImageOpacity = 100;
}
Save();
}
}
}
public int BackgroundImageOpacity
{
get => _dockSettings.BackgroundImageOpacity;
set
{
if (_dockSettings.BackgroundImageOpacity != value)
{
_dockSettings.BackgroundImageOpacity = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBrightness
{
get => _dockSettings.BackgroundImageBrightness;
set
{
if (_dockSettings.BackgroundImageBrightness != value)
{
_dockSettings.BackgroundImageBrightness = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBlurAmount
{
get => _dockSettings.BackgroundImageBlurAmount;
set
{
if (_dockSettings.BackgroundImageBlurAmount != value)
{
_dockSettings.BackgroundImageBlurAmount = value;
OnPropertyChanged();
Save();
}
}
}
public BackgroundImageFit BackgroundImageFit
{
get => _dockSettings.BackgroundImageFit;
set
{
if (_dockSettings.BackgroundImageFit != value)
{
_dockSettings.BackgroundImageFit = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BackgroundImageFitIndex));
Save();
}
}
}
public int BackgroundImageFitIndex
{
get => BackgroundImageFit switch
{
BackgroundImageFit.Fill => 1,
_ => 0,
};
set => BackgroundImageFit = value switch
{
1 => BackgroundImageFit.Fill,
_ => BackgroundImageFit.UniformToFill,
};
}
[ObservableProperty]
public partial bool IsColorizationDetailsExpanded { get; set; }
public bool IsCustomTintVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image;
public bool IsCustomTintIntensityVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
public bool IsBackgroundControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.Image;
public bool IsNoBackgroundVisible => _dockSettings.ColorizationMode is ColorizationMode.None;
public bool IsAccentColorControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.WindowsAccentColor;
public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme;
public Color EffectiveThemeColor => ColorizationMode switch
{
ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
_ => Colors.Transparent,
};
// Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen).
public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f);
public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0;
public ImageSource? EffectiveBackgroundImageSource =>
ColorizationMode is ColorizationMode.Image
&& !string.IsNullOrWhiteSpace(BackgroundImagePath)
&& Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
: null;
public DockAppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeServiceOnThemeChanged;
_settings = settings;
_dockSettings = settings.DockSettings;
_uiSettings = new UISettings();
_uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged;
UpdateAccentColor(_uiSettings);
Reapply();
IsColorizationDetailsExpanded = _dockSettings.ColorizationMode != ColorizationMode.None;
}
private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender));
private void UpdateAccentColor(UISettings sender)
{
_currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent);
if (ColorizationMode == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
}
private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Save()
{
SettingsModel.SaveSettings(_settings);
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Reapply()
{
OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness));
OnPropertyChanged(nameof(EffectiveBackgroundImageSource));
OnPropertyChanged(nameof(EffectiveThemeColor));
OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount));
// LOAD BEARING:
// We need to cycle through the EffectiveTheme property to force reload of resources.
_elementThemeOverride = ElementTheme.Light;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = ElementTheme.Dark;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = null;
OnPropertyChanged(nameof(EffectiveTheme));
}
[RelayCommand]
private void ResetBackgroundImageProperties()
{
BackgroundImageBrightness = 0;
BackgroundImageBlurAmount = 0;
BackgroundImageFit = BackgroundImageFit.UniformToFill;
BackgroundImageOpacity = 100;
ColorIntensity = 0;
}
public void Dispose()
{
_uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged;
_themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
}
}

View File

@@ -59,7 +59,7 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatc
LogIfDefaultScheduler();
}
protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
private protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
{
ArgumentNullException.ThrowIfNull(contextRef);

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -8,8 +8,6 @@ public class GlobalLogPageContext : IPageContext
{
public TaskScheduler Scheduler { get; private init; }
ICommandProviderContext IPageContext.ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint)
{ /*do nothing*/
}

View File

@@ -9,9 +9,4 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public interface IContextMenuFactory
{
List<IContextItemViewModel> UnsafeBuildAndInitMoreCommands(IContextItem[] items, CommandItemViewModel commandItem);
void AddMoreCommandsToTopLevel(
TopLevelViewModel topLevelItem,
ICommandProviderContext providerContext,
List<IContextItem?> contextItems);
}

View File

@@ -1,18 +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.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public static class Icons
{
public static IconInfo PinIcon => new("\uE718"); // Pin icon
public static IconInfo UnpinIcon => new("\uE77A"); // Unpin icon
public static IconInfo SettingsIcon => new("\uE713"); // Settings icon
public static IconInfo EditIcon => new("\uE70F"); // Edit icon
}

View File

@@ -1,15 +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.
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class ItemsUpdatedEventArgs : EventArgs
{
public bool ForceFirstItem { get; }
public ItemsUpdatedEventArgs(bool forceFirstItem)
{
ForceFirstItem = forceFirstItem;
}
}

View File

@@ -63,7 +63,7 @@ public partial class ListItemViewModel : CommandItemViewModel
}
}
public ListItemViewModel(IListItem model, WeakReference<IPageContext> context, IContextMenuFactory contextMenuFactory)
public ListItemViewModel(IListItem model, WeakReference<IPageContext> context, IContextMenuFactory? contextMenuFactory = null)
: base(new(model), context, contextMenuFactory)
{
Model = new ExtensionObject<IListItem>(model);

View File

@@ -3,10 +3,8 @@
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
@@ -18,10 +16,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListViewModel : PageViewModel, IDisposable
{
// private readonly HashSet<ListItemViewModel> _itemCache = [];
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
@@ -35,12 +32,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
private readonly ExtensionObject<IListPage> _model;
private readonly Lock _listLock = new();
private readonly IContextMenuFactory _contextMenuFactory;
private readonly IContextMenuFactory? _contextMenuFactory;
private InterlockedBoolean _isLoading;
private bool _isFetching;
public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;
public event TypedEventHandler<ListViewModel, object>? ItemsUpdated;
public bool ShowEmptyContent =>
IsInitialized &&
@@ -83,9 +80,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
private ListItemViewModel? _lastSelectedItem;
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
private CancellationTokenSource? _selectedItemCts;
public override bool IsInitialized
{
get => base.IsInitialized; protected set
@@ -95,12 +89,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
}
public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext, IContextMenuFactory contextMenuFactory)
public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext, IContextMenuFactory? contextMenuFactory)
: base(model, scheduler, host, providerContext)
{
_model = new(model);
_contextMenuFactory = contextMenuFactory;
EmptyContent = new(new(null), PageContext, contextMenuFactory: null);
EmptyContent = new(new(null), PageContext, _contextMenuFactory);
}
private void FiltersPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -119,6 +113,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
protected override void OnSearchTextBoxUpdated(string searchTextBox)
{
//// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view
//// and manage filtering below, but we should be smarter about this and understand caching and other requirements...
//// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList?
// Dynamic pages will handler their own filtering. They will tell us if
// something needs to change, by raising ItemsChanged.
if (_isDynamic)
@@ -134,24 +132,24 @@ public partial class ListViewModel : PageViewModel, IDisposable
// concurrently.
_ = filterTaskFactory.StartNew(
() =>
{
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
{
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
try
try
{
if (_model.Unsafe is IDynamicListPage dynamic)
{
if (_model.Unsafe is IDynamicListPage dynamic)
{
dynamic.SearchText = searchTextBox;
}
dynamic.SearchText = searchTextBox;
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
}
},
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
}
},
filterCancellationTokenSource.Token,
TaskCreationOptions.None,
filterTaskFactory.Scheduler!);
@@ -164,7 +162,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
ApplyFilterUnderLock();
}
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
ItemsUpdated?.Invoke(this, EventArgs.Empty);
UpdateEmptyContent();
_isLoading.Clear();
}
@@ -200,10 +198,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
var cancellationToken = _fetchItemsCancellationTokenSource.Token;
// TEMPORARY: just plop all the items into a single group
// see 9806fe5d8 for the last commit that had this with sections
_isFetching = true;
// Collect all the items into new viewmodels
List<ListItemViewModel> newViewModels = [];
Collection<ListItemViewModel> newViewModels = [];
try
{
@@ -221,57 +221,30 @@ public partial class ListViewModel : PageViewModel, IDisposable
return;
}
// TODO we can probably further optimize this by also keeping a
// HashSet of every ExtensionObject we currently have, and only
// building new viewmodels for the ones we haven't already built.
var showsTitle = GridProperties?.ShowTitle ?? true;
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
var created = 0;
var reused = 0;
foreach (var item in newItems)
{
try
// Check for cancellation during item processing
if (cancellationToken.IsCancellationRequested)
{
if (item is null)
{
continue;
}
// Check for cancellation during item processing
if (cancellationToken.IsCancellationRequested)
{
return;
}
if (_vmCache.TryGetValue(item, out var existing))
{
existing.LayoutShowsTitle = showsTitle;
existing.LayoutShowsSubtitle = showsSubtitle;
newViewModels.Add(existing);
reused++;
continue;
}
var viewModel = new ListItemViewModel(item, new(this), _contextMenuFactory);
// If an item fails to load, silently ignore it.
if (viewModel.SafeFastInit())
{
viewModel.LayoutShowsTitle = showsTitle;
viewModel.LayoutShowsSubtitle = showsSubtitle;
_vmCache[item] = viewModel;
newViewModels.Add(viewModel);
created++;
}
return;
}
catch (Exception ex)
ListItemViewModel viewModel = new(item, new(this));
// If an item fails to load, silently ignore it.
if (viewModel.SafeFastInit())
{
CoreLogger.LogError("Failed to load item:\n", ex + ToString());
viewModel.LayoutShowsTitle = showsTitle;
viewModel.LayoutShowsSubtitle = showsSubtitle;
newViewModels.Add(viewModel);
}
}
#if DEBUG
CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {_vmCache.Count} cached");
#endif
// Check for cancellation before initializing first twenty items
if (cancellationToken.IsCancellationRequested)
{
@@ -298,22 +271,13 @@ public partial class ListViewModel : PageViewModel, IDisposable
return;
}
List<ListItemViewModel> removedItems;
List<ListItemViewModel> removedItems = [];
lock (_listLock)
{
// Now that we have new ViewModels for everything from the
// extension, smartly update our list of VMs
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
_vmCache.Clear();
foreach (var vm in newViewModels)
{
if (vm.Model.Unsafe is { } li)
{
_vmCache[li] = vm;
}
}
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
// you'll clean up list items that we've now transferred into
// .Items
@@ -324,6 +288,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
{
removedItem.SafeCleanup();
}
// TODO: Iterate over everything in Items, and prune items from the
// cache if we don't need them anymore
}
catch (OperationCanceledException)
{
@@ -375,14 +342,13 @@ public partial class ListViewModel : PageViewModel, IDisposable
{
// A dynamic list? Even better! Just stick everything into
// FilteredItems. The extension already did any filtering it cared about.
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState));
}
UpdateEmptyContent();
}
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(IsRootPage));
ItemsUpdated?.Invoke(this, EventArgs.Empty);
_isLoading.Clear();
});
}
@@ -519,58 +485,40 @@ public partial class ListViewModel : PageViewModel, IDisposable
private void SetSelectedItem(ListItemViewModel item)
{
if (!item.SafeSlowInit())
{
// Even if initialization fails, we need to hide any previously shown details
DoOnUiThread(() =>
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
});
return;
}
// GH #322:
// For inexplicable reasons, if you try updating the command bar and
// the details on the same UI thread tick as updating the list, we'll
// explode
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
if (ShowDetails && item.HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
TextToSuggest = item.TextToSuggest;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
});
_lastSelectedItem = item;
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
// Cancel any in-flight slow init from a previous selection and defer
// the expensive work (extension IPC for MoreCommands, details) so
// rapid arrow-key navigation skips intermediate items entirely.
_selectedItemCts?.Cancel();
var cts = _selectedItemCts = new CancellationTokenSource();
var ct = cts.Token;
_ = Task.Run(
() =>
{
if (ct.IsCancellationRequested)
{
return;
}
if (!item.SafeSlowInit())
{
if (ct.IsCancellationRequested)
{
return;
}
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
return;
}
if (ct.IsCancellationRequested)
{
return;
}
// SafeSlowInit completed on a background thread — details
// messages will be marshalled to the UI thread by the receiver.
if (ShowDetails && item.HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
TextToSuggest = item.TextToSuggest;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
},
ct);
}
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -609,12 +557,21 @@ public partial class ListViewModel : PageViewModel, IDisposable
private void ClearSelectedItem()
{
_selectedItemCts?.Cancel();
// GH #322:
// For inexplicable reasons, if you try updating the command bar and
// the details on the same UI thread tick as updating the list, we'll
// explode
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
TextToSuggest = string.Empty;
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
TextToSuggest = string.Empty;
});
}
public override void InitializeProperties()
@@ -647,7 +604,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
UpdateProperty(nameof(SearchText));
UpdateProperty(nameof(InitialSearchText));
EmptyContent = new(new(model.EmptyContent), PageContext, _contextMenuFactory);
EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent.SlowInitializeProperties();
Filters?.PropertyChanged -= FiltersPropertyChanged;
@@ -743,7 +700,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
SearchText = model.SearchText;
break;
case nameof(EmptyContent):
EmptyContent = new(new(model.EmptyContent), PageContext, contextMenuFactory: null);
EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent.SlowInitializeProperties();
break;
case nameof(Filters):
@@ -806,10 +763,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
_fetchItemsCancellationTokenSource?.Cancel();
_fetchItemsCancellationTokenSource?.Dispose();
_fetchItemsCancellationTokenSource = null;
_selectedItemCts?.Cancel();
_selectedItemCts?.Dispose();
_selectedItemCts = null;
}
protected override void UnsafeCleanup()
@@ -817,12 +770,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
base.UnsafeCleanup();
EmptyContent?.SafeCleanup();
EmptyContent = new(new(null), PageContext, contextMenuFactory: null); // necessary?
EmptyContent = new(new(null), PageContext); // necessary?
_cancellationTokenSource?.Cancel();
filterCancellationTokenSource?.Cancel();
_fetchItemsCancellationTokenSource?.Cancel();
_selectedItemCts?.Cancel();
lock (_listLock)
{
@@ -849,11 +801,4 @@ public partial class ListViewModel : PageViewModel, IDisposable
model.ItemsChanged -= Model_ItemsChanged;
}
}
private sealed class ProxyReferenceEqualityComparer : IEqualityComparer<IListItem>
{
public bool Equals(IListItem? x, IListItem? y) => ReferenceEquals(x, y);
public int GetHashCode(IListItem obj) => RuntimeHelpers.GetHashCode(obj);
}
}

View File

@@ -12,7 +12,6 @@ public partial class LoadingPageViewModel : PageViewModel
: base(model, scheduler, host, CommandProviderContext.Empty)
{
ModelIsLoading = true;
HasBackButton = false;
IsInitialized = false;
}
}

View File

@@ -1,7 +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.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record EnterDockEditModeMessage();

View File

@@ -4,4 +4,4 @@
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken, bool TransientPage = false);
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken);

View File

@@ -18,8 +18,6 @@ public record PerformCommandMessage
public bool WithAnimation { get; set; } = true;
public bool TransientPage { get; set; }
public PerformCommandMessage(ExtensionObject<ICommand> command)
{
Command = command;

View File

@@ -1,7 +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.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record PinToDockMessage(string ProviderId, string CommandId, bool Pin);

View File

@@ -1,7 +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.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record ShowHideDockMessage(bool ShowDock);

View File

@@ -1,11 +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.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Telemetry message sent when the dock is initialized.
/// Captures the dock configuration for telemetry tracking.
/// </summary>
public record TelemetryDockConfigurationMessage(bool IsDockEnabled, string DockSide, string StartBands, string CenterBands, string EndBands);

View File

@@ -1,7 +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.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record UnpinCommandItemMessage(string ProviderId, string CommandId);

View File

@@ -1,7 +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.
namespace Microsoft.CmdPal.ViewModels.Messages;
public sealed record WindowHiddenMessage();

View File

@@ -4,11 +4,5 @@
namespace Microsoft.CmdPal.UI.ViewModels;
internal sealed partial class NullPageViewModel : PageViewModel
{
internal NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
: base(null, scheduler, extensionHost, CommandProviderContext.Empty)
{
HasBackButton = false;
}
}
internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
: PageViewModel(null, scheduler, extensionHost, CommandProviderContext.Empty);

View File

@@ -26,25 +26,8 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
[ObservableProperty]
public partial string ErrorMessage { get; protected set; } = string.Empty;
/// <summary>
/// Explicitly: is this page, the VM for the root page. This is used
/// slightly differently than being "nested". When we open CmdPal as a
/// transient window, we want that page to not have a back button, but that
/// page is _not_ the root page.
///
/// Later in ListViewModel, we will have logic that checks if it is the root
/// page, and modify how selection is handled when the list changes.
/// </summary>
[ObservableProperty]
public partial bool IsRootPage { get; set; } = true;
/// <summary>
/// This is used to determine whether to show the back button on this page.
/// When a nested page is opened for the transient "dock flyout" window,
/// then we don't want to show the back button.
/// </summary>
[ObservableProperty]
public partial bool HasBackButton { get; set; } = true;
public partial bool IsNested { get; set; } = true;
// This is set from the SearchBar
[ObservableProperty]
@@ -93,9 +76,9 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
public IconInfoViewModel Icon { get; protected set; }
public ICommandProviderContext ProviderContext { get; protected set; }
public CommandProviderContext ProviderContext { get; protected set; }
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, ICommandProviderContext providerContext)
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, CommandProviderContext providerContext)
: base(scheduler)
{
InitializeSelfAsPageContext();
@@ -284,8 +267,6 @@ public interface IPageContext
void ShowException(Exception ex, string? extensionHint = null);
TaskScheduler Scheduler { get; }
ICommandProviderContext ProviderContext { get; }
}
public interface IPageViewModelFactoryService
@@ -297,5 +278,5 @@ public interface IPageViewModelFactoryService
/// <param name="nested">Indicates whether the page is not the top-level page.</param>
/// <param name="host">The command palette host that will host the page (for status messages)</param>
/// <returns>A new instance of the page view model.</returns>
PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, ICommandProviderContext providerContext);
PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext);
}

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
@@ -60,15 +60,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open Command Palette.
/// </summary>
public static string builtin_command_palette_title {
get {
return ResourceManager.GetString("builtin_command_palette_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create another.
/// </summary>
@@ -303,15 +294,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Built-in.
/// </summary>
public static string builtin_extension_name_fallback {
get {
return ResourceManager.GetString("builtin_extension_name_fallback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} commands.
/// </summary>
@@ -483,42 +465,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Edit dock.
/// </summary>
public static string dock_edit_dock_name {
get {
return ResourceManager.GetString("dock_edit_dock_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} items.
/// </summary>
public static string dock_item_count_plural {
get {
return ResourceManager.GetString("dock_item_count_plural", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to 1 item.
/// </summary>
public static string dock_item_count_singular {
get {
return ResourceManager.GetString("dock_item_count_singular", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dock settings.
/// </summary>
public static string dock_settings_name {
get {
return ResourceManager.GetString("dock_settings_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Fallbacks.
/// </summary>
@@ -528,23 +474,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
public static string PinnedItemSuffix {
get {
return ResourceManager.GetString("PinnedItemSuffix", resourceCulture);
}
}
/// Looks up a localized string similar to Results.
/// </summary>
public static string results {
get {
return ResourceManager.GetString("results", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show details.
/// </summary>

View File

@@ -254,46 +254,14 @@
<data name="builtin_settings_extension_n_extensions_installed" xml:space="preserve">
<value>{0} extensions installed</value>
</data>
<data name="builtin_extension_name_fallback" xml:space="preserve">
<value>Built-in</value>
<comment>Fallback name for built-in extensions</comment>
</data>
<data name="dock_item_count_singular" xml:space="preserve">
<value>1 item</value>
<comment>Singular form for item count in dock band</comment>
</data>
<data name="dock_item_count_plural" xml:space="preserve">
<value>{0} items</value>
<comment>Plural form for item count in dock band</comment>
</data>
<data name="builtin_command_palette_title" xml:space="preserve">
<value>Open Command Palette</value>
<comment>Title for the command to open the command palette</comment>
</data>
<data name="builtin_settings_appearance_pick_background_image_title" xml:space="preserve">
<value>Pick background image</value>
</data>
<data name="fallbacks" xml:space="preserve">
<value>Fallbacks</value>
</data>
<data name="dock_edit_dock_name" xml:space="preserve">
<value>Edit dock</value>
<comment>Command name for editing the dock</comment>
</data>
<data name="dock_settings_name" xml:space="preserve">
<value>Dock settings</value>
<comment>Command name for opening dock settings</comment>
</data>
<data name="ShowDetailsCommand" xml:space="preserve">
<value>Show details</value>
<comment>Name for the command that shows details of an item</comment>
</data>
<data name="PinnedItemSuffix" xml:space="preserve">
<value>Pinned</value>
<comment>Suffix shown for pinned items in the dock</comment>
</data>
<data name="results" xml:space="preserve">
<value>Results</value>
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
</data>
</root>

View File

@@ -1,73 +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.UI.ViewModels.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Represents a snapshot of dock theme-related visual settings, including accent color, theme preference,
/// backdrop, and background image configuration, for use in rendering the Dock UI.
/// </summary>
public sealed class DockThemeSnapshot
{
/// <summary>
/// Gets the accent tint color used by the Dock visuals.
/// </summary>
public required Color Tint { get; init; }
/// <summary>
/// Gets the intensity of the accent tint color (0-1 range).
/// </summary>
public required float TintIntensity { get; init; }
/// <summary>
/// Gets the configured application theme preference for the Dock.
/// </summary>
public required ElementTheme Theme { get; init; }
/// <summary>
/// Gets the backdrop type for the Dock.
/// </summary>
public required DockBackdrop Backdrop { get; init; }
/// <summary>
/// Gets the image source to render as the background, if any.
/// </summary>
/// <remarks>
/// Returns <see langword="null"/> when no background image is configured.
/// </remarks>
public required ImageSource? BackgroundImageSource { get; init; }
/// <summary>
/// Gets the stretch mode used to lay out the background image.
/// </summary>
public required Stretch BackgroundImageStretch { get; init; }
/// <summary>
/// Gets the opacity applied to the background image.
/// </summary>
/// <value>
/// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque.
/// </value>
public required double BackgroundImageOpacity { get; init; }
/// <summary>
/// Gets the effective acrylic backdrop parameters based on current settings and theme.
/// </summary>
public required BackdropParameters BackdropParameters { get; init; }
/// <summary>
/// Gets the blur amount for the background image.
/// </summary>
public required int BlurAmount { get; init; }
/// <summary>
/// Gets the brightness adjustment for the background (0-1 range).
/// </summary>
public required float BackgroundBrightness { get; init; }
}

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
@@ -38,9 +36,4 @@ public interface IThemeService
/// Gets the current theme settings.
/// </summary>
ThemeSnapshot Current { get; }
/// <summary>
/// Gets the current dock theme settings.
/// </summary>
DockThemeSnapshot CurrentDockTheme { get; }
}

View File

@@ -1,172 +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.Text.Json.Serialization;
using Microsoft.UI;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
#pragma warning disable SA1402 // File may only contain a single type
/// <summary>
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>.
/// </summary>
public class DockSettings
{
public DockSide Side { get; set; } = DockSide.Top;
public DockSize DockSize { get; set; } = DockSize.Small;
public DockSize DockIconsSize { get; set; } = DockSize.Small;
// <Theme settings>
public DockBackdrop Backdrop { get; set; } = DockBackdrop.Acrylic;
public UserTheme Theme { get; set; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
public Color CustomThemeColor { get; set; } = Colors.Transparent;
public int CustomThemeColorIntensity { get; set; } = 100;
public int BackgroundImageOpacity { get; set; } = 20;
public int BackgroundImageBlurAmount { get; set; }
public int BackgroundImageBrightness { get; set; }
public BackgroundImageFit BackgroundImageFit { get; set; }
public string? BackgroundImagePath { get; set; }
// </Theme settings>
// public List<string> PinnedCommands { get; set; } = [];
public List<DockBandSettings> StartBands { get; set; } = [];
public List<DockBandSettings> CenterBands { get; set; } = [];
public List<DockBandSettings> EndBands { get; set; } = [];
public bool ShowLabels { get; set; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
public DockSettings()
{
// Initialize with default values
// PinnedCommands = [
// "com.microsoft.cmdpal.winget"
// ];
StartBands.Add(new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.core",
CommandId = "com.microsoft.cmdpal.home",
});
StartBands.Add(new DockBandSettings
{
ProviderId = "WinGet",
CommandId = "com.microsoft.cmdpal.winget",
ShowLabels = false,
});
EndBands.Add(new DockBandSettings
{
ProviderId = "PerformanceMonitor",
CommandId = "com.microsoft.cmdpal.performanceWidget",
});
EndBands.Add(new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
}
}
/// <summary>
/// Settings for a specific dock band. These are per-band settings stored
/// within the overall <see cref="DockSettings"/>.
/// </summary>
public class DockBandSettings
{
public required string ProviderId { get; set; }
public required string CommandId { get; set; }
/// <summary>
/// Gets or sets whether titles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowTitles { get; set; }
/// <summary>
/// Gets or sets whether subtitles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowSubtitles { get; set; }
/// <summary>
/// Gets or sets a value for backward compatibility. Maps to ShowTitles.
/// </summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool? ShowLabels
{
get => ShowTitles;
set => ShowTitles = value;
}
/// <summary>
/// Resolves the effective value of <see cref="ShowTitles"/> for this band.
/// If this band doesn't have a specific value set, we'll fall back to the
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary>
public bool ResolveShowTitles(bool defaultValue) => ShowTitles ?? defaultValue;
/// <summary>
/// Resolves the effective value of <see cref="ShowSubtitles"/> for this band.
/// If this band doesn't have a specific value set, we'll fall back to the
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary>
public bool ResolveShowSubtitles(bool defaultValue) => ShowSubtitles ?? defaultValue;
public DockBandSettings Clone()
{
return new()
{
ProviderId = this.ProviderId,
CommandId = this.CommandId,
ShowTitles = this.ShowTitles,
ShowSubtitles = this.ShowSubtitles,
};
}
}
public enum DockSide
{
Left = 0,
Top = 1,
Right = 2,
Bottom = 3,
}
public enum DockSize
{
Small,
Medium,
Large,
}
public enum DockBackdrop
{
Transparent,
Acrylic,
}
#pragma warning restore SA1402 // File may only contain a single type

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -68,11 +68,6 @@ public partial class SettingsModel : ObservableObject
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
public bool EnableDock { get; set; }
public DockSettings DockSettings { get; set; } = new();
// Theme settings
public UserTheme Theme { get; set; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
@@ -97,8 +92,6 @@ public partial class SettingsModel : ObservableObject
public int BackdropOpacity { get; set; } = 100;
// </Theme settings>
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
@@ -237,7 +230,7 @@ public partial class SettingsModel : ObservableObject
return false;
}
public static void SaveSettings(SettingsModel model, bool hotReload = true)
public static void SaveSettings(SettingsModel model)
{
if (string.IsNullOrEmpty(FilePath))
{
@@ -272,10 +265,7 @@ public partial class SettingsModel : ObservableObject
// TODO: Instead of just raising the event here, we should
// have a file change watcher on the settings file, and
// reload the settings then
if (hotReload)
{
model.SettingsChanged?.Invoke(model, null);
}
model.SettingsChanged?.Invoke(model, null);
}
else
{
@@ -321,7 +311,6 @@ public partial class SettingsModel : ObservableObject
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(Color))]
[JsonSerializable(typeof(HistoryItem))]
[JsonSerializable(typeof(SettingsModel))]
[JsonSerializable(typeof(WindowPosition))]

View File

@@ -4,8 +4,6 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -34,8 +32,6 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public AppearanceSettingsViewModel Appearance { get; }
public DockAppearanceSettingsViewModel DockAppearance { get; }
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
@@ -187,58 +183,6 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
public DockSide Dock_Side
{
get => _settings.DockSettings.Side;
set
{
_settings.DockSettings.Side = value;
Save();
}
}
public DockSize Dock_DockSize
{
get => _settings.DockSettings.DockSize;
set
{
_settings.DockSettings.DockSize = value;
Save();
}
}
public DockBackdrop Dock_Backdrop
{
get => _settings.DockSettings.Backdrop;
set
{
_settings.DockSettings.Backdrop = value;
Save();
}
}
public bool Dock_ShowLabels
{
get => _settings.DockSettings.ShowLabels;
set
{
_settings.DockSettings.ShowLabels = value;
Save();
}
}
public bool EnableDock
{
get => _settings.EnableDock;
set
{
_settings.EnableDock = value;
Save();
WeakReferenceMessenger.Default.Send(new ShowHideDockMessage(value));
WeakReferenceMessenger.Default.Send(new ReloadCommandsMessage()); // TODO! we need to update the MoreCommands of all top level items, but we don't _really_ want to reload
}
}
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = new();
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();
@@ -251,7 +195,6 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_topLevelCommandManager = topLevelCommandManager;
Appearance = new AppearanceSettingsViewModel(themeService, _settings);
DockAppearance = new DockAppearanceSettingsViewModel(themeService, _settings);
var activeProviders = GetCommandProviders();
var allProviderSettings = _settings.ProviderSettings;

View File

@@ -9,7 +9,6 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -17,8 +16,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ShellViewModel : ObservableObject,
IDisposable,
IRecipient<PerformCommandMessage>,
IRecipient<HandleCommandResultMessage>,
IRecipient<WindowHiddenMessage>
IRecipient<HandleCommandResultMessage>
{
private readonly IRootPageService _rootPageService;
private readonly IAppHostService _appHostService;
@@ -81,9 +79,8 @@ public partial class ShellViewModel : ObservableObject,
private IPage? _rootPage;
private bool _isNested;
private bool _currentlyTransient;
public bool IsNested => _isNested && !_currentlyTransient;
public bool IsNested => _isNested;
public PageViewModel NullPage { get; private set; }
@@ -104,7 +101,6 @@ public partial class ShellViewModel : ObservableObject,
// Register to receive messages
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
WeakReferenceMessenger.Default.Register<WindowHiddenMessage>(this);
}
[RelayCommand]
@@ -264,7 +260,7 @@ public partial class ShellViewModel : ObservableObject,
var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
var providerContext = _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext);
_rootPageService.OnPerformCommand(message.Context, CurrentPage.IsRootPage, host);
_rootPageService.OnPerformCommand(message.Context, !CurrentPage.IsNested, host);
try
{
@@ -274,7 +270,6 @@ public partial class ShellViewModel : ObservableObject,
var isMainPage = command == _rootPage;
_isNested = !isMainPage;
_currentlyTransient = message.TransientPage;
// Telemetry: Track extension page navigation for session metrics
if (host is not null)
@@ -294,9 +289,6 @@ public partial class ShellViewModel : ObservableObject,
throw new NotSupportedException();
}
pageViewModel.IsRootPage = isMainPage;
pageViewModel.HasBackButton = IsNested;
// Clear command bar, ViewModel initialization can already set new commands if it wants to
OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)));
@@ -316,8 +308,7 @@ public partial class ShellViewModel : ObservableObject,
_scheduler);
// While we're loading in the background, immediately move to the next page.
NavigateToPageMessage msg = new(pageViewModel, message.WithAnimation, navigationToken, message.TransientPage);
WeakReferenceMessenger.Default.Send(msg);
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation, navigationToken));
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
// See RootFrame_Navigated event handler.
@@ -488,19 +479,6 @@ public partial class ShellViewModel : ObservableObject,
UnsafeHandleCommandResult(message.Result.Unsafe);
}
public void Receive(WindowHiddenMessage message)
{
// If the window was hidden while we had a transient page, we need to reset that state.
if (_currentlyTransient)
{
_currentlyTransient = false;
// navigate back to the main page without animation
GoHome(withAnimation: false, focusSearch: false);
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage)));
}
}
private void OnUIThread(Action action)
{
_ = Task.Factory.StartNew(

View File

@@ -22,8 +22,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>,
IRecipient<PinCommandItemMessage>,
IRecipient<UnpinCommandItemMessage>,
IRecipient<PinToDockMessage>,
IPageContext,
IDisposable
{
private readonly IServiceProvider _serviceProvider;
@@ -33,13 +32,10 @@ public partial class TopLevelCommandManager : ObservableObject,
private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
private readonly Lock _commandProvidersLock = new();
// watch out: if you add code that locks CommandProviders, be sure to always
// lock CommandProviders before locking DockBands, or you will cause a
// deadlock.
private readonly Lock _dockBandsLock = new();
private readonly SupersedingAsyncGate _reloadCommandsGate;
TaskScheduler IPageContext.Scheduler => _taskScheduler;
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
{
_serviceProvider = serviceProvider;
@@ -47,15 +43,11 @@ public partial class TopLevelCommandManager : ObservableObject,
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
WeakReferenceMessenger.Default.Register<PinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<UnpinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<PinToDockMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
}
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelViewModel> DockBands { get; set; } = [];
[ObservableProperty]
public partial bool IsLoading { get; private set; } = true;
@@ -91,26 +83,12 @@ public partial class TopLevelCommandManager : ObservableObject,
_builtInCommands.Add(wrapper);
}
var objects = await LoadTopLevelCommandsFromProvider(wrapper);
var commands = await LoadTopLevelCommandsFromProvider(wrapper);
lock (TopLevelCommands)
{
if (objects.Commands is IEnumerable<TopLevelViewModel> commands)
foreach (var c in commands)
{
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
}
}
lock (_dockBandsLock)
{
if (objects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var c in bands)
{
DockBands.Add(c);
}
TopLevelCommands.Add(c);
}
}
}
@@ -123,15 +101,16 @@ public partial class TopLevelCommandManager : ObservableObject,
}
// May be called from a background thread
private async Task<TopLevelObjectSets> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
private async Task<IEnumerable<TopLevelViewModel>> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
{
await commandProvider.LoadTopLevelCommands(_serviceProvider);
WeakReference<IPageContext> weakSelf = new(this);
await commandProvider.LoadTopLevelCommands(_serviceProvider, weakSelf);
var commands = await Task.Factory.StartNew(
() =>
{
List<TopLevelViewModel> commands = [];
List<TopLevelViewModel> bands = [];
foreach (var item in commandProvider.TopLevelItems)
{
commands.Add(item);
@@ -145,15 +124,7 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
foreach (var item in commandProvider.DockBandItems)
{
bands.Add(item);
}
var commandsCount = commands.Count;
var bandsCount = bands.Count;
Logger.LogDebug($"{commandProvider.ProviderId}: Loaded {commandsCount} commands, {bandsCount} bands");
return new TopLevelObjectSets(commands, bands);
return commands;
},
CancellationToken.None,
TaskCreationOptions.None,
@@ -181,7 +152,8 @@ public partial class TopLevelCommandManager : ObservableObject,
/// <returns>an awaitable task</returns>
private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, IItemsChangedEventArgs args)
{
await sender.LoadTopLevelCommands(_serviceProvider);
WeakReference<IPageContext> weakSelf = new(this);
await sender.LoadTopLevelCommands(_serviceProvider, weakSelf);
List<TopLevelViewModel> newItems = [.. sender.TopLevelItems];
foreach (var i in sender.FallbackItems)
@@ -192,8 +164,6 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
List<TopLevelViewModel> newBands = [.. sender.DockBandItems];
// modify the TopLevelCommands under shared lock; event if we clone it, we don't want
// TopLevelCommands to get modified while we're working on it. Otherwise, we might
// out clone would be stale at the end of this method.
@@ -212,16 +182,6 @@ public partial class TopLevelCommandManager : ObservableObject,
ListHelpers.InPlaceUpdateList(TopLevelCommands, clone);
}
lock (_dockBandsLock)
{
// same idea for DockBands
List<TopLevelViewModel> dockClone = [.. DockBands];
var dockStartIndex = FindIndexForFirstProviderItem(dockClone, sender.ProviderId);
dockClone.RemoveAll(item => item.CommandProviderId == sender.ProviderId);
dockClone.InsertRange(dockStartIndex, newBands);
ListHelpers.InPlaceUpdateList(DockBands, dockClone);
}
return;
static int FindIndexForFirstProviderItem(List<TopLevelViewModel> topLevelItems, string providerId)
@@ -268,11 +228,6 @@ public partial class TopLevelCommandManager : ObservableObject,
TopLevelCommands.Clear();
}
lock (_dockBandsLock)
{
DockBands.Clear();
}
await LoadBuiltinsAsync();
_ = Task.Run(LoadExtensionsAsync);
}
@@ -347,31 +302,13 @@ public partial class TopLevelCommandManager : ObservableObject,
var commandSets = (await Task.WhenAll(loadTasks)).Where(results => results is not null).Select(r => r!).ToList();
foreach (var providerObjects in commandSets)
lock (TopLevelCommands)
{
var commandsCount = providerObjects.Commands?.Count() ?? 0;
var bandsCount = providerObjects.DockBands?.Count() ?? 0;
Logger.LogDebug($"(some provider) Loaded {commandsCount} commands and {bandsCount} bands");
lock (TopLevelCommands)
foreach (var commands in commandSets)
{
if (providerObjects.Commands is IEnumerable<TopLevelViewModel> commands)
foreach (var c in commands)
{
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
}
}
lock (_dockBandsLock)
{
if (providerObjects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var c in bands)
{
DockBands.Add(c);
}
TopLevelCommands.Add(c);
}
}
}
@@ -395,9 +332,7 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
private record TopLevelObjectSets(IEnumerable<TopLevelViewModel>? Commands, IEnumerable<TopLevelViewModel>? DockBands);
private async Task<TopLevelObjectSets?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper)
private async Task<IEnumerable<TopLevelViewModel>?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper)
{
try
{
@@ -423,7 +358,6 @@ public partial class TopLevelCommandManager : ObservableObject,
{
// Then find all the top-level commands that belonged to that extension
List<TopLevelViewModel> commandsToRemove = [];
List<TopLevelViewModel> bandsToRemove = [];
lock (TopLevelCommands)
{
foreach (var extension in extensions)
@@ -436,15 +370,6 @@ public partial class TopLevelCommandManager : ObservableObject,
commandsToRemove.Add(command);
}
}
foreach (var band in DockBands)
{
var host = band.ExtensionHost;
if (host?.Extension == extension)
{
bandsToRemove.Add(band);
}
}
}
}
@@ -464,17 +389,6 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
}
lock (_dockBandsLock)
{
if (bandsToRemove.Count != 0)
{
foreach (var deleted in bandsToRemove)
{
DockBands.Remove(deleted);
}
}
}
},
CancellationToken.None,
TaskCreationOptions.None,
@@ -498,22 +412,6 @@ public partial class TopLevelCommandManager : ObservableObject,
return null;
}
public TopLevelViewModel? LookupDockBand(string id)
{
lock (_dockBandsLock)
{
foreach (var command in DockBands)
{
if (command.Id == id)
{
return command;
}
}
}
return null;
}
public void Receive(ReloadCommandsMessage message) =>
ReloadAllCommandsAsync().ConfigureAwait(false);
@@ -523,28 +421,7 @@ public partial class TopLevelCommandManager : ObservableObject,
wrapper?.PinCommand(message.CommandId, _serviceProvider);
}
public void Receive(UnpinCommandItemMessage message)
{
var wrapper = LookupProvider(message.ProviderId);
wrapper?.UnpinCommand(message.CommandId, _serviceProvider);
}
public void Receive(PinToDockMessage message)
{
if (LookupProvider(message.ProviderId) is CommandProviderWrapper wrapper)
{
if (message.Pin)
{
wrapper?.PinDockBand(message.CommandId, _serviceProvider);
}
else
{
wrapper?.UnpinDockBand(message.CommandId, _serviceProvider);
}
}
}
public CommandProviderWrapper? LookupProvider(string providerId)
private CommandProviderWrapper? LookupProvider(string providerId)
{
lock (_commandProvidersLock)
{
@@ -553,6 +430,12 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
void IPageContext.ShowException(Exception ex, string? extensionHint)
{
var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? "TopLevelCommandManager");
CommandPaletteHost.Instance.Log(message);
}
internal bool IsProviderActive(string id)
{
lock (_commandProvidersLock)
@@ -562,53 +445,6 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
internal void PinDockBand(TopLevelViewModel bandVm)
{
lock (_dockBandsLock)
{
foreach (var existing in DockBands)
{
if (existing.Id == bandVm.Id)
{
// already pinned
Logger.LogDebug($"Dock band '{bandVm.Id}' is already pinned.");
return;
}
}
Logger.LogDebug($"Attempting to pin dock band '{bandVm.Id}' from provider '{bandVm.CommandProviderId}'.");
var providerId = bandVm.CommandProviderId;
var foundProvider = false;
// WATCH OUT: This locks CommandProviders. If you add code that
// locks CommandProviders first, before locking DockBands, you will
// cause a deadlock.
foreach (var provider in CommandProviders)
{
if (provider.Id == providerId)
{
Logger.LogDebug($"Found provider '{providerId}' to pin dock band '{bandVm.Id}'.");
provider.PinDockBand(bandVm);
foundProvider = true;
break;
}
}
if (!foundProvider)
{
Logger.LogWarning($"Could not find provider '{providerId}' to pin dock band '{bandVm.Id}'.");
}
else
{
// Add the band to DockBands if not already present
if (!DockBands.Any(b => b.Id == bandVm.Id))
{
DockBands.Add(bandVm);
}
}
}
}
public void Dispose()
{
_reloadCommandsGate.Dispose();

View File

@@ -1,36 +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.Helpers;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Used as the PageContext for top-level items. Top level items are displayed
/// on the MainListPage, which _we_ own. We need to have a placeholder page
/// context for each provider that still connects those top-level items to the
/// CommandProvider they came from.
/// </summary>
public partial class TopLevelItemPageContext : IPageContext
{
public TaskScheduler Scheduler { get; private set; }
public ICommandProviderContext ProviderContext { get; private set; }
TaskScheduler IPageContext.Scheduler => Scheduler;
ICommandProviderContext IPageContext.ProviderContext => ProviderContext;
internal TopLevelItemPageContext(CommandProviderWrapper provider, TaskScheduler scheduler)
{
ProviderContext = provider.GetProviderContext();
Scheduler = scheduler;
}
public void ShowException(Exception ex, string? extensionHint = null)
{
var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? $"TopLevelItemPageContext({ProviderContext.ProviderId})");
CommandPaletteHost.Instance.Log(message);
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -25,9 +25,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private readonly ProviderSettings _providerSettings;
private readonly IServiceProvider _serviceProvider;
private readonly CommandItemViewModel _commandItemViewModel;
private readonly IContextMenuFactory _contextMenuFactory;
public ICommandProviderContext ProviderContext { get; private set; }
public CommandProviderContext ProviderContext { get; private set; }
private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id;
@@ -53,28 +52,39 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
public CommandPaletteHost ExtensionHost { get; private set; }
public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
public CommandViewModel CommandViewModel => _commandItemViewModel.Command;
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
public string CommandProviderId => ProviderContext.ProviderId;
public IconInfoViewModel IconViewModel => _commandItemViewModel.Icon;
////// ICommandItem
public string Title => _commandItemViewModel.Title;
public string Subtitle => _commandItemViewModel.Subtitle;
public IIconInfo Icon => (IIconInfo)IconViewModel;
public IIconInfo Icon => _commandItemViewModel.Icon;
public IIconInfo InitialIcon => _initialIcon ?? _commandItemViewModel.Icon;
ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe;
IContextItem?[] ICommandItem.MoreCommands => BuildContextMenu();
IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands
.Select(item =>
{
if (item is ISeparatorContextItem)
{
return item as IContextItem;
}
else if (item is CommandContextItemViewModel commandItem)
{
return commandItem.Model.Unsafe;
}
else
{
return null;
}
}).ToArray();
////// IListItem
ITag[] IListItem.Tags => Tags.ToArray();
@@ -173,46 +183,17 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
}
}
// Dock properties
public bool IsDockBand { get; private set; }
public DockBandSettings? DockBandSettings
{
get
{
if (!IsDockBand)
{
return null;
}
var bandSettings = _settings.DockSettings.StartBands
.Concat(_settings.DockSettings.CenterBands)
.Concat(_settings.DockSettings.EndBands)
.FirstOrDefault(band => band.CommandId == this.Id);
if (bandSettings is null)
{
return new DockBandSettings()
{
ProviderId = this.CommandProviderId,
CommandId = this.Id,
ShowLabels = true,
};
}
return bandSettings;
}
}
public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
public TopLevelViewModel(
CommandItemViewModel item,
TopLevelType topLevelType,
bool isFallback,
CommandPaletteHost extensionHost,
ICommandProviderContext commandProviderContext,
CommandProviderContext commandProviderContext,
SettingsModel settings,
ProviderSettings providerSettings,
IServiceProvider serviceProvider,
ICommandItem? commandItem,
IContextMenuFactory? contextMenuFactory)
ICommandItem? commandItem)
{
_serviceProvider = serviceProvider;
_settings = settings;
@@ -220,26 +201,22 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
ProviderContext = commandProviderContext;
_commandItemViewModel = item;
_contextMenuFactory = contextMenuFactory ?? DefaultContextMenuFactory.Instance;
IsFallback = topLevelType == TopLevelType.Fallback;
IsDockBand = topLevelType == TopLevelType.DockBand;
IsFallback = isFallback;
ExtensionHost = extensionHost;
if (IsFallback && commandItem is FallbackCommandItem fallback)
if (isFallback && commandItem is FallbackCommandItem fallback)
{
_fallbackId = fallback.Id;
}
item.PropertyChangedBackground += Item_PropertyChanged;
// UpdateAlias();
// UpdateHotkey();
// UpdateTags();
}
internal void InitializeProperties()
{
// Init first, so that we get the ID & titles,
// then generate the ID,
// then slow init for the context menu
ItemViewModel.InitializeProperties();
GenerateId();
ItemViewModel.SlowInitializeProperties();
if (IsFallback)
@@ -301,7 +278,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
return;
}
_initialIcon = (IIconInfo?)_commandItemViewModel.Icon;
_initialIcon = _commandItemViewModel.Icon;
if (raiseNotification)
{
@@ -475,43 +452,4 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{
return ToString();
}
/// <summary>
/// Helper to convert our context menu viewmodels back into the API
/// interfaces that ICommandItem expects.
/// </summary>
private IContextItem?[] BuildContextMenu()
{
List<IContextItem?> contextItems = new();
foreach (var item in _commandItemViewModel.MoreCommands)
{
if (item is ISeparatorContextItem)
{
contextItems.Add(item as IContextItem);
}
else if (item is CommandContextItemViewModel commandItem)
{
contextItems.Add(commandItem.Model.Unsafe);
}
}
_contextMenuFactory.AddMoreCommandsToTopLevel(this, this.ProviderContext, contextItems);
return contextItems.ToArray();
}
internal ICommandItem ToPinnedDockBandItem()
{
var item = new PinnedDockItem(item: this, id: Id);
return item;
}
}
public enum TopLevelType
{
Normal,
Fallback,
DockBand,
}

View File

@@ -4,7 +4,6 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:ptcontrols="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:services="using:Microsoft.CmdPal.UI.Services">
@@ -13,7 +12,7 @@
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Other merged dictionaries here -->
<ResourceDictionary Source="ms-appx:///Styles/TeachingTip.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/Colors.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TextBox.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/Settings.xaml" />
@@ -22,7 +21,6 @@
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///Dock/DockItemControl.xaml" />
<!-- Default theme dictionary -->
<ResourceDictionary Source="ms-appx:///Styles/Theme.Normal.xaml" />
<services:MutableOverridesDictionary />
@@ -31,12 +29,6 @@
<x:Double x:Key="SettingActionControlMinWidth">240</x:Double>
<Style BasedOn="{StaticResource DefaultCheckBoxStyle}" TargetType="ptcontrols:CheckBoxWithDescriptionControl" />
<converters:StringVisibilityConverter
x:Key="StringNotEmptyToVisibilityConverter"
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -28,7 +28,6 @@ using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
@@ -221,7 +220,6 @@ public partial class App : Application, IDisposable
// ViewModels
services.AddSingleton<ShellViewModel>();
services.AddSingleton<DockViewModel>();
services.AddSingleton<IContextMenuFactory, CommandPaletteContextMenuFactory>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
}

View File

@@ -2,14 +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 CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
namespace Microsoft.CmdPal.UI;
@@ -24,301 +18,14 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
_topLevelCommandManager = topLevelCommandManager;
}
/// <summary>
/// Constructs the view models for the MoreCommands of a
/// CommandItemViewModel. In our case, we can use our settings to add a
/// contextually-relevant pin/unpin command to this item.
///
/// This is called on all CommandItemViewModels. There are however some
/// weird edge cases we need to handle, concerning
/// </summary>
public List<IContextItemViewModel> UnsafeBuildAndInitMoreCommands(
IContextItem[] items,
CommandItemViewModel commandItem)
{
var results = DefaultContextMenuFactory.Instance.UnsafeBuildAndInitMoreCommands(items, commandItem);
IPageContext? page = null;
var succeeded = commandItem.PageContext.TryGetTarget(out page);
if (!succeeded || page is null)
{
return results;
}
var isTopLevelItem = page is TopLevelItemPageContext;
if (isTopLevelItem)
{
// Bail early. We'll handle it below.
return results;
}
List<IContextItem> moreCommands = [];
var itemId = commandItem.Command.Id;
var providerContext = page.ProviderContext;
var supportsPinning = providerContext.SupportsPinning;
if (supportsPinning &&
!string.IsNullOrEmpty(itemId))
{
// Add pin/unpin commands for pinning items to the top-level or to
// the dock.
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
var providerSettings = _settingsModel.GetProviderSettings(provider);
var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId);
// Don't add pin/unpin commands for items displayed as
// TopLevelViewModels that aren't already pinned.
//
// We can't look up if this command item is in the top level
// items in the manager, because we are being called _before_ we
// get added to the manager's list of commands.
if (!isTopLevelItem || alreadyPinnedToTopLevel)
{
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !alreadyPinnedToTopLevel,
PinLocation.TopLevel,
_settingsModel,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem);
}
}
if (moreCommands.Count > 0)
{
moreCommands.Insert(0, new Separator());
var moreResults = DefaultContextMenuFactory.Instance.UnsafeBuildAndInitMoreCommands(moreCommands.ToArray(), commandItem);
results.AddRange(moreResults);
}
// TODO: #45201 Here, we'll want to add pin/unpin commands for pinning
// items to the top-level or to the dock.
return results;
}
/// <summary>
/// Called to create the context menu on TopLevelViewModels.
///
/// These are handled differently from everyone else. With
/// TopLevelViewModels, the ID isn't on the Command, it is on the
/// TopLevelViewModel itself. Basically, we can't figure out how to add
/// pin/unpin commands directly attached to the ICommandItems that we get
/// from the API.
///
/// Instead, this method is used to extend the set of IContextItems that are
/// added to the TopLevelViewModel itself. This lets us pin/unpin the
/// generated ID of the TopLevelViewModel, even if the command didn't have
/// one.
/// </summary>
public void AddMoreCommandsToTopLevel(
TopLevelViewModel topLevelItem,
ICommandProviderContext providerContext,
List<IContextItem?> contextItems)
{
var itemId = topLevelItem.Id;
var supportsPinning = providerContext.SupportsPinning;
List<IContextItem> moreCommands = [];
var commandItem = topLevelItem.ItemViewModel;
// Add pin/unpin commands for pinning items to the top-level or to
// the dock.
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
var providerSettings = _settingsModel.GetProviderSettings(provider);
var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId);
if (isPinnedSubCommand)
{
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !isPinnedSubCommand,
PinLocation.TopLevel,
_settingsModel,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem);
}
if (moreCommands.Count > 0)
{
moreCommands.Insert(0, new Separator());
// var moreResults = DefaultContextMenuFactory.Instance.UnsafeBuildAndInitMoreCommands(moreCommands.ToArray(), commandItem);
contextItems.AddRange(moreCommands);
}
}
private void TryAddPinToDockCommand(
ProviderSettings providerSettings,
string itemId,
string providerId,
List<IContextItem> moreCommands,
CommandItemViewModel commandItem)
{
if (!_settingsModel.EnableDock)
{
return;
}
var inStartBands = _settingsModel.DockSettings.StartBands.Any(band => MatchesBand(band, itemId, providerId));
var inCenterBands = _settingsModel.DockSettings.CenterBands.Any(band => MatchesBand(band, itemId, providerId));
var inEndBands = _settingsModel.DockSettings.EndBands.Any(band => MatchesBand(band, itemId, providerId));
var alreadyPinned = inStartBands || inCenterBands || inEndBands; /** &&
_settingsModel.DockSettings.PinnedCommands.Contains(this.Id)**/
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !alreadyPinned,
PinLocation.Dock,
_settingsModel,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
internal static bool MatchesBand(DockBandSettings bandSettings, string commandId, string providerId)
{
return bandSettings.CommandId == commandId &&
bandSettings.ProviderId == providerId;
}
internal enum PinLocation
{
TopLevel,
Dock,
}
private sealed partial class PinToContextItem : CommandContextItem
{
private readonly PinToCommand _command;
private readonly CommandItemViewModel _commandItem;
public PinToContextItem(PinToCommand command, CommandItemViewModel commandItem)
: base(command)
{
_command = command;
_commandItem = commandItem;
command.PinStateChanged += this.OnPinStateChanged;
}
private void OnPinStateChanged(object? sender, EventArgs e)
{
// update our MoreCommands
_commandItem.RefreshMoreCommands();
}
~PinToContextItem()
{
_command.PinStateChanged -= this.OnPinStateChanged;
}
}
private sealed partial class PinToCommand : InvokableCommand
{
private readonly string _commandId;
private readonly string _providerId;
private readonly SettingsModel _settings;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly bool _pin;
private readonly PinLocation _pinLocation;
private bool IsPinToDock => _pinLocation == PinLocation.Dock;
public override IconInfo Icon => _pin ? Icons.PinIcon : Icons.UnpinIcon;
public override string Name => _pin ?
(IsPinToDock ? RS_.GetString("dock_pin_command_name") : RS_.GetString("top_level_pin_command_name")) :
(IsPinToDock ? RS_.GetString("dock_unpin_command_name") : RS_.GetString("top_level_unpin_command_name"));
internal event EventHandler? PinStateChanged;
public PinToCommand(
string commandId,
string providerId,
bool pin,
PinLocation pinLocation,
SettingsModel settings,
TopLevelCommandManager topLevelCommandManager)
{
_commandId = commandId;
_providerId = providerId;
_pinLocation = pinLocation;
_settings = settings;
_topLevelCommandManager = topLevelCommandManager;
_pin = pin;
}
public override CommandResult Invoke()
{
Logger.LogDebug($"PinTo{_pinLocation}Command.Invoke({_pin}): {_providerId}/{_commandId}");
if (_pin)
{
switch (_pinLocation)
{
case PinLocation.TopLevel:
PinToTopLevel();
break;
case PinLocation.Dock:
PinToDock();
break;
}
}
else
{
switch (_pinLocation)
{
case PinLocation.TopLevel:
UnpinFromTopLevel();
break;
case PinLocation.Dock:
UnpinFromDock();
break;
}
}
PinStateChanged?.Invoke(this, EventArgs.Empty);
return CommandResult.KeepOpen();
}
private void PinToTopLevel()
{
PinCommandItemMessage message = new(_providerId, _commandId);
WeakReferenceMessenger.Default.Send(message);
}
private void UnpinFromTopLevel()
{
UnpinCommandItemMessage message = new(_providerId, _commandId);
WeakReferenceMessenger.Default.Send(message);
}
private void PinToDock()
{
PinToDockMessage message = new(_providerId, _commandId, true);
WeakReferenceMessenger.Default.Send(message);
}
private void UnpinFromDock()
{
PinToDockMessage message = new(_providerId, _commandId, false);
WeakReferenceMessenger.Default.Send(message);
}
}
}

View File

@@ -97,7 +97,7 @@
<Grid
x:Name="IconRoot"
Margin="3,0,-5,0"
Visibility="{x:Bind CurrentPageViewModel.IsRootPage, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay}">
<Button
x:Name="StatusMessagesButton"
x:Uid="StatusMessagesButton"
@@ -134,7 +134,7 @@
x:Uid="SettingsButton"
Click="SettingsIcon_Clicked"
Style="{StaticResource SubtleButtonStyle}"
Visibility="{x:Bind CurrentPageViewModel.IsRootPage, Mode=OneWay}">
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon
VerticalAlignment="Center"
@@ -153,7 +153,7 @@
Text="{x:Bind CurrentPageViewModel.Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind CurrentPageViewModel.IsRootPage, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}" />
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay}" />
<StackPanel
Grid.Column="2"
Padding="0,0,4,0"

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