Compare commits

..

3 Commits

168 changed files with 1359 additions and 4874 deletions

View File

@@ -127,7 +127,6 @@ HOLDSPACE
HOLDBACKSPACE
IDIGNORE
KBDLLHOOKSTRUCT
keydowns
keyevent
LAlt
LBUTTON
@@ -336,10 +335,8 @@ ABE
Mdt
HTCAPTION
POSCHANGED
QPC
QUERYPOS
SETAUTOHIDEBAR
ULW
WINDOWPOS
WINEVENTPROC
WORKERW

View File

@@ -2278,7 +2278,6 @@ THEMECHANGED
thickframe
Tianma
tmain
tontrager
tskill
tweakable
UBreak

View File

@@ -0,0 +1,232 @@
name: Scheduled Issue Product Labeling
on:
schedule:
- cron: "20 */6 * * *" # Every 6 hours at :20
workflow_dispatch: # Allow manual trigger
permissions:
models: read
issues: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
label-issues:
runs-on: ubuntu-latest
steps:
- name: Label issues missing Product labels
uses: actions/github-script@v7
with:
script: |
// ── Product label mapping ──────────────────────────────────
// Canonical list of Product-* labels used in this repo,
// derived from .github/skills/release-note-generation/references/step2-labeling.md
const PRODUCT_LABELS = [
"Product-Advanced Paste",
"Product-Always on Top",
"Product-Awake",
"Product-ColorPicker",
"Product-Command not found",
"Product-Command Palette",
"Product-CropAndLock",
"Product-Cursor Wrap",
"Product-Environment Variables",
"Product-FancyZones",
"Product-File Explorer",
"Product-File Locksmith",
"Product-Find My Mouse",
"Product-Hosts",
"Product-Image Resizer",
"Product-Keyboard Manager",
"Product-LightSwitch",
"Product-Mouse Highlighter",
"Product-Mouse Jump",
"Product-Mouse Pointer Crosshairs",
"Product-Mouse Without Borders",
"Product-New+",
"Product-Peek",
"Product-PowerRename",
"Product-PowerToys Run",
"Product-Quick Accent",
"Product-Registry Preview",
"Product-Screen Ruler",
"Product-Settings",
"Product-Shortcut Guide",
"Product-Text Extractor",
"Product-Workspaces",
"Product-ZoomIt",
];
// Map from bug-report "Area(s) with issue?" dropdown values
// to Product-* labels (used as strong hints when the issue body
// contains the area dropdown answer).
const AREA_TO_LABEL = {
"Advanced Paste": "Product-Advanced Paste",
"Always on Top": "Product-Always on Top",
"Awake": "Product-Awake",
"ColorPicker": "Product-ColorPicker",
"Command not found": "Product-Command not found",
"Command Palette": "Product-Command Palette",
"Crop and Lock": "Product-CropAndLock",
"Environment Variables": "Product-Environment Variables",
"FancyZones": "Product-FancyZones",
"FancyZones Editor": "Product-FancyZones",
"File Locksmith": "Product-File Locksmith",
"File Explorer: Preview Pane": "Product-File Explorer",
"File Explorer: Thumbnail preview": "Product-File Explorer",
"Hosts File Editor": "Product-Hosts",
"Image Resizer": "Product-Image Resizer",
"Keyboard Manager": "Product-Keyboard Manager",
"Light Switch": "Product-LightSwitch",
"Mouse Utilities": "Product-Find My Mouse",
"Mouse Without Borders": "Product-Mouse Without Borders",
"New+": "Product-New+",
"Peek": "Product-Peek",
"PowerRename": "Product-PowerRename",
"PowerToys Run": "Product-PowerToys Run",
"Quick Accent": "Product-Quick Accent",
"Registry Preview": "Product-Registry Preview",
"Screen ruler": "Product-Screen Ruler",
"Shortcut Guide": "Product-Shortcut Guide",
"TextExtractor": "Product-Text Extractor",
"Workspaces": "Product-Workspaces",
"ZoomIt": "Product-ZoomIt",
};
// ── Helpers ────────────────────────────────────────────────
function hasProductLabel(labels) {
return labels.some((l) => l.name.startsWith("Product-"));
}
// Try to extract the area from the structured bug-report body
// (the "Area(s) with issue?" dropdown).
function extractAreaFromBody(body) {
if (!body) return null;
// The rendered issue body contains a heading followed by the selected values
const areaMatch = body.match(
/### Area\(s\) with issue\?\s*\n+(.+?)(?:\n###|\n\n|$)/s
);
if (!areaMatch) return null;
const areaText = areaMatch[1].trim();
if (areaText === "_No response_" || areaText === "General") return null;
// Could be comma-separated; take the first specific one
const areas = areaText.split(",").map((a) => a.trim());
for (const area of areas) {
if (AREA_TO_LABEL[area]) return AREA_TO_LABEL[area];
}
return null;
}
// Use GitHub Models to classify an issue when the dropdown area
// is not available or is "General".
const MAX_BODY_LENGTH = 3000; // Truncate body to stay within model token limits while keeping enough context
const MAX_COMPLETION_TOKENS = 60; // Enough for a Product-* label name with some margin
async function classifyWithAI(title, body) {
const truncatedBody = (body || "").slice(0, MAX_BODY_LENGTH);
const labelList = PRODUCT_LABELS.join("\n- ");
const prompt = `You are a GitHub issue triager for the microsoft/PowerToys repository.
Given the issue title and body below, determine which ONE Product label best fits.
Reply with ONLY the label name (e.g. "Product-FancyZones") or "UNKNOWN" if you cannot determine it.
Available labels:
- ${labelList}
Issue title: ${title}
Issue body:
${truncatedBody}`;
try {
const response = await fetch(
"https://models.github.ai/inference/chat/completions",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "openai/gpt-4o",
messages: [{ role: "user", content: prompt }],
max_tokens: MAX_COMPLETION_TOKENS,
temperature: 0,
}),
}
);
if (!response.ok) {
core.warning(`AI classification failed: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
const answer = data.choices?.[0]?.message?.content?.trim();
if (!answer || answer === "UNKNOWN") return null;
// Validate the answer is a known label
if (PRODUCT_LABELS.includes(answer)) return answer;
// Try fuzzy match (the model may include extra text)
const found = PRODUCT_LABELS.find((l) => answer.includes(l));
return found || null;
} catch (err) {
core.warning(`AI classification error: ${err.message}`);
return null;
}
}
// ── Main ───────────────────────────────────────────────────
const MAX_ISSUES = 50; // Process up to 50 issues per run
let labeled = 0;
let skipped = 0;
core.info("Searching for open issues with Needs-Triage but no Product-* label...");
// Paginate through open issues labeled Needs-Triage
for await (const response of github.paginate.iterator(
github.rest.issues.listForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
labels: "Needs-Triage",
sort: "created",
direction: "desc",
per_page: 100,
}
)) {
for (const issue of response.data) {
if (labeled + skipped >= MAX_ISSUES) break;
// Skip pull requests (the API returns them too)
if (issue.pull_request) continue;
if (hasProductLabel(issue.labels)) continue;
core.info(`Processing #${issue.number}: ${issue.title}`);
// 1) Try structured area dropdown first (fast, no AI needed)
let label = extractAreaFromBody(issue.body);
// 2) Fall back to AI classification
if (!label) {
label = await classifyWithAI(issue.title, issue.body);
}
if (label) {
core.info(` → Applying "${label}" to #${issue.number}`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [label],
});
labeled++;
} else {
core.info(` → Could not determine product label for #${issue.number}, skipping.`);
skipped++;
}
}
if (labeled + skipped >= MAX_ISSUES) break;
}
core.info(`Done. Labeled: ${labeled}, Skipped: ${skipped}`);

View File

@@ -319,10 +319,6 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Indexer.UnitTests/Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

@@ -29,13 +29,13 @@ PowerToys includes over 30 utilities to help you customize and optimize your Win
| [<img src="doc/images/icons/AdvancedPaste.png" alt="Advanced Paste icon" height="16"> Advanced Paste](https://aka.ms/PowerToysOverview_AdvancedPaste) | [<img src="doc/images/icons/Always%20On%20Top.png" alt="Always on Top icon" height="16"> Always on Top](https://aka.ms/PowerToysOverview_AoT) | [<img src="doc/images/icons/Awake.png" alt="Awake icon" height="16"> Awake](https://aka.ms/PowerToysOverview_Awake) |
| [<img src="doc/images/icons/Color%20Picker.png" alt="Color Picker icon" height="16"> Color Picker](https://aka.ms/PowerToysOverview_ColorPicker) | [<img src="doc/images/icons/Command%20Not%20Found.png" alt="Command Not Found icon" height="16"> Command Not Found](https://aka.ms/PowerToysOverview_CmdNotFound) | [<img src="doc/images/icons/Command Palette.png" alt="Command Palette icon" height="16"> Command Palette](https://aka.ms/PowerToysOverview_CmdPal) |
| [<img src="doc/images/icons/Crop%20And%20Lock.png" alt="Crop and Lock icon" height="16"> Crop And Lock](https://aka.ms/PowerToysOverview_CropAndLock) | [<img src="doc/images/icons/Environment%20Manager.png" alt="Environment Variables icon" height="16"> Environment Variables](https://aka.ms/PowerToysOverview_EnvironmentVariables) | [<img src="doc/images/icons/FancyZones.png" alt="FancyZones icon" height="16"> FancyZones](https://aka.ms/PowerToysOverview_FancyZones) |
| [<img src="doc/images/icons/File%20Explorer%20Preview.png" alt="File Explorer Add-ons icon" height="16"> File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [<img src="doc/images/icons/File%20Locksmith.png" alt="File Locksmith icon" height="16"> File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [<img src="doc/images/icons/GrabAndMove.png" alt="Grab And Move icon" height="16"> Grab And Move](https://aka.ms/PowerToysOverview_GrabAndMove) |
| [<img src="doc/images/icons/Host%20File%20Editor.png" alt="Hosts File Editor icon" height="16"> Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) | [<img src="doc/images/icons/Image%20Resizer.png" alt="Image Resizer icon" height="16"> Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [<img src="doc/images/icons/Keyboard%20Manager.png" alt="Keyboard Manager icon" height="16"> Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) |
| [<img src="doc/images/icons/Light Switch.png" alt="Light Switch icon" height="16"> Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) | [<img src="doc/images/icons/Find My Mouse.png" alt="Mouse Utilities icon" height="16"> Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [<img src="doc/images/icons/MouseWithoutBorders.png" alt="Mouse Without Borders icon" height="16"> Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) |
| [<img src="doc/images/icons/NewPlus.png" alt="New+ icon" height="16"> New+](https://aka.ms/PowerToysOverview_NewPlus) | [<img src="doc/images/icons/Peek.png" alt="Peek icon" height="16"> Peek](https://aka.ms/PowerToysOverview_Peek) | [<img src="doc/images/icons/PowerDisplay.png" alt="PowerDisplay icon" height="16"> PowerDisplay](https://aka.ms/PowerToysOverview_PowerDisplay) |
| [<img src="doc/images/icons/PowerRename.png" alt="PowerRename icon" height="16"> PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [<img src="doc/images/icons/PowerToys%20Run.png" alt="PowerToys Run icon" height="16"> PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) | [<img src="doc/images/icons/PowerAccent.png" alt="Quick Accent icon" height="16"> Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) |
| [<img src="doc/images/icons/Registry%20Preview.png" alt="Registry Preview icon" height="16"> Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [<img src="doc/images/icons/MeasureTool.png" alt="Screen Ruler icon" height="16"> Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) | [<img src="doc/images/icons/Shortcut%20Guide.png" alt="Shortcut Guide icon" height="16"> Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) |
| [<img src="doc/images/icons/PowerOCR.png" alt="Text Extractor icon" height="16"> Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [<img src="doc/images/icons/Workspaces.png" alt="Workspaces icon" height="16"> Workspaces](https://aka.ms/PowerToysOverview_Workspaces) | [<img src="doc/images/icons/ZoomIt.png" alt="ZoomIt icon" height="16"> ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) |
| [<img src="doc/images/icons/File%20Explorer%20Preview.png" alt="File Explorer Add-ons icon" height="16"> File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [<img src="doc/images/icons/File%20Locksmith.png" alt="File Locksmith icon" height="16"> File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [<img src="doc/images/icons/Host%20File%20Editor.png" alt="Hosts File Editor icon" height="16"> Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) |
| [<img src="doc/images/icons/Image%20Resizer.png" alt="Image Resizer icon" height="16"> Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [<img src="doc/images/icons/Keyboard%20Manager.png" alt="Keyboard Manager icon" height="16"> Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) | [<img src="doc/images/icons/Light Switch.png" alt="Light Switch icon" height="16"> Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) |
| [<img src="doc/images/icons/Find My Mouse.png" alt="Mouse Utilities icon" height="16"> Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [<img src="doc/images/icons/MouseWithoutBorders.png" alt="Mouse Without Borders icon" height="16"> Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) | [<img src="doc/images/icons/NewPlus.png" alt="New+ icon" height="16"> New+](https://aka.ms/PowerToysOverview_NewPlus) |
| [<img src="doc/images/icons/Peek.png" alt="Peek icon" height="16"> Peek](https://aka.ms/PowerToysOverview_Peek) | [<img src="doc/images/icons/PowerRename.png" alt="PowerRename icon" height="16"> PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [<img src="doc/images/icons/PowerToys%20Run.png" alt="PowerToys Run icon" height="16"> PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) |
| [<img src="doc/images/icons/PowerAccent.png" alt="Quick Accent icon" height="16"> Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) | [<img src="doc/images/icons/Registry%20Preview.png" alt="Registry Preview icon" height="16"> Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [<img src="doc/images/icons/MeasureTool.png" alt="Screen Ruler icon" height="16"> Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) |
| [<img src="doc/images/icons/Shortcut%20Guide.png" alt="Shortcut Guide icon" height="16"> Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) | [<img src="doc/images/icons/PowerOCR.png" alt="Text Extractor icon" height="16"> Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [<img src="doc/images/icons/Workspaces.png" alt="Workspaces icon" height="16"> Workspaces](https://aka.ms/PowerToysOverview_Workspaces) |
| [<img src="doc/images/icons/ZoomIt.png" alt="ZoomIt icon" height="16"> ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) | | |
## 📦 Installation

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -67,11 +67,8 @@
<RegistryValue Type="string" Name="svgs_icons" Value="" KeyPath="yes" />
</RegistryKey>
<File Id="icon.ico" Source="$(var.BinDir)svgs\icon.ico" />
<File Id="iconUpdate.ico" Source="$(var.BinDir)svgs\iconUpdate.ico" />
<File Id="PowerToysWhite.ico" Source="$(var.BinDir)svgs\PowerToysWhite.ico" />
<File Id="PowerToysWhiteUpdate.ico" Source="$(var.BinDir)svgs\PowerToysWhiteUpdate.ico" />
<File Id="PowerToysDark.ico" Source="$(var.BinDir)svgs\PowerToysDark.ico" />
<File Id="PowerToysDarkUpdate.ico" Source="$(var.BinDir)svgs\PowerToysDarkUpdate.ico" />
</Component>
</Directory>
</DirectoryRef>

View File

@@ -17,7 +17,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="WinUIEx" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />

View File

@@ -1,223 +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.Runtime.InteropServices;
using ManagedCommon;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
/// <summary>
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
/// (e.g. Quick Access, PowerDisplay) that are pinned to a corner of the work area.
///
/// The public API takes sizes in device-independent pixels (DIP). The helper resolves the
/// target monitor's effective DPI and converts to physical pixels. All window positioning
/// uses absolute screen physical-pixel coordinates via
/// <see cref="AppWindow.MoveAndResize(RectInt32)"/> — the same pattern used by the original
/// Settings.UI flyout, which proved reliable across multi-monitor and mixed-DPI setups.
/// </summary>
public static partial class FlyoutWindowHelper
{
private const uint MdtEffectiveDpi = 0;
private const int DefaultDpi = 96;
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetCursorPos(out POINT lpPoint);
[LibraryImport("shcore.dll")]
private static partial int GetDpiForMonitor(nint hMonitor, uint dpiType, out uint dpiX, out uint dpiY);
/// <summary>
/// Get the DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%) for a window.
/// </summary>
public static double GetDpiScale(WindowEx window)
{
ArgumentNullException.ThrowIfNull(window);
return (double)window.GetDpiForWindow() / DefaultDpi;
}
/// <summary>
/// Get the DPI scale factor for a given <see cref="DisplayArea"/>.
/// Resolves DPI from the underlying monitor handle so the value reflects the
/// target display, regardless of which monitor the window is currently on.
/// </summary>
public static double GetDpiScale(DisplayArea displayArea)
{
ArgumentNullException.ThrowIfNull(displayArea);
return (double)GetEffectiveDpi(global::Microsoft.UI.Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId)) / DefaultDpi;
}
/// <summary>
/// Convert device-independent pixels (DIP) to physical pixels (rounding up).
/// </summary>
public static int ScaleToPhysicalPixels(int dip, double dpiScale)
{
return (int)Math.Ceiling(dip * dpiScale);
}
/// <summary>
/// Convert physical pixels to device-independent pixels (DIP) (rounding down).
/// </summary>
public static int ScaleToDip(int physicalPixels, double dpiScale)
{
return (int)Math.Floor(physicalPixels / dpiScale);
}
/// <summary>
/// Look up the <see cref="DisplayArea"/> currently containing the mouse cursor.
/// </summary>
public static bool TryGetDisplayAreaAtCursor(out DisplayArea? displayArea)
{
displayArea = null;
if (!GetCursorPos(out var cursorPos))
{
return false;
}
displayArea = DisplayArea.GetFromPoint(new PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.Nearest);
return displayArea is not null;
}
/// <summary>
/// Position a flyout-style window at the bottom-right corner of the work area on the
/// monitor under the mouse cursor.
/// </summary>
public static void PositionWindowBottomRight(
WindowEx window,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
ArgumentNullException.ThrowIfNull(window);
if (!TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
Logger.LogWarning("FlyoutWindowHelper.PositionWindowBottomRight: unable to determine display from cursor; skipping positioning");
return;
}
PositionWindowBottomRight(window, displayArea, widthDip, heightDip, rightMarginDip, bottomMarginDip);
}
/// <summary>
/// Position a flyout-style window at the bottom-right corner of the specified display
/// area's work area. Use this overload when the caller has already resolved the target
/// <see cref="DisplayArea"/> (e.g. the cursor monitor) so size and placement are computed
/// from the same source.
///
/// Internally moves the window in two steps to avoid <c>WM_DPICHANGED</c> double-scaling
/// when the target monitor has a different DPI than the one the window was previously on:
/// first a 1×1 teleport into the target display, then the real position+size while the
/// window is already on that monitor (no DPI boundary crossing).
/// </summary>
public static void PositionWindowBottomRight(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(displayArea);
double dpiScale = GetDpiScale(displayArea);
var work = displayArea.WorkArea;
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
int marginRight = ScaleToPhysicalPixels(rightMarginDip, dpiScale);
int marginBottom = ScaleToPhysicalPixels(bottomMarginDip, dpiScale);
// Clamp size so the window never extends past the work area minus margins.
// Guards against the bottom/right edge spilling into the taskbar when rounding
// (Math.Ceiling above) would push it just past the boundary.
int maxW = Math.Max(0, work.Width - marginRight);
int maxH = Math.Max(0, work.Height - marginBottom);
w = Math.Min(w, maxW);
h = Math.Min(h, maxH);
// Absolute screen physical-pixel coordinates. WorkArea is in screen coordinates,
// so for non-primary monitors WorkArea.X/Y will be non-zero (and may be negative).
int x = work.X + work.Width - w - marginRight;
int y = work.Y + work.Height - h - marginBottom;
MoveAndResizeOnDisplay(window, displayArea, new RectInt32(x, y, w, h));
}
/// <summary>
/// Center a window within the specified display area's work area.
/// Uses a 1×1 teleport into the target display first to avoid WM_DPICHANGED
/// double-scaling when crossing monitors with different DPI.
/// </summary>
public static void CenterWindowOnDisplay(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(displayArea);
double dpiScale = GetDpiScale(displayArea);
var work = displayArea.WorkArea;
int w = Math.Min(ScaleToPhysicalPixels(widthDip, dpiScale), work.Width);
int h = Math.Min(ScaleToPhysicalPixels(heightDip, dpiScale), work.Height);
int x = work.X + ((work.Width - w) / 2);
int y = work.Y + ((work.Height - h) / 2);
MoveAndResizeOnDisplay(window, displayArea, new RectInt32(x, y, w, h));
}
/// <summary>
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
/// rect the effect is invisible). Then sets the real position+size while the window
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
/// handler doesn't fire and overwrite our computed rect.
///
/// Skips the teleport when the window is already on the target display, since there
/// is no boundary to cross.
/// </summary>
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
{
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;
if (needsTeleport)
{
var work = targetDisplay.WorkArea;
window.AppWindow.MoveAndResize(new RectInt32(work.X, work.Y, 1, 1));
}
window.AppWindow.MoveAndResize(finalRect);
}
private static int GetEffectiveDpi(nint hMonitor)
{
if (hMonitor == 0)
{
return DefaultDpi;
}
var hr = GetDpiForMonitor(hMonitor, MdtEffectiveDpi, out var dpiX, out _);
return hr >= 0 && dpiX > 0 ? (int)dpiX : DefaultDpi;
}
}

View File

@@ -1,92 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Subclasses a window's WndProc and invokes a preprocessor callback for every
/// message before the default window procedure runs. Useful for routing low-level
/// Win32 messages (e.g. <c>WM_HOTKEY</c>) into managed handlers without depending
/// on the WinUI XAML message loop.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// _hook = new WindowMessageHook(window, (uMsg, wParam, lParam) =>
/// _hotkeyService.HandleMessage(uMsg, wParam));
/// </code>
/// Dispose to restore the original WndProc.
/// </remarks>
public sealed partial class WindowMessageHook : IDisposable
{
// Called for every message before default processing. Return true to swallow.
private readonly Func<uint, nuint, nint, bool> _preProcessor;
private const int GwlWndProc = -4;
private readonly nint _hwnd;
private nint _originalWndProc;
private WndProcDelegate? _wndProcDelegate;
private bool _disposed;
private delegate nint WndProcDelegate(nint hwnd, uint uMsg, nuint wParam, nint lParam);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
/// <summary>
/// Initializes a new instance of the <see cref="WindowMessageHook"/> class
/// and subclasses the supplied window's WndProc.
/// </summary>
/// <param name="window">Window to subclass.</param>
/// <param name="preProcessor">Callback invoked for every message before the
/// default WndProc. Receives <c>(uMsg, wParam, lParam)</c>. Return
/// <see langword="true"/> to swallow the message.</param>
public WindowMessageHook(WindowEx window, Func<uint, nuint, nint, bool> preProcessor)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(preProcessor);
_hwnd = window.GetWindowHandle();
_preProcessor = preProcessor;
_wndProcDelegate = WndProc;
var ptr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
_originalWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, ptr);
}
private nint WndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
if (_preProcessor(uMsg, wParam, lParam))
{
return 0;
}
return CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_originalWndProc != 0)
{
SetWindowLongPtr(_hwnd, GwlWndProc, _originalWndProc);
_originalWndProc = 0;
}
_wndProcDelegate = null;
}
}

View File

@@ -13,15 +13,8 @@ namespace Microsoft.Interop.Tests
[TestClass]
public class InteropTests : IDisposable
{
// Pipe names are machine-global, so two concurrent test runs on the same CI agent
// (or a leaked handle from a prior run) would deadlock if we used a shared constant.
// Suffix with process id + a GUID so every test run gets its own pair.
private const string PipePrefix = @"\\.\pipe\";
private static readonly string PipeSuffix = $"{Environment.ProcessId}_{Guid.NewGuid():N}";
private static readonly string ServerSidePipe = $"{PipePrefix}serverside_{PipeSuffix}";
private static readonly string ClientSidePipe = $"{PipePrefix}clientside_{PipeSuffix}";
private static readonly TimeSpan MessageWaitTimeout = TimeSpan.FromSeconds(30);
private const string ServerSidePipe = "\\\\.\\pipe\\serverside";
private const string ClientSidePipe = "\\\\.\\pipe\\clientside";
internal TwoWayPipeMessageIPCManaged ClientPipe { get; set; }
@@ -61,11 +54,7 @@ namespace Microsoft.Interop.Tests
Thread.Sleep(100);
ClientPipe.Send(testString);
// Bounded wait so a broken pipe handshake fails the test quickly
// instead of hanging the CI agent until the job-level timeout.
var timeoutMessage = $"Pipe callback was not invoked within {MessageWaitTimeout.TotalSeconds}s. Server='{ServerSidePipe}' Client='{ClientSidePipe}'.";
Assert.IsTrue(reset.WaitOne(MessageWaitTimeout), timeoutMessage);
reset.WaitOne();
serverPipe.End();
}

View File

@@ -910,12 +910,6 @@ public:
return powertoys_gpo::getConfiguredAdvancedPasteEnabledValue();
}
// Returns whether the PowerToys should be enabled by default
virtual bool is_enabled_by_default() const override
{
return false;
}
virtual bool get_config(wchar_t* buffer, int* buffer_size) override
{
HINSTANCE hinstance = reinterpret_cast<HINSTANCE>(&__ImageBase);

View File

@@ -75,12 +75,6 @@ public:
return powertoys_gpo::getConfiguredCropAndLockEnabledValue();
}
// Returns whether the PowerToys should be enabled by default
virtual bool is_enabled_by_default() const override
{
return false;
}
// Return JSON with the configuration options.
// These are the settings shown on the settings page along with their current values.
virtual bool get_config(wchar_t* buffer, int* buffer_size) override

View File

@@ -226,12 +226,6 @@ public:
return powertoys_gpo::getConfiguredEnvironmentVariablesEnabledValue();
}
// Returns whether the PowerToys should be enabled by default
virtual bool is_enabled_by_default() const override
{
return false;
}
virtual bool get_config(wchar_t* /*buffer*/, int* /*buffer_size*/) override
{
return false;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 766 B

File diff suppressed because it is too large Load Diff

View File

@@ -6,12 +6,9 @@
#include <shellapi.h>
#include <commctrl.h>
#include <TraceLoggingProvider.h>
#include <atomic>
#include <limits>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

View File

@@ -243,12 +243,6 @@ public:
return powertoys_gpo::getConfiguredHostsFileEditorEnabledValue();
}
// Returns whether the PowerToys should be enabled by default
virtual bool is_enabled_by_default() const override
{
return false;
}
virtual bool get_config(wchar_t* /*buffer*/, int* /*buffer_size*/) override
{
return false;

View File

@@ -92,12 +92,6 @@ public:
return powertoys_gpo::getConfiguredWorkspacesEnabledValue();
}
// Returns whether the PowerToys should be enabled by default
virtual bool is_enabled_by_default() const override
{
return false;
}
// Return JSON with the configuration options.
// These are the settings shown on the settings page along with their current values.
virtual bool get_config(_Out_ PWSTR buffer, _Out_ int* buffer_size) override

View File

@@ -26,7 +26,6 @@
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Indexer.UnitTests\\Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Registry.UnitTests\\Microsoft.CmdPal.Ext.Registry.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Shell.UnitTests\\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj",
@@ -63,4 +62,4 @@
"src\\settings-ui\\Settings.UI.Library\\Settings.UI.Library.csproj"
]
}
}
}

View File

@@ -1,10 +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.Common.Messages;
/// <summary>
/// Message to request hiding the window.
/// </summary>
public sealed partial record HideWindowMessage;

View File

@@ -86,14 +86,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel? SecondaryCommand => _secondaryMoreCommand;
public bool CanOpenContextMenu =>
// BEAR LOADING: A visible synthetic primary command makes the item
// context-openable immediately, even if out-of-proc MoreCommands are still
// hydrating. Without this fast path, the first open request can race slow
// menu initialization and get dropped.
_defaultCommandContextItemViewModel?.ShouldBeVisible == true ||
_moreCommandsSnapshot.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);
public bool CanOpenContextMenu => AllCommands.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
@@ -139,15 +132,13 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
return;
}
var command = model.Command;
Command = new(command, PageContext);
Command = new(model.Command, PageContext);
Command.FastInitializeProperties();
_itemTitle = model.Title;
Subtitle = model.Subtitle;
_titleCache.Invalidate();
_subtitleCache.Invalidate();
TryCreateDefaultCommandContextItem(command);
Initialized |= InitializedState.FastInitialized;
}
@@ -224,7 +215,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
BuildAndInitMoreCommands();
TryCreateDefaultCommandContextItem(model.Command);
TryCreateDefaultCommandContextItem(model);
lock (_moreCommandsLock)
{
@@ -325,8 +316,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
{
case nameof(Command):
Command.PropertyChanged -= Command_PropertyChanged;
var command = model.Command;
Command = new(command, PageContext);
Command = new(model.Command, PageContext);
Command.InitializeProperties();
Command.PropertyChanged += Command_PropertyChanged;
@@ -342,7 +332,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
else
{
TryCreateDefaultCommandContextItem(command);
TryCreateDefaultCommandContextItem(model);
}
UpdateProperty(nameof(Name));
@@ -417,7 +407,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
else
{
TryCreateDefaultCommandContextItem(model.Command);
TryCreateDefaultCommandContextItem(model);
}
break;
@@ -437,22 +427,19 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
/// When a new instance is created, the snapshot is refreshed and
/// <see cref="AllCommands"/> is notified.
/// </summary>
private void TryCreateDefaultCommandContextItem(ICommand? commandModel)
private void TryCreateDefaultCommandContextItem(ICommandItem model)
{
if (_defaultCommandContextItemViewModel is not null)
{
return;
}
// We only synthesize the primary entry when the command is already
// usable; a null/empty primary must still fall back to late
// MoreCommands-based opening.
if (string.IsNullOrEmpty(Command.Name) || commandModel is null)
if (string.IsNullOrEmpty(model.Command?.Name))
{
return;
}
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(commandModel), PageContext)
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext)
{
_itemTitle = Name,
Subtitle = Subtitle,

View File

@@ -61,11 +61,6 @@ public sealed partial class DockViewModel
}
Logger.LogDebug("Starting DockBands_CollectionChanged");
// Refresh settings so newly pinned/unpinned bands are visible.
// Pin/unpin operations save with hotReload:false (to avoid
// double-updates), so _settings can be stale here.
_settings = _settingsService.Settings.DockSettings;
SetupBands();
Logger.LogDebug("Ended DockBands_CollectionChanged");
}

View File

@@ -199,15 +199,8 @@ public partial class ExtensionService : IExtensionService, IDisposable
var extensions = await GetInstalledAppExtensionsAsync();
foreach (var extension in extensions)
{
try
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load extension '{extension.DisplayName}': {ex.Message}");
}
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
}
@@ -252,15 +245,8 @@ public partial class ExtensionService : IExtensionService, IDisposable
List<ExtensionWrapper> wrappers = [];
foreach (var classId in classIds)
{
try
{
var extensionWrapper = CreateExtensionWrapper(extension, cmdPalProvider, classId);
wrappers.Add(extensionWrapper);
}
catch (Exception ex)
{
Logger.LogError($"Failed to create wrapper for extension '{extension.DisplayName}' classId '{classId}': {ex.Message}");
}
var extensionWrapper = CreateExtensionWrapper(extension, cmdPalProvider, classId);
wrappers.Add(extensionWrapper);
}
return wrappers;

View File

@@ -18,7 +18,9 @@ public record DockSettings
{
public DockSide Side { get; init; } = DockSide.Top;
public DockSize DockSize { get; init; } = DockSize.Default;
public DockSize DockSize { get; init; } = DockSize.Small;
public DockSize DockIconsSize { get; init; } = DockSize.Small;
public bool AlwaysOnTop { get; set; } = true;
@@ -137,8 +139,9 @@ public enum DockSide
public enum DockSize
{
Default,
Compact,
Small,
Medium,
Large,
}
public enum DockBackdrop

View File

@@ -4,7 +4,7 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Commands;

View File

@@ -17,9 +17,12 @@
<UserControl.Resources>
<ResourceDictionary>
<StackLayout x:Key="ItemsOrientationLayout" Orientation="{x:Bind ItemsOrientation, Mode=OneWay}" />
<StackLayout
x:Key="ItemsOrientationLayout"
Orientation="{x:Bind ItemsOrientation, Mode=OneWay}"
Spacing="4" />
<ItemsPanelTemplate x:Key="HorizontalItemsPanel">
<StackPanel Orientation="Horizontal" />
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="VerticalItemsPanel">
<StackPanel Orientation="Vertical" Spacing="4" />
@@ -73,7 +76,7 @@
<Style x:Key="DockBandListViewItemStyle" TargetType="ListViewItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Margin" Value="0,0,4,0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
@@ -206,13 +209,13 @@
<Grid
x:Name="RootGrid"
Background="Transparent"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
RightTapped="RootGrid_RightTapped">
<!-- Dock content with Start / Center / End sections -->
<local:DockContentControl
x:Name="ContentGrid"
Margin="4"
Background="Transparent"
IsEditMode="{x:Bind IsEditMode, Mode=OneWay}"
RightTapped="RootGrid_RightTapped">
<local:DockContentControl.StartSource>
@@ -244,6 +247,7 @@
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</local:DockContentControl.StartActionButton>
<local:DockContentControl.CenterSource>
<ListView
x:Name="CenterListView"
@@ -278,8 +282,6 @@
<ListView
x:Name="EndListView"
MinWidth="48"
MinHeight="0"
HorizontalContentAlignment="Stretch"
DragEnter="BandListView_DragEnter"
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
@@ -309,6 +311,7 @@
</Button>
</local:DockContentControl.EndActionButton>
</local:DockContentControl>
<TeachingTip
x:Name="EditButtonsTeachingTip"
MinWidth="0"
@@ -341,7 +344,7 @@
<ui:IsEqualStateTrigger Value="{x:Bind DockSide, Mode=OneWay}" To="Top" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="4,0,4,0" />
<Setter Target="ContentGrid.Margin" Value="4,0,4,4" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="DockOnBottom">
@@ -388,25 +391,6 @@
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<!--
Compact overrides: zeroes margins/borders set by DockOrientation.
Declared after DockOrientation so its setters win when both groups
target the same property.
-->
<VisualStateGroup x:Name="DockSizeStates">
<VisualState x:Name="DefaultSize" />
<VisualState x:Name="CompactSize">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind DockSize, Mode=OneWay}" To="Compact" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="0" />
<Setter Target="ContentGrid.Padding" Value="0" />
<Setter Target="RootGrid.BorderThickness" Value="0" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</UserControl>

View File

@@ -47,15 +47,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
set => SetValue(DockSideProperty, value);
}
public static readonly DependencyProperty DockSizeProperty =
DependencyProperty.Register(nameof(DockSize), typeof(DockSize), typeof(DockControl), new PropertyMetadata(DockSize.Default));
public DockSize DockSize
{
get => (DockSize)GetValue(DockSizeProperty);
set => SetValue(DockSizeProperty, value);
}
public static readonly DependencyProperty IsEditModeProperty =
DependencyProperty.Register(nameof(IsEditMode), typeof(bool), typeof(DockControl), new PropertyMetadata(false, OnIsEditModeChanged));
@@ -243,10 +234,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
DockSide = settings.Side;
// Compact mode is only supported for Top/Bottom positions
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
var effectiveSize = isHorizontal ? settings.DockSize : DockSize.Default;
DockSize = effectiveSize;
ItemsOrientation = isHorizontal ? Orientation.Horizontal : Orientation.Vertical;
@@ -302,11 +290,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
ShowTitlesMenuItem.IsChecked = _editModeContextBand.ShowTitles;
ShowSubtitlesMenuItem.IsChecked = _editModeContextBand.ShowSubtitles;
// Hide subtitle toggle in compact mode — no subtitle in the template
ShowSubtitlesMenuItem.Visibility = DockSize == DockSize.Compact
? Visibility.Collapsed
: Visibility.Visible;
PreparePopupForShow(EditModeContextMenu, dockItem);
EditModeContextMenu.ShowAt(
dockItem,

View File

@@ -43,7 +43,7 @@
<CornerRadius x:Key="DockItemCornerRadius">4</CornerRadius>
<Thickness x:Key="DockItemPadding">4,0,4,0</Thickness>
<Thickness x:Key="DockItemMargin">2,0,2,0</Thickness>
<Style BasedOn="{StaticResource DefaultDockItemControlStyle}" TargetType="local:DockItemControl" />
<Style x:Key="DefaultDockItemControlStyle" TargetType="local:DockItemControl">
@@ -60,13 +60,12 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockItemControl">
<Grid
x:Name="PART_RootGrid"
Padding="{StaticResource DockItemMargin}"
Background="Transparent">
<Grid x:Name="PART_HitTestGrid" Background="Transparent">
<Grid
x:Name="PART_BackPlate"
x:Name="PART_RootGrid"
MinWidth="32"
MinHeight="30"
Margin="{TemplateBinding InnerMargin}"
Padding="{TemplateBinding Padding}"
VerticalAlignment="Stretch"
Background="{TemplateBinding Background}"
@@ -129,20 +128,20 @@
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBorderBrushPointerOver}" />
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBorderBrushPointerOver}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBorderBrushPressed}" />
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBorderBrushPressed}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBackground}" />
<Setter Target="IconPresenter.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
<Setter Target="TitleText.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
<Setter Target="SubtitleText.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
@@ -193,16 +192,6 @@
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="CompactStates">
<VisualState x:Name="DefaultLayout" />
<VisualState x:Name="Compact">
<VisualState.Setters>
<Setter Target="PART_RootGrid.Padding" Value="0" />
<Setter Target="SubtitleText.Visibility" Value="Collapsed" />
<Setter Target="TitleText.Margin" Value="0,-1,0,0" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>

View File

@@ -84,35 +84,12 @@ public sealed partial class DockItemControl : Control
set => SetValue(TextVisibilityProperty, value);
}
public static readonly DependencyProperty IsCompactProperty =
DependencyProperty.Register(nameof(IsCompact), typeof(bool), typeof(DockItemControl), new PropertyMetadata(false, OnIsCompactPropertyChanged));
public bool IsCompact
{
get => (bool)GetValue(IsCompactProperty);
set => SetValue(IsCompactProperty, value);
}
private static void OnIsCompactPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is DockItemControl control)
{
control.UpdateCompactState();
}
}
private void UpdateCompactState()
{
VisualStateManager.GoToState(this, IsCompact ? "Compact" : "DefaultLayout", true);
}
private const string IconPresenterName = "IconPresenter";
private FrameworkElement? _iconPresenter;
private DockControl? _parentDock;
private ToolTip? _toolTip;
private long _dockSideCallbackToken = -1;
private long _dockSizeCallbackToken = -1;
private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
@@ -145,14 +122,6 @@ public sealed partial class DockItemControl : Control
private void UpdateTextVisibilityState()
{
// When TextVisibility is Collapsed, always hide text and collapse the
// grid column/spacing so the icon-only layout doesn't waste space.
if (TextVisibility == Visibility.Collapsed)
{
VisualStateManager.GoToState(this, "TextHidden", true);
return;
}
// Determine which visual state to use based on title/subtitle presence
var stateName = (HasTitle, HasSubtitle) switch
{
@@ -215,7 +184,6 @@ public sealed partial class DockItemControl : Control
UpdateIconVisibility();
UpdateToolTip();
UpdateAlignment();
UpdateCompactState();
}
private void UpdateToolTip()
@@ -281,14 +249,10 @@ public sealed partial class DockItemControl : Control
{
_parentDock = dock;
UpdateInnerMarginForDockSide(dock.DockSide);
UpdateCompactFromParent(dock);
UpdateAllVisibility();
_dockSideCallbackToken = dock.RegisterPropertyChangedCallback(
DockControl.DockSideProperty,
OnParentDockSideChanged);
_dockSizeCallbackToken = dock.RegisterPropertyChangedCallback(
DockControl.DockSizeProperty,
OnParentDockSizeChanged);
}
UpdateToolTip();
@@ -302,24 +266,12 @@ public sealed partial class DockItemControl : Control
private void DockItemControl_Unloaded(object sender, RoutedEventArgs e)
{
if (_parentDock is not null)
if (_parentDock is not null && _dockSideCallbackToken >= 0)
{
if (_dockSideCallbackToken >= 0)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSideProperty,
_dockSideCallbackToken);
_dockSideCallbackToken = -1;
}
if (_dockSizeCallbackToken >= 0)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSizeProperty,
_dockSizeCallbackToken);
_dockSizeCallbackToken = -1;
}
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSideProperty,
_dockSideCallbackToken);
_dockSideCallbackToken = -1;
_parentDock = null;
}
@@ -331,23 +283,11 @@ public sealed partial class DockItemControl : Control
{
if (sender is DockControl dock)
{
UpdateInnerMarginForDockSide(dock.DockSide);
UpdateAlignment();
}
}
private void OnParentDockSizeChanged(DependencyObject sender, DependencyProperty dp)
{
if (sender is DockControl dock)
{
UpdateCompactFromParent(dock);
}
}
private void UpdateCompactFromParent(DockControl dock)
{
IsCompact = dock.DockSize == DockSize.Compact;
}
private void UpdateInnerMarginForDockSide(DockSide side)
{
// Push the visual (PART_RootGrid) inward on the screen-edge side so
@@ -356,7 +296,7 @@ public sealed partial class DockItemControl : Control
// DockControl's ContentGrid on the screen-edge side.
InnerMargin = side switch
{
DockSide.Top => new Thickness(0, 0, 0, 0),
DockSide.Top => new Thickness(0, 4, 0, 0),
DockSide.Bottom => new Thickness(0, 0, 0, 4),
DockSide.Left => new Thickness(8, 0, 0, 0),
DockSide.Right => new Thickness(0, 0, 8, 0),

View File

@@ -13,8 +13,9 @@ internal static class DockSettingsToViews
{
return size switch
{
DockSize.Default => 86,
DockSize.Compact => 86,
DockSize.Small => 128,
DockSize.Medium => 192,
DockSize.Large => 256,
_ => throw new NotImplementedException(),
};
}
@@ -23,8 +24,9 @@ internal static class DockSettingsToViews
{
return size switch
{
DockSize.Default => 38,
DockSize.Compact => 24,
DockSize.Small => 38,
DockSize.Medium => 54,
DockSize.Large => 76,
_ => throw new NotImplementedException(),
};
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
x:Class="Microsoft.CmdPal.UI.Dock.DockWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

View File

@@ -77,7 +77,7 @@ public sealed partial class DockWindow : WindowEx,
_settingsService = serviceProvider.GetRequiredService<ISettingsService>();
_settingsService.SettingsChanged += SettingsChangedHandler;
_settings = mainSettings.DockSettings;
_lastSize = EffectiveDockSize(_settings);
_lastSize = _settings.DockSize;
viewModel = serviceProvider.GetService<DockViewModel>()!;
_themeService = serviceProvider.GetRequiredService<IThemeService>();
@@ -174,7 +174,7 @@ public sealed partial class DockWindow : WindowEx,
if (_appBarData.hWnd != IntPtr.Zero)
{
var sameEdge = _appBarData.uEdge == side;
var sameSize = _lastSize == EffectiveDockSize(_settings);
var sameSize = _lastSize == _settings.DockSize;
if (sameEdge && sameSize)
{
UpdateTopmostState();
@@ -332,7 +332,7 @@ public sealed partial class DockWindow : WindowEx,
// Stash the last size we created the bar at, so we know when to hot-
// reload it
_lastSize = EffectiveDockSize(_settings);
_lastSize = _settings.DockSize;
UpdateWindowPosition();
}
@@ -384,9 +384,15 @@ public sealed partial class DockWindow : WindowEx,
var dpi = PInvoke.GetDpiForWindow(_hwnd);
var screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
// Get system border metrics
var borderWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXBORDER);
var edgeWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXEDGE);
var frameWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXFRAME);
var scaleFactor = dpi / 96.0;
var effectiveSize = EffectiveDockSize(_settings);
UpdateAppBarDataForEdge(_settings.Side, effectiveSize, scaleFactor);
UpdateAppBarDataForEdge(_settings.Side, _settings.DockSize, scaleFactor);
// Query and set position
PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
@@ -400,16 +406,16 @@ public sealed partial class DockWindow : WindowEx,
switch (_settings.Side)
{
case DockSide.Top:
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(_settings.DockSize) * scaleFactor);
break;
case DockSide.Bottom:
_appBarData.rc.top = _appBarData.rc.bottom - (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
_appBarData.rc.top = _appBarData.rc.bottom - (int)(DockSettingsToViews.HeightForSize(_settings.DockSize) * scaleFactor);
break;
case DockSide.Left:
_appBarData.rc.right = _appBarData.rc.left + (int)(DockSettingsToViews.WidthForSize(effectiveSize) * scaleFactor);
_appBarData.rc.right = _appBarData.rc.left + (int)(DockSettingsToViews.WidthForSize(_settings.DockSize) * scaleFactor);
break;
case DockSide.Right:
_appBarData.rc.left = _appBarData.rc.right - (int)(DockSettingsToViews.WidthForSize(effectiveSize) * scaleFactor);
_appBarData.rc.left = _appBarData.rc.right - (int)(DockSettingsToViews.WidthForSize(_settings.DockSize) * scaleFactor);
break;
}
@@ -422,28 +428,23 @@ public sealed partial class DockWindow : WindowEx,
// PInvoke.SHAppBarMessage(ABM_SETSTATE, ref _appBarData);
// PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAR, ref _appBarData);
// The dock window is borderless (SetBorderAndTitleBar(false, false),
// IsResizable = false) so no frame compensation is needed — the
// app bar rect matches the window rect exactly.
// Account for system borders when moving the window
// Adjust position to account for window frame/border
var adjustedLeft = _appBarData.rc.left - frameWidth;
var adjustedTop = _appBarData.rc.top - frameWidth;
var adjustedWidth = (_appBarData.rc.right - _appBarData.rc.left) + (2 * frameWidth);
var adjustedHeight = (_appBarData.rc.bottom - _appBarData.rc.top) + (2 * frameWidth);
// Move the actual window
PInvoke.MoveWindow(
_hwnd,
_appBarData.rc.left,
_appBarData.rc.top,
_appBarData.rc.right - _appBarData.rc.left,
_appBarData.rc.bottom - _appBarData.rc.top,
adjustedLeft,
adjustedTop,
adjustedWidth,
adjustedHeight,
true);
}
/// <summary>
/// Compact mode is only supported for Top/Bottom dock positions.
/// For Left/Right, always use Default size.
/// </summary>
private static DockSize EffectiveDockSize(DockSettings settings)
{
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
return isHorizontal ? settings.DockSize : DockSize.Default;
}
private void UpdateAppBarDataForEdge(DockSide side, DockSize size, double scaleFactor)
{
Logger.LogDebug("UpdateAppBarDataForEdge");
@@ -586,21 +587,11 @@ public sealed partial class DockWindow : WindowEx,
}
}
// Handle WM_GETMINMAXINFO to allow the dock to be smaller than
// the default minimum window size (SM_CYMINTRACK ~36px).
// Handle WM_GETMINMAXINFO to control window size limits
else if (msg == PInvoke.WM_GETMINMAXINFO)
{
// Call the original WndProc first so it fills default values,
// then override the minimum tracking size.
var result = PInvoke.CallWindowProc(_originalWndProc, hwnd, msg, wParam, lParam);
unsafe
{
var minMaxInfo = (MINMAXINFO*)lParam.Value;
minMaxInfo->ptMinTrackSize.X = 1;
minMaxInfo->ptMinTrackSize.Y = 1;
}
return result;
// We can modify the min/max tracking info here if needed
// For now, let it pass through but we could restrict max size
}
// Handle the AppBarMessage message

View File

@@ -43,8 +43,6 @@ public sealed partial class ListPage : Page,
private ListItemViewModel? _stickySelectedItem;
private ListItemViewModel? _lastPushedToVm;
private long _pendingContextMenuOpenRequestId;
private Action? _cancelPendingContextMenuOpen;
// A single search-text change can produce multiple ItemsUpdated calls
// dispatched as separate UI-thread callbacks. A later "soft" update
@@ -126,8 +124,6 @@ public sealed partial class ListPage : Page,
{
base.OnNavigatingFrom(e);
CancelPendingContextMenuOpen();
WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this);
@@ -287,7 +283,17 @@ public sealed partial class ListPage : Page,
ViewModel?.UpdateSelectedItemCommand.Execute(item);
var pos = e.GetPosition(element);
RequestContextMenuOpen(item, element, pos);
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
}
}
@@ -1008,14 +1014,21 @@ public sealed partial class ListPage : Page,
pos = new(0, element.ActualHeight);
}
ViewModel?.UpdateSelectedItemCommand.Execute(item);
RequestContextMenuOpen(item, element, pos);
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
e.Handled = true;
}
private void Items_OnContextCanceled(UIElement sender, RoutedEventArgs e)
{
CancelPendingContextMenuOpen();
_ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>());
}
@@ -1197,87 +1210,6 @@ public sealed partial class ListPage : Page,
scroll.ChangeView(horizontalOffset: null, verticalOffset: 0, zoomFactor: null, disableAnimation: true);
}
private void RequestContextMenuOpen(ListItemViewModel item, FrameworkElement element, Point pos)
{
// BEAR LOADING: Right-click can arrive before the selected item's slow
// context-menu hydration completes, especially for out-of-proc
// providers. Keep this exact open request alive until the same
// selected item becomes context-openable instead of dropping the first
// click.
CancelPendingContextMenuOpen();
var requestId = Interlocked.Increment(ref _pendingContextMenuOpenRequestId);
System.ComponentModel.PropertyChangedEventHandler? onItemChanged = null;
Action? detach = null;
detach = () =>
{
if (onItemChanged is not null)
{
item.PropertyChanged -= onItemChanged;
}
if (ReferenceEquals(_cancelPendingContextMenuOpen, detach))
{
_cancelPendingContextMenuOpen = null;
}
};
onItemChanged = (_, args) =>
{
if (args.PropertyName is nameof(ListItemViewModel.CanOpenContextMenu) or nameof(ListItemViewModel.AllCommands) &&
TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
};
item.PropertyChanged += onItemChanged;
_cancelPendingContextMenuOpen = detach;
if (TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
}
private bool TryOpenContextMenuIfReady(ListItemViewModel item, FrameworkElement element, Point pos, long requestId)
{
// Ignore stale requests so rapid selection changes or cancelled opens
// can't resurrect an old context menu on the wrong item.
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item) ||
!item.CanOpenContextMenu)
{
return false;
}
_ = DispatcherQueue.TryEnqueue(
() =>
{
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item))
{
return;
}
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
return true;
}
private void CancelPendingContextMenuOpen()
{
Interlocked.Increment(ref _pendingContextMenuOpenRequestId);
_cancelPendingContextMenuOpen?.Invoke();
_cancelPendingContextMenuOpen = null;
}
private IDisposable SuppressSelectionChangedScope()
{
_suppressSelectionChanged = true;

View File

@@ -8,8 +8,8 @@ using CmdPalKeyboardService;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.Dock;
using Microsoft.CmdPal.UI.Events;

View File

@@ -38,11 +38,6 @@
<DefineConstants>$(DefineConstants)</DefineConstants>
</PropertyGroup>
<!-- Added to ensure telemetry events are triggered from AOT build -->
<PropertyGroup>
<EventSourceSupport>true</EventSourceSupport>
</PropertyGroup>
<!-- For debugging purposes, uncomment this block to enable AOT builds -->
<!-- <PropertyGroup>
<EnableCmdPalAOT>true</EnableCmdPalAOT>

View File

@@ -94,7 +94,6 @@ WM_WINDOWPOSCHANGING
WM_SHOWWINDOW
WM_SIZE
WM_GETMINMAXINFO
MINMAXINFO
SetWinEventHook
WINDOW_STYLE
SC_MINIMIZE

View File

@@ -67,20 +67,6 @@
</ComboBox>
</controls:SettingsCard>
<!-- Dock Size (only for Top/Bottom positions) -->
<controls:SettingsCard
x:Name="DockSizeSettingsCard"
x:Uid="DockAppearance_DockSize_SettingsCard"
HeaderIcon="{ui:FontIcon Glyph=&#xE799;}">
<ComboBox
x:Name="DockSizeComboBox"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind SelectedDockSizeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="DockAppearance_DockSize_Default" />
<ComboBoxItem x:Uid="DockAppearance_DockSize_Compact" />
</ComboBox>
</controls:SettingsCard>
<controls:SettingsCard x:Uid="DockAppearance_AppTheme_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE793;}">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.DockAppearance.ThemeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="Settings_GeneralPage_AppTheme_Mode_System_Automation" Tag="Default">

View File

@@ -42,9 +42,7 @@ public sealed partial class DockSettingsPage : Page
{
// Initialize UI controls to match current settings
DockPositionComboBox.SelectedIndex = SelectedSideIndex;
DockSizeComboBox.SelectedIndex = SelectedDockSizeIndex;
BackdropComboBox.SelectedIndex = SelectedBackdropIndex;
UpdateDockSizeCardVisibility();
}
private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e)
@@ -110,11 +108,7 @@ public sealed partial class DockSettingsPage : Page
public int SelectedSideIndex
{
get => SideToSelectedIndex(ViewModel.Dock_Side);
set
{
ViewModel.Dock_Side = SelectedIndexToSide(value);
UpdateDockSizeCardVisibility();
}
set => ViewModel.Dock_Side = SelectedIndexToSide(value);
}
public int SelectedBackdropIndex
@@ -132,16 +126,18 @@ public sealed partial class DockSettingsPage : Page
// Conversion methods for ComboBox bindings
private static int DockSizeToSelectedIndex(DockSize size) => size switch
{
DockSize.Default => 0,
DockSize.Compact => 1,
DockSize.Small => 0,
DockSize.Medium => 1,
DockSize.Large => 2,
_ => 0,
};
private static DockSize SelectedIndexToDockSize(int index) => index switch
{
0 => DockSize.Default,
1 => DockSize.Compact,
_ => DockSize.Default,
0 => DockSize.Small,
1 => DockSize.Medium,
2 => DockSize.Large,
_ => DockSize.Small,
};
private static int SideToSelectedIndex(DockSide side) => side switch
@@ -176,13 +172,6 @@ public sealed partial class DockSettingsPage : Page
_ => DockBackdrop.Acrylic,
};
private void UpdateDockSizeCardVisibility()
{
var side = ViewModel.Dock_Side;
var isTopOrBottom = side == DockSide.Top || side == DockSide.Bottom;
DockSizeSettingsCard.Visibility = isTopOrBottom ? Visibility.Visible : Visibility.Collapsed;
}
private List<TopLevelViewModel> GetAllBands()
{
var allBands = new List<TopLevelViewModel>();

View File

@@ -939,18 +939,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="DockAppearance_DockPosition_Bottom.Content" xml:space="preserve">
<value>Bottom</value>
</data>
<data name="DockAppearance_DockSize_SettingsCard.Header" xml:space="preserve">
<value>Size</value>
</data>
<data name="DockAppearance_DockSize_SettingsCard.Description" xml:space="preserve">
<value>Choose the dock size; subtitles of dock items are hidden in compact mode</value>
</data>
<data name="DockAppearance_DockSize_Default.Content" xml:space="preserve">
<value>Default</value>
</data>
<data name="DockAppearance_DockSize_Compact.Content" xml:space="preserve">
<value>Compact</value>
</data>
<data name="top_level_pin_command_name" xml:space="preserve">
<value>Pin to home</value>
<comment>Command name for pinning an item to the top level list of commands</comment>

View File

@@ -5,7 +5,6 @@
using System.Globalization;
using System.Linq;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
@@ -19,49 +18,47 @@ public class CloseOnEnterTests
public void PrimaryIsCopy_WhenCloseOnEnterTrue()
{
var settings = new Settings(closeOnEnter: true);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResultForPage(
var item = ResultHelper.CreateResult(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
Assert.IsTrue(item.MoreCommands.OfType<CommandContextItem>().All(command => command.Command is not SaveCommand));
var result = ((CopyTextCommand)item.Command).Result;
Assert.AreEqual(CommandResultKind.ShowToast, result.Kind);
var toastArgs = result.Args as ToastArgs;
Assert.IsNotNull(toastArgs);
Assert.AreEqual(CommandResultKind.Hide, ((CommandResult)toastArgs.Result).Kind);
var firstMore = item.MoreCommands.First();
Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem));
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(SaveCommand));
}
[TestMethod]
public void PrimaryIsCopy_WhenCloseOnEnterFalse()
public void PrimaryIsSave_WhenCloseOnEnterFalse()
{
var settings = new Settings(closeOnEnter: false);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResultForPage(
var item = ResultHelper.CreateResult(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
Assert.IsTrue(item.MoreCommands.OfType<CommandContextItem>().All(command => command.Command is not SaveCommand));
Assert.IsInstanceOfType(item.Command, typeof(SaveCommand));
var result = ((CopyTextCommand)item.Command).Result;
Assert.AreEqual(CommandResultKind.ShowToast, result.Kind);
var toastArgs = result.Args as ToastArgs;
Assert.IsNotNull(toastArgs);
Assert.AreEqual(CommandResultKind.KeepOpen, ((CommandResult)toastArgs.Result).Kind);
var firstMore = item.MoreCommands.First();
Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem));
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CopyTextCommand));
}
}

View File

@@ -1,98 +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.Globalization;
using System.Linq;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Pages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class PrimaryActionTests
{
[TestMethod]
public void PrimaryActionPaste_UsesPasteAsPrimaryAndCopyAsSecondary()
{
var settings = new Settings(primaryAction: PrimaryAction.Paste);
TypedEventHandler<object, object> handleReplace = (_, _) => { };
var item = ResultHelper.CreateResultForPage(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CalculatorPasteCommand));
var firstMore = item.MoreCommands.OfType<CommandContextItem>().FirstOrDefault();
Assert.IsNotNull(firstMore);
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CalculatorCopyCommand));
}
[TestMethod]
public void HistoryItemsUsePasteWhenPrimaryActionPaste()
{
var settings = new Settings(primaryAction: PrimaryAction.Paste);
settings.AddHistoryItem(new HistoryItem("2+2", "4", DateTime.UtcNow));
var page = new CalculatorListPage(settings);
var historyItem = page.GetItems().FirstOrDefault(item => item.Title == "4");
Assert.IsNotNull(historyItem);
Assert.IsInstanceOfType(historyItem.Command, typeof(CalculatorPasteCommand));
}
[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public void FallbackItemsUseCalculatorCommandsForCopyAndPaste(bool saveFallbackResultsToHistory)
{
var settings = new Settings(saveFallbackResultsToHistory: saveFallbackResultsToHistory);
var page = new CalculatorListPage(settings);
var item = new FallbackCalculatorItem(settings, page);
item.UpdateQuery("2+2");
Assert.IsInstanceOfType(item.Command, typeof(CalculatorCopyCommand));
Assert.IsInstanceOfType(GetFallbackSecondaryCommand(item), typeof(CalculatorPasteCommand));
}
[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public void FallbackItemsRespectPrimaryActionWhenHistorySavingToggles(bool saveFallbackResultsToHistory)
{
var settings = new Settings(
primaryAction: PrimaryAction.Paste,
saveFallbackResultsToHistory: saveFallbackResultsToHistory);
var page = new CalculatorListPage(settings);
var item = new FallbackCalculatorItem(settings, page);
item.UpdateQuery("2+2");
Assert.IsInstanceOfType(item.Command, typeof(CalculatorPasteCommand));
Assert.IsInstanceOfType(GetFallbackSecondaryCommand(item), typeof(CalculatorCopyCommand));
}
private static ICommand GetFallbackSecondaryCommand(FallbackCalculatorItem item)
{
var secondaryCommand = item.MoreCommands
.OfType<CommandContextItem>()
.Skip(1)
.Select(contextItem => ((CommandItem)contextItem).Command)
.FirstOrDefault();
Assert.IsNotNull(secondaryCommand);
return secondaryCommand;
}
}

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Calc.Helper;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
@@ -16,12 +14,7 @@ public class Settings : ISettingsInterface
private readonly bool closeOnEnter;
private readonly bool copyResultToSearchBarIfQueryEndsWithEqualSign;
private readonly bool autoFixQuery;
private readonly bool saveFallbackResultsToHistory;
private readonly bool deleteHistoryRequiresConfirmation;
private readonly PrimaryAction primaryAction;
private readonly bool inputNormalization;
private readonly List<HistoryItem> historyItems = [];
private readonly bool replaceQueryOnEnter;
public Settings(
CalculateEngine.TrigMode trigUnit = CalculateEngine.TrigMode.Radians,
@@ -30,11 +23,7 @@ public class Settings : ISettingsInterface
bool closeOnEnter = true,
bool copyResultToSearchBarIfQueryEndsWithEqualSign = true,
bool autoFixQuery = true,
bool saveFallbackResultsToHistory = false,
bool deleteHistoryRequiresConfirmation = true,
PrimaryAction primaryAction = PrimaryAction.Default,
bool inputNormalization = true,
bool replaceQueryOnEnter = true)
bool inputNormalization = true)
{
this.trigUnit = trigUnit;
this.inputUseEnglishFormat = inputUseEnglishFormat;
@@ -42,11 +31,7 @@ public class Settings : ISettingsInterface
this.closeOnEnter = closeOnEnter;
this.copyResultToSearchBarIfQueryEndsWithEqualSign = copyResultToSearchBarIfQueryEndsWithEqualSign;
this.autoFixQuery = autoFixQuery;
this.saveFallbackResultsToHistory = saveFallbackResultsToHistory;
this.deleteHistoryRequiresConfirmation = deleteHistoryRequiresConfirmation;
this.primaryAction = primaryAction;
this.inputNormalization = inputNormalization;
this.replaceQueryOnEnter = replaceQueryOnEnter;
}
public CalculateEngine.TrigMode TrigUnit => trigUnit;
@@ -61,46 +46,5 @@ public class Settings : ISettingsInterface
public bool AutoFixQuery => autoFixQuery;
public bool SaveFallbackResultsToHistory => saveFallbackResultsToHistory;
public bool DeleteHistoryRequiresConfirmation => deleteHistoryRequiresConfirmation;
public PrimaryAction PrimaryAction => primaryAction;
public bool InputNormalization => inputNormalization;
public event EventHandler HistoryChanged;
#pragma warning disable CS0067 // Event is never used
public event EventHandler SettingsChanged;
#pragma warning restore CS0067 // Event is never used
public IReadOnlyList<HistoryItem> HistoryItems => historyItems;
public bool ReplaceQueryOnEnter => replaceQueryOnEnter;
public void AddHistoryItem(HistoryItem historyItem)
{
historyItems.Add(historyItem);
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
public void RemoveHistoryItem(Guid historyItemId)
{
if (historyItems.RemoveAll(item => item.Id == historyItemId) > 0)
{
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
}
public void ClearHistory()
{
if (historyItems.Count == 0)
{
return;
}
historyItems.Clear();
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -35,9 +33,6 @@ public class SettingsManagerTests
Assert.IsFalse(settings.InputUseEnglishFormat);
Assert.IsFalse(settings.OutputUseEnglishFormat);
Assert.IsTrue(settings.CloseOnEnter);
Assert.IsTrue(settings.SaveFallbackResultsToHistory);
Assert.IsTrue(settings.DeleteHistoryRequiresConfirmation);
Assert.AreEqual(PrimaryAction.Default, settings.PrimaryAction);
}
[TestMethod]
@@ -57,38 +52,4 @@ public class SettingsManagerTests
Assert.IsTrue(settings.OutputUseEnglishFormat);
Assert.IsFalse(settings.CloseOnEnter);
}
[TestMethod]
public void HistorySettingsAddRemoveClearTest()
{
var settingsManager = new SettingsManager();
settingsManager.ClearHistory();
var historyItem = new HistoryItem("1+1", "2", DateTime.UtcNow);
settingsManager.AddHistoryItem(historyItem);
Assert.AreEqual(1, settingsManager.HistoryItems.Count);
settingsManager.RemoveHistoryItem(historyItem.Id);
Assert.AreEqual(0, settingsManager.HistoryItems.Count);
settingsManager.AddHistoryItem(new HistoryItem("2+2", "4", DateTime.UtcNow));
settingsManager.ClearHistory();
Assert.AreEqual(0, settingsManager.HistoryItems.Count);
}
[TestMethod]
public void HistorySettingsTrimsToCapacityTest()
{
var settingsManager = new SettingsManager();
settingsManager.ClearHistory();
for (var i = 0; i < 105; i++)
{
settingsManager.AddHistoryItem(new HistoryItem($"{i}+{i}", (i + i).ToString(CultureInfo.InvariantCulture), DateTime.UtcNow));
}
Assert.AreEqual(100, settingsManager.HistoryItems.Count);
settingsManager.ClearHistory();
}
}

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.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class FallbackOpenFileItemTests
{
[TestMethod]
public void GetFallbackNoticeText_UsesExtensionNameAsTitle()
{
var notice = new SearchNoticeInfo(Resources.Indexer_SearchFailedMessage!, Resources.Indexer_SearchFailedMessageTip!);
var text = FallbackOpenFileItem.GetFallbackNoticeText(notice);
Assert.AreEqual(Resources.IndexerCommandsProvider_DisplayName, text.Title);
Assert.AreEqual(Resources.Indexer_SearchFailedMessage, text.Subtitle);
}
}

View File

@@ -1,78 +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.Ext.Indexer.Indexer.Utils;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class ImplicitWildcardQueryBuilderTests
{
[DataTestMethod]
[DataRow("term", null, "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("term Kind:Folder", "Kind:Folder", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("System.Kind:folders term", "System.Kind:folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("System.Kind:NOT folders term", "System.Kind:NOT folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("\"two words\"", null, "((CONTAINS(System.ItemNameDisplay, '\"two words\"') OR CONTAINS(System.ItemNameDisplay, '\"two words*\"') OR CONTAINS(System.ItemNameDisplay, '\"two\" AND \"words\"') OR CONTAINS(System.ItemNameDisplay, '\"two*\" AND \"words*\"')) OR System.FileName LIKE '%two words%')", "System.FileName LIKE '%two words%'")]
[DataRow("foo bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
[DataRow("foo-bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR System.FileName LIKE '%foo-bar%')", "System.FileName LIKE '%foo-bar%'")]
[DataRow("foo & bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
[DataRow("tonträger", null, "((CONTAINS(System.ItemNameDisplay, '\"tonträger\"') OR CONTAINS(System.ItemNameDisplay, '\"tonträger*\"')) OR System.FileName LIKE '%tonträger%')", "System.FileName LIKE '%tonträger%'")]
[DataRow("O'Hara", null, "((CONTAINS(System.ItemNameDisplay, '\"Hara\"') OR CONTAINS(System.ItemNameDisplay, '\"Hara*\"')) OR System.FileName LIKE '%O''Hara%')", "System.FileName LIKE '%O''Hara%'")]
[DataRow("AT&T", null, "System.FileName LIKE '%AT&T%'", null)]
[DataRow("file_100%", null, "((CONTAINS(System.ItemNameDisplay, '\"file 100\"') OR CONTAINS(System.ItemNameDisplay, '\"file 100*\"') OR CONTAINS(System.ItemNameDisplay, '\"file\" AND \"100\"') OR CONTAINS(System.ItemNameDisplay, '\"file*\" AND \"100*\"')) OR System.FileName LIKE '%file[_]100[%]%')", "System.FileName LIKE '%file[_]100[%]%'")]
public void BuildExpandedQuery_BuildsExpectedRestrictions(string query, string expectedStructuredSearchText, string expectedPrimaryClause, string expectedFallbackClause)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
Assert.AreEqual(expectedStructuredSearchText, expandedQuery.StructuredSearchText);
Assert.AreEqual(expectedPrimaryClause, expandedQuery.PrimaryRestriction);
Assert.AreEqual(expectedFallbackClause, expandedQuery.FallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_PreservesBracketWrappedTermAsLiteralOnly()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("[red]");
Assert.AreEqual("System.FileName LIKE '%[[]red[]]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_TreatsSinglePercentAsLiteralCharacter()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("%");
Assert.AreEqual("System.FileName LIKE '%[%]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_TreatsSingleUnderscoreAsLiteralCharacter()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("_");
Assert.AreEqual("System.FileName LIKE '%[_]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[DataTestMethod]
[DataRow("kind:folder")]
[DataRow("name:term")]
[DataRow("name: term")]
[DataRow("name:\"two words\"")]
[DataRow("*term*")]
[DataRow("C:\\Users")]
[DataRow("System.Kind:folders")]
[DataRow("kind:folder AND term")]
public void BuildExpandedQuery_DoesNotBroadenStructuredOrExplicitQueries(string query)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
Assert.IsFalse(expandedQuery.HasPrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
}

View File

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

View File

@@ -1,92 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class SearchNoticeInfoBuilderTests
{
[DataTestMethod]
[DataRow((int)SearchQuery.QueryState.NullDataSource)]
[DataRow((int)SearchQuery.QueryState.CreateSessionFailed)]
[DataRow((int)SearchQuery.QueryState.CreateCommandFailed)]
public void FromQueryStatus_ReturnsUnavailableNotice_ForInfrastructureFailures(int stateValue)
{
var state = (SearchQuery.QueryState)stateValue;
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, "failure"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessageTip, notice.Value.Subtitle);
}
[TestMethod]
public void FromQueryStatus_ReturnsUnavailableNotice_ForRpcFailures()
{
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
new SearchQuery.SearchExecutionStatus(
SearchQuery.QueryState.ExecuteFailed,
unchecked((int)0x800706BA),
"RPC server unavailable"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
}
[TestMethod]
public void FromQueryStatus_ReturnsGenericFailureNotice_ForUnexpectedFailures()
{
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
new SearchQuery.SearchExecutionStatus(
SearchQuery.QueryState.ExecuteFailed,
unchecked((int)0x80004005),
"unexpected"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchFailedMessage, notice.Value.Title);
Assert.AreEqual(Resources.Indexer_SearchFailedMessageTip, notice.Value.Subtitle);
}
[DataTestMethod]
[DataRow((int)SearchQuery.QueryState.Completed)]
[DataRow((int)SearchQuery.QueryState.NoResults)]
[DataRow((int)SearchQuery.QueryState.AllNoise)]
public void FromQueryStatus_ReturnsNull_ForNonFailureStates(int stateValue)
{
var state = (SearchQuery.QueryState)stateValue;
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, null));
Assert.IsNull(notice);
}
[TestMethod]
public void FromCatalogStatus_ReturnsIndexingNotice_WhenItemsArePending()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(42, null));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchIndexingMessage, notice.Value.Title);
StringAssert.Contains(notice.Value.Subtitle, "42");
}
[TestMethod]
public void FromCatalogStatus_ReturnsNull_WhenStatusReadFails()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, unchecked((int)0x800706BA)));
Assert.IsNull(notice);
}
[TestMethod]
public void FromCatalogStatus_ReturnsNull_WhenIndexingIsIdle()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, null));
Assert.IsNull(notice);
}
}

View File

@@ -78,25 +78,6 @@ public class CommandItemViewModelTests
Assert.AreEqual("Secondary", viewModel.SecondaryCommand.Name);
}
[TestMethod]
public void FastInitializeProperties_CreatesPrimaryContextItem()
{
// Context menus are opened from fast-initialized list items before slow init completes.
// The synthetic primary command must already exist so the first right-click can open the menu.
var pageContext = new TestPageContext();
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
{
Title = "Primary",
};
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.FastInitializeProperties();
Assert.AreEqual(1, viewModel.AllCommands.Count);
Assert.IsTrue(viewModel.CanOpenContextMenu);
Assert.AreEqual("Primary", ((CommandContextItemViewModel)viewModel.AllCommands[0]).Name);
}
[TestMethod]
public void LatePrimaryCommandCreation_AddsPrimaryToAllCommands()
{

View File

@@ -12,23 +12,20 @@ namespace Microsoft.CmdPal.Ext.Calc;
public partial class CalculatorCommandProvider : CommandProvider
{
private readonly ISettingsInterface _settings = new SettingsManager();
private readonly ListItem _listItem;
private readonly FallbackCalculatorItem _fallback;
private static ISettingsInterface settings = new SettingsManager();
private readonly ListItem _listItem = new(new CalculatorListPage(settings))
{
MoreCommands = [new CommandContextItem(((SettingsManager)settings).Settings.SettingsPage)],
};
private readonly FallbackCalculatorItem _fallback = new(settings);
public CalculatorCommandProvider()
{
Id = "com.microsoft.cmdpal.builtin.calculator";
DisplayName = Resources.calculator_display_name;
Icon = Icons.CalculatorIcon;
Settings = ((SettingsManager)_settings).Settings;
var calculatorListPage = new CalculatorListPage(_settings);
_listItem = new ListItem(calculatorListPage)
{
MoreCommands = [new CommandContextItem(((SettingsManager)_settings).Settings.SettingsPage)],
};
_fallback = new(_settings, calculatorListPage);
Settings = ((SettingsManager)settings).Settings;
}
public override ICommandItem[] TopLevelCommands() => [_listItem];

View File

@@ -1,56 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed partial class CalculatorCopyCommand : CopyTextCommand
{
public event TypedEventHandler<object, object> ReplaceRequested;
private readonly ISettingsInterface _settings;
private readonly Func<bool> _canStoreHistory;
private string _query;
public CalculatorCopyCommand(string result, string query, ISettingsInterface settings, bool canStoreHistory = true)
: this(result, query, settings, () => canStoreHistory)
{
}
public CalculatorCopyCommand(string result, string query, ISettingsInterface settings, Func<bool> canStoreHistory)
: base(result)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(canStoreHistory);
_settings = settings;
_canStoreHistory = canStoreHistory;
_query = query ?? string.Empty;
Name = Properties.Resources.calculator_copy_command_name;
Result = ResultHelper.CreateCopyCommandResult(settings.CloseOnEnter);
}
public void Update(string text, string query)
{
Text = text;
_query = query ?? string.Empty;
}
public override ICommandResult Invoke()
{
ClipboardHelper.SetText(Text);
if (_canStoreHistory())
{
_settings.AddHistoryItem(new HistoryItem(_query, Text, DateTime.UtcNow));
}
ReplaceRequested?.Invoke(this, null);
return Result;
}
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed partial class CalculatorPasteCommand : InvokableCommand
{
public event TypedEventHandler<object, object> ReplaceRequested;
private readonly ISettingsInterface _settings;
private readonly Func<bool> _canStoreHistory;
private string _query;
private string _text;
public CalculatorPasteCommand(string result, string query, ISettingsInterface settings, bool canStoreHistory = true)
: this(result, query, settings, () => canStoreHistory)
{
}
public CalculatorPasteCommand(string result, string query, ISettingsInterface settings, Func<bool> canStoreHistory)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(canStoreHistory);
_settings = settings;
_canStoreHistory = canStoreHistory;
_query = query ?? string.Empty;
_text = result;
Name = Resources.calculator_paste_command_name;
Icon = Icons.PasteIcon;
}
private static void HideWindow()
{
// TODO GH #524: This isn't great - this requires us to have Secret Sauce in
// the clipboard extension to be able to manipulate the HWND.
// We probably need to put some window manipulation into the API, but
// what form that takes is not clear yet.
WeakReferenceMessenger.Default.Send<HideWindowMessage>();
}
public void Update(string text, string query)
{
_text = text;
_query = query ?? string.Empty;
}
public override ICommandResult Invoke()
{
ClipboardHelper.SetText(_text);
if (_canStoreHistory())
{
_settings.AddHistoryItem(new HistoryItem(_query, _text, DateTime.UtcNow));
}
HideWindow();
// Give the window some time to hide, and allow the other app to gain focus.
// Since we don't currently have a way to wait until the other window is ready
// to receive input, we just wing it with a short delay.
Thread.Sleep(200);
PasteHelper.SendPasteKeyCombination();
ReplaceRequested?.Invoke(this, null);
return CommandResult.ShowToast(new ToastArgs()
{
Message = Resources.calculator_paste_toast_text,
Result = CommandResult.KeepOpen(),
});
}
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed partial class ClearHistoryCommand : InvokableCommand
{
private readonly ISettingsInterface _settings;
public ClearHistoryCommand(ISettingsInterface settings)
{
ArgumentNullException.ThrowIfNull(settings);
_settings = settings;
Name = Resources.calculator_history_delete_all;
Icon = Icons.DeleteIcon;
}
public override CommandResult Invoke()
{
_settings.ClearHistory();
return CommandResult.KeepOpen();
}
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed partial class DeleteHistoryItemCommand : InvokableCommand
{
private readonly ISettingsInterface _settings;
private readonly Guid _historyItemId;
public DeleteHistoryItemCommand(ISettingsInterface settings, Guid historyItemId)
{
ArgumentNullException.ThrowIfNull(settings);
_settings = settings;
_historyItemId = historyItemId;
Name = Resources.calculator_history_delete;
Icon = Icons.DeleteIcon;
}
public override CommandResult Invoke()
{
_settings.RemoveHistoryItem(_historyItemId);
return CommandResult.KeepOpen();
}
}

View File

@@ -1,17 +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.Text.Json.Serialization;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
[JsonSerializable(typeof(DateTime))]
[JsonSerializable(typeof(Guid))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(HistoryItem))]
[JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryItemList")]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
internal sealed partial class CalculatorJsonSerializationContext : JsonSerializerContext;

View File

@@ -1,30 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed class HistoryItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Query { get; set; } = string.Empty;
public string Result { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public HistoryItem()
{
}
public HistoryItem(string query, string result, DateTime timestamp)
{
Id = Guid.NewGuid();
Query = query;
Result = result;
Timestamp = timestamp;
}
}

View File

@@ -1,162 +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.Text.Json;
using System.Threading;
using ManagedCommon;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed class HistoryStore
{
private readonly string _filePath;
private readonly List<HistoryItem> _items = [];
private readonly Lock _lock = new();
private int _capacity;
public event EventHandler Changed;
public HistoryStore(string filePath, int capacity)
{
ArgumentNullException.ThrowIfNull(filePath);
ArgumentOutOfRangeException.ThrowIfNegative(capacity);
_filePath = filePath;
_capacity = capacity;
_items.AddRange(LoadFromDiskSafe());
TrimNoLock();
}
public IReadOnlyList<HistoryItem> HistoryItems
{
get
{
lock (_lock)
{
return [.. _items];
}
}
}
public void Add(HistoryItem item)
{
ArgumentNullException.ThrowIfNull(item);
lock (_lock)
{
_items.Add(item);
_ = TrimNoLock();
SaveNoLock();
}
Changed?.Invoke(this, EventArgs.Empty);
}
public bool Remove(Guid id)
{
var removed = false;
lock (_lock)
{
var index = _items.FindIndex(item => item.Id == id);
if (index >= 0)
{
_items.RemoveAt(index);
SaveNoLock();
removed = true;
}
}
if (removed)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return removed;
}
public bool Clear()
{
var cleared = false;
lock (_lock)
{
if (_items.Count > 0)
{
_items.Clear();
SaveNoLock();
cleared = true;
}
}
if (cleared)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return cleared;
}
public void SetCapacity(int capacity)
{
ArgumentOutOfRangeException.ThrowIfNegative(capacity);
bool trimmed;
lock (_lock)
{
_capacity = capacity;
trimmed = TrimNoLock();
if (trimmed)
{
SaveNoLock();
}
}
if (trimmed)
{
Changed?.Invoke(this, EventArgs.Empty);
}
}
private bool TrimNoLock()
{
var max = _capacity;
if (_items.Count > max)
{
_items.RemoveRange(0, _items.Count - max);
return true;
}
return false;
}
private List<HistoryItem> LoadFromDiskSafe()
{
try
{
if (!File.Exists(_filePath))
{
return [];
}
var fileContent = File.ReadAllText(_filePath);
var historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(fileContent, CalculatorJsonSerializationContext.Default.HistoryItemList) ?? [];
return historyItems;
}
catch (Exception ex)
{
Logger.LogError("Unable to load calculator history", ex);
return [];
}
}
private void SaveNoLock()
{
var json = JsonSerializer.Serialize(_items, CalculatorJsonSerializationContext.Default.HistoryItemList);
File.WriteAllText(_filePath, json);
}
}

View File

@@ -2,17 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public interface ISettingsInterface
{
public event EventHandler HistoryChanged;
public event EventHandler SettingsChanged;
public CalculateEngine.TrigMode TrigUnit { get; }
public bool InputUseEnglishFormat { get; }
@@ -21,23 +14,7 @@ public interface ISettingsInterface
public bool CloseOnEnter { get; }
public bool ReplaceQueryOnEnter { get; }
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign { get; }
public bool AutoFixQuery { get; }
public bool SaveFallbackResultsToHistory { get; }
public bool DeleteHistoryRequiresConfirmation { get; }
public PrimaryAction PrimaryAction { get; }
public IReadOnlyList<HistoryItem> HistoryItems { get; }
public void AddHistoryItem(HistoryItem historyItem);
public void RemoveHistoryItem(Guid historyItemId);
public void ClearHistory();
}

View File

@@ -1,150 +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.Runtime.InteropServices;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal static partial class PasteHelper
{
private const nuint IgnoreKeyEventFlag = 0x5555;
private static void SendSingleKeyboardInput(VirtualKey keyCode, KeyEventF keyStatus)
{
var input = new INPUT
{
type = INPUTTYPE.INPUT_KEYBOARD,
data = new InputUnion
{
ki = new KEYBDINPUT
{
wVk = (short)keyCode,
dwFlags = (uint)keyStatus,
// Any key event with the extraInfo set to this value will be ignored
// by the keyboard hook and sent to the system instead.
dwExtraInfo = IgnoreKeyEventFlag,
},
},
};
Span<INPUT> inputs = [input];
_ = SendInput(1, inputs, INPUT.Size);
}
private static bool IsKeyDown(VirtualKey key) => (GetAsyncKeyState((int)key) & 0x8000) != 0;
private static void ReleaseModifierIfPressed(VirtualKey key)
{
if (IsKeyDown(key))
{
SendSingleKeyboardInput(key, KeyEventF.KeyUp);
}
}
internal static void SendPasteKeyCombination()
{
ExtensionHost.LogMessage(new LogMessage { Message = "Sending paste keys..." });
// Only release modifier keys that are actually pressed
ReleaseModifierIfPressed(VirtualKey.LeftControl);
ReleaseModifierIfPressed(VirtualKey.RightControl);
ReleaseModifierIfPressed(VirtualKey.LeftWindows);
ReleaseModifierIfPressed(VirtualKey.RightWindows);
ReleaseModifierIfPressed(VirtualKey.LeftShift);
ReleaseModifierIfPressed(VirtualKey.RightShift);
ReleaseModifierIfPressed(VirtualKey.LeftMenu);
ReleaseModifierIfPressed(VirtualKey.RightMenu);
// Send Ctrl + V
SendSingleKeyboardInput(VirtualKey.Control, KeyEventF.KeyDown);
SendSingleKeyboardInput(VirtualKey.V, KeyEventF.KeyDown);
SendSingleKeyboardInput(VirtualKey.V, KeyEventF.KeyUp);
SendSingleKeyboardInput(VirtualKey.Control, KeyEventF.KeyUp);
ExtensionHost.LogMessage(new LogMessage { Message = "Paste sent" });
}
[LibraryImport("user32.dll")]
private static partial uint SendInput(uint nInputs, Span<INPUT> pInputs, int cbSize);
[LibraryImport("user32.dll")]
private static partial short GetAsyncKeyState(int vKey);
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct INPUT
{
public INPUTTYPE type;
public InputUnion data;
public static int Size => Marshal.SizeOf<INPUT>();
}
[StructLayout(LayoutKind.Explicit)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct InputUnion
{
[FieldOffset(0)]
public MOUSEINPUT mi;
[FieldOffset(0)]
public KEYBDINPUT ki;
[FieldOffset(0)]
public HARDWAREINPUT hi;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct MOUSEINPUT
{
public int dx;
public int dy;
public int mouseData;
public uint dwFlags;
public uint time;
public nuint dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct KEYBDINPUT
{
public short wVk;
public short wScan;
public uint dwFlags;
public int time;
public nuint dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct HARDWAREINPUT
{
public int uMsg;
public short wParamL;
public short wParamH;
}
private enum INPUTTYPE : uint
{
INPUT_MOUSE = 0,
INPUT_KEYBOARD = 1,
INPUT_HARDWARE = 2,
}
[Flags]
private enum KeyEventF : uint
{
KeyDown = 0x0000,
ExtendedKey = 0x0001,
KeyUp = 0x0002,
Unicode = 0x0004,
Scancode = 0x0008,
}
}

View File

@@ -1,12 +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.Ext.Calc.Helper;
public enum PrimaryAction
{
Default,
Copy,
Paste,
}

View File

@@ -17,9 +17,14 @@ public static partial class QueryHelper
ISettingsInterface settings,
bool isFallbackSearch,
out string displayQuery,
TypedEventHandler<object, object> handleSave = null,
TypedEventHandler<object, object> handleReplace = null)
{
ArgumentNullException.ThrowIfNull(query);
if (!isFallbackSearch)
{
ArgumentNullException.ThrowIfNull(handleSave);
}
CultureInfo inputCulture =
settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
@@ -82,9 +87,13 @@ public static partial class QueryHelper
return errorMessage == default ? null : ErrorHandler.OnError(isFallbackSearch, query, errorMessage);
}
return isFallbackSearch
? ResultHelper.CreateResultForFallback(result.RoundedResult, inputCulture, outputCulture, displayQuery)
: ResultHelper.CreateResultForPage(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleReplace);
if (isFallbackSearch)
{
// Fallback search
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery);
}
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleSave, handleReplace);
}
catch (OverflowException)
{

View File

@@ -15,21 +15,13 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class ResultHelper
{
internal static CommandResult CreateCopyCommandResult(bool hideOnCopy)
{
return CommandResult.ShowToast(new ToastArgs
{
Message = Properties.Resources.calculator_copy_toast_text,
Result = hideOnCopy ? CommandResult.Hide() : CommandResult.KeepOpen(),
});
}
public static ListItem CreateResultForPage(
public static ListItem CreateResult(
decimal? roundedResult,
CultureInfo inputCulture,
CultureInfo outputCulture,
string query,
ISettingsInterface settings,
TypedEventHandler<object, object> handleSave,
TypedEventHandler<object, object> handleReplace)
{
// Return null when the expression is not a valid calculator query.
@@ -40,44 +32,33 @@ public static class ResultHelper
var result = roundedResult?.ToString(outputCulture);
// Create a SaveCommand and subscribe to the SaveRequested event
// This can append the result to the history list.
var saveCommand = new SaveCommand(result);
saveCommand.SaveRequested += handleSave;
var replaceCommand = new ReplaceQueryCommand();
replaceCommand.ReplaceRequested += handleReplace;
var copyCommand = new CalculatorCopyCommand(result, query, settings);
copyCommand.ReplaceRequested += ReplaceOnAction;
var pasteCommand = new CalculatorPasteCommand(result, query, settings);
pasteCommand.ReplaceRequested += ReplaceOnAction;
var usePaste = settings.PrimaryAction == PrimaryAction.Paste;
var primaryCommand = usePaste ? (ICommand)pasteCommand : copyCommand;
var secondaryCommand = usePaste ? (ICommand)copyCommand : pasteCommand;
var copyCommandItem = CreateResultItem(roundedResult, inputCulture, outputCulture, query, primaryCommand, settings.CloseOnEnter);
var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query);
// No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is,
// as the user is typing it.
return new ListItem(primaryCommand)
return new ListItem(settings.CloseOnEnter ? copyCommandItem.Command : saveCommand)
{
// Using CurrentCulture since this is user facing
Icon = Icons.ResultIcon,
Title = result,
Subtitle = query,
MoreCommands = [
new CommandContextItem(secondaryCommand),
new CommandContextItem(settings.CloseOnEnter ? saveCommand : copyCommandItem.Command),
new CommandContextItem(replaceCommand) { RequestedShortcut = KeyChords.CopyResultToSearchBox, },
..copyCommandItem.MoreCommands,
],
};
void ReplaceOnAction(object sender, object args)
{
if (settings.ReplaceQueryOnEnter)
{
handleReplace(sender, args);
}
}
}
public static ListItem CreateResultForFallback(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query)
public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query)
{
// Return null when the expression is not a valid calculator query.
if (roundedResult is null)
@@ -85,13 +66,6 @@ public static class ResultHelper
return null;
}
var decimalResult = roundedResult?.ToString(outputCulture);
var copyCommand = CreateCopyCommand(decimalResult, Properties.Resources.calculator_copy_command_name, hideOnCopy: true);
return CreateResultItem(roundedResult, inputCulture, outputCulture, query, copyCommand, hideOnCopy: true);
}
private static ListItem CreateResultItem(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query, ICommand copyCommand, bool hideOnCopy)
{
var decimalResult = roundedResult?.ToString(outputCulture);
var decimalValue = (decimal)roundedResult;
@@ -109,7 +83,7 @@ public static class ResultHelper
try
{
var hexResult = BaseConverter.Convert(i, 16);
context.Add(new CommandContextItem(CreateCopyCommand(hexResult, Properties.Resources.calculator_copy_hex, hideOnCopy))
context.Add(new CommandContextItem(new CopyTextCommand(hexResult) { Name = Properties.Resources.calculator_copy_hex })
{
Title = hexResult,
});
@@ -123,7 +97,7 @@ public static class ResultHelper
try
{
var binaryResult = BaseConverter.Convert(i, 2);
context.Add(new CommandContextItem(CreateCopyCommand(binaryResult, Properties.Resources.calculator_copy_binary, hideOnCopy))
context.Add(new CommandContextItem(new CopyTextCommand(binaryResult) { Name = Properties.Resources.calculator_copy_binary })
{
Title = binaryResult,
});
@@ -137,7 +111,7 @@ public static class ResultHelper
try
{
var octalResult = BaseConverter.Convert(i, 8);
context.Add(new CommandContextItem(CreateCopyCommand(octalResult, Properties.Resources.calculator_copy_octal, hideOnCopy))
context.Add(new CommandContextItem(new CopyTextCommand(octalResult) { Name = Properties.Resources.calculator_copy_octal })
{
Title = octalResult,
});
@@ -153,7 +127,7 @@ public static class ResultHelper
Logger.LogError("Error creating integer context items", ex);
}
return new ListItem(copyCommand)
return new ListItem(new CopyTextCommand(decimalResult))
{
// Using CurrentCulture since this is user facing
Title = decimalResult,
@@ -162,15 +136,4 @@ public static class ResultHelper
MoreCommands = context.ToArray(),
};
}
private static CopyTextCommand CreateCopyCommand(string text, string name, bool hideOnCopy)
{
var command = new CopyTextCommand(text)
{
Name = name,
Result = CreateCopyCommandResult(hideOnCopy),
};
return command;
}
}

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

@@ -2,10 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
@@ -13,17 +11,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
public class SettingsManager : JsonSettingsManager, ISettingsInterface
{
private static readonly string _namespace = "calculator";
private const int HistoryCapacity = 100;
public event EventHandler HistoryChanged
{
add => _history.Changed += value;
remove => _history.Changed -= value;
}
public event EventHandler SettingsChanged;
private readonly HistoryStore _history;
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
@@ -58,12 +45,6 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Properties.Resources.calculator_settings_close_on_enter_description,
true);
private readonly ToggleSetting _replaceQueryOnEnter = new(
Namespaced(nameof(ReplaceQueryOnEnter)),
Properties.Resources.calculator_settings_replace_query_on_enter,
Properties.Resources.calculator_settings_replace_query_on_enter_description,
true);
private readonly ToggleSetting _copyResultToSearchBarIfQueryEndsWithEqualSign = new(
Namespaced(nameof(CopyResultToSearchBarIfQueryEndsWithEqualSign)),
Properties.Resources.calculator_settings_copy_result_to_search_bar,
@@ -76,28 +57,6 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Properties.Resources.calculator_settings_auto_fix_query_description,
true);
private readonly ToggleSetting _saveFallbackResultsToHistory = new(
Namespaced(nameof(SaveFallbackResultsToHistory)),
Properties.Resources.calculator_settings_fallback_history,
Properties.Resources.calculator_settings_fallback_history_description,
true);
private readonly ToggleSetting _confirmDelete = new(
Namespaced(nameof(DeleteHistoryRequiresConfirmation)),
Properties.Resources.calculator_settings_confirm_delete_title,
Properties.Resources.calculator_settings_confirm_delete_description,
true);
private readonly ChoiceSetSetting _primaryAction = new(
Namespaced(nameof(PrimaryAction)),
Properties.Resources.calculator_settings_primary_action_title,
Properties.Resources.calculator_settings_primary_action_description,
[
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_default, PrimaryAction.Default.ToString("G")),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_copy, PrimaryAction.Copy.ToString("G")),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_paste, PrimaryAction.Paste.ToString("G")),
]);
public CalculateEngine.TrigMode TrigUnit
{
get
@@ -134,20 +93,10 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
public bool CloseOnEnter => _closeOnEnter.Value;
public bool ReplaceQueryOnEnter => _replaceQueryOnEnter.Value;
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => _copyResultToSearchBarIfQueryEndsWithEqualSign.Value;
public bool AutoFixQuery => _autoFixQuery.Value;
public bool SaveFallbackResultsToHistory => _saveFallbackResultsToHistory.Value;
public bool DeleteHistoryRequiresConfirmation => _confirmDelete.Value;
public PrimaryAction PrimaryAction => Enum.TryParse<PrimaryAction>(_primaryAction.Value, out var action) ? action : PrimaryAction.Default;
public IReadOnlyList<HistoryItem> HistoryItems => _history.HistoryItems;
internal static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
@@ -164,68 +113,11 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Settings.Add(_inputUseEnNumberFormat);
Settings.Add(_outputUseEnNumberFormat);
Settings.Add(_closeOnEnter);
Settings.Add(_replaceQueryOnEnter);
Settings.Add(_copyResultToSearchBarIfQueryEndsWithEqualSign);
Settings.Add(_autoFixQuery);
Settings.Add(_saveFallbackResultsToHistory);
Settings.Add(_confirmDelete);
Settings.Add(_primaryAction);
LoadSettings();
_history = new HistoryStore(HistoryStateJsonPath(), HistoryCapacity);
Settings.SettingsChanged += (s, a) =>
{
this.SaveSettings();
SettingsChanged?.Invoke(this, EventArgs.Empty);
};
}
private static string HistoryStateJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
return Path.Combine(directory, "calculator_history.json");
}
public void AddHistoryItem(HistoryItem historyItem)
{
try
{
_history.Add(historyItem);
}
catch (Exception ex)
{
Logger.LogError("Failed to add item to the calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public void RemoveHistoryItem(Guid historyItemId)
{
try
{
_history.Remove(historyItemId);
}
catch (Exception ex)
{
Logger.LogError("Failed to remove item from the calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public void ClearHistory()
{
try
{
_history.Clear();
}
catch (Exception ex)
{
Logger.LogError("Failed to clear calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
Settings.SettingsChanged += (s, a) => this.SaveSettings();
}
}

View File

@@ -6,7 +6,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc;
internal static class Icons
internal sealed class Icons
{
internal static IconInfo CalculatorIcon => IconHelpers.FromRelativePath("Assets\\Calculator.svg");
@@ -14,11 +14,5 @@ internal static class Icons
internal static IconInfo SaveIcon => new("\uE74E"); // Save icon
internal static IconInfo DeleteIcon => new("\uE74D"); // Delete icon
internal static IconInfo HistoryIcon => new("\uE81C"); // History icon
internal static IconInfo PasteIcon => new("\uE77F"); // Paste icon
internal static IconInfo ErrorIcon => new("\uE783"); // Error icon
}

View File

@@ -10,8 +10,4 @@ namespace Microsoft.CmdPal.Ext.Calc;
internal static class KeyChords
{
internal static KeyChord CopyResultToSearchBox { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Enter, 0);
internal static KeyChord DeleteItemFromHistory { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Delete, 0);
internal static KeyChord ClearHistory { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift | VirtualKeyModifiers.Menu, (int)VirtualKey.Delete, 0);
}

View File

@@ -18,14 +18,8 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\CalculatorEngineCommon\CalculatorEngineCommon.vcxproj" />
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props -->
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<CsWinRTInputs Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" />
<None Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" Link="CalculatorEngineCommon.winmd">

View File

@@ -2,10 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -25,11 +23,10 @@ namespace Microsoft.CmdPal.Ext.Calc.Pages;
public sealed partial class CalculatorListPage : DynamicListPage
{
private readonly Lock _resultsLock = new();
private readonly Lock _historyLock = new();
private readonly ISettingsInterface _settingsManager;
private readonly List<ListItem> _items = [];
private readonly List<ListItem> _history = [];
private readonly ListItem _emptyItem;
private List<ListItem> _historyItems = [];
// This is the text that saved when the user click the result.
// We need to avoid the double calculation. This may cause some wierd behaviors.
@@ -54,26 +51,9 @@ public sealed partial class CalculatorListPage : DynamicListPage
Title = Resources.calculator_placeholder_text,
};
_settingsManager.HistoryChanged += SettingsManagerOnHistoryChanged;
_settingsManager.SettingsChanged += SettingsManagerOnSettingsChanged;
UpdateHistory();
AppendResult(null);
UpdateSearchText(string.Empty, string.Empty);
}
private void SettingsManagerOnHistoryChanged(object sender, EventArgs e)
{
UpdateHistory();
AppendResult(GetCurrentResultItem());
}
private void SettingsManagerOnSettingsChanged(object sender, EventArgs e)
{
UpdateHistory();
AppendResult(RequeryCurrentResult());
}
private void HandleReplaceQuery(object sender, object args)
{
var lastResult = _items[0].Title;
@@ -110,9 +90,9 @@ public sealed partial class CalculatorListPage : DynamicListPage
_emptyItem.Subtitle = newSearch;
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleReplaceQuery);
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleSave, HandleReplaceQuery);
AppendResult(result);
UpdateResult(result);
if (copyResultToSearchText && result is not null)
{
@@ -125,7 +105,7 @@ public sealed partial class CalculatorListPage : DynamicListPage
}
}
private void AppendResult(ListItem result)
private void UpdateResult(ListItem result)
{
lock (_resultsLock)
{
@@ -140,114 +120,42 @@ public sealed partial class CalculatorListPage : DynamicListPage
_items.Add(_emptyItem);
}
lock (_historyLock)
{
if (_historyItems.Count > 0)
{
this._items.Add(CreateSectionHeader(Resources.calculator_history_header));
this._items.AddRange(_historyItems);
}
}
this._items.AddRange(_history);
}
RaiseItemsChanged(this._items.Count);
}
private void UpdateHistory()
private void HandleSave(object sender, object args)
{
List<ListItem> history = [];
var items = _settingsManager.HistoryItems;
for (var index = items.Count - 1; index >= 0; index--)
var lastResult = _items[0].Title;
if (!string.IsNullOrEmpty(lastResult))
{
var historyItem = items[index];
history.Add(CreateHistoryItem(historyItem));
}
var li = new ListItem(new CopyTextCommand(lastResult))
{
Title = _items[0].Title,
Subtitle = _items[0].Subtitle,
TextToSuggest = lastResult,
};
lock (_historyLock)
{
_historyItems = history;
}
}
_history.Insert(0, li);
_items.Insert(1, li);
private ListItem CreateHistoryItem(HistoryItem historyItem)
{
var copyCommand = new CalculatorCopyCommand(historyItem.Result, historyItem.Query, _settingsManager, canStoreHistory: false);
var pasteCommand = new CalculatorPasteCommand(historyItem.Result, historyItem.Query, _settingsManager, canStoreHistory: false);
var primaryCommand = _settingsManager.PrimaryAction == PrimaryAction.Paste ? (ICommand)pasteCommand : copyCommand;
var secondaryCommand = _settingsManager.PrimaryAction == PrimaryAction.Paste ? (ICommand)copyCommand : pasteCommand;
// Why we need to clean the query record? Removed, but if necessary, please move it back.
// _items[0].Subtitle = string.Empty;
var replaceResultCommand = new ReplaceQueryCommand();
replaceResultCommand.ReplaceRequested += (_, _) =>
{
_skipQuerySearchText = SearchText = historyItem.Result;
// this change will call the UpdateSearchText again.
// We need to avoid it.
_skipQuerySearchText = lastResult;
SearchText = lastResult;
// LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification,
// so we must raise it explicitly to ensure the UI updates correctly.
OnPropertyChanged(nameof(SearchText));
};
var deleteConfirmationCommand = new ConfirmableCommand
{
Command = new DeleteHistoryItemCommand(_settingsManager, historyItem.Id),
ConfirmationTitle = Resources.calculator_delete_confirmation_title,
ConfirmationMessage = Resources.calculator_delete_confirmation_message,
IsConfirmationRequired = () => _settingsManager.DeleteHistoryRequiresConfirmation,
};
var deleteAllConfirmationCommand = new ConfirmableCommand
{
Command = new ClearHistoryCommand(_settingsManager),
ConfirmationTitle = Resources.calculator_delete_all_confirmation_title,
ConfirmationMessage = Resources.calculator_delete_all_confirmation_message,
IsConfirmationRequired = () => _settingsManager.DeleteHistoryRequiresConfirmation,
};
return new ListItem(primaryCommand)
{
Icon = Icons.HistoryIcon,
Title = historyItem.Result,
Subtitle = historyItem.Query,
TextToSuggest = historyItem.Result,
MoreCommands =
[
new CommandContextItem(secondaryCommand),
new CommandContextItem(replaceResultCommand),
new Separator(),
new CommandContextItem(deleteConfirmationCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteItemFromHistory, },
new CommandContextItem(deleteAllConfirmationCommand) { IsCritical = true, RequestedShortcut = KeyChords.ClearHistory, },
],
};
}
private ListItem GetCurrentResultItem()
{
lock (_resultsLock)
{
return _items.Count > 0 ? _items[0] : _emptyItem;
RaiseItemsChanged(this._items.Count);
}
}
private ListItem RequeryCurrentResult()
{
var searchText = SearchText ?? string.Empty;
if (string.IsNullOrEmpty(searchText))
{
return null;
}
return QueryHelper.Query(searchText, _settingsManager, isFallbackSearch: false, out _, HandleReplaceQuery);
}
public override IListItem[] GetItems() => _items.ToArray();
private static ListItem CreateSectionHeader(string title)
{
return new ListItem(new NoOpCommand())
{
Title = title,
Section = title,
Command = null!,
};
}
}

View File

@@ -2,10 +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.Collections.Generic;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Pages;
@@ -13,30 +11,18 @@ namespace Microsoft.CmdPal.Ext.Calc.Pages;
public sealed partial class FallbackCalculatorItem : FallbackCommandItem
{
private const string _id = "com.microsoft.cmdpal.builtin.calculator.fallback";
private readonly NoOpCommand _noOpCommand = new();
private readonly CalculatorCopyCommand _copyCommand;
private readonly CalculatorPasteCommand _pasteCommand;
private readonly CopyTextCommand _copyCommand = new(string.Empty);
private readonly ISettingsInterface _settings;
private readonly CalculatorListPage _calculatorListPage;
private readonly CommandContextItem _openCalculatorPageContextItem;
public FallbackCalculatorItem(ISettingsInterface settings, CalculatorListPage calculatorListPage)
public FallbackCalculatorItem(ISettingsInterface settings)
: base(new NoOpCommand(), Resources.calculator_title, _id)
{
_copyCommand = new CalculatorCopyCommand(string.Empty, string.Empty, settings, () => settings.SaveFallbackResultsToHistory);
_pasteCommand = new CalculatorPasteCommand(string.Empty, string.Empty, settings, () => settings.SaveFallbackResultsToHistory);
Command = _noOpCommand;
Command = _copyCommand;
_copyCommand.Name = string.Empty;
Title = string.Empty;
Subtitle = Resources.calculator_placeholder_text;
Icon = Icons.CalculatorIcon;
_settings = settings;
_calculatorListPage = calculatorListPage;
_openCalculatorPageContextItem = new CommandContextItem(_calculatorListPage)
{
Title = Resources.calculator_open_in_calculator,
};
}
public override void UpdateQuery(string query)
@@ -45,22 +31,16 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
if (result is null)
{
Command = _noOpCommand;
_copyCommand.Text = string.Empty;
_copyCommand.Name = string.Empty;
Title = string.Empty;
Subtitle = string.Empty;
MoreCommands = [];
return;
}
var pasteIsPrimary = _settings.PrimaryAction == PrimaryAction.Paste;
var primaryCommand = pasteIsPrimary ? (IInvokableCommand)_pasteCommand : _copyCommand;
var secondaryCommand = pasteIsPrimary ? (IInvokableCommand)_copyCommand : _pasteCommand;
// Update the selected commands with current query/result
UpdateCommand(primaryCommand, query, result);
UpdateCommand(secondaryCommand, query, result);
Command = primaryCommand;
_copyCommand.Text = result.Title;
_copyCommand.Name = string.IsNullOrWhiteSpace(query) ? string.Empty : Resources.calculator_copy_command_name;
Title = result.Title;
// we have to make the subtitle into an equation,
@@ -68,28 +48,6 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
// Otherwise, something like 1+2 will have a title of "3" and not match
Subtitle = query;
// Set the search text in the calculator list page
_calculatorListPage.SearchText = query;
var fallbackCommands = new List<IContextItem>
{
_openCalculatorPageContextItem,
new CommandContextItem(secondaryCommand),
};
MoreCommands = [.. fallbackCommands, .. result.MoreCommands];
}
private static void UpdateCommand(IInvokableCommand command, string query, ListItem result)
{
switch (command)
{
case CalculatorPasteCommand pasteCommand:
pasteCommand.Update(result.Title, query);
break;
case CalculatorCopyCommand copyCommand:
copyCommand.Update(result.Title, query);
break;
}
MoreCommands = result.MoreCommands;
}
}

View File

@@ -105,51 +105,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copied to clipboard.
/// </summary>
public static string calculator_copy_toast_text {
get {
return ResourceManager.GetString("calculator_copy_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete all history items?.
/// </summary>
public static string calculator_delete_all_confirmation_message {
get {
return ResourceManager.GetString("calculator_delete_all_confirmation_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete all history.
/// </summary>
public static string calculator_delete_all_confirmation_title {
get {
return ResourceManager.GetString("calculator_delete_all_confirmation_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete this history item?.
/// </summary>
public static string calculator_delete_confirmation_message {
get {
return ResourceManager.GetString("calculator_delete_confirmation_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete history item.
/// </summary>
public static string calculator_delete_confirmation_title {
get {
return ResourceManager.GetString("calculator_delete_confirmation_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Calculator.
/// </summary>
@@ -204,33 +159,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
public static string calculator_history_delete {
get {
return ResourceManager.GetString("calculator_history_delete", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete all.
/// </summary>
public static string calculator_history_delete_all {
get {
return ResourceManager.GetString("calculator_history_delete_all", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to History.
/// </summary>
public static string calculator_history_header {
get {
return ResourceManager.GetString("calculator_history_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Calculation result is not a valid number (NaN).
/// </summary>
@@ -249,33 +177,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open in calculator.
/// </summary>
public static string calculator_open_in_calculator {
get {
return ResourceManager.GetString("calculator_open_in_calculator", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>
public static string calculator_paste_command_name {
get {
return ResourceManager.GetString("calculator_paste_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pasted from calculator.
/// </summary>
public static string calculator_paste_toast_text {
get {
return ResourceManager.GetString("calculator_paste_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Type an equation....
/// </summary>
@@ -330,24 +231,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Prompt before deleting history entries.
/// </summary>
public static string calculator_settings_confirm_delete_description {
get {
return ResourceManager.GetString("calculator_settings_confirm_delete_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Ask for confirmation before deleting items.
/// </summary>
public static string calculator_settings_confirm_delete_title {
get {
return ResourceManager.GetString("calculator_settings_confirm_delete_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace query with result on equals.
/// </summary>
@@ -366,24 +249,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Save fallback calculations.
/// </summary>
public static string calculator_settings_fallback_history {
get {
return ResourceManager.GetString("calculator_settings_fallback_history", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save copied results from fallback calculations to history.
/// </summary>
public static string calculator_settings_fallback_history_description {
get {
return ResourceManager.GetString("calculator_settings_fallback_history_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Use English (United States) number format for input.
/// </summary>
@@ -438,51 +303,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copy.
/// </summary>
public static string calculator_settings_primary_action_copy {
get {
return ResourceManager.GetString("calculator_settings_primary_action_copy", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Default.
/// </summary>
public static string calculator_settings_primary_action_default {
get {
return ResourceManager.GetString("calculator_settings_primary_action_default", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Choose the default action for results.
/// </summary>
public static string calculator_settings_primary_action_description {
get {
return ResourceManager.GetString("calculator_settings_primary_action_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>
public static string calculator_settings_primary_action_paste {
get {
return ResourceManager.GetString("calculator_settings_primary_action_paste", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Primary action (Enter key).
/// </summary>
public static string calculator_settings_primary_action_title {
get {
return ResourceManager.GetString("calculator_settings_primary_action_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace input if query ends with &apos;=&apos;.
/// </summary>
@@ -501,24 +321,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Replace query on Enter.
/// </summary>
public static string calculator_settings_replace_query_on_enter {
get {
return ResourceManager.GetString("calculator_settings_replace_query_on_enter", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace input when executing Copy or Paste.
/// </summary>
public static string calculator_settings_replace_query_on_enter_description {
get {
return ResourceManager.GetString("calculator_settings_replace_query_on_enter_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Degrees.
/// </summary>

View File

@@ -140,24 +140,6 @@
<data name="calculator_copy_command_name" xml:space="preserve">
<value>Copy</value>
</data>
<data name="calculator_paste_command_name" xml:space="preserve">
<value>Paste</value>
</data>
<data name="calculator_paste_toast_text" xml:space="preserve">
<value>Pasted from calculator</value>
</data>
<data name="calculator_copy_toast_text" xml:space="preserve">
<value>Copied to clipboard</value>
</data>
<data name="calculator_history_delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="calculator_history_header" xml:space="preserve">
<value>History</value>
</data>
<data name="calculator_history_delete_all" xml:space="preserve">
<value>Delete all</value>
</data>
<data name="calculator_calculation_failed_title" xml:space="preserve">
<value>Failed to calculate the input</value>
</data>
@@ -217,9 +199,6 @@
<data name="calculator_not_covert_to_decimal" xml:space="preserve">
<value>Result value was either too large or too small for a decimal number</value>
</data>
<data name="calculator_open_in_calculator" xml:space="preserve">
<value>Open in calculator</value>
</data>
<data name="calculator_copy_hex" xml:space="preserve">
<value>Copy hexadecimal</value>
</data>
@@ -238,45 +217,6 @@
<data name="calculator_settings_auto_fix_query" xml:space="preserve">
<value>Fix incomplete calculations automatically</value>
</data>
<data name="calculator_settings_fallback_history" xml:space="preserve">
<value>Save fallback calculations</value>
</data>
<data name="calculator_settings_fallback_history_description" xml:space="preserve">
<value>Save copied results from fallback calculations to history</value>
</data>
<data name="calculator_settings_confirm_delete_title" xml:space="preserve">
<value>Ask for confirmation before deleting items</value>
</data>
<data name="calculator_settings_confirm_delete_description" xml:space="preserve">
<value>Prompt before deleting history entries</value>
</data>
<data name="calculator_settings_primary_action_title" xml:space="preserve">
<value>Primary action (Enter key)</value>
</data>
<data name="calculator_settings_primary_action_description" xml:space="preserve">
<value>Choose the default action for results</value>
</data>
<data name="calculator_settings_primary_action_default" xml:space="preserve">
<value>Default</value>
</data>
<data name="calculator_settings_primary_action_copy" xml:space="preserve">
<value>Copy</value>
</data>
<data name="calculator_settings_primary_action_paste" xml:space="preserve">
<value>Paste</value>
</data>
<data name="calculator_delete_confirmation_title" xml:space="preserve">
<value>Delete history item</value>
</data>
<data name="calculator_delete_confirmation_message" xml:space="preserve">
<value>Are you sure you want to delete this history item?</value>
</data>
<data name="calculator_delete_all_confirmation_title" xml:space="preserve">
<value>Delete all history</value>
</data>
<data name="calculator_delete_all_confirmation_message" xml:space="preserve">
<value>Are you sure you want to delete all history items?</value>
</data>
<data name="calculator_settings_auto_fix_query_description" xml:space="preserve">
<value>Attempt to evaluate incomplete calculations by ignoring extra operators or symbols</value>
</data>
@@ -289,10 +229,4 @@
<data name="calculator_copy_octal" xml:space="preserve">
<value>Copy octal</value>
</data>
<data name="calculator_settings_replace_query_on_enter" xml:space="preserve">
<value>Replace query on Enter</value>
</data>
<data name="calculator_settings_replace_query_on_enter_description" xml:space="preserve">
<value>Replace input when executing Copy or Paste</value>
</data>
</root>

View File

@@ -3,8 +3,8 @@
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;

View File

@@ -2,9 +2,14 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Messages;
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
/// <summary>
/// Message to request hiding the window.
///
/// Yes, it's a little weird that this lives in the ClipboardHistory extension.
/// Until we need it somewhere else, this is good enough.
/// </summary>
public partial record HideWindowMessage();
public partial record HideWindowMessage()
{
}

View File

@@ -12,7 +12,6 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Helpers;
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -121,19 +120,12 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDispo
ct.ThrowIfCancellationRequested();
// We only need to know whether there are 0, 1, or more than one result
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, out var notice, noIcons: true);
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, noIcons: true);
var count = results.Count;
if (count == 0)
{
if (notice is { } searchNotice)
{
UpdateSearchNoticeForCurrentQuery(query, searchNotice, ct);
}
else
{
ClearResultForCurrentQuery(ct);
}
ClearResultForCurrentQuery(ct);
}
else if (count == 1)
{
@@ -241,35 +233,6 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDispo
}
}
private bool UpdateSearchNoticeForCurrentQuery(string query, SearchNoticeInfo notice, CancellationToken ct)
{
var (title, subtitle) = GetFallbackNoticeText(notice);
var indexerPage = new IndexerPage(query);
var set = UpdateResultForCurrentQuery(
title,
subtitle,
Icons.FileExplorerIcon,
indexerPage,
[
new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }),
],
null,
skipIcon: false,
ct);
if (!set)
{
indexerPage.Dispose();
}
return set;
}
internal static (string Title, string Subtitle) GetFallbackNoticeText(SearchNoticeInfo notice)
{
return (Resources.IndexerCommandsProvider_DisplayName!, notice.Title);
}
private void UpdateIconForCurrentQuery(IIconInfo icon, CancellationToken ct)
{
lock (_resultLock)

View File

@@ -26,26 +26,19 @@ internal static class DataSourceManager
private static bool InitializeDataSource()
{
var riid = typeof(IDBInitialize).GUID;
try
{
_dataSource = ComHelper.CreateComInstance<IDBInitialize>(ref Unsafe.AsRef(in CLSID.CollatorDataSource), CLSCTX.InProcServer);
}
catch (Exception ex)
catch (Exception e)
{
Logger.LogError("Failed to create datasource.", ex);
Logger.LogError($"Failed to create datasource. ex: {e.Message}");
return false;
}
try
{
_dataSource.Initialize();
}
catch (Exception ex)
{
Logger.LogError("Failed to initialize datasource.", ex);
_dataSource = null;
return false;
}
_dataSource.Initialize();
return true;
}

View File

@@ -1,10 +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.Ext.Indexer.Indexer;
internal readonly record struct SearchCatalogStatus(uint PendingItemsCount, int? HResult)
{
public bool IsAvailable => HResult is null;
}

View File

@@ -1,76 +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.
#nullable enable
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using ManagedCommon;
using ManagedCsWin32;
using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
internal static class SearchCatalogStatusReader
{
private const string SystemIndex = "SystemIndex";
private static readonly Lock FailureLoggingLock = new();
private static int? _lastLoggedFailureHResult;
internal static SearchCatalogStatus GetStatus()
{
try
{
var catalogManager = CreateCatalogManager();
var pendingItemsCount = catalogManager.NumberOfItemsToIndex();
ResetFailureLoggingState();
return new SearchCatalogStatus(pendingItemsCount, null);
}
catch (Exception ex)
{
LogFailure(ex);
return new SearchCatalogStatus(0, ex.HResult);
}
}
private static ISearchCatalogManager CreateCatalogManager()
{
var searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
var catalogManager = searchManager.GetCatalog(SystemIndex);
return catalogManager ?? throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
private static void LogFailure(Exception ex)
{
var shouldLogWarning = false;
lock (FailureLoggingLock)
{
if (_lastLoggedFailureHResult != ex.HResult)
{
_lastLoggedFailureHResult = ex.HResult;
shouldLogWarning = true;
}
}
var message = $"Failed to read Windows Search catalog status. HResult=0x{ex.HResult:X8}, Message={ex.Message}";
if (shouldLogWarning)
{
Logger.LogWarning(message);
}
else
{
Logger.LogDebug(message);
}
}
private static void ResetFailureLoggingState()
{
lock (FailureLoggingLock)
{
_lastLoggedFailureHResult = null;
}
}
}

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.Ext.Indexer.Indexer;
public readonly record struct SearchNoticeInfo(string Title, string Subtitle);

View File

@@ -1,67 +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.
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.CmdPal.Ext.Indexer.Properties;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
internal static class SearchNoticeInfoBuilder
{
private const int RpcServerUnavailable = unchecked((int)0x800706BA);
private const int RpcDisconnected = unchecked((int)0x80010108);
private const int RpcCallRejected = unchecked((int)0x80010001);
private const int RpcServerCallRetryLater = unchecked((int)0x8001010A);
private const int ServiceDisabled = unchecked((int)0x80070422);
private const int ServiceNotActive = unchecked((int)0x80070426);
private const int ClassNotRegistered = unchecked((int)0x80040154);
private const int ServerExecutionFailed = unchecked((int)0x80080005);
internal static SearchNoticeInfo? FromQueryStatus(SearchQuery.SearchExecutionStatus status)
{
return status.State switch
{
SearchQuery.QueryState.NullDataSource or
SearchQuery.QueryState.CreateSessionFailed or
SearchQuery.QueryState.CreateCommandFailed => CreateUnavailableNotice(),
SearchQuery.QueryState.ExecuteFailed when IsSearchUnavailableHResult(status.HResult) => CreateUnavailableNotice(),
SearchQuery.QueryState.ExecuteFailed => CreateSearchFailedNotice(),
_ => null,
};
}
[SuppressMessage("Performance", "CA1863:Cache a 'CompositeFormat' for repeated use in this formatting operation", Justification = "Formatting a low-frequency user-visible notice once per query is sufficient.")]
internal static SearchNoticeInfo? FromCatalogStatus(SearchCatalogStatus status)
{
if (status.PendingItemsCount > 0)
{
return new SearchNoticeInfo(
Resources.Indexer_SearchIndexingMessage,
string.Format(CultureInfo.CurrentCulture, Resources.Indexer_SearchIndexingMessageTip, status.PendingItemsCount));
}
return null;
}
private static SearchNoticeInfo CreateUnavailableNotice() =>
new(Resources.Indexer_SearchUnavailableMessage, Resources.Indexer_SearchUnavailableMessageTip);
private static SearchNoticeInfo CreateSearchFailedNotice() =>
new(Resources.Indexer_SearchFailedMessage, Resources.Indexer_SearchFailedMessageTip);
private static bool IsSearchUnavailableHResult(int? hresult) =>
hresult is RpcServerUnavailable
or RpcDisconnected
or RpcCallRejected
or RpcServerCallRetryLater
or ServiceDisabled
or ServiceNotActive
or ClassNotRegistered
or ServerExecutionFailed;
}

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.
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
@@ -25,19 +23,17 @@ internal sealed partial class SearchQuery : IDisposable
private readonly Lock _lockObject = new();
private IRowset? _currentRowset;
private SearchSqlQueryPlan _queryPlan;
private bool _fallbackAttempted;
private IRowset _currentRowset;
public QueryState State { get; private set; } = QueryState.NotStarted;
private int? LastHResult { get; set; }
private string? LastErrorMessage { get; set; }
private string LastErrorMessage { get; set; }
public uint Cookie { get; private set; }
public string SearchText { get; private set; } = string.Empty;
public string SearchText { get; private set; }
public ConcurrentQueue<SearchResult> SearchResults { get; private set; } = [];
@@ -56,44 +52,18 @@ internal sealed partial class SearchQuery : IDisposable
{
SearchText = searchText;
Cookie = cookie;
_fallbackAttempted = false;
try
{
_queryPlan = QueryStringBuilder.GenerateQueryPlan(searchText);
}
catch (Exception ex)
{
lock (_lockObject)
{
State = QueryState.ExecuteFailed;
LastHResult = ex.HResult;
LastErrorMessage = ex.Message;
_currentRowset = null;
SearchResults.Clear();
}
Logger.LogError("Error preparing query", ex);
return;
}
ExecuteSyncInternal(_queryPlan.PrimarySqlQuery);
if (_currentRowset is null && State is QueryState.NoResults or QueryState.AllNoise)
{
TryExecuteFallbackQuery("primary query returned no rowset");
}
ExecuteSyncInternal();
}
private void ExecuteSyncInternal(string queryStr)
private void ExecuteSyncInternal()
{
lock (_lockObject)
{
State = QueryState.Running;
LastHResult = null;
LastErrorMessage = null;
_currentRowset = null;
var queryStr = QueryStringBuilder.GenerateQuery(SearchText);
try
{
var result = ExecuteCommand(queryStr);
@@ -147,11 +117,6 @@ internal sealed partial class SearchQuery : IDisposable
{
if (_currentRowset is null)
{
if (offset == 0 && State is QueryState.NoResults or QueryState.AllNoise && TryExecuteFallbackQuery("primary query returned no results"))
{
return FetchRows(offset, limit);
}
var message = $"No rowset to fetch rows from. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'";
switch (State)
@@ -184,7 +149,7 @@ internal sealed partial class SearchQuery : IDisposable
Logger.LogInfo($"Reset the current rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'");
Logger.LogError("Failed to cast current rowset to IGetRow", ex);
ExecuteSyncInternal(_queryPlan.PrimarySqlQuery);
ExecuteSyncInternal();
if (_currentRowset is null)
{
@@ -215,11 +180,6 @@ internal sealed partial class SearchQuery : IDisposable
if (rowCountReturned == 0)
{
if (offset == 0 && TryExecuteFallbackQuery("primary query returned zero rows"))
{
return FetchRows(offset, limit);
}
// No more rows to fetch
return false;
}
@@ -258,20 +218,6 @@ internal sealed partial class SearchQuery : IDisposable
}
}
private bool TryExecuteFallbackQuery(string reason)
{
if (_fallbackAttempted || !_queryPlan.HasFallback || State == QueryState.Cancelled)
{
return false;
}
_fallbackAttempted = true;
Logger.LogInfo($"Retrying search with implicit filename wildcard matching. Reason={reason}, Query=\"{SearchText}\"");
ExecuteSyncInternal(_queryPlan.FallbackSqlQuery!);
return _currentRowset is not null;
}
private static ExecuteCommandResult ExecuteCommand(string queryStr)
{
if (string.IsNullOrEmpty(queryStr))
@@ -340,14 +286,6 @@ internal sealed partial class SearchQuery : IDisposable
CancelOutstandingQueries();
}
internal SearchExecutionStatus GetExecutionStatus()
{
lock (_lockObject)
{
return new SearchExecutionStatus(State, LastHResult, LastErrorMessage);
}
}
internal enum QueryState
{
NotStarted = 0,
@@ -363,13 +301,8 @@ internal sealed partial class SearchQuery : IDisposable
}
private readonly record struct ExecuteCommandResult(
IRowset? Rowset,
IRowset Rowset,
QueryState State,
int? HResult,
string? ErrorMessage);
internal readonly record struct SearchExecutionStatus(
QueryState State,
int? HResult,
string? ErrorMessage);
string ErrorMessage);
}

View File

@@ -1,450 +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.
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
internal static class ImplicitWildcardQueryBuilder
{
private const int MinimumContainsTermLength = 3;
internal static ImplicitWildcardExpandedQuery BuildExpandedQuery(string searchText)
{
if (string.IsNullOrWhiteSpace(searchText) || ContainsExplicitWildcards(searchText))
{
return default;
}
var parsedTokens = ParseTokens(searchText);
if (parsedTokens.Count == 0 || parsedTokens.Any(static token => token.Kind == ParsedTokenKind.ComplexSyntax))
{
return default;
}
var rawTerms = parsedTokens
.Where(static token => token.Kind == ParsedTokenKind.PlainTextTerm)
.Select(static token => token.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (rawTerms.Count == 0)
{
return default;
}
var structuredTokens = parsedTokens
.Where(static token => token.Kind == ParsedTokenKind.StructuredToken)
.Select(static token => token.Value)
.ToList();
var structuredSearchText = structuredTokens.Count > 0
? string.Join(' ', structuredTokens)
: null;
var containsRestriction = BuildContainsRestriction(ExtractContainsTerms(rawTerms));
var likeRestriction = BuildLikeRestriction(rawTerms);
var primaryRestriction = CombineRestrictions(containsRestriction, likeRestriction);
if (string.IsNullOrWhiteSpace(primaryRestriction))
{
return default;
}
var fallbackRestriction = !string.IsNullOrWhiteSpace(containsRestriction) && !string.IsNullOrWhiteSpace(likeRestriction)
? likeRestriction
: null;
return new ImplicitWildcardExpandedQuery(
structuredSearchText,
primaryRestriction,
fallbackRestriction);
}
private static List<ParsedToken> ParseTokens(string searchText)
{
var parsedTokens = new List<ParsedToken>();
var seenTerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var expectsStructuredValue = false;
foreach (var token in Tokenize(searchText))
{
if (string.IsNullOrWhiteSpace(token))
{
continue;
}
if (IsComplexSyntaxToken(token))
{
parsedTokens.Add(new ParsedToken(token, ParsedTokenKind.ComplexSyntax));
expectsStructuredValue = false;
continue;
}
if (expectsStructuredValue || IsStructuredToken(token))
{
parsedTokens.Add(new ParsedToken(token, ParsedTokenKind.StructuredToken));
expectsStructuredValue = ExpectsAnotherStructuredValue(token);
continue;
}
var candidate = Unquote(token).Trim();
if (candidate.Length == 0 || !ContainsSearchableCharacters(candidate))
{
expectsStructuredValue = false;
continue;
}
if (seenTerms.Add(candidate))
{
parsedTokens.Add(new ParsedToken(candidate, ParsedTokenKind.PlainTextTerm));
}
expectsStructuredValue = false;
}
return parsedTokens;
}
private static bool ContainsExplicitWildcards(string searchText)
{
return searchText.Contains('*') || searchText.Contains('?');
}
private static List<string> Tokenize(string searchText)
{
var tokens = new List<string>();
var currentToken = new StringBuilder();
var inQuotes = false;
foreach (var ch in searchText)
{
if (ch == '"')
{
inQuotes = !inQuotes;
currentToken.Append(ch);
continue;
}
if (char.IsWhiteSpace(ch) && !inQuotes)
{
AppendCurrentToken(tokens, currentToken);
continue;
}
currentToken.Append(ch);
}
AppendCurrentToken(tokens, currentToken);
return tokens;
}
private static void AppendCurrentToken(List<string> tokens, StringBuilder currentToken)
{
if (currentToken.Length == 0)
{
return;
}
tokens.Add(currentToken.ToString());
currentToken.Clear();
}
private static bool IsStructuredToken(string token)
{
if (token.Length > 0 && token[0] is '+' or '-')
{
return true;
}
if (token.Contains('\\') || token.Contains('/'))
{
return true;
}
if (token.Contains('=') || token.Contains('>') || token.Contains('<'))
{
return true;
}
return token.Contains(':') && !LooksLikeDrivePath(token);
}
private static bool ExpectsAnotherStructuredValue(string token)
{
if (!token.Contains(':') || LooksLikeDrivePath(token))
{
return false;
}
var suffix = token[(token.LastIndexOf(':') + 1)..];
return suffix.Length == 0 || suffix.Equals("NOT", StringComparison.OrdinalIgnoreCase);
}
private static bool IsComplexSyntaxToken(string token)
{
return token.Contains('(')
|| token.Contains(')')
|| IsBooleanOperator(token);
}
private static bool IsBooleanOperator(string token)
{
return token.Equals("AND", StringComparison.OrdinalIgnoreCase)
|| token.Equals("OR", StringComparison.OrdinalIgnoreCase)
|| token.Equals("NOT", StringComparison.OrdinalIgnoreCase);
}
private static bool LooksLikeDrivePath(string token)
{
return token.Length >= 2
&& char.IsLetter(token[0])
&& token[1] == ':'
&& (token.Length == 2 || token[2] is '\\' or '/');
}
private static bool ContainsSearchableCharacters(string token)
{
foreach (var ch in token)
{
if (char.IsLetterOrDigit(ch) || IsLiteralLikeSearchCharacter(ch))
{
return true;
}
}
return false;
}
private static bool IsLiteralLikeSearchCharacter(char ch)
{
return ch is '%' or '_';
}
private static string Unquote(string token)
{
return token switch
{
['"', .. var inner, '"'] => inner,
_ => token,
};
}
private static List<string> ExtractContainsTerms(IReadOnlyList<string> rawTerms)
{
var terms = new List<string>();
var seenTerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawTerm in rawTerms)
{
foreach (var candidate in ExtractContainsTermCandidates(rawTerm))
{
if (candidate.Length < MinimumContainsTermLength)
{
continue;
}
if (seenTerms.Add(candidate))
{
terms.Add(candidate);
}
}
}
return terms;
}
private static IEnumerable<string> ExtractContainsTermCandidates(string rawTerm)
{
if (ShouldUseLiteralOnlyMatching(rawTerm))
{
return [];
}
var normalized = new StringBuilder(rawTerm.Length);
foreach (var ch in rawTerm)
{
normalized.Append(char.IsLetterOrDigit(ch) ? ch : ' ');
}
return normalized
.ToString()
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static bool ShouldUseLiteralOnlyMatching(string rawTerm)
{
if (rawTerm.Length < 2 || !IsWrapperPair(rawTerm[0], rawTerm[^1]))
{
return false;
}
var inner = rawTerm[1..^1];
if (!ContainsSearchableCharacters(inner))
{
return false;
}
return !HasInternalSeparatorPunctuation(inner);
}
private static bool IsWrapperPair(char start, char end) =>
(start, end) is ('[', ']') or ('{', '}') or ('<', '>');
private static bool HasInternalSeparatorPunctuation(string value)
{
for (var i = 1; i < value.Length - 1; i++)
{
if (!char.IsLetterOrDigit(value[i]) && IsLetterOrDigitNeighbor(value, i - 1, i + 1))
{
return true;
}
}
return false;
}
private static bool IsLetterOrDigitNeighbor(string value, int leftIndex, int rightIndex) =>
char.IsLetterOrDigit(value[leftIndex]) && char.IsLetterOrDigit(value[rightIndex]);
private static string? BuildContainsRestriction(IReadOnlyList<string> terms)
{
if (terms.Count == 0)
{
return null;
}
var predicates = new List<string>();
if (terms.Count == 1)
{
predicates.Add(BuildContainsPredicate(terms[0], usePrefixWildcard: false));
predicates.Add(BuildContainsPredicate(terms[0], usePrefixWildcard: true));
}
else
{
var phrase = string.Join(' ', terms);
predicates.Add(BuildContainsPredicate(phrase, usePrefixWildcard: false));
predicates.Add(BuildContainsPredicate(phrase, usePrefixWildcard: true));
predicates.Add(BuildContainsAllTermsPredicate(terms, usePrefixWildcard: false));
predicates.Add(BuildContainsAllTermsPredicate(terms, usePrefixWildcard: true));
}
return $"({string.Join(" OR ", predicates)})";
}
private static string BuildContainsPredicate(string term, bool usePrefixWildcard)
{
var escapedTerm = EscapeContainsTerm(term);
var query = usePrefixWildcard
? $"\"{escapedTerm}*\""
: $"\"{escapedTerm}\"";
return $"CONTAINS(System.ItemNameDisplay, '{query}')";
}
private static string BuildContainsAllTermsPredicate(IReadOnlyList<string> terms, bool usePrefixWildcard)
{
var joinedTerms = string.Join(
" AND ",
terms.Select(term =>
{
var escapedTerm = EscapeContainsTerm(term);
return usePrefixWildcard
? $"\"{escapedTerm}*\""
: $"\"{escapedTerm}\"";
}));
return $"CONTAINS(System.ItemNameDisplay, '{joinedTerms}')";
}
private static string? BuildLikeRestriction(IReadOnlyList<string> rawTerms)
{
if (rawTerms.Count == 0)
{
return null;
}
var predicates = rawTerms
.Select(BuildLikePredicate)
.ToList();
return predicates.Count == 1
? predicates[0]
: $"({string.Join(" AND ", predicates)})";
}
private static string BuildLikePredicate(string term)
{
var escapedTerm = EscapeLikeTerm(term);
return $"System.FileName LIKE '%{escapedTerm}%'";
}
private static string? CombineRestrictions(string? containsRestriction, string? likeRestriction)
{
if (string.IsNullOrWhiteSpace(containsRestriction))
{
return likeRestriction;
}
if (string.IsNullOrWhiteSpace(likeRestriction))
{
return containsRestriction;
}
return $"({containsRestriction} OR {likeRestriction})";
}
private static string EscapeContainsTerm(string value)
{
return value
.Replace("'", "''", StringComparison.Ordinal)
.Replace("\"", "\"\"", StringComparison.Ordinal);
}
private static string EscapeLikeTerm(string value)
{
var escaped = new StringBuilder(value.Length);
foreach (var ch in value)
{
escaped.Append(ch switch
{
'[' => "[[]",
']' => "[]]",
'%' => "[%]",
'_' => "[_]",
'\'' => "''",
_ => ch,
});
}
return escaped.ToString();
}
internal readonly record struct ImplicitWildcardExpandedQuery(
string? StructuredSearchText,
string? PrimaryRestriction,
string? FallbackRestriction)
{
public bool HasPrimaryRestriction => !string.IsNullOrWhiteSpace(PrimaryRestriction);
public bool HasFallbackRestriction => !string.IsNullOrWhiteSpace(FallbackRestriction);
}
private enum ParsedTokenKind
{
PlainTextTerm = 0,
StructuredToken,
ComplexSyntax,
}
private readonly record struct ParsedToken(string Value, ParsedTokenKind Kind);
}

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.
#nullable enable
using System;
using System.Runtime.CompilerServices;
using ManagedCommon;
@@ -18,89 +16,43 @@ internal static class QueryStringBuilder
private const string SystemIndex = "SystemIndex";
private const string ScopeFileConditions = "SCOPE='file:'";
private const string OrderConditions = "System.DateModified DESC";
private const string ContentProperties = "System.FileName";
public static SearchSqlQueryPlan GenerateQueryPlan(string searchText)
private static ISearchQueryHelper queryHelper;
public static string GenerateQuery(string searchText)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(searchText);
var primarySqlQuery = expandedQuery.HasPrimaryRestriction
? BuildQuery(expandedQuery.StructuredSearchText, expandedQuery.PrimaryRestriction!)
: GenerateQuery(searchText);
var fallbackSqlQuery = expandedQuery.HasFallbackRestriction
? BuildQuery(expandedQuery.StructuredSearchText, expandedQuery.FallbackRestriction!)
: null;
return new SearchSqlQueryPlan(primarySqlQuery, fallbackSqlQuery);
}
private static string GenerateQuery(string searchText, string? additionalRestrictions = null)
{
var queryHelper = CreateQueryHelper();
queryHelper.SetQuerySelectColumns(Properties);
queryHelper.SetQueryContentProperties(ContentProperties);
queryHelper.SetQuerySorting(OrderConditions);
queryHelper.SetQuerySyntax(SEARCH_QUERY_SYNTAX.SEARCH_ADVANCED_QUERY_SYNTAX);
var restrictions = $"AND {ScopeFileConditions}";
if (!string.IsNullOrWhiteSpace(additionalRestrictions))
{
restrictions += $" AND ({additionalRestrictions})";
}
queryHelper.SetQueryWhereRestrictions(restrictions);
return queryHelper.GenerateSQLFromUserQuery(searchText);
}
private static string BuildQuery(string? structuredSearchText, string restriction)
{
return string.IsNullOrWhiteSpace(structuredSearchText)
? GenerateRestrictionOnlyQuery(restriction)
: GenerateQuery(structuredSearchText, restriction);
}
private static string GenerateRestrictionOnlyQuery(string restriction)
{
return $"""
SELECT {Properties}
FROM {SystemIndex}
WHERE {ScopeFileConditions} AND ({restriction})
ORDER BY {OrderConditions}
""";
}
private static ISearchQueryHelper CreateQueryHelper()
{
ISearchManager searchManager;
try
{
searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
}
catch (Exception ex)
{
Logger.LogError("Failed to create searchManager.", ex);
throw;
}
var catalogManager = searchManager.GetCatalog(SystemIndex);
if (catalogManager is null)
{
throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
var queryHelper = catalogManager.GetQueryHelper();
if (queryHelper is null)
{
throw new ArgumentException("Failed to get query helper from catalog manager");
ISearchManager searchManager;
try
{
searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
}
catch (Exception ex)
{
Logger.LogError($"Failed to create searchManager. ex: {ex.Message}");
throw;
}
var catalogManager = searchManager.GetCatalog(SystemIndex);
if (catalogManager is null)
{
throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
queryHelper = catalogManager.GetQueryHelper();
if (queryHelper is null)
{
throw new ArgumentException("Failed to get query helper from catalog manager");
}
queryHelper.SetQuerySelectColumns(Properties);
queryHelper.SetQueryContentProperties("System.FileName");
queryHelper.SetQuerySorting(OrderConditions);
}
return queryHelper;
queryHelper.SetQueryWhereRestrictions($"AND {ScopeFileConditions}");
return queryHelper.GenerateSQLFromUserQuery(searchText);
}
}
internal readonly record struct SearchSqlQueryPlan(string PrimarySqlQuery, string? FallbackSqlQuery)
{
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackSqlQuery);
}

View File

@@ -59,8 +59,4 @@
<AdditionalFiles Include="NativeMethods.json" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Indexer.UnitTests" />
</ItemGroup>
</Project>

View File

@@ -34,13 +34,10 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
private CommandItem? _noSearchEmptyContent;
private CommandItem? _nothingFoundEmptyContent;
private CommandItem? _noticeEmptyContent;
private ListItem? _noticeListItem;
private SearchNoticeInfo? _currentNotice;
private bool _deferredLoad;
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _currentNotice is null ? _nothingFoundEmptyContent! : _noticeEmptyContent!;
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _nothingFoundEmptyContent!;
public IndexerPage()
{
@@ -97,19 +94,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
},
],
};
_noticeEmptyContent = new CommandItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! })
{
Icon = Icon,
};
_noticeListItem = new ListItem(new NoOpCommand())
{
Icon = Icon,
MoreCommands = [
new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }),
],
};
}
private void StartManualSearch()
@@ -143,9 +127,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
_deferredLoad = false;
}
return _currentNotice is null
? [.. _indexerListItems]
: [_noticeListItem!, .. _indexerListItems];
return [.. _indexerListItems];
}
private string FullSearchString(string query)
@@ -178,8 +160,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
offset = _indexerListItems.Count;
}
SearchNoticeInfo? notice = null;
var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore, out notice) ?? [];
var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore) ?? [];
if (ct?.IsCancellationRequested == true)
{
@@ -195,11 +176,10 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
return;
}
ApplyNotice(notice);
_indexerListItems.AddRange(results);
HasMoreItems = hasMore;
IsLoading = false;
RaiseItemsChanged(GetVisibleItemCount());
RaiseItemsChanged(_indexerListItems.Count);
}
}
@@ -208,8 +188,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
lock (_searchLock)
{
_indexerListItems.Clear();
var notice = _searchEngine?.Query(query, queryCookie: HardQueryCookie);
ApplyNotice(notice);
_searchEngine?.Query(query, queryCookie: HardQueryCookie);
}
}
@@ -247,7 +226,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
// If the user hasn't provided any base query text, results should be empty
// regardless of the currently selected filter.
_isEmptyQuery = string.IsNullOrWhiteSpace(newSearch);
ApplyNotice(null);
if (_isEmptyQuery)
{
@@ -278,7 +256,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
lock (_searchLock)
{
RaiseItemsChanged(GetVisibleItemCount());
OnPropertyChanged(nameof(EmptyContent));
}
},
@@ -304,21 +281,4 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
GC.SuppressFinalize(this);
}
private void ApplyNotice(SearchNoticeInfo? notice)
{
_currentNotice = notice;
if (notice is null)
{
return;
}
_noticeEmptyContent!.Title = notice.Value.Title;
_noticeEmptyContent.Subtitle = notice.Value.Subtitle;
_noticeListItem!.Title = notice.Value.Title;
_noticeListItem.Subtitle = notice.Value.Subtitle;
}
private int GetVisibleItemCount() => _indexerListItems.Count + (_currentNotice is null ? 0 : 1);
}

View File

@@ -295,60 +295,6 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Search couldn&apos;t be completed.
/// </summary>
internal static string Indexer_SearchFailedMessage {
get {
return ResourceManager.GetString("Indexer_SearchFailedMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search returned an unexpected error. Try again, or open Windows Search settings if the problem continues..
/// </summary>
internal static string Indexer_SearchFailedMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchFailedMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search is still indexing files.
/// </summary>
internal static string Indexer_SearchIndexingMessage {
get {
return ResourceManager.GetString("Indexer_SearchIndexingMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are still {0:N0} items waiting to be indexed, so some files and folders might not appear yet..
/// </summary>
internal static string Indexer_SearchIndexingMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchIndexingMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search is unavailable.
/// </summary>
internal static string Indexer_SearchUnavailableMessage {
get {
return ResourceManager.GetString("Indexer_SearchUnavailableMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The Windows Search service or connection is unavailable right now. Start the service, then try your search again..
/// </summary>
internal static string Indexer_SearchUnavailableMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchUnavailableMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Always on.
/// </summary>

View File

@@ -159,24 +159,6 @@
<data name="Indexer_PlaceholderText" xml:space="preserve">
<value>Search for files and folders...</value>
</data>
<data name="Indexer_SearchFailedMessage" xml:space="preserve">
<value>Search couldn't be completed</value>
</data>
<data name="Indexer_SearchFailedMessageTip" xml:space="preserve">
<value>Windows Search returned an unexpected error. Try again, or open Windows Search settings if the problem continues.</value>
</data>
<data name="Indexer_SearchIndexingMessage" xml:space="preserve">
<value>Windows Search is still indexing files</value>
</data>
<data name="Indexer_SearchIndexingMessageTip" xml:space="preserve">
<value>There are still {0:N0} items waiting to be indexed, so some files and folders might not appear yet.</value>
</data>
<data name="Indexer_SearchUnavailableMessage" xml:space="preserve">
<value>Windows Search is unavailable</value>
</data>
<data name="Indexer_SearchUnavailableMessageTip" xml:space="preserve">
<value>The Windows Search service or connection is unavailable right now. Start the service, then try your search again.</value>
</data>
<data name="Indexer_Settings_FallbackCommand_AlwaysOn" xml:space="preserve">
<value>Always on</value>
</data>
@@ -232,4 +214,4 @@ You can try searching all files on this PC or adjust your indexing settings.</va
<data name="Indexer_Fallback_MultipleResults_Subtitle" xml:space="preserve">
<value>The query matches multiple items</value>
</data>
</root>
</root>

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