Compare commits

..

3 Commits

Author SHA1 Message Date
Gordon Lam (SH)
16d4b13eb3 [Peek] Fix Space key triggering during file rename (#44845)
Don't show error window when CurrentItem is null - just return silently.
This restores the original behavior where CaretVisible() detection in
GetSelectedItems() would suppress Peek by returning null, and no window
would be shown.

PR #44703 added an error window for virtual folders (Home/Recent), but
this also triggered when user was typing (rename, search, address bar),
stealing focus and cancelling the operation.

Fixes #44845
2026-01-23 21:35:51 +08:00
Shawn Yuan
086c63b6af [Settings] Fix right click menu display issue (#44982)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
This pull request updates the tray icon context menu logic to better
reflect the state of the "Quick Access" feature. The menu now
dynamically updates its items and labels based on whether Quick Access
is enabled or disabled, improving clarity for users.

**Menu behavior improvements:**

* The tray icon menu now reloads itself when the Quick Access setting
changes, ensuring the menu always matches the current state.
* The "Settings" menu item label changes to "Settings\tLeft-click" when
Quick Access is disabled, providing clearer instructions to users.
[[1]](diffhunk://#diff-e5efbda4c356e159a6ca82a425db84438ab4014d1d90377b98a2eb6d9632d32dR176-R179)
[[2]](diffhunk://#diff-7139ecb2cf76e472c574a155268c19e919e2cce05d9d345c50c1f1bffc939e1aR198-R248)
* The Quick Access menu item is removed from the context menu when the
feature is disabled, preventing confusion.

**Internal state tracking:**

* Added a new variable `last_quick_access_state` to track the previous
Quick Access state and trigger menu reloads only when necessary.
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

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

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

<img width="1537" height="312" alt="image"
src="https://github.com/user-attachments/assets/5d51f24e-ccb4-4973-afaa-8b64cc35db87"
/>

- When Quick Access is enabled
<img width="1601" height="201" alt="image"
src="https://github.com/user-attachments/assets/56366d10-bcec-4892-b2d2-f8213ad726aa"
/>

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-01-23 14:47:35 +08:00
moooyo
d192672c74 fix: Improve Unicode normalization and add regex metachar tests (#44944)
Enhanced SanitizeAndNormalize to handle Unicode normalization more
robustly, ensuring correct buffer sizing and error handling. Added unit
tests for regex metacharacters `$` and `^` to verify correct replacement
behavior at string boundaries. Improves Unicode support and test
coverage for regex edge cases.

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

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

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

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

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

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
2026-01-23 14:43:21 +08:00
54 changed files with 795 additions and 3680 deletions

View File

@@ -886,6 +886,7 @@ Ldr
LEFTALIGN
LEFTSCROLLBAR
LEFTTEXT
leftclick
LError
LEVELID
LExit
@@ -1011,6 +1012,7 @@ MENUITEMINFO
MENUITEMINFOW
MERGECOPY
MERGEPAINT
Metacharacter
metadatamatters
Metadatas
metafile

View File

@@ -0,0 +1,61 @@
---
description: 'Guidelines for shared libraries including logging, IPC, settings, DPI, telemetry, and utilities consumed by multiple modules'
applyTo: 'src/common/**'
---
# Common Libraries Shared Code Guidance
Guidelines for modifying shared code in `src/common/`. Changes here can have wide-reaching impact across the entire PowerToys codebase.
## Scope
- Logging infrastructure (`src/common/logger/`)
- IPC primitives and named pipe utilities
- Settings serialization and management
- DPI awareness and scaling utilities
- Telemetry helpers
- General utilities (JSON parsing, string helpers, etc.)
## Guidelines
### API Stability
- Avoid breaking public headers/APIs; if changed, search & update all callers
- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility
- When modifying public interfaces, grep the entire codebase for usages
### Performance
- Watch perf in hot paths (hooks, timers, serialization)
- Avoid avoidable allocations in frequently called code
- Profile changes that touch performance-sensitive areas
### Dependencies
- Ask before adding third-party deps or changing serialization formats
- New dependencies must be MIT-licensed or approved by PM team
- Add any new external packages to `NOTICE.md`
### Logging
- C++ logging uses spdlog (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`)
- Initialize with `init_logger()` early in startup
- Keep hot paths quiet no logging in tight loops or hooks
## Acceptance Criteria
- No unintended ABI breaks
- No noisy logs in hot paths
- New non-obvious symbols briefly commented
- All callers updated when interfaces change
## Code Style
- **C++**: Follow `.clang-format` in `src/`; use Modern C++ patterns per C++ Core Guidelines
- **C#**: Follow `src/.editorconfig`; enforce StyleCop.Analyzers
## Validation
- Build: `tools\build\build.cmd` from `src/common/` folder
- Verify no ABI breaks: grep for changed function/struct names across codebase
- Check logs: ensure no new logging in performance-critical paths

View File

@@ -0,0 +1,68 @@
---
description: 'Guidelines for Runner and Settings UI components that communicate via named pipes and manage module lifecycle'
applyTo: 'src/runner/**,src/settings-ui/**'
---
# Runner & Settings UI Core Components Guidance
Guidelines for modifying the Runner (tray/module loader) and Settings UI (configuration app). These components communicate via Windows Named Pipes using JSON messages.
## Runner (`src/runner/`)
### Scope
- Module bootstrap, hotkey management, settings bridge, update/elevation handling
### Guidelines
- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**`
- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules
- Keep startup lean: avoid blocking/network calls in early init path
- Preserve GPO & elevation behaviors; confirm no regression in policy handling
- Ask before modifying update workflow or elevation logic
### Acceptance Criteria
- Stable startup, consistent contracts, no unnecessary logging noise
## Settings UI (`src/settings-ui/`)
### Scope
- WinUI/WPF UI, communicates with Runner over named pipes; manages persisted settings schema
### Guidelines
- Don't break settings schema silently; add migration when shape changes
- If IPC/JSON contracts change, align with `src/runner/**` implementation
- Keep UI responsive: marshal to UI thread for UI-bound operations
- Reuse existing styles/resources; avoid duplicate theme keys
- Add/adjust migration or serialization tests when changing persisted settings
### Acceptance Criteria
- Schema integrity preserved, responsive UI, consistent contracts, no style duplication
## Shared Concerns
### IPC Contract Changes
When modifying the JSON message format between Runner and Settings UI:
1. Update both `src/runner/` and `src/settings-ui/` in the same PR
2. Preserve backward compatibility where possible
3. Add migration logic for settings schema changes
4. Test both directions of communication
### Code Style
- **C++ (Runner)**: Follow `.clang-format` in `src/`
- **C# (Settings UI)**: Follow `src/.editorconfig`, use StyleCop.Analyzers
- **XAML**: Use XamlStyler or run `.\.pipelines\applyXamlStyling.ps1 -Main`
## Validation
- Build Runner: `tools\build\build.cmd` from `src/runner/`
- Build Settings UI: `tools\build\build.cmd` from `src/settings-ui/`
- Test IPC: Launch both Runner and Settings UI, verify communication works
- Schema changes: Run serialization tests if settings shape changed

165
AGENTS.md Normal file
View File

@@ -0,0 +1,165 @@
---
description: 'Top-level AI contributor guidance for developing PowerToys - a collection of Windows productivity utilities'
applyTo: '**'
---
# PowerToys AI Contributor Guide
This is the top-level guidance for AI contributions to PowerToys. Keep changes atomic, follow existing patterns, and cite exact paths in PRs.
## Overview
PowerToys is a set of utilities for power users to tune and streamline their Windows experience.
| Area | Location | Description |
|------|----------|-------------|
| Runner | `src/runner/` | Main executable, tray icon, module loader, hotkey management |
| Settings UI | `src/settings-ui/` | WinUI/WPF configuration app communicating via named pipes |
| Modules | `src/modules/` | Individual PowerToys utilities (each in its own subfolder) |
| Common Libraries | `src/common/` | Shared code: logging, IPC, settings, DPI, telemetry, utilities |
| Build Tools | `tools/build/` | Build scripts and automation |
| Documentation | `doc/devdocs/` | Developer documentation |
| Installer | `installer/` | WiX-based installer projects |
For architecture details and module types, see [Architecture Overview](doc/devdocs/core/architecture.md).
## Conventions
For detailed coding conventions, see:
- [Coding Guidelines](doc/devdocs/development/guidelines.md) Dependencies, testing, PR management
- [Coding Style](doc/devdocs/development/style.md) Formatting, C++/C#/XAML style rules
- [Logging](doc/devdocs/development/logging.md) C++ spdlog and C# Logger usage
### Component-Specific Instructions
These instruction files are automatically applied when working in their respective areas:
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md) IPC contracts, schema migrations
- [Common Libraries](.github/instructions/common-libraries.instructions.md) ABI stability, shared code guidelines
## Build
### Prerequisites
- Visual Studio 2022 17.4+
- Windows 10 1803+ (April 2018 Update or newer)
- Initialize submodules once: `git submodule update --init --recursive`
### Build Commands
| Task | Command |
|------|---------|
| First build / NuGet restore | `tools\build\build-essentials.cmd` |
| Build current folder | `tools\build\build.cmd` |
| Build with options | `build.ps1 -Platform x64 -Configuration Release` |
### Build Discipline
1. One terminal per operation (build → test). Do not switch or open new ones mid-flow
2. After making changes, `cd` to the project folder that changed (`.csproj`/`.vcxproj`)
3. Use scripts to build: `tools/build/build.ps1` or `tools/build/build.cmd`
4. For first build or missing NuGet packages, run `build-essentials.cmd` first
5. **Exit code 0 = success; non-zero = failure** treat this as absolute
6. On failure, read the errors log: `build.<config>.<platform>.errors.log`
7. Do not start tests or launch Runner until the build succeeds
### Build Logs
Located next to the solution/project being built:
- `build.<configuration>.<platform>.errors.log` errors only (check this first)
- `build.<configuration>.<platform>.all.log` full log
- `build.<configuration>.<platform>.trace.binlog` for MSBuild Structured Log Viewer
For complete details, see [Build Guidelines](tools/build/BUILD-GUIDELINES.md).
## Tests
### Test Discovery
- Find test projects by product code prefix (e.g., `FancyZones`, `AdvancedPaste`)
- Look for sibling folders or 1-2 levels up named `<Product>*UnitTests` or `<Product>*UITests`
### Running Tests
1. **Build the test project first**, wait for exit code 0
2. Run via VS Test Explorer (`Ctrl+E, T`) or `vstest.console.exe` with filters
3. **Avoid `dotnet test`** in this repo use VS Test Explorer or vstest.console.exe
### Test Types
| Type | Requirements | Setup |
|------|--------------|-------|
| Unit Tests | Standard dev environment | None |
| UI Tests | WinAppDriver v1.2.1, Developer Mode | Install from [WinAppDriver releases](https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1) |
| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
### Test Discipline
1. Add or adjust tests when changing behavior
2. If tests skipped, state why (e.g., comment-only change, string rename)
3. New modules handling file I/O or user input **must** implement fuzzing tests
### Special Requirements
- **Mouse Without Borders**: Requires 2+ physical computers (not VMs)
- **Multi-monitor utilities**: Test with 2+ monitors, different DPI settings
For UI test setup details, see [UI Tests](doc/devdocs/development/ui-tests.md).
## Boundaries
### Ask for Clarification When
- Ambiguous spec after scanning relevant docs
- Cross-module impact (shared enum/struct) is unclear
- Security, elevation, or installer changes involved
- GPO or policy handling modifications needed
### Areas Requiring Extra Care
| Area | Concern | Reference |
|------|---------|-----------|
| `src/common/` | ABI breaks | [Common Libraries Instructions](.github/instructions/common-libraries.instructions.md) |
| `src/runner/`, `src/settings-ui/` | IPC contracts, schema | [Runner & Settings UI Instructions](.github/instructions/runner-settings-ui.instructions.md) |
| Installer files | Release impact | Careful review required |
| Elevation/GPO logic | Security | Confirm no regression in policy handling |
### What NOT to Do
- Don't merge incomplete features into main (use feature branches)
- Don't break IPC/JSON contracts without updating both runner and settings-ui
- Don't add noisy logs in hot paths
- Don't introduce third-party deps without PM approval and `NOTICE.md` update
## Validation Checklist
Before finishing, verify:
- [ ] Build clean with exit code 0
- [ ] Tests updated and passing locally
- [ ] No unintended ABI breaks or schema changes
- [ ] IPC contracts consistent between runner and settings-ui
- [ ] New dependencies added to `NOTICE.md`
- [ ] PR is atomic (one logical change), with issue linked
## Documentation Index
### Core Architecture
- [Architecture Overview](doc/devdocs/core/architecture.md)
- [Runner](doc/devdocs/core/runner.md)
- [Settings System](doc/devdocs/core/settings/readme.md)
- [Module Interface](doc/devdocs/modules/interface.md)
### Development
- [Coding Guidelines](doc/devdocs/development/guidelines.md)
- [Coding Style](doc/devdocs/development/style.md)
- [Logging](doc/devdocs/development/logging.md)
- [UI Tests](doc/devdocs/development/ui-tests.md)
- [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md)
### Build & Tools
- [Build Guidelines](tools/build/BUILD-GUIDELINES.md)
- [Tools Overview](doc/devdocs/tools/readme.md)
### Instructions (Auto-Applied)
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
- [Common Libraries](.github/instructions/common-libraries.instructions.md)

View File

@@ -63,7 +63,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="9.0.10" />
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
@@ -77,8 +77,10 @@
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.231216.1"/>
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental4" />
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="2.0.130-experimental" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.251106002" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="1.8.251104000" />
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="1.8.39" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="1.8.251106002" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="2.0.9" />
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageVersion Include="ModernWpfUI" Version="0.9.4" />

View File

@@ -1,244 +0,0 @@
# Windows App SDK Semantic Search API 总结
## 1. 环境与依赖
| 项目 | 版本/值 |
|------|---------|
| **Windows App SDK** | `2.0.0-experimental3` |
| **.NET** | `net9.0-windows10.0.26100.0` |
| **AI Search NuGet** | `Microsoft.WindowsAppSDK.AI` (2.0.57-experimental) |
| **命名空间** | `Microsoft.Windows.AI.Search.Experimental.AppContentIndex` |
| **应用类型** | WinUI 3 MSIX 打包应用 |
---
## 2. 核心 API
### 2.1 索引管理
```csharp
// 创建/打开索引
var result = AppContentIndexer.GetOrCreateIndex("indexName");
if (result.Succeeded) {
_indexer = result.Indexer;
// result.Status: CreatedNew | OpenedExisting
}
// 等待索引能力就绪
await _indexer.WaitForIndexCapabilitiesAsync();
// 等待索引空闲(建索引完成)
await _indexer.WaitForIndexingIdleAsync(TimeSpan.FromSeconds(120));
// 清理
_indexer.RemoveAll(); // 删除所有索引
_indexer.Remove(id); // 删除单个
_indexer.Dispose();
```
### 2.2 添加内容到索引
```csharp
// 索引文本 → 自动建立 TextLexical + TextSemantic 索引
IndexableAppContent textContent = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer.AddOrUpdate(textContent);
// 索引图片 → 自动建立 ImageSemantic + ImageOcr 索引
IndexableAppContent imageContent = AppManagedIndexableAppContent.CreateFromBitmap(id, softwareBitmap);
_indexer.AddOrUpdate(imageContent);
```
### 2.3 查询
```csharp
// 文本查询
TextQueryOptions options = new TextQueryOptions {
Language = "en-US", // 可选
MatchScope = QueryMatchScope.Unconstrained, // 匹配范围
TextMatchType = TextLexicalMatchType.Fuzzy // Fuzzy | Exact
};
AppIndexTextQuery query = _indexer.CreateTextQuery(searchText, options);
IReadOnlyList<TextQueryMatch> matches = query.GetNextMatches(5);
// 图片查询
ImageQueryOptions imgOptions = new ImageQueryOptions {
MatchScope = QueryMatchScope.Unconstrained,
ImageOcrTextMatchType = TextLexicalMatchType.Fuzzy
};
AppIndexImageQuery imgQuery = _indexer.CreateImageQuery(searchText, imgOptions);
IReadOnlyList<ImageQueryMatch> imgMatches = imgQuery.GetNextMatches(5);
```
### 2.4 能力检查(只读)
```csharp
IndexCapabilities capabilities = _indexer.GetIndexCapabilities();
bool textLexicalOK = capabilities.GetCapabilityState(IndexCapability.TextLexical)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
bool textSemanticOK = capabilities.GetCapabilityState(IndexCapability.TextSemantic)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
bool imageSemanticOK = capabilities.GetCapabilityState(IndexCapability.ImageSemantic)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
bool imageOcrOK = capabilities.GetCapabilityState(IndexCapability.ImageOcr)
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
```
---
## 3. 四种索引能力
| 能力 | 说明 | 触发方式 |
|------|------|----------|
| `TextLexical` | 词法/关键词搜索 | CreateFromString() 自动 |
| `TextSemantic` | AI 语义搜索 (Embedding) | CreateFromString() 自动 |
| `ImageSemantic` | 图像语义搜索 | CreateFromBitmap() 自动 |
| `ImageOcr` | 图片 OCR 文字搜索 | CreateFromBitmap() 自动 |
---
## 4. 可控选项(有限)
### TextQueryOptions
| 属性 | 类型 | 说明 |
|------|------|------|
| `Language` | string | 查询语言(可选,如 "en-US"|
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
| `TextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact仅影响 Lexical|
### ImageQueryOptions
| 属性 | 类型 | 说明 |
|------|------|------|
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
| `ImageOcrTextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact仅影响 OCR|
### 枚举值说明
**QueryMatchScope:**
- `Unconstrained` - 无约束,同时使用 Lexical + Semantic
- `Region` - 限制在特定区域
- `ContentItem` - 限制在单个内容项
**TextLexicalMatchType:**
- `Fuzzy` - 模糊匹配,允许拼写错误、近似词
- `Exact` - 精确匹配,必须完全一致
---
## 5. 关键限制 ⚠️
| 限制 | 说明 |
|------|------|
| **不能单独指定 Semantic/Lexical** | 系统自动同时使用所有可用能力 |
| **Fuzzy/Exact 只影响 Lexical** | 对 Semantic 搜索无效 |
| **能力检查是只读的** | `GetIndexCapabilities()` 只能查看,不能控制 |
| **无相似度阈值** | 不能设置 Semantic 匹配的阈值 |
| **无结果排序控制** | 无法指定按相关度或其他方式排序 |
| **语言需手动传** | 不会自动检测,需开发者指定 |
| **无相关度分数** | 查询结果不返回匹配分数 |
---
## 6. 典型使用流程
```
┌─────────────────────────────────────────────────────────┐
│ App 启动时 │
├─────────────────────────────────────────────────────────┤
│ 1. GetOrCreateIndex("name") // 创建/打开索引 │
│ 2. WaitForIndexCapabilitiesAsync() // 等待能力就绪 │
│ 3. GetIndexCapabilities() // 检查可用能力 │
│ 4. IndexAll() // 索引所有数据 │
│ ├─ CreateFromString() × N │
│ └─ CreateFromBitmap() × N │
│ 5. WaitForIndexingIdleAsync() // 等待索引完成 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 运行时查询 │
├─────────────────────────────────────────────────────────┤
│ 1. CreateTextQuery(text, options) // 创建查询 │
│ 2. query.GetNextMatches(N) // 获取结果 │
│ 3. 处理 TextQueryMatch / ImageQueryMatch │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ App 退出时 │
├─────────────────────────────────────────────────────────┤
│ 1. _indexer.RemoveAll() // 清理索引 │
│ 2. _indexer.Dispose() // 释放资源 │
└─────────────────────────────────────────────────────────┘
```
---
## 7. 查询结果处理
```csharp
// 文本查询结果
foreach (var match in textMatches)
{
if (match.ContentKind == QueryMatchContentKind.AppManagedText)
{
AppManagedTextQueryMatch textResult = (AppManagedTextQueryMatch)match;
string contentId = match.ContentId; // 内容 ID
int offset = textResult.TextOffset; // 匹配文本偏移
int length = textResult.TextLength; // 匹配文本长度
}
}
// 图片查询结果
foreach (var match in imageMatches)
{
if (match.ContentKind == QueryMatchContentKind.AppManagedImage)
{
AppManagedImageQueryMatch imageResult = (AppManagedImageQueryMatch)match;
string contentId = imageResult.ContentId; // 图片 ID
}
}
```
---
## 8. 能力变化监听
```csharp
// 监听索引能力变化
_indexer.Listener.IndexCapabilitiesChanged += (indexer, capabilities) =>
{
// 重新检查能力状态,更新 UI
LoadAppIndexCapabilities();
};
```
---
## 9. 结论
这是一个**高度封装的黑盒 API**
### 优点 ✅
- 简单易用,几行代码即可实现搜索
- 自动处理 Lexical + Semantic
- 支持文本和图片多模态搜索
- 系统级集成,无需额外部署模型
### 缺点 ❌
- 无法精细控制搜索类型
- 不能只用 Semantic Search
- 选项有限,缺乏高级配置
- 实验性 API可能变更
### 替代方案
**如果需要纯 Semantic Search向量搜索**,建议:
- 直接使用 Embedding 模型生成向量
- 配合向量数据库Azure Cosmos DB、FAISS、Qdrant 等)
---
## 10. 相关 NuGet 包
```xml
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
<PackageReference Include="Microsoft.WindowsAppSDK.AI" Version="2.0.57-experimental" />
```
---
*文档生成日期: 2026-01-21*

View File

@@ -1,496 +0,0 @@
# Common.Search Library Specification
## Overview
本文档描述 `Common.Search` 库的重构设计目标是提供一个通用的、可插拔的搜索框架支持多种搜索引擎实现Fuzzy Match、Semantic Search 等)。
## Goals
1. **解耦** - 搜索引擎与数据源完全解耦
2. **可插拔** - 支持替换不同的搜索引擎实现
3. **泛型** - 不绑定特定业务类型(如 SettingEntry
4. **可组合** - 支持多引擎组合(即时 Fuzzy + 延迟 Semantic
5. **可复用** - 可被 PowerToys 多个模块使用
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Consumer (e.g., Settings.UI) │
├─────────────────────────────────────────────────────────────────┤
│ SettingsDataProvider ← 业务特定的数据加载 │
│ SettingsSearchService ← 业务特定的搜索服务 │
│ SettingEntry : ISearchable ← 业务实体实现搜索契约 │
└─────────────────────────────────────────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────────────┐
│ Common.Search (Library) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Core Abstractions │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ ISearchable ← 可搜索内容契约 │ │
│ │ ISearchEngine<T> ← 搜索引擎接口 │ │
│ │ SearchResult<T> ← 统一结果模型 │ │
│ │ SearchOptions ← 搜索选项 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Implementations │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ FuzzSearch/ │ │
│ │ ├── FuzzSearchEngine<T> ← 内存 Fuzzy 搜索 │ │
│ │ ├── StringMatcher ← 现有的模糊匹配算法 │ │
│ │ └── MatchResult ← Fuzzy 匹配结果 │ │
│ │ │ │
│ │ SemanticSearch/ │ │
│ │ ├── SemanticSearchEngine ← Windows AI Search 封装 │ │
│ │ └── SemanticSearchCapabilities │ │
│ │ │ │
│ │ CompositeSearch/ │ │
│ │ └── CompositeSearchEngine<T> ← 多引擎组合 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Core Interfaces
### ISearchable
定义可搜索内容的最小契约。
```csharp
namespace Common.Search;
/// <summary>
/// Defines a searchable item that can be indexed and searched.
/// </summary>
public interface ISearchable
{
/// <summary>
/// Gets the unique identifier for this item.
/// </summary>
string Id { get; }
/// <summary>
/// Gets the primary searchable text (e.g., title, header).
/// </summary>
string SearchableText { get; }
/// <summary>
/// Gets optional secondary searchable text (e.g., description).
/// Returns null if not available.
/// </summary>
string? SecondarySearchableText { get; }
}
```
### ISearchEngine&lt;T&gt;
搜索引擎核心接口。
```csharp
namespace Common.Search;
/// <summary>
/// Defines a pluggable search engine that can index and search items.
/// </summary>
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
public interface ISearchEngine<T> : IDisposable
where T : ISearchable
{
/// <summary>
/// Gets a value indicating whether the engine is ready to search.
/// </summary>
bool IsReady { get; }
/// <summary>
/// Gets the engine capabilities.
/// </summary>
SearchEngineCapabilities Capabilities { get; }
/// <summary>
/// Initializes the search engine.
/// </summary>
Task InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Indexes a single item.
/// </summary>
Task IndexAsync(T item, CancellationToken cancellationToken = default);
/// <summary>
/// Indexes multiple items in batch.
/// </summary>
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an item from the index by its ID.
/// </summary>
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Clears all indexed items.
/// </summary>
Task ClearAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Searches for items matching the query.
/// </summary>
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default);
}
```
### SearchResult&lt;T&gt;
统一的搜索结果模型。
```csharp
namespace Common.Search;
/// <summary>
/// Represents a search result with the matched item and scoring information.
/// </summary>
public sealed class SearchResult<T>
where T : ISearchable
{
/// <summary>
/// Gets the matched item.
/// </summary>
public required T Item { get; init; }
/// <summary>
/// Gets the relevance score (higher is more relevant).
/// </summary>
public required double Score { get; init; }
/// <summary>
/// Gets the type of match that produced this result.
/// </summary>
public required SearchMatchKind MatchKind { get; init; }
/// <summary>
/// Gets the match details for highlighting (optional).
/// </summary>
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
}
/// <summary>
/// Represents a span of matched text for highlighting.
/// </summary>
public readonly record struct MatchSpan(int Start, int Length);
/// <summary>
/// Specifies the kind of match that produced a search result.
/// </summary>
public enum SearchMatchKind
{
/// <summary>Exact text match.</summary>
Exact,
/// <summary>Fuzzy/approximate text match.</summary>
Fuzzy,
/// <summary>Semantic/AI-based match.</summary>
Semantic,
/// <summary>Combined match from multiple engines.</summary>
Composite,
}
```
### SearchOptions
搜索配置选项。
```csharp
namespace Common.Search;
/// <summary>
/// Options for configuring search behavior.
/// </summary>
public sealed class SearchOptions
{
/// <summary>
/// Gets or sets the maximum number of results to return.
/// Default is 20.
/// </summary>
public int MaxResults { get; set; } = 20;
/// <summary>
/// Gets or sets the minimum score threshold (0.0 to 1.0).
/// Results below this score are filtered out.
/// Default is 0.0 (no filtering).
/// </summary>
public double MinScore { get; set; } = 0.0;
/// <summary>
/// Gets or sets the language hint for the search (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include match spans for highlighting.
/// Default is false.
/// </summary>
public bool IncludeMatchSpans { get; set; } = false;
}
```
### SearchEngineCapabilities
引擎能力描述。
```csharp
namespace Common.Search;
/// <summary>
/// Describes the capabilities of a search engine.
/// </summary>
public sealed class SearchEngineCapabilities
{
/// <summary>
/// Gets a value indicating whether the engine supports fuzzy matching.
/// </summary>
public bool SupportsFuzzyMatch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports semantic search.
/// </summary>
public bool SupportsSemanticSearch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine persists the index to disk.
/// </summary>
public bool PersistsIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports incremental indexing.
/// </summary>
public bool SupportsIncrementalIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports match span highlighting.
/// </summary>
public bool SupportsMatchSpans { get; init; }
}
```
## Implementations
### FuzzSearchEngine&lt;T&gt;
基于现有 StringMatcher 的内存搜索引擎。
**特点:**
- 纯内存,无持久化
- 即时响应(毫秒级)
- 支持 match spans 高亮
- 基于字符的模糊匹配
**Capabilities**
```csharp
new SearchEngineCapabilities
{
SupportsFuzzyMatch = true,
SupportsSemanticSearch = false,
PersistsIndex = false,
SupportsIncrementalIndex = true,
SupportsMatchSpans = true,
}
```
### SemanticSearchEngine
基于 Windows App SDK AI Search API 的语义搜索引擎。
**特点:**
- 系统管理的持久化索引
- AI 驱动的语义理解
- 需要模型初始化(可能较慢)
- 可能不可用(依赖系统支持)
**Capabilities**
```csharp
new SearchEngineCapabilities
{
SupportsFuzzyMatch = true, // API 同时提供 lexical + semantic
SupportsSemanticSearch = true,
PersistsIndex = true,
SupportsIncrementalIndex = true,
SupportsMatchSpans = false, // API 不返回详细位置
}
```
**注意:** SemanticSearchEngine 不是泛型的,因为它需要将内容转换为字符串存入系统索引。实现时通过 `ISearchable` 接口提取文本。
### CompositeSearchEngine&lt;T&gt;
组合多个搜索引擎,支持 fallback 和结果合并。
```csharp
namespace Common.Search;
/// <summary>
/// A search engine that combines results from multiple engines.
/// </summary>
public sealed class CompositeSearchEngine<T> : ISearchEngine<T>
where T : ISearchable
{
/// <summary>
/// Strategy for combining results from multiple engines.
/// </summary>
public enum CombineStrategy
{
/// <summary>Use first ready engine only.</summary>
FirstReady,
/// <summary>Merge results from all ready engines.</summary>
MergeAll,
/// <summary>Use primary, fallback to secondary if primary not ready.</summary>
PrimaryWithFallback,
}
}
```
**典型用法:** Fuzzy 作为即时响应Semantic 准备好后增强结果。
## Directory Structure
```
src/common/Common.Search/
├── Common.Search.csproj
├── GlobalSuppressions.cs
├── ISearchable.cs
├── ISearchEngine.cs
├── SearchResult.cs
├── SearchOptions.cs
├── SearchEngineCapabilities.cs
├── SearchMatchKind.cs
├── MatchSpan.cs
├── FuzzSearch/
│ ├── FuzzSearchEngine.cs
│ ├── StringMatcher.cs (existing)
│ ├── MatchOption.cs (existing)
│ ├── MatchResult.cs (existing)
│ └── SearchPrecisionScore.cs (existing)
├── SemanticSearch/
│ ├── SemanticSearchEngine.cs
│ ├── SemanticSearchCapabilities.cs
│ └── SemanticSearchAdapter.cs (adapts ISearchable to Windows API)
└── CompositeSearch/
└── CompositeSearchEngine.cs
```
## Consumer Usage (Settings.UI)
### SettingEntry 实现 ISearchable
```csharp
// Settings.UI.Library/SettingEntry.cs
public struct SettingEntry : ISearchable
{
// Existing properties...
// ISearchable implementation
public string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
public string SearchableText => Header ?? string.Empty;
public string? SecondarySearchableText => Description;
}
```
### SettingsSearchService
```csharp
// Settings.UI/Services/SettingsSearchService.cs
public sealed class SettingsSearchService : IDisposable
{
private readonly ISearchEngine<SettingEntry> _engine;
public SettingsSearchService(ISearchEngine<SettingEntry> engine)
{
_engine = engine;
}
public async Task InitializeAsync(IEnumerable<SettingEntry> entries)
{
await _engine.InitializeAsync();
await _engine.IndexBatchAsync(entries);
}
public async Task<List<SettingEntry>> SearchAsync(string query, CancellationToken ct = default)
{
var results = await _engine.SearchAsync(query, cancellationToken: ct);
return results.Select(r => r.Item).ToList();
}
}
```
### Startup Configuration
```csharp
// Option 1: Fuzzy only (default, immediate)
var engine = new FuzzSearchEngine<SettingEntry>();
// Option 2: Semantic only (requires Windows AI)
var engine = new SemanticSearchAdapter<SettingEntry>("PowerToysSettings");
// Option 3: Composite (best of both worlds)
var engine = new CompositeSearchEngine<SettingEntry>(
primary: new SemanticSearchAdapter<SettingEntry>("PowerToysSettings"),
fallback: new FuzzSearchEngine<SettingEntry>(),
strategy: CombineStrategy.PrimaryWithFallback
);
var searchService = new SettingsSearchService(engine);
await searchService.InitializeAsync(settingEntries);
```
## Migration Plan
### Phase 1: Core Abstractions
1. 创建 `ISearchable`, `ISearchEngine<T>`, `SearchResult<T>` 等核心接口
2. 保持现有 FuzzSearch 代码不变
### Phase 2: FuzzSearchEngine&lt;T&gt;
1. 创建泛型 `FuzzSearchEngine<T>` 实现
2. 内部复用现有 `StringMatcher`
### Phase 3: SemanticSearchEngine
1. 完善现有 `SemanticSearchEngine` 实现
2. 创建 `SemanticSearchAdapter<T>` 桥接泛型接口
### Phase 4: Settings.UI Migration
1. `SettingEntry` 实现 `ISearchable`
2. 创建 `SettingsSearchService`
3. 迁移 `SearchIndexService` 到新架构
4. 保持 API 兼容,逐步废弃旧方法
### Phase 5: CompositeSearchEngine (Optional)
1. 实现组合引擎
2. 支持 Fuzzy + Semantic 混合搜索
## Open Questions
1. **是否需要支持图片搜索?** 当前 SemanticSearchEngine 支持 `IndexImage`,但 `ISearchable` 只有文本。如果需要图片,可能需要 `IImageSearchable` 扩展。
2. **结果去重策略?** CompositeEngine 合并结果时,同一个 Item 可能被多个引擎匹配,如何去重和合并分数?
3. **异步 vs 同步?** FuzzSearch 完全可以同步执行,但接口统一用 `Task` 是否合适?考虑提供同步重载?
4. **索引更新策略?** 当 Settings 内容变化时(例如用户切换语言),如何高效更新索引?
---
*Document Version: 1.0*
*Last Updated: 2026-01-21*

View File

@@ -29,7 +29,6 @@
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.26226.0" />
<PackageDependency Name="Microsoft.WindowsAppRuntime.2.0-experimental3" MinVersion="0.676.658.0" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" />
</Dependencies>
<Capabilities>
<Capability Name="internetClient" />

View File

@@ -4,16 +4,5 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS8305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.WindowsAppSDK.AI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ManagedCommon\ManagedCommon.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,306 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
namespace Common.Search.FuzzSearch;
/// <summary>
/// A search engine that uses fuzzy string matching for search.
/// </summary>
/// <typeparam name="T">The type of items to search.</typeparam>
public sealed class FuzzSearchEngine<T> : ISearchEngine<T>
where T : ISearchable
{
private readonly object _lockObject = new();
private readonly Dictionary<string, T> _itemsById = new();
private readonly Dictionary<string, (string PrimaryNorm, string? SecondaryNorm)> _normalizedCache = new();
private bool _isReady;
private bool _disposed;
/// <inheritdoc/>
public bool IsReady
{
get
{
lock (_lockObject)
{
return _isReady;
}
}
}
/// <inheritdoc/>
public SearchEngineCapabilities Capabilities { get; } = new()
{
SupportsFuzzyMatch = true,
SupportsSemanticSearch = false,
PersistsIndex = false,
SupportsIncrementalIndex = true,
SupportsMatchSpans = true,
};
/// <inheritdoc/>
public Task InitializeAsync(CancellationToken cancellationToken = default)
{
lock (_lockObject)
{
_isReady = true;
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById[item.Id] = item;
_normalizedCache[item.Id] = (
NormalizeString(item.SearchableText),
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
);
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(items);
ThrowIfDisposed();
lock (_lockObject)
{
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
_itemsById[item.Id] = item;
_normalizedCache[item.Id] = (
NormalizeString(item.SearchableText),
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
);
}
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Remove(id);
_normalizedCache.Remove(id);
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task ClearAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Clear();
_normalizedCache.Clear();
}
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
options ??= new SearchOptions();
var normalizedQuery = NormalizeString(query);
List<KeyValuePair<string, T>> snapshot;
lock (_lockObject)
{
if (_itemsById.Count == 0)
{
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
snapshot = _itemsById.ToList();
}
var bag = new ConcurrentBag<SearchResult<T>>();
var po = new ParallelOptions
{
CancellationToken = cancellationToken,
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
};
try
{
Parallel.ForEach(snapshot, po, kvp =>
{
var (primaryNorm, secondaryNorm) = GetNormalizedTexts(kvp.Key);
var primaryResult = StringMatcher.FuzzyMatch(normalizedQuery, primaryNorm);
double score = primaryResult.Score;
List<int>? matchData = primaryResult.MatchData;
if (!string.IsNullOrEmpty(secondaryNorm))
{
var secondaryResult = StringMatcher.FuzzyMatch(normalizedQuery, secondaryNorm);
if (secondaryResult.Success && secondaryResult.Score * 0.8 > score)
{
score = secondaryResult.Score * 0.8;
matchData = null; // Secondary matches don't have primary text spans
}
}
if (score > options.MinScore)
{
var result = new SearchResult<T>
{
Item = kvp.Value,
Score = score,
MatchKind = SearchMatchKind.Fuzzy,
MatchSpans = options.IncludeMatchSpans && matchData != null
? ConvertToMatchSpans(matchData)
: null,
};
bag.Add(result);
}
});
}
catch (OperationCanceledException)
{
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
var results = bag
.OrderByDescending(r => r.Score)
.Take(options.MaxResults)
.ToList();
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
lock (_lockObject)
{
_itemsById.Clear();
_normalizedCache.Clear();
_isReady = false;
}
_disposed = true;
}
private (string PrimaryNorm, string? SecondaryNorm) GetNormalizedTexts(string id)
{
lock (_lockObject)
{
if (_normalizedCache.TryGetValue(id, out var cached))
{
return cached;
}
}
return (string.Empty, null);
}
private static IReadOnlyList<MatchSpan> ConvertToMatchSpans(List<int> matchData)
{
if (matchData == null || matchData.Count == 0)
{
return Array.Empty<MatchSpan>();
}
// Convert individual match indices to spans
var spans = new List<MatchSpan>();
var sortedIndices = matchData.OrderBy(i => i).ToList();
int start = sortedIndices[0];
int length = 1;
for (int i = 1; i < sortedIndices.Count; i++)
{
if (sortedIndices[i] == sortedIndices[i - 1] + 1)
{
// Consecutive index, extend the span
length++;
}
else
{
// Gap found, save current span and start new one
spans.Add(new MatchSpan(start, length));
start = sortedIndices[i];
length = 1;
}
}
// Add the last span
spans.Add(new MatchSpan(start, length));
return spans;
}
private static string NormalizeString(string? input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
var sb = new StringBuilder(normalized.Length);
foreach (var c in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(c);
if (category != UnicodeCategory.NonSpacingMark)
{
sb.Append(c);
}
}
return sb.ToString();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}

View File

@@ -25,9 +25,9 @@ public class StringMatcher
/// 6. Move onto the next substring's characters until all substrings are checked.
/// 7. Consider success and move onto scoring if every char or substring without whitespaces matched
/// </summary>
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption? opt = null)
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt = null)
{
opt ??= new MatchOption();
opt = opt ?? new MatchOption();
if (string.IsNullOrEmpty(stringToCompare))
{

View File

@@ -11,10 +11,6 @@ using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.MatchResult._rawScore")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._defaultMatchOption")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._indexName")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._indexer")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._disposed")]
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._capabilities")]
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore)")]
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore,System.Collections.Generic.List{System.Int32},System.Int32)")]
[assembly: SuppressMessage("Compiler", "CS8618:Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.", Justification = "Coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]

View File

@@ -1,73 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Defines a pluggable search engine that can index and search items.
/// </summary>
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
public interface ISearchEngine<T> : IDisposable
where T : ISearchable
{
/// <summary>
/// Gets a value indicating whether the engine is ready to search.
/// </summary>
bool IsReady { get; }
/// <summary>
/// Gets the engine capabilities.
/// </summary>
SearchEngineCapabilities Capabilities { get; }
/// <summary>
/// Initializes the search engine.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Indexes a single item.
/// </summary>
/// <param name="item">The item to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task IndexAsync(T item, CancellationToken cancellationToken = default);
/// <summary>
/// Indexes multiple items in batch.
/// </summary>
/// <param name="items">The items to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an item from the index by its ID.
/// </summary>
/// <param name="id">The ID of the item to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Clears all indexed items.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the async operation.</returns>
Task ClearAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Searches for items matching the query.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="options">Optional search options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of search results ordered by relevance.</returns>
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default);
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Defines a searchable item that can be indexed and searched.
/// </summary>
public interface ISearchable
{
/// <summary>
/// Gets the unique identifier for this item.
/// </summary>
string Id { get; }
/// <summary>
/// Gets the primary searchable text (e.g., title, header).
/// </summary>
string SearchableText { get; }
/// <summary>
/// Gets optional secondary searchable text (e.g., description).
/// Returns null if not available.
/// </summary>
string? SecondarySearchableText { get; }
}

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 Common.Search;
/// <summary>
/// Represents a span of matched text for highlighting.
/// </summary>
/// <param name="Start">The starting index of the match.</param>
/// <param name="Length">The length of the match.</param>
public readonly record struct MatchSpan(int Start, int Length);

View File

@@ -1,36 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Describes the capabilities of a search engine.
/// </summary>
public sealed class SearchEngineCapabilities
{
/// <summary>
/// Gets a value indicating whether the engine supports fuzzy matching.
/// </summary>
public bool SupportsFuzzyMatch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports semantic search.
/// </summary>
public bool SupportsSemanticSearch { get; init; }
/// <summary>
/// Gets a value indicating whether the engine persists the index to disk.
/// </summary>
public bool PersistsIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports incremental indexing.
/// </summary>
public bool SupportsIncrementalIndex { get; init; }
/// <summary>
/// Gets a value indicating whether the engine supports match span highlighting.
/// </summary>
public bool SupportsMatchSpans { get; init; }
}

View File

@@ -1,134 +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 Common.Search;
/// <summary>
/// Represents an error that occurred during a search operation.
/// </summary>
public sealed class SearchError
{
/// <summary>
/// Initializes a new instance of the <see cref="SearchError"/> class.
/// </summary>
/// <param name="code">The error code.</param>
/// <param name="message">The error message.</param>
/// <param name="details">Optional additional details.</param>
/// <param name="exception">Optional exception that caused the error.</param>
public SearchError(SearchErrorCode code, string message, string? details = null, Exception? exception = null)
{
Code = code;
Message = message;
Details = details;
Exception = exception;
Timestamp = DateTime.UtcNow;
}
/// <summary>
/// Gets the error code.
/// </summary>
public SearchErrorCode Code { get; }
/// <summary>
/// Gets the error message.
/// </summary>
public string Message { get; }
/// <summary>
/// Gets additional details about the error.
/// </summary>
public string? Details { get; }
/// <summary>
/// Gets the exception that caused the error, if any.
/// </summary>
public Exception? Exception { get; }
/// <summary>
/// Gets the timestamp when the error occurred.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Creates an error for initialization failure.
/// </summary>
/// <param name="indexName">The name of the index.</param>
/// <param name="details">Optional details.</param>
/// <param name="exception">Optional exception.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError InitializationFailed(string indexName, string? details = null, Exception? exception = null)
=> new(SearchErrorCode.InitializationFailed, $"Failed to initialize search index '{indexName}'.", details, exception);
/// <summary>
/// Creates an error for indexing failure.
/// </summary>
/// <param name="contentId">The ID of the content that failed to index.</param>
/// <param name="details">Optional details.</param>
/// <param name="exception">Optional exception.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError IndexingFailed(string contentId, string? details = null, Exception? exception = null)
=> new(SearchErrorCode.IndexingFailed, $"Failed to index content '{contentId}'.", details, exception);
/// <summary>
/// Creates an error for search query failure.
/// </summary>
/// <param name="query">The search query that failed.</param>
/// <param name="details">Optional details.</param>
/// <param name="exception">Optional exception.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError SearchFailed(string query, string? details = null, Exception? exception = null)
=> new(SearchErrorCode.SearchFailed, $"Search query '{query}' failed.", details, exception);
/// <summary>
/// Creates an error for engine not ready.
/// </summary>
/// <param name="operation">The operation that was attempted.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError EngineNotReady(string operation)
=> new(SearchErrorCode.EngineNotReady, $"Search engine is not ready. Operation '{operation}' cannot be performed.");
/// <summary>
/// Creates an error for capability unavailable.
/// </summary>
/// <param name="capability">The capability that is unavailable.</param>
/// <param name="details">Optional details.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError CapabilityUnavailable(string capability, string? details = null)
=> new(SearchErrorCode.CapabilityUnavailable, $"Search capability '{capability}' is not available.", details);
/// <summary>
/// Creates an error for timeout.
/// </summary>
/// <param name="operation">The operation that timed out.</param>
/// <param name="timeout">The timeout duration.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError Timeout(string operation, TimeSpan timeout)
=> new(SearchErrorCode.Timeout, $"Operation '{operation}' timed out after {timeout.TotalSeconds:F1} seconds.");
/// <summary>
/// Creates an error for an unexpected error.
/// </summary>
/// <param name="operation">The operation that failed.</param>
/// <param name="exception">The exception that occurred.</param>
/// <returns>A new SearchError instance.</returns>
public static SearchError Unexpected(string operation, Exception exception)
=> new(SearchErrorCode.Unexpected, $"Unexpected error during '{operation}'.", exception.Message, exception);
/// <inheritdoc/>
public override string ToString()
{
var result = $"[{Code}] {Message}";
if (!string.IsNullOrEmpty(Details))
{
result += $" Details: {Details}";
}
if (Exception != null)
{
result += $" Exception: {Exception.GetType().Name}: {Exception.Message}";
}
return result;
}
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search;
/// <summary>
/// Defines error codes for search operations.
/// </summary>
public enum SearchErrorCode
{
/// <summary>
/// No error occurred.
/// </summary>
None = 0,
/// <summary>
/// The search engine failed to initialize.
/// </summary>
InitializationFailed = 1,
/// <summary>
/// Failed to index content.
/// </summary>
IndexingFailed = 2,
/// <summary>
/// The search query failed to execute.
/// </summary>
SearchFailed = 3,
/// <summary>
/// The search engine is not ready to perform the operation.
/// </summary>
EngineNotReady = 4,
/// <summary>
/// A required capability is not available.
/// </summary>
CapabilityUnavailable = 5,
/// <summary>
/// The operation timed out.
/// </summary>
Timeout = 6,
/// <summary>
/// An unexpected error occurred.
/// </summary>
Unexpected = 99,
}

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.
namespace Common.Search;
/// <summary>
/// Specifies the kind of match that produced a search result.
/// </summary>
public enum SearchMatchKind
{
/// <summary>
/// Exact text match.
/// </summary>
Exact,
/// <summary>
/// Fuzzy/approximate text match.
/// </summary>
Fuzzy,
/// <summary>
/// Semantic/AI-based match.
/// </summary>
Semantic,
/// <summary>
/// Combined match from multiple engines.
/// </summary>
Composite,
}

View File

@@ -1,53 +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 Common.Search;
/// <summary>
/// Represents the result of a search operation that may have errors.
/// </summary>
public sealed class SearchOperationResult
{
private SearchOperationResult(bool success, SearchError? error = null)
{
IsSuccess = success;
Error = error;
}
/// <summary>
/// Gets a value indicating whether the operation was successful.
/// </summary>
public bool IsSuccess { get; }
/// <summary>
/// Gets a value indicating whether the operation failed.
/// </summary>
public bool IsFailure => !IsSuccess;
/// <summary>
/// Gets the error if the operation failed, null otherwise.
/// </summary>
public SearchError? Error { get; }
/// <summary>
/// Creates a successful result.
/// </summary>
/// <returns>A successful SearchOperationResult.</returns>
public static SearchOperationResult Success() => new(true);
/// <summary>
/// Creates a failed result with the specified error.
/// </summary>
/// <param name="error">The error that caused the failure.</param>
/// <returns>A failed SearchOperationResult.</returns>
public static SearchOperationResult Failure(SearchError error)
{
ArgumentNullException.ThrowIfNull(error);
return new SearchOperationResult(false, error);
}
/// <inheritdoc/>
public override string ToString()
=> IsSuccess ? "Success" : $"Failure: {Error}";
}

View File

@@ -1,87 +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.Diagnostics.CodeAnalysis;
#pragma warning disable SA1649 // File name should match first type name - Generic type file naming convention
namespace Common.Search;
/// <summary>
/// Represents the result of a search operation that returns a value and may have errors.
/// </summary>
/// <typeparam name="T">The type of the result value.</typeparam>
public sealed class SearchOperationResult<T>
{
private SearchOperationResult(bool success, T? value, SearchError? error)
{
IsSuccess = success;
Value = value;
Error = error;
}
/// <summary>
/// Gets a value indicating whether the operation was successful.
/// </summary>
public bool IsSuccess { get; }
/// <summary>
/// Gets a value indicating whether the operation failed.
/// </summary>
public bool IsFailure => !IsSuccess;
/// <summary>
/// Gets the result value if the operation was successful.
/// </summary>
public T? Value { get; }
/// <summary>
/// Gets the error if the operation failed, null otherwise.
/// </summary>
public SearchError? Error { get; }
/// <summary>
/// Gets the value or a default if the operation failed.
/// </summary>
/// <param name="defaultValue">The default value to return if the operation failed.</param>
/// <returns>The value if successful, otherwise the default value.</returns>
public T GetValueOrDefault(T defaultValue) => IsSuccess && Value is not null ? Value : defaultValue;
/// <inheritdoc/>
public override string ToString()
=> IsSuccess ? $"Success: {Value}" : $"Failure: {Error}";
/// <summary>
/// Creates a successful result with the specified value.
/// </summary>
/// <param name="value">The result value.</param>
/// <returns>A successful SearchOperationResult.</returns>
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
public static SearchOperationResult<T> Success(T value) => new(true, value, null);
/// <summary>
/// Creates a failed result with the specified error.
/// </summary>
/// <param name="error">The error that caused the failure.</param>
/// <returns>A failed SearchOperationResult.</returns>
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
public static SearchOperationResult<T> Failure(SearchError error)
{
ArgumentNullException.ThrowIfNull(error);
return new SearchOperationResult<T>(false, default, error);
}
/// <summary>
/// Creates a failed result with the specified error and a fallback value.
/// </summary>
/// <param name="error">The error that caused the failure.</param>
/// <param name="fallbackValue">A fallback value to use despite the failure.</param>
/// <returns>A failed SearchOperationResult with a fallback value.</returns>
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
public static SearchOperationResult<T> FailureWithFallback(SearchError error, T fallbackValue)
{
ArgumentNullException.ThrowIfNull(error);
return new SearchOperationResult<T>(false, fallbackValue, error);
}
}

View File

@@ -1,35 +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 Common.Search;
/// <summary>
/// Options for configuring search behavior.
/// </summary>
public sealed class SearchOptions
{
/// <summary>
/// Gets or sets the maximum number of results to return.
/// Default is 20.
/// </summary>
public int MaxResults { get; set; } = 20;
/// <summary>
/// Gets or sets the minimum score threshold.
/// Results below this score are filtered out.
/// Default is 0.0 (no filtering).
/// </summary>
public double MinScore { get; set; }
/// <summary>
/// Gets or sets the language hint for the search (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include match spans for highlighting.
/// Default is false.
/// </summary>
public bool IncludeMatchSpans { get; set; }
}

View File

@@ -1,33 +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 Common.Search;
/// <summary>
/// Represents a search result with the matched item and scoring information.
/// </summary>
/// <typeparam name="T">The type of the matched item.</typeparam>
public sealed class SearchResult<T>
where T : ISearchable
{
/// <summary>
/// Gets the matched item.
/// </summary>
public required T Item { get; init; }
/// <summary>
/// Gets the relevance score (higher is more relevant).
/// </summary>
public required double Score { get; init; }
/// <summary>
/// Gets the type of match that produced this result.
/// </summary>
public required SearchMatchKind MatchKind { get; init; }
/// <summary>
/// Gets the match details for highlighting (optional).
/// </summary>
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
}

View File

@@ -1,46 +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 Common.Search.SemanticSearch;
/// <summary>
/// Represents the capabilities of the semantic search index.
/// </summary>
public class SemanticSearchCapabilities
{
/// <summary>
/// Gets or sets a value indicating whether text lexical (keyword) search is available.
/// </summary>
public bool TextLexicalAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether text semantic (AI embedding) search is available.
/// </summary>
public bool TextSemanticAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether image semantic search is available.
/// </summary>
public bool ImageSemanticAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether image OCR search is available.
/// </summary>
public bool ImageOcrAvailable { get; set; }
/// <summary>
/// Gets a value indicating whether any search capability is available.
/// </summary>
public bool AnyAvailable => TextLexicalAvailable || TextSemanticAvailable || ImageSemanticAvailable || ImageOcrAvailable;
/// <summary>
/// Gets a value indicating whether text search (lexical or semantic) is available.
/// </summary>
public bool TextSearchAvailable => TextLexicalAvailable || TextSemanticAvailable;
/// <summary>
/// Gets a value indicating whether image search (semantic or OCR) is available.
/// </summary>
public bool ImageSearchAvailable => ImageSemanticAvailable || ImageOcrAvailable;
}

View File

@@ -1,21 +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 Common.Search.SemanticSearch;
/// <summary>
/// Specifies the kind of content in a semantic search result.
/// </summary>
public enum SemanticSearchContentKind
{
/// <summary>
/// Text content.
/// </summary>
Text,
/// <summary>
/// Image content.
/// </summary>
Image,
}

View File

@@ -1,406 +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 ManagedCommon;
namespace Common.Search.SemanticSearch;
/// <summary>
/// A semantic search engine that implements the common search interface.
/// </summary>
/// <typeparam name="T">The type of items to search.</typeparam>
public sealed class SemanticSearchEngine<T> : ISearchEngine<T>
where T : ISearchable
{
private readonly SemanticSearchIndex _index;
private readonly Dictionary<string, T> _itemsById = new();
private readonly object _lockObject = new();
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchEngine{T}"/> class.
/// </summary>
/// <param name="indexName">The name of the search index.</param>
public SemanticSearchEngine(string indexName)
{
Logger.LogDebug($"[SemanticSearchEngine] Creating engine. IndexName={indexName}, ItemType={typeof(T).Name}");
_index = new SemanticSearchIndex(indexName);
}
/// <inheritdoc/>
public bool IsReady => _index.IsInitialized;
/// <inheritdoc/>
public SearchEngineCapabilities Capabilities { get; } = new()
{
SupportsFuzzyMatch = true,
SupportsSemanticSearch = true,
PersistsIndex = true,
SupportsIncrementalIndex = true,
SupportsMatchSpans = false,
};
/// <summary>
/// Gets the underlying semantic search capabilities.
/// </summary>
public SemanticSearchCapabilities? SemanticCapabilities => _index.Capabilities;
/// <summary>
/// Gets the last error that occurred during a search operation, or null if no error occurred.
/// </summary>
public SearchError? LastError => _index.LastError;
/// <summary>
/// Occurs when the semantic search capabilities change.
/// </summary>
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged
{
add => _index.CapabilitiesChanged += value;
remove => _index.CapabilitiesChanged -= value;
}
/// <inheritdoc/>
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync starting. ItemType={typeof(T).Name}");
var result = await _index.InitializeAsync().ConfigureAwait(false);
if (result.IsFailure)
{
Logger.LogWarning($"[SemanticSearchEngine] InitializeAsync failed. ItemType={typeof(T).Name}, Error={result.Error?.Message}");
}
else
{
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync completed. ItemType={typeof(T).Name}");
}
// Note: We don't throw here to maintain backward compatibility,
// but callers can check LastError for details if initialization failed.
}
/// <summary>
/// Initializes the search engine and returns the result with error details if any.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public async Task<SearchOperationResult> InitializeWithResultAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
return await _index.InitializeAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
ThrowIfDisposed();
var text = BuildSearchableText(item);
if (string.IsNullOrWhiteSpace(text))
{
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync skipped (empty text). Id={item.Id}");
return Task.CompletedTask;
}
lock (_lockObject)
{
_itemsById[item.Id] = item;
}
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync. Id={item.Id}, TextLength={text.Length}");
// Note: Errors are captured in LastError for external logging
_ = _index.IndexText(item.Id, text);
return Task.CompletedTask;
}
/// <summary>
/// Indexes a single item and returns the result with error details if any.
/// </summary>
/// <param name="item">The item to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public Task<SearchOperationResult> IndexWithResultAsync(T item, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(item);
ThrowIfDisposed();
var text = BuildSearchableText(item);
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult(SearchOperationResult.Success());
}
lock (_lockObject)
{
_itemsById[item.Id] = item;
}
return Task.FromResult(_index.IndexText(item.Id, text));
}
/// <inheritdoc/>
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(items);
ThrowIfDisposed();
var batch = new List<(string Id, string Text)>();
lock (_lockObject)
{
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
Logger.LogDebug($"[SemanticSearchEngine] IndexBatchAsync cancelled. ItemsProcessed={batch.Count}");
break;
}
var text = BuildSearchableText(item);
if (!string.IsNullOrWhiteSpace(text))
{
_itemsById[item.Id] = item;
batch.Add((item.Id, text));
}
}
}
Logger.LogInfo($"[SemanticSearchEngine] IndexBatchAsync. BatchSize={batch.Count}");
// Note: Errors are captured in LastError for external logging
_ = _index.IndexTextBatch(batch);
return Task.CompletedTask;
}
/// <summary>
/// Indexes multiple items in batch and returns the result with error details if any.
/// </summary>
/// <param name="items">The items to index.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public Task<SearchOperationResult> IndexBatchWithResultAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(items);
ThrowIfDisposed();
var batch = new List<(string Id, string Text)>();
lock (_lockObject)
{
foreach (var item in items)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
var text = BuildSearchableText(item);
if (!string.IsNullOrWhiteSpace(text))
{
_itemsById[item.Id] = item;
batch.Add((item.Id, text));
}
}
}
return Task.FromResult(_index.IndexTextBatch(batch));
}
/// <inheritdoc/>
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ThrowIfDisposed();
lock (_lockObject)
{
_itemsById.Remove(id);
}
Logger.LogDebug($"[SemanticSearchEngine] RemoveAsync. Id={id}");
// Note: Errors are captured in LastError for external logging
_ = _index.Remove(id);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task ClearAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
int count;
lock (_lockObject)
{
count = _itemsById.Count;
_itemsById.Clear();
}
Logger.LogInfo($"[SemanticSearchEngine] ClearAsync. ItemsCleared={count}");
// Note: Errors are captured in LastError for external logging
_ = _index.RemoveAll();
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync skipped (empty query).");
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
}
options ??= new SearchOptions();
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync starting. Query={query}, MaxResults={options.MaxResults}");
var semanticOptions = new SemanticSearchOptions
{
MaxResults = options.MaxResults,
Language = options.Language,
MatchScope = SemanticSearchMatchScope.Unconstrained,
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
};
var searchResult = _index.SearchText(query, semanticOptions);
// Note: Errors are captured in LastError for external logging
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
var results = new List<SearchResult<T>>();
lock (_lockObject)
{
foreach (var match in matches)
{
if (_itemsById.TryGetValue(match.ContentId, out var item))
{
results.Add(new SearchResult<T>
{
Item = item,
Score = 100.0, // Semantic search doesn't return scores, use fixed value
MatchKind = SearchMatchKind.Semantic,
MatchSpans = null,
});
}
}
}
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync completed. Query={query}, Matches={matches.Count}, Results={results.Count}");
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
}
/// <summary>
/// Searches for items matching the query and returns the result with error details if any.
/// </summary>
/// <param name="query">The search query.</param>
/// <param name="options">Optional search options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A result containing search results or error details.</returns>
public Task<SearchOperationResult<IReadOnlyList<SearchResult<T>>>> SearchWithResultAsync(
string query,
SearchOptions? options = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(Array.Empty<SearchResult<T>>()));
}
options ??= new SearchOptions();
var semanticOptions = new SemanticSearchOptions
{
MaxResults = options.MaxResults,
Language = options.Language,
MatchScope = SemanticSearchMatchScope.Unconstrained,
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
};
var searchResult = _index.SearchText(query, semanticOptions);
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
var results = new List<SearchResult<T>>();
lock (_lockObject)
{
foreach (var match in matches)
{
if (_itemsById.TryGetValue(match.ContentId, out var item))
{
results.Add(new SearchResult<T>
{
Item = item,
Score = 100.0,
MatchKind = SearchMatchKind.Semantic,
MatchSpans = null,
});
}
}
}
if (searchResult.IsFailure)
{
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.FailureWithFallback(searchResult.Error!, results));
}
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(results));
}
/// <summary>
/// Waits for the indexing process to complete.
/// </summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task representing the async operation.</returns>
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
{
ThrowIfDisposed();
await _index.WaitForIndexingCompleteAsync(timeout).ConfigureAwait(false);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
Logger.LogDebug($"[SemanticSearchEngine] Disposing. ItemType={typeof(T).Name}");
_index.Dispose();
lock (_lockObject)
{
_itemsById.Clear();
}
_disposed = true;
}
private static string BuildSearchableText(T item)
{
var primary = item.SearchableText ?? string.Empty;
var secondary = item.SecondarySearchableText;
if (string.IsNullOrWhiteSpace(secondary))
{
return primary;
}
return $"{primary} {secondary}";
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}

View File

@@ -1,455 +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 ManagedCommon;
using Microsoft.Windows.Search.AppContentIndex;
using Windows.Graphics.Imaging;
using SearchOperationResult = Common.Search.SearchOperationResult;
using SearchOperationResultT = Common.Search.SearchOperationResult<System.Collections.Generic.IReadOnlyList<Common.Search.SemanticSearch.SemanticSearchResult>>;
namespace Common.Search.SemanticSearch;
/// <summary>
/// A semantic search engine powered by Windows App SDK AI Search APIs.
/// Provides text and image indexing with lexical and semantic search capabilities.
/// </summary>
public sealed class SemanticSearchIndex : IDisposable
{
private readonly string _indexName;
private AppContentIndexer? _indexer;
private bool _disposed;
private SemanticSearchCapabilities? _capabilities;
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchIndex"/> class.
/// </summary>
/// <param name="indexName">The name of the search index.</param>
public SemanticSearchIndex(string indexName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(indexName);
_indexName = indexName;
}
/// <summary>
/// Gets the last error that occurred during an operation, or null if no error occurred.
/// </summary>
public SearchError? LastError { get; private set; }
/// <summary>
/// Occurs when the index capabilities change.
/// </summary>
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged;
/// <summary>
/// Gets a value indicating whether the search engine is initialized.
/// </summary>
public bool IsInitialized => _indexer != null;
/// <summary>
/// Gets the current index capabilities, or null if not initialized.
/// </summary>
public SemanticSearchCapabilities? Capabilities => _capabilities;
/// <summary>
/// Initializes the search engine and creates or opens the index.
/// </summary>
/// <returns>A task that represents the asynchronous operation. Returns a result indicating success or failure with error details.</returns>
public async Task<SearchOperationResult> InitializeAsync()
{
ThrowIfDisposed();
LastError = null;
if (_indexer != null)
{
Logger.LogDebug($"[SemanticSearchIndex] Already initialized. IndexName={_indexName}");
return SearchOperationResult.Success();
}
Logger.LogInfo($"[SemanticSearchIndex] Initializing. IndexName={_indexName}");
try
{
var result = AppContentIndexer.GetOrCreateIndex(_indexName);
if (!result.Succeeded)
{
var errorDetails = $"Succeeded={result.Succeeded}, ExtendedError={result.ExtendedError}";
Logger.LogError($"[SemanticSearchIndex] GetOrCreateIndex failed. IndexName={_indexName}, {errorDetails}");
LastError = SearchError.InitializationFailed(_indexName, errorDetails);
return SearchOperationResult.Failure(LastError);
}
_indexer = result.Indexer;
// Wait for index capabilities to be ready
Logger.LogDebug($"[SemanticSearchIndex] Waiting for index capabilities. IndexName={_indexName}");
await _indexer.WaitForIndexCapabilitiesAsync();
// Load capabilities
_capabilities = LoadCapabilities();
Logger.LogInfo($"[SemanticSearchIndex] Initialized successfully. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
// Subscribe to capability changes
_indexer.Listener.IndexCapabilitiesChanged += OnIndexCapabilitiesChanged;
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] Initialization failed with exception. IndexName={_indexName}", ex);
LastError = SearchError.InitializationFailed(_indexName, ex.Message, ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Waits for the indexing process to complete.
/// </summary>
/// <param name="timeout">The maximum time to wait for indexing to complete.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
await _indexer!.WaitForIndexingIdleAsync(timeout);
}
/// <summary>
/// Gets the current index capabilities.
/// </summary>
/// <returns>The current capabilities of the search index.</returns>
public SemanticSearchCapabilities GetCapabilities()
{
ThrowIfDisposed();
ThrowIfNotInitialized();
return _capabilities ?? LoadCapabilities();
}
/// <summary>
/// Adds or updates text content in the index.
/// </summary>
/// <param name="id">The unique identifier for the content.</param>
/// <param name="text">The text content to index.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult IndexText(string id, string text)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(text);
try
{
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer!.AddOrUpdate(content);
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] IndexText failed. Id={id}", ex);
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Adds or updates multiple text contents in the index.
/// </summary>
/// <param name="items">A collection of id-text pairs to index.</param>
/// <returns>A result indicating success or failure with error details. Contains the first error encountered if any.</returns>
public SearchOperationResult IndexTextBatch(IEnumerable<(string Id, string Text)> items)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentNullException.ThrowIfNull(items);
SearchError? firstError = null;
foreach (var (id, text) in items)
{
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(text))
{
try
{
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
_indexer!.AddOrUpdate(content);
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] IndexTextBatch item failed. Id={id}", ex);
firstError ??= SearchError.IndexingFailed(id, ex.Message, ex);
}
}
}
if (firstError != null)
{
LastError = firstError;
return SearchOperationResult.Failure(firstError);
}
return SearchOperationResult.Success();
}
/// <summary>
/// Adds or updates image content in the index.
/// </summary>
/// <param name="id">The unique identifier for the image.</param>
/// <param name="bitmap">The image bitmap to index.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult IndexImage(string id, SoftwareBitmap bitmap)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentNullException.ThrowIfNull(bitmap);
try
{
var content = AppManagedIndexableAppContent.CreateFromBitmap(id, bitmap);
_indexer!.AddOrUpdate(content);
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] IndexImage failed. Id={id}", ex);
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Removes content from the index by its identifier.
/// </summary>
/// <param name="id">The unique identifier of the content to remove.</param>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult Remove(string id)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(id);
try
{
_indexer!.Remove(id);
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] Remove failed. Id={id}", ex);
LastError = SearchError.Unexpected("Remove", ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Removes all content from the index.
/// </summary>
/// <returns>A result indicating success or failure with error details.</returns>
public SearchOperationResult RemoveAll()
{
ThrowIfDisposed();
ThrowIfNotInitialized();
try
{
_indexer!.RemoveAll();
return SearchOperationResult.Success();
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] RemoveAll failed.", ex);
LastError = SearchError.Unexpected("RemoveAll", ex);
return SearchOperationResult.Failure(LastError);
}
}
/// <summary>
/// Searches for text content in the index.
/// </summary>
/// <param name="searchText">The text to search for.</param>
/// <param name="options">Optional search options.</param>
/// <returns>A result containing search results or error details.</returns>
public SearchOperationResultT SearchText(string searchText, SemanticSearchOptions? options = null)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
options ??= new SemanticSearchOptions();
try
{
var queryOptions = new TextQueryOptions
{
MatchScope = ConvertMatchScope(options.MatchScope),
TextMatchType = ConvertTextMatchType(options.TextMatchType),
};
if (!string.IsNullOrEmpty(options.Language))
{
queryOptions.Language = options.Language;
}
var query = _indexer!.CreateTextQuery(searchText, queryOptions);
var matches = query.GetNextMatches(options.MaxResults);
return SearchOperationResultT.Success(ConvertTextMatches(matches));
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] SearchText failed. Query={searchText}", ex);
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
}
}
/// <summary>
/// Searches for image content in the index using text.
/// </summary>
/// <param name="searchText">The text to search for in images.</param>
/// <param name="options">Optional search options.</param>
/// <returns>A result containing search results or error details.</returns>
public SearchOperationResultT SearchImages(string searchText, SemanticSearchOptions? options = null)
{
ThrowIfDisposed();
ThrowIfNotInitialized();
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
options ??= new SemanticSearchOptions();
try
{
var queryOptions = new ImageQueryOptions
{
MatchScope = ConvertMatchScope(options.MatchScope),
ImageOcrTextMatchType = ConvertTextMatchType(options.TextMatchType),
};
var query = _indexer!.CreateImageQuery(searchText, queryOptions);
var matches = query.GetNextMatches(options.MaxResults);
return SearchOperationResultT.Success(ConvertImageMatches(matches));
}
catch (Exception ex)
{
Logger.LogError($"[SemanticSearchIndex] SearchImages failed. Query={searchText}", ex);
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
}
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
if (_indexer != null)
{
_indexer.Listener.IndexCapabilitiesChanged -= OnIndexCapabilitiesChanged;
_indexer.Dispose();
_indexer = null;
}
_disposed = true;
}
private SemanticSearchCapabilities LoadCapabilities()
{
var capabilities = _indexer!.GetIndexCapabilities();
return new SemanticSearchCapabilities
{
TextLexicalAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextLexical),
TextSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextSemantic),
ImageSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageSemantic),
ImageOcrAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageOcr),
};
}
private static bool IsCapabilityInitialized(IndexCapabilities capabilities, IndexCapability capability)
{
var state = capabilities.GetCapabilityState(capability);
return state.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
}
private void OnIndexCapabilitiesChanged(AppContentIndexer indexer, IndexCapabilities capabilities)
{
_capabilities = LoadCapabilities();
Logger.LogInfo($"[SemanticSearchIndex] Capabilities changed. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
CapabilitiesChanged?.Invoke(this, _capabilities);
}
private static QueryMatchScope ConvertMatchScope(SemanticSearchMatchScope scope)
{
return scope switch
{
SemanticSearchMatchScope.Unconstrained => QueryMatchScope.Unconstrained,
SemanticSearchMatchScope.Region => QueryMatchScope.Region,
SemanticSearchMatchScope.ContentItem => QueryMatchScope.ContentItem,
_ => QueryMatchScope.Unconstrained,
};
}
private static TextLexicalMatchType ConvertTextMatchType(SemanticSearchTextMatchType matchType)
{
return matchType switch
{
SemanticSearchTextMatchType.Fuzzy => TextLexicalMatchType.Fuzzy,
SemanticSearchTextMatchType.Exact => TextLexicalMatchType.Exact,
_ => TextLexicalMatchType.Fuzzy,
};
}
private static IReadOnlyList<SemanticSearchResult> ConvertTextMatches(IReadOnlyList<TextQueryMatch> matches)
{
var results = new List<SemanticSearchResult>();
foreach (var match in matches)
{
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Text);
if (match.ContentKind == QueryMatchContentKind.AppManagedText &&
match is AppManagedTextQueryMatch textMatch)
{
result.TextOffset = textMatch.TextOffset;
result.TextLength = textMatch.TextLength;
}
results.Add(result);
}
return results;
}
private static IReadOnlyList<SemanticSearchResult> ConvertImageMatches(IReadOnlyList<ImageQueryMatch> matches)
{
var results = new List<SemanticSearchResult>();
foreach (var match in matches)
{
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Image);
results.Add(result);
}
return results;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private void ThrowIfNotInitialized()
{
if (_indexer == null)
{
throw new InvalidOperationException("Search engine is not initialized. Call InitializeAsync() first.");
}
}
}

View File

@@ -1,26 +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 Common.Search.SemanticSearch;
/// <summary>
/// Specifies the scope for semantic search matching.
/// </summary>
public enum SemanticSearchMatchScope
{
/// <summary>
/// No constraints, uses both Lexical and Semantic matching.
/// </summary>
Unconstrained,
/// <summary>
/// Restrict matching to a specific region.
/// </summary>
Region,
/// <summary>
/// Restrict matching to a single content item.
/// </summary>
ContentItem,
}

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.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Options for configuring semantic search queries.
/// </summary>
public class SemanticSearchOptions
{
/// <summary>
/// Gets or sets the language for the search query (e.g., "en-US").
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets the match scope for the search.
/// </summary>
public SemanticSearchMatchScope MatchScope { get; set; } = SemanticSearchMatchScope.Unconstrained;
/// <summary>
/// Gets or sets the text match type for lexical matching.
/// </summary>
public SemanticSearchTextMatchType TextMatchType { get; set; } = SemanticSearchTextMatchType.Fuzzy;
/// <summary>
/// Gets or sets the maximum number of results to return.
/// </summary>
public int MaxResults { get; set; } = 10;
}

View File

@@ -1,42 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Common.Search.SemanticSearch;
/// <summary>
/// Represents a search result from the semantic search engine.
/// </summary>
public class SemanticSearchResult
{
/// <summary>
/// Initializes a new instance of the <see cref="SemanticSearchResult"/> class.
/// </summary>
/// <param name="contentId">The unique identifier of the matched content.</param>
/// <param name="contentKind">The kind of content matched (text or image).</param>
public SemanticSearchResult(string contentId, SemanticSearchContentKind contentKind)
{
ContentId = contentId;
ContentKind = contentKind;
}
/// <summary>
/// Gets the unique identifier of the matched content.
/// </summary>
public string ContentId { get; }
/// <summary>
/// Gets the kind of content that was matched.
/// </summary>
public SemanticSearchContentKind ContentKind { get; }
/// <summary>
/// Gets or sets the text offset where the match was found (for text matches only).
/// </summary>
public int TextOffset { get; set; }
/// <summary>
/// Gets or sets the length of the matched text (for text matches only).
/// </summary>
public int TextLength { get; set; }
}

View File

@@ -1,21 +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 Common.Search.SemanticSearch;
/// <summary>
/// Specifies the type of text matching for lexical searches.
/// </summary>
public enum SemanticSearchTextMatchType
{
/// <summary>
/// Fuzzy matching allows spelling errors and approximate words.
/// </summary>
Fuzzy,
/// <summary>
/// Exact matching requires exact text matches.
/// </summary>
Exact,
}

View File

@@ -153,21 +153,9 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp
{
if (message == WM_HOTKEY)
{
int hotkeyId = static_cast<int>(wparam);
if (HWND fw{ GetForegroundWindow() })
{
if (hotkeyId == static_cast<int>(HotkeyId::Pin))
{
ProcessCommand(fw);
}
else if (hotkeyId == static_cast<int>(HotkeyId::IncreaseOpacity))
{
AdjustTransparency(fw, Settings::transparencyStep);
}
else if (hotkeyId == static_cast<int>(HotkeyId::DecreaseOpacity))
{
AdjustTransparency(fw, -Settings::transparencyStep);
}
ProcessCommand(fw);
}
}
else if (message == WM_PRIV_SETTINGS_CHANGED)
@@ -203,11 +191,6 @@ void AlwaysOnTop::ProcessCommand(HWND window)
m_topmostWindows.erase(iter);
}
// Restore transparency when unpinning
RemoveTransparency(window);
m_windowOriginalLayeredState.erase(window);
m_windowTransparency.erase(window);
Trace::AlwaysOnTop::UnpinWindow();
}
}
@@ -217,7 +200,6 @@ void AlwaysOnTop::ProcessCommand(HWND window)
{
soundType = Sound::Type::On;
AssignBorder(window);
Trace::AlwaysOnTop::PinWindow();
}
}
@@ -287,22 +269,11 @@ void AlwaysOnTop::RegisterHotkey() const
{
if (m_useCentralizedLLKH)
{
// All hotkeys are handled by centralized LLKH
return;
}
// Register hotkeys only when not using centralized LLKH
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::Pin));
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity));
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity));
// Register pin hotkey
RegisterHotKey(m_window, static_cast<int>(HotkeyId::Pin), AlwaysOnTopSettings::settings().hotkey.get_modifiers(), AlwaysOnTopSettings::settings().hotkey.get_code());
// Register transparency hotkeys using the same modifiers as the pin hotkey
UINT modifiers = AlwaysOnTopSettings::settings().hotkey.get_modifiers();
RegisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity), modifiers, VK_OEM_PLUS);
RegisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity), modifiers, VK_OEM_MINUS);
}
void AlwaysOnTop::RegisterLLKH()
@@ -314,8 +285,6 @@ void AlwaysOnTop::RegisterLLKH()
m_hPinEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT);
m_hTerminateEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT);
m_hIncreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT);
m_hDecreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT);
if (!m_hPinEvent)
{
@@ -329,54 +298,30 @@ void AlwaysOnTop::RegisterLLKH()
return;
}
if (!m_hIncreaseOpacityEvent)
{
Logger::warn(L"Failed to create increaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
}
if (!m_hDecreaseOpacityEvent)
{
Logger::warn(L"Failed to create decreaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
}
HANDLE handles[4] = { m_hPinEvent,
m_hTerminateEvent,
m_hIncreaseOpacityEvent,
m_hDecreaseOpacityEvent };
HANDLE handles[2] = { m_hPinEvent,
m_hTerminateEvent };
m_thread = std::thread([this, handles]() {
MSG msg;
while (m_running)
{
DWORD dwEvt = MsgWaitForMultipleObjects(4, handles, false, INFINITE, QS_ALLINPUT);
DWORD dwEvt = MsgWaitForMultipleObjects(2, handles, false, INFINITE, QS_ALLINPUT);
if (!m_running)
{
break;
}
switch (dwEvt)
{
case WAIT_OBJECT_0: // Pin event
case WAIT_OBJECT_0:
if (HWND fw{ GetForegroundWindow() })
{
ProcessCommand(fw);
}
break;
case WAIT_OBJECT_0 + 1: // Terminate event
case WAIT_OBJECT_0 + 1:
PostThreadMessage(m_mainThreadId, WM_QUIT, 0, 0);
break;
case WAIT_OBJECT_0 + 2: // Increase opacity event
if (HWND fw{ GetForegroundWindow() })
{
AdjustTransparency(fw, Settings::transparencyStep);
}
break;
case WAIT_OBJECT_0 + 3: // Decrease opacity event
if (HWND fw{ GetForegroundWindow() })
{
AdjustTransparency(fw, -Settings::transparencyStep);
}
break;
case WAIT_OBJECT_0 + 4: // Message queue
case WAIT_OBJECT_0 + 2:
if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
@@ -425,13 +370,9 @@ void AlwaysOnTop::UnpinAll()
{
Logger::error(L"Unpinning topmost window failed");
}
// Restore transparency when unpinning all
RemoveTransparency(topWindow);
}
m_topmostWindows.clear();
m_windowTransparency.clear();
m_windowOriginalLayeredState.clear();
}
void AlwaysOnTop::CleanUp()
@@ -515,8 +456,6 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
for (const auto window : toErase)
{
m_topmostWindows.erase(window);
m_windowTransparency.erase(window);
m_windowOriginalLayeredState.erase(window);
}
switch (data->event)
@@ -617,182 +556,4 @@ void AlwaysOnTop::RefreshBorders()
}
}
}
}
// Transparency adjustment methods
HWND AlwaysOnTop::GetTransparencyTarget(HWND window)
{
if (!window || !IsWindow(window))
{
return nullptr;
}
// Only allow transparency adjustment on tracked/pinned windows
if (!IsTracked(window) && !IsPinned(window))
{
return nullptr;
}
// Use GA_ROOTOWNER to get the root owner window
HWND targetWindow = GetAncestor(window, GA_ROOTOWNER);
if (!targetWindow)
{
targetWindow = window;
}
// Filter out desktop, shell, invisible windows
if (targetWindow == GetDesktopWindow() || targetWindow == GetShellWindow())
{
return nullptr;
}
if (!IsWindowVisible(targetWindow))
{
return nullptr;
}
return targetWindow;
}
void AlwaysOnTop::AdjustTransparency(HWND window, int delta)
{
HWND targetWindow = GetTransparencyTarget(window);
if (!targetWindow)
{
return;
}
auto it = m_windowTransparency.find(targetWindow);
int currentTransparency = (it != m_windowTransparency.end()) ? it->second : Settings::maxTransparencyPercentage;
int newTransparency = (std::max)(Settings::minTransparencyPercentage,
(std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta));
if (newTransparency != currentTransparency)
{
SetTransparency(targetWindow, newTransparency);
if (AlwaysOnTopSettings::settings().enableSound)
{
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
}
Logger::trace(L"Transparency adjusted to {}%", newTransparency);
}
}
void AlwaysOnTop::SetTransparency(HWND window, int percentage)
{
if (!window || !IsWindow(window))
{
return;
}
percentage = (std::max)(Settings::minTransparencyPercentage,
(std::min)(Settings::maxTransparencyPercentage, percentage));
m_windowTransparency[window] = percentage;
if (percentage == Settings::maxTransparencyPercentage)
{
RemoveTransparency(window);
return;
}
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0;
// Cache original state on first transparency application
if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end())
{
WindowLayeredState state;
state.hadLayeredStyle = isCurrentlyLayered;
if (isCurrentlyLayered)
{
BYTE alpha = 255;
COLORREF colorKey = 0;
DWORD flags = 0;
if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags))
{
state.originalAlpha = alpha;
state.usedColorKey = (flags & LWA_COLORKEY) != 0;
state.colorKey = colorKey;
}
else
{
Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping");
return;
}
}
m_windowOriginalLayeredState[window] = state;
}
// Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works
if (isCurrentlyLayered)
{
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
exStyle = GetWindowLong(window, GWL_EXSTYLE);
}
BYTE alphaValue = static_cast<BYTE>((255 * percentage) / 100);
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA);
}
void AlwaysOnTop::RemoveTransparency(HWND window)
{
if (!window || !IsWindow(window))
{
return;
}
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
auto it = m_windowOriginalLayeredState.find(window);
if (it != m_windowOriginalLayeredState.end())
{
const auto& originalState = it->second;
if (originalState.hadLayeredStyle)
{
// Window originally had WS_EX_LAYERED - restore original attributes
// Clear and re-add to ensure clean state
if (exStyle & WS_EX_LAYERED)
{
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
exStyle = GetWindowLong(window, GWL_EXSTYLE);
}
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
// Restore original alpha and/or color key
DWORD flags = LWA_ALPHA;
if (originalState.usedColorKey)
{
flags |= LWA_COLORKEY;
}
SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
else
{
// Window originally didn't have WS_EX_LAYERED - remove it completely
if (exStyle & WS_EX_LAYERED)
{
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
}
}
m_windowOriginalLayeredState.erase(it);
}
else
{
// Fallback: no cached state, just remove layered style
if (exStyle & WS_EX_LAYERED)
{
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
}
}
}

View File

@@ -10,7 +10,6 @@
#include <common/hooks/WinHookEvent.h>
#include <common/notifications/NotificationUtil.h>
#include <common/utils/window.h>
class AlwaysOnTop : public SettingsObserver
{
@@ -39,8 +38,6 @@ private:
enum class HotkeyId : int
{
Pin = 1,
IncreaseOpacity = 2,
DecreaseOpacity = 3,
};
static inline AlwaysOnTop* s_instance = nullptr;
@@ -51,21 +48,8 @@ private:
HWND m_window{ nullptr };
HINSTANCE m_hinstance;
std::map<HWND, std::unique_ptr<WindowBorder>> m_topmostWindows{};
std::map<HWND, int> m_windowTransparency{}; // Track transparency per window (20-100)
// Store original window layered state for proper restoration
struct WindowLayeredState {
bool hadLayeredStyle = false;
BYTE originalAlpha = 255;
bool usedColorKey = false;
COLORREF colorKey = 0;
};
std::map<HWND, WindowLayeredState> m_windowOriginalLayeredState{};
HANDLE m_hPinEvent;
HANDLE m_hTerminateEvent;
HANDLE m_hIncreaseOpacityEvent;
HANDLE m_hDecreaseOpacityEvent;
DWORD m_mainThreadId;
std::thread m_thread;
const bool m_useCentralizedLLKH;
@@ -94,12 +78,6 @@ private:
bool AssignBorder(HWND window);
void RefreshBorders();
// Transparency methods
HWND GetTransparencyTarget(HWND window);
void AdjustTransparency(HWND window, int delta);
void SetTransparency(HWND window, int percentage);
void RemoveTransparency(HWND window);
virtual void SettingsUpdate(SettingId type) override;
static void CALLBACK WinHookProc(HWINEVENTHOOK winEventHook,

View File

@@ -5,12 +5,12 @@
<ItemGroup>
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />

View File

@@ -197,16 +197,10 @@ namespace Peek.UI
ViewModel.Initialize(selectedItem);
// If no files were found (e.g., in virtual folders like Home/Recent), show an error
// If no files were found (e.g., user is typing in rename/search box, or in virtual folders),
// don't show anything - just return silently to avoid stealing focus
if (ViewModel.CurrentItem == null)
{
Logger.LogInfo("Peek: No files found to preview, showing error.");
var errorMessage = ResourceLoaderInstance.ResourceLoader.GetString("NoFilesSelected");
ViewModel.ShowError(errorMessage);
// Still show the window so user can see the warning
this.Show();
WindowHelpers.BringToForeground(this.GetWindowHandle());
return;
}

View File

@@ -33,23 +33,27 @@ static std::wstring SanitizeAndNormalize(const std::wstring& input)
// Normalize to NFC (Precomposed).
// Get the size needed for the normalized string, including null terminator.
int size = NormalizeString(NormalizationC, sanitized.c_str(), -1, nullptr, 0);
if (size <= 0)
int sizeEstimate = NormalizeString(NormalizationC, sanitized.c_str(), -1, nullptr, 0);
if (sizeEstimate <= 0)
{
return sanitized; // Return unaltered if normalization fails.
}
// Perform the normalization.
std::wstring normalized;
normalized.resize(size);
NormalizeString(NormalizationC, sanitized.c_str(), -1, &normalized[0], size);
normalized.resize(sizeEstimate);
int actualSize = NormalizeString(NormalizationC, sanitized.c_str(), -1, &normalized[0], sizeEstimate);
// Remove the explicit null terminator added by NormalizeString.
if (!normalized.empty() && normalized.back() == L'\0')
if (actualSize <= 0)
{
normalized.pop_back();
// Normalization failed, return sanitized string.
return sanitized;
}
// Resize to actual size minus the null terminator.
// actualSize includes the null terminator when input length is -1.
normalized.resize(static_cast<size_t>(actualSize) - 1);
return normalized;
}

View File

@@ -695,6 +695,38 @@ TEST_METHOD(VerifyUnicodeAndWhitespaceNormalizationRegex)
VerifyNormalizationHelper(UseRegularExpressions);
}
TEST_METHOD(VerifyRegexMetacharacterDollarSign)
{
CComPtr<IPowerRenameRegEx> renameRegEx;
Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK);
DWORD flags = UseRegularExpressions;
Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK);
PWSTR result = nullptr;
Assert::IsTrue(renameRegEx->PutSearchTerm(L"$") == S_OK);
Assert::IsTrue(renameRegEx->PutReplaceTerm(L"_end") == S_OK);
unsigned long index = {};
Assert::IsTrue(renameRegEx->Replace(L"test.txt", &result, index) == S_OK);
Assert::AreEqual(L"test.txt_end", result);
CoTaskMemFree(result);
}
TEST_METHOD(VerifyRegexMetacharacterCaret)
{
CComPtr<IPowerRenameRegEx> renameRegEx;
Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK);
DWORD flags = UseRegularExpressions;
Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK);
PWSTR result = nullptr;
Assert::IsTrue(renameRegEx->PutSearchTerm(L"^") == S_OK);
Assert::IsTrue(renameRegEx->PutReplaceTerm(L"start_") == S_OK);
unsigned long index = {};
Assert::IsTrue(renameRegEx->Replace(L"test.txt", &result, index) == S_OK);
Assert::AreEqual(L"start_test.txt", result);
CoTaskMemFree(result);
}
#ifndef TESTS_PARTIAL
};
}

View File

@@ -173,6 +173,10 @@
<value>Settings\tDouble-click</value>
<comment>Don't localize "\t" as that is what separates the click portion to be right aligned in the menu.</comment>
</data>
<data name="SETTINGS_MENU_TEXT_LEFTCLICK" xml:space="preserve">
<value>Settings\tLeft-click</value>
<comment>Don't localize "\t" as that is what separates the click portion to be right aligned in the menu. This is shown when Quick Access is disabled.</comment>
</data>
<data name="DOCUMENTATION_MENU_TEXT" xml:space="preserve">
<value>Documentation</value>
</data>

View File

@@ -3,7 +3,6 @@
#include <Sddl.h>
#include <sstream>
#include <aclapi.h>
#include <shobjidl.h>
#include "powertoy_module.h"
#include <common/interop/two_way_pipe_message_ipc.h>
@@ -65,74 +64,6 @@ json::JsonObject get_all_settings()
return result;
}
namespace
{
constexpr wchar_t SettingsApplicationId[] = L"PowerToys.SettingsUI";
constexpr wchar_t SparseAppFamilyDev[] = L"Microsoft.PowerToys.SparseApp_djwsxzxb4ksa8";
constexpr wchar_t SparseAppFamilyStore[] = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe";
bool try_activate_settings_with_identity(const std::wstring& arguments, PROCESS_INFORMATION& process_info)
{
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
const bool should_uninit = SUCCEEDED(hr);
if (hr == RPC_E_CHANGED_MODE)
{
hr = S_OK;
}
if (FAILED(hr))
{
Logger::warn(L"Settings: CoInitializeEx failed. hr=0x{:x}", hr);
return false;
}
IApplicationActivationManager* activation_manager = nullptr;
hr = CoCreateInstance(CLSID_ApplicationActivationManager,
nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(&activation_manager));
if (FAILED(hr))
{
Logger::warn(L"Settings: CoCreateInstance(ApplicationActivationManager) failed. hr=0x{:x}", hr);
if (should_uninit)
{
CoUninitialize();
}
return false;
}
auto try_activate = [&](const wchar_t* family) -> bool {
std::wstring aumid = std::wstring(family) + L"!" + SettingsApplicationId;
DWORD pid = 0;
HRESULT hr_activate = activation_manager->ActivateApplication(aumid.c_str(),
arguments.c_str(),
AO_NONE,
&pid);
if (SUCCEEDED(hr_activate) && pid != 0)
{
process_info = {};
process_info.dwProcessId = pid;
process_info.hProcess = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
Logger::info(L"Settings: Activated via AUMID {} (pid {}).", aumid, pid);
return true;
}
Logger::warn(L"Settings: ActivateApplication failed for {}. hr=0x{:x}", aumid, hr_activate);
return false;
};
const bool activated = try_activate(SparseAppFamilyDev) || try_activate(SparseAppFamilyStore);
activation_manager->Release();
if (should_uninit)
{
CoUninitialize();
}
return activated;
}
}
std::optional<std::wstring> dispatch_json_action_to_module(const json::JsonObject& powertoys_configs)
{
std::optional<std::wstring> result;
@@ -591,34 +522,15 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op
settings_showOobe,
settings_showScoobe,
settings_containsSettingsWindow);
std::wstring activation_args = fmt::format(L"{} {} {} {} {} {} {} {} {}",
powertoys_pipe_name,
settings_pipe_name,
std::to_wstring(powertoys_pid),
settings_theme,
settings_elevatedStatus,
settings_isUserAnAdmin,
settings_showOobe,
settings_showScoobe,
settings_containsSettingsWindow);
if (settings_window.has_value())
{
executable_args.append(L" ");
executable_args.append(settings_window.value());
activation_args.append(L" ");
activation_args.append(settings_window.value());
}
BOOL process_created = false;
// Prefer activating via package identity so the package graph (framework deps) is applied.
if (try_activate_settings_with_identity(activation_args, process_info))
{
process_created = true;
g_isLaunchInProgress = false;
}
// Commented out to fix #22659
// Running settings non-elevated and modules elevated when PowerToys is running elevated results
// in settings making changes in one file (non-elevated user dir) and modules are reading settings

View File

@@ -40,6 +40,7 @@ namespace
bool double_click_timer_running = false;
bool double_clicked = false;
POINT tray_icon_click_point;
std::optional<bool> last_quick_access_state; // Track the last known Quick Access state
static ThemeListener theme_listener;
static bool theme_adaptive_enabled = false;
@@ -195,6 +196,18 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam
case WM_RBUTTONUP:
case WM_CONTEXTMENU:
{
bool quick_access_enabled = get_general_settings().enableQuickAccess;
// Reload menu if Quick Access state has changed or is first time
if (h_menu && (!last_quick_access_state.has_value() || quick_access_enabled != last_quick_access_state.value()))
{
DestroyMenu(h_menu);
h_menu = nullptr;
h_sub_menu = nullptr;
}
last_quick_access_state = quick_access_enabled;
if (!h_menu)
{
h_menu = LoadMenu(reinterpret_cast<HINSTANCE>(&__ImageBase), MAKEINTRESOURCE(ID_TRAY_MENU));
@@ -202,17 +215,39 @@ LRESULT __stdcall tray_icon_window_proc(HWND window, UINT message, WPARAM wparam
if (h_menu)
{
static std::wstring settings_menuitem_label = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT);
static std::wstring settings_menuitem_label_leftclick = GET_RESOURCE_STRING(IDS_SETTINGS_MENU_TEXT_LEFTCLICK);
static std::wstring close_menuitem_label = GET_RESOURCE_STRING(IDS_CLOSE_MENU_TEXT);
static std::wstring submit_bug_menuitem_label = GET_RESOURCE_STRING(IDS_SUBMIT_BUG_TEXT);
static std::wstring documentation_menuitem_label = GET_RESOURCE_STRING(IDS_DOCUMENTATION_MENU_TEXT);
static std::wstring quick_access_menuitem_label = GET_RESOURCE_STRING(IDS_QUICK_ACCESS_MENU_TEXT);
change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data());
// Update Settings menu text based on Quick Access state
if (quick_access_enabled)
{
change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label.data());
}
else
{
change_menu_item_text(ID_SETTINGS_MENU_COMMAND, settings_menuitem_label_leftclick.data());
}
change_menu_item_text(ID_CLOSE_MENU_COMMAND, close_menuitem_label.data());
change_menu_item_text(ID_REPORT_BUG_COMMAND, submit_bug_menuitem_label.data());
bool bug_report_disabled = is_bug_report_running();
EnableMenuItem(h_sub_menu, ID_REPORT_BUG_COMMAND, MF_BYCOMMAND | (bug_report_disabled ? MF_GRAYED : MF_ENABLED));
change_menu_item_text(ID_DOCUMENTATION_MENU_COMMAND, documentation_menuitem_label.data());
change_menu_item_text(ID_QUICK_ACCESS_MENU_COMMAND, quick_access_menuitem_label.data());
// Hide or show Quick Access menu item based on setting
if (!h_sub_menu)
{
h_sub_menu = GetSubMenu(h_menu, 0);
}
if (!quick_access_enabled)
{
// Remove Quick Access menu item when disabled
DeleteMenu(h_sub_menu, ID_QUICK_ACCESS_MENU_COMMAND, MF_BYCOMMAND);
}
}
if (!h_sub_menu)
{

View File

@@ -2,10 +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 Common.Search;
namespace Settings.UI.Library
{
public enum EntryType
@@ -15,7 +11,7 @@ namespace Settings.UI.Library
SettingsExpander,
}
public struct SettingEntry : ISearchable
public struct SettingEntry
{
public EntryType Type { get; set; }
@@ -33,23 +29,16 @@ namespace Settings.UI.Library
public string Icon { get; set; }
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string? parentElementName = null, string? description = null, string? icon = null)
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null)
{
Type = type;
Header = header;
PageTypeName = pageTypeName;
ElementName = elementName;
ElementUid = elementUid;
ParentElementName = parentElementName ?? string.Empty;
Description = description ?? string.Empty;
Icon = icon ?? string.Empty;
ParentElementName = parentElementName;
Description = description;
Icon = icon;
}
// ISearchable implementation
public readonly string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
public readonly string SearchableText => Header ?? string.Empty;
public readonly string? SecondarySearchableText => Description;
}
}

View File

@@ -1,25 +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.Collections.Generic;
using Common.Search;
namespace Settings.UI.Library
{
/// <summary>
/// Represents a search result for a settings entry with scoring metadata.
/// </summary>
public sealed class SettingSearchResult
{
public required SettingEntry Entry { get; init; }
public required double Score { get; init; }
public required SearchMatchKind MatchKind { get; init; }
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
}
}

View File

@@ -23,7 +23,6 @@
<ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" />
<ProjectReference Include="..\..\common\Common.Search\Common.Search.csproj" />
</ItemGroup>
<ItemGroup>

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.
using System.Collections.Generic;
using System.Threading.Tasks;
using Common.Search.FuzzSearch;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.UnitTests.Services
{
[TestClass]
[DoNotParallelize]
public class SettingsSearchTests
{
[TestMethod]
public void LoadIndexFromJson_ReturnsEntries()
{
const string json = @"
[
{
""type"": 0,
""header"": ""General"",
""pageTypeName"": ""GeneralPage"",
""elementName"": """",
""elementUid"": ""General_Page"",
""parentElementName"": """",
""description"": ""General settings"",
""icon"": """"
},
{
""type"": 1,
""header"": ""Mouse Utilities"",
""pageTypeName"": ""MouseUtilsPage"",
""elementName"": ""MouseUtilsSetting"",
""elementUid"": ""MouseUtils_Setting"",
""parentElementName"": """",
""description"": ""Adjust mouse settings"",
""icon"": """"
}
]
";
var entries = SettingsSearch.LoadIndexFromJson(json);
Assert.AreEqual(2, entries.Count);
Assert.AreEqual(EntryType.SettingsPage, entries[0].Type);
Assert.AreEqual("GeneralPage", entries[0].PageTypeName);
Assert.AreEqual("MouseUtilsPage", entries[1].PageTypeName);
}
[TestMethod]
public async Task InitializeIndexAsync_InitializesSearchAndReturnsResults()
{
var entries = new List<SettingEntry>
{
new(EntryType.SettingsPage, "General", "GeneralPage", string.Empty, "General_Page"),
new(EntryType.SettingsCard, "Mouse Utilities", "MouseUtilsPage", "MouseUtilsSetting", "MouseUtils_Setting", description: "Adjust mouse settings"),
};
using var search = new SettingsSearch(new FuzzSearchEngine<SettingEntry>());
await search.InitializeIndexAsync(entries);
Assert.IsTrue(search.IsReady);
var results = await search.SearchAsync("mouse", options: null);
Assert.IsTrue(results.Count > 0);
Assert.AreEqual("Mouse Utilities", results[0].Entry.Header);
Assert.IsTrue(results[0].Score > 0);
Assert.IsNotNull(results[0].MatchSpans);
}
}
}

View File

@@ -215,4 +215,4 @@
<Message Importance="high" Text="[Settings] Building XamlIndexBuilder prior to compile. Views='$(MSBuildProjectDirectory)\SettingsXAML\Views' Out='$(GeneratedJsonFile)'" />
<MSBuild Projects="..\Settings.UI.XamlIndexBuilder\Settings.UI.XamlIndexBuilder.csproj" Targets="Build" Properties="Configuration=$(Configuration);Platform=Any CPU;TargetFramework=net9.0;XamlViewsDir=$(MSBuildProjectDirectory)\SettingsXAML\Views;GeneratedJsonFile=$(GeneratedJsonFile)" />
</Target>
</Project>
</Project>

View File

@@ -0,0 +1,338 @@
// 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.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Common.Search.FuzzSearch;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.Windows.ApplicationModel.Resources;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Services
{
public static class SearchIndexService
{
private static readonly object _lockObject = new();
private static readonly Dictionary<string, string> _pageNameCache = [];
private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
private static readonly Dictionary<string, Type> _pageTypeCache = new();
private static ImmutableArray<SettingEntry> _index = [];
private static bool _isIndexBuilt;
private static bool _isIndexBuilding;
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true };
public static ImmutableArray<SettingEntry> Index
{
get
{
lock (_lockObject)
{
return _index;
}
}
}
public static bool IsIndexReady
{
get
{
lock (_lockObject)
{
return _isIndexBuilt;
}
}
}
public static void BuildIndex()
{
lock (_lockObject)
{
if (_isIndexBuilt || _isIndexBuilding)
{
return;
}
_isIndexBuilding = true;
// Clear caches on rebuild
_normalizedTextCache.Clear();
_pageTypeCache.Clear();
}
try
{
var builder = ImmutableArray.CreateBuilder<SettingEntry>();
LoadIndexFromPrebuiltData(builder);
lock (_lockObject)
{
_index = builder.ToImmutable();
_isIndexBuilt = true;
_isIndexBuilding = false;
}
}
catch (Exception ex)
{
Debug.WriteLine($"[SearchIndexService] CRITICAL ERROR building search index: {ex.Message}\n{ex.StackTrace}");
lock (_lockObject)
{
_isIndexBuilding = false;
_isIndexBuilt = false;
}
}
}
private static void LoadIndexFromPrebuiltData(ImmutableArray<SettingEntry>.Builder builder)
{
var assembly = Assembly.GetExecutingAssembly();
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
SettingEntry[] metadataList;
Debug.WriteLine($"[SearchIndexService] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
try
{
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
if (stream == null)
{
Debug.WriteLine($"[SearchIndexService] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
return;
}
using StreamReader reader = new(stream);
string json = reader.ReadToEnd();
if (string.IsNullOrWhiteSpace(json))
{
Debug.WriteLine("[SearchIndexService] ERROR: Embedded resource was empty.");
return;
}
metadataList = JsonSerializer.Deserialize<SettingEntry[]>(json, _serializerOptions);
}
catch (Exception ex)
{
Debug.WriteLine($"[SearchIndexService] ERROR: Failed to load or deserialize prebuilt index: {ex.Message}");
return;
}
if (metadataList == null || metadataList.Length == 0)
{
Debug.WriteLine("[SearchIndexService] Prebuilt index is empty or deserialization failed.");
return;
}
foreach (ref var metadata in metadataList.AsSpan())
{
if (metadata.Type == EntryType.SettingsPage)
{
(metadata.Header, metadata.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, metadata.ElementUid);
}
else
{
(metadata.Header, metadata.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, metadata.ElementUid);
}
if (string.IsNullOrEmpty(metadata.Header))
{
continue;
}
builder.Add(metadata);
// Cache the page name mapping for SettingsPage entries
if (metadata.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(metadata.Header))
{
_pageNameCache[metadata.PageTypeName] = metadata.Header;
}
}
Debug.WriteLine($"[SearchIndexService] Finished loading index. Total entries: {builder.Count}");
}
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string header = GetString(resourceLoader, $"{elementUid}/Header");
string description = GetString(resourceLoader, $"{elementUid}/Description");
if (string.IsNullOrEmpty(header))
{
header = GetString(resourceLoader, $"{elementUid}/Content");
}
return (header, description);
}
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
return (title, description);
}
private static string GetString(ResourceLoader rl, string key)
{
try
{
string value = rl.GetString(key);
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
}
catch (Exception)
{
return string.Empty;
}
}
public static List<SettingEntry> Search(string query)
{
return Search(query, CancellationToken.None);
}
public static List<SettingEntry> Search(string query, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
var currentIndex = Index;
if (currentIndex.IsEmpty)
{
Debug.WriteLine("[SearchIndexService] Search called but index is empty.");
return [];
}
var normalizedQuery = NormalizeString(query);
var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>();
var po = new ParallelOptions
{
CancellationToken = token,
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
};
try
{
Parallel.ForEach(currentIndex, po, entry =>
{
var (headerNorm, descNorm) = GetNormalizedTexts(entry);
var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm);
double score = captionScoreResult.Score;
if (!string.IsNullOrEmpty(descNorm))
{
var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm);
if (descriptionScoreResult.Success)
{
score = Math.Max(score, descriptionScoreResult.Score * 0.8);
}
}
if (score > 0)
{
var pageType = GetPageTypeFromName(entry.PageTypeName);
if (pageType != null)
{
bag.Add((entry, score));
}
}
});
}
catch (OperationCanceledException)
{
return [];
}
return bag
.OrderByDescending(r => r.Score)
.Select(r => r.Hit)
.ToList();
}
private static Type GetPageTypeFromName(string pageTypeName)
{
if (string.IsNullOrEmpty(pageTypeName))
{
return null;
}
lock (_lockObject)
{
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
{
return cached;
}
var assembly = typeof(GeneralPage).Assembly;
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
_pageTypeCache[pageTypeName] = type;
return type;
}
}
private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
{
if (entry.ElementUid == null && entry.Header == null)
{
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
}
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
lock (_lockObject)
{
if (_normalizedTextCache.TryGetValue(key, out var cached))
{
return cached;
}
}
var headerNorm = NormalizeString(entry.Header);
var descNorm = NormalizeString(entry.Description);
lock (_lockObject)
{
_normalizedTextCache[key] = (headerNorm, descNorm);
}
return (headerNorm, descNorm);
}
private static string NormalizeString(string input)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
var stringBuilder = new StringBuilder();
foreach (var c in normalized)
{
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
{
stringBuilder.Append(c);
}
}
return stringBuilder.ToString();
}
public static string GetLocalizedPageName(string pageTypeName)
{
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
}
}
}

View File

@@ -1,434 +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.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Common.Search;
using Common.Search.SemanticSearch;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.Windows.ApplicationModel.Resources;
using Settings.UI.Library;
namespace Microsoft.PowerToys.Settings.UI.Services
{
public sealed class SettingsSearch : IDisposable
{
private static readonly Lazy<SettingsSearch> DefaultInstance = new(() => new SettingsSearch());
private readonly object _lockObject = new();
private readonly Dictionary<string, string> _pageNameCache = [];
private readonly Dictionary<string, Type> _pageTypeCache = new();
private readonly ISearchEngine<SettingEntry> _searchEngine;
private ImmutableArray<SettingEntry> _index = [];
private bool _isIndexBuilt;
private bool _isIndexBuilding;
private bool _disposed;
private Task _buildTask;
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true };
private const string DefaultIndexName = "PowerToys.Settings";
public SettingsSearch()
: this(new SemanticSearchEngine<SettingEntry>(DefaultIndexName))
{
}
public SettingsSearch(ISearchEngine<SettingEntry> searchEngine)
{
ArgumentNullException.ThrowIfNull(searchEngine);
_searchEngine = searchEngine;
}
public static SettingsSearch Default => DefaultInstance.Value;
public ImmutableArray<SettingEntry> Index
{
get
{
lock (_lockObject)
{
return _index;
}
}
}
public bool IsReady
{
get
{
lock (_lockObject)
{
return _isIndexBuilt && _searchEngine.IsReady;
}
}
}
public Task BuildIndexAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
lock (_lockObject)
{
if (_isIndexBuilt)
{
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index already built.");
return Task.CompletedTask;
}
if (_isIndexBuilding)
{
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index build already in progress.");
return _buildTask ?? Task.CompletedTask;
}
if (_buildTask != null)
{
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: build task already scheduled.");
return _buildTask;
}
Logger.LogInfo("[SettingsSearch] BuildIndexAsync started.");
_buildTask = BuildIndexInternalAsync(cancellationToken);
return _buildTask;
}
}
public async Task InitializeIndexAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entries);
ThrowIfDisposed();
var builtIndex = entries is ImmutableArray<SettingEntry> immutableEntries
? immutableEntries
: ImmutableArray.CreateRange(entries);
lock (_lockObject)
{
_isIndexBuilding = true;
_isIndexBuilt = false;
_index = builtIndex;
_pageNameCache.Clear();
_pageTypeCache.Clear();
}
Logger.LogInfo($"[SettingsSearch] Initializing index. Entries={builtIndex.Length}, Engine={_searchEngine.GetType().Name}.");
CachePageNames(builtIndex);
try
{
if (_searchEngine.IsReady)
{
Logger.LogDebug("[SettingsSearch] Clearing existing search engine index.");
await _searchEngine.ClearAsync(cancellationToken).ConfigureAwait(false);
}
else
{
Logger.LogDebug("[SettingsSearch] Initializing search engine.");
await _searchEngine.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
if (!_searchEngine.IsReady)
{
Logger.LogWarning("[SettingsSearch] Search engine not ready after initialization. Skipping indexing.");
return;
}
await _searchEngine.IndexBatchAsync(builtIndex, cancellationToken).ConfigureAwait(false);
lock (_lockObject)
{
_isIndexBuilt = true;
}
Logger.LogInfo($"[SettingsSearch] Index initialized. Entries={builtIndex.Length}, EngineReady={_searchEngine.IsReady}.");
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] CRITICAL ERROR initializing search engine: {ex.Message}", ex);
}
finally
{
lock (_lockObject)
{
_isIndexBuilding = false;
}
}
}
public async Task<IReadOnlyList<SettingSearchResult>> SearchAsync(
string query,
SearchOptions options = null,
CancellationToken token = default)
{
ThrowIfDisposed();
if (string.IsNullOrWhiteSpace(query))
{
return Array.Empty<SettingSearchResult>();
}
if (!IsReady)
{
Logger.LogWarning("[SettingsSearch] Search called but index is not ready.");
return Array.Empty<SettingSearchResult>();
}
var effectiveOptions = options ?? new SearchOptions
{
MaxResults = Index.Length,
IncludeMatchSpans = true,
};
try
{
var sw = Stopwatch.StartNew();
Logger.LogDebug($"[SettingsSearch] Search start. QueryLength={query.Length}, MaxResults={effectiveOptions.MaxResults}.");
var results = await Task.Run(
() => _searchEngine.SearchAsync(query, effectiveOptions, token),
token).ConfigureAwait(false);
var filtered = FilterValidPageTypes(results);
sw.Stop();
Logger.LogDebug($"[SettingsSearch] Search complete. Results={filtered.Count}, ElapsedMs={sw.ElapsedMilliseconds}.");
return filtered;
}
catch (OperationCanceledException)
{
Logger.LogDebug("[SettingsSearch] Search canceled.");
return Array.Empty<SettingSearchResult>();
}
}
public static IReadOnlyList<SettingEntry> LoadIndexFromJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return Array.Empty<SettingEntry>();
}
try
{
return JsonSerializer.Deserialize<SettingEntry[]>(json, SerializerOptions) ?? Array.Empty<SettingEntry>();
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] ERROR: Failed to load index from json: {ex.Message}", ex);
return Array.Empty<SettingEntry>();
}
}
public string GetLocalizedPageName(string pageTypeName)
{
lock (_lockObject)
{
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_searchEngine.Dispose();
_disposed = true;
}
private async Task BuildIndexInternalAsync(CancellationToken cancellationToken)
{
try
{
var sw = Stopwatch.StartNew();
var entries = LoadIndexFromPrebuiltData();
Logger.LogInfo($"[SettingsSearch] Prebuilt index loaded. Entries={entries.Length}.");
await InitializeIndexAsync(entries, cancellationToken).ConfigureAwait(false);
sw.Stop();
Logger.LogInfo($"[SettingsSearch] BuildIndexAsync finished. ElapsedMs={sw.ElapsedMilliseconds}, Ready={IsReady}.");
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] CRITICAL ERROR building search index: {ex.Message}", ex);
}
finally
{
lock (_lockObject)
{
_buildTask = null;
}
}
}
private void CachePageNames(ImmutableArray<SettingEntry> entries)
{
lock (_lockObject)
{
foreach (var entry in entries)
{
if (entry.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(entry.Header))
{
_pageNameCache[entry.PageTypeName] = entry.Header;
}
}
}
}
private ImmutableArray<SettingEntry> LoadIndexFromPrebuiltData()
{
var assembly = Assembly.GetExecutingAssembly();
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
Logger.LogInfo($"[SettingsSearch] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
string json;
try
{
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
if (stream == null)
{
Logger.LogError($"[SettingsSearch] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
return ImmutableArray<SettingEntry>.Empty;
}
using StreamReader reader = new(stream);
json = reader.ReadToEnd();
}
catch (Exception ex)
{
Logger.LogError($"[SettingsSearch] ERROR: Failed to read prebuilt index: {ex.Message}", ex);
return ImmutableArray<SettingEntry>.Empty;
}
if (string.IsNullOrWhiteSpace(json))
{
Logger.LogError("[SettingsSearch] ERROR: Embedded resource was empty.");
return ImmutableArray<SettingEntry>.Empty;
}
var metadataList = LoadIndexFromJson(json);
if (metadataList == null || metadataList.Count == 0)
{
Logger.LogWarning("[SettingsSearch] Prebuilt index is empty or deserialization failed.");
return ImmutableArray<SettingEntry>.Empty;
}
var builder = ImmutableArray.CreateBuilder<SettingEntry>(metadataList.Count);
foreach (var metadata in metadataList)
{
var entry = metadata;
if (entry.Type == EntryType.SettingsPage)
{
(entry.Header, entry.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, entry.ElementUid);
}
else
{
(entry.Header, entry.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, entry.ElementUid);
}
if (string.IsNullOrEmpty(entry.Header))
{
continue;
}
builder.Add(entry);
}
Logger.LogInfo($"[SettingsSearch] Finished loading index. Total entries: {builder.Count}");
return builder.ToImmutable();
}
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string header = GetString(resourceLoader, $"{elementUid}/Header");
string description = GetString(resourceLoader, $"{elementUid}/Description");
if (string.IsNullOrEmpty(header))
{
header = GetString(resourceLoader, $"{elementUid}/Content");
}
return (header, description);
}
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
{
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
return (title, description);
}
private static string GetString(ResourceLoader rl, string key)
{
try
{
string value = rl.GetString(key);
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
}
catch (Exception)
{
return string.Empty;
}
}
private IReadOnlyList<SettingSearchResult> FilterValidPageTypes(IReadOnlyList<SearchResult<SettingEntry>> results)
{
var filtered = new List<SettingSearchResult>(results.Count);
foreach (var result in results)
{
var entry = result.Item;
if (GetPageTypeFromName(entry.PageTypeName) != null)
{
filtered.Add(new SettingSearchResult
{
Entry = entry,
Score = result.Score,
MatchKind = result.MatchKind,
MatchSpans = result.MatchSpans,
});
}
}
return filtered;
}
private Type GetPageTypeFromName(string pageTypeName)
{
if (string.IsNullOrEmpty(pageTypeName))
{
return null;
}
lock (_lockObject)
{
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
{
return cached;
}
var assembly = typeof(GeneralPage).Assembly;
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
_pageTypeCache[pageTypeName] = type;
return type;
}
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
}

View File

@@ -32,12 +32,12 @@
IsTabStop="False"
ItemsSource="{x:Bind ViewModel.ModuleResults, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:SettingSearchResult">
<DataTemplate x:DataType="models:SettingEntry">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Click="ModuleButton_Click"
Header="{x:Bind Entry.Header}"
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE8B7;}"
Header="{x:Bind Header}"
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE8B7;}"
IsClickEnabled="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -52,19 +52,19 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SettingsGroup">
<controls:SettingsGroup Header="{x:Bind GroupName}">
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:SettingSearchResult">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Click="SettingButton_Click"
Description="{x:Bind Entry.Description}"
Header="{x:Bind Entry.Header}"
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE713;}"
IsClickEnabled="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:SettingEntry">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Click="SettingButton_Click"
Description="{x:Bind Description}"
Header="{x:Bind Header}"
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=&#xE713;}"
IsClickEnabled="True" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</controls:SettingsGroup>
</DataTemplate>
</ItemsControl.ItemTemplate>

View File

@@ -44,17 +44,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private void ModuleButton_Click(object sender, RoutedEventArgs e)
{
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
{
NavigateToModule(tagResult.Entry);
NavigateToModule(tagEntry);
}
}
private void SettingButton_Click(object sender, RoutedEventArgs e)
{
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
{
NavigateToSetting(tagResult.Entry);
NavigateToSetting(tagEntry);
}
}
@@ -98,9 +98,9 @@ namespace Microsoft.PowerToys.Settings.UI.Views
{
public string Query { get; set; }
public List<SettingSearchResult> Results { get; set; }
public List<SettingEntry> Results { get; set; }
public SearchResultsNavigationParams(string query, List<SettingSearchResult> results)
public SearchResultsNavigationParams(string query, List<SettingEntry> results)
{
Query = query;
Results = results;

View File

@@ -8,6 +8,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Common.Search;
using Common.Search.FuzzSearch;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Controls;
using Microsoft.PowerToys.Settings.UI.Helpers;
@@ -384,7 +385,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
private void ShellPage_Loaded(object sender, RoutedEventArgs e)
{
_ = Task.Run(() => SettingsSearch.Default.BuildIndexAsync());
Task.Run(() =>
{
SearchIndexService.BuildIndex();
})
.ContinueWith(_ => { });
}
private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
@@ -422,7 +427,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0);
}
private List<SettingSearchResult> _lastSearchResults = new();
private List<SettingEntry> _lastSearchResults = new();
private string _lastQueryText = string.Empty;
private async void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
@@ -465,10 +470,11 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
// Query the index on a background thread to avoid blocking UI
List<SettingSearchResult> results = null;
List<SettingEntry> results = null;
try
{
results = (await SettingsSearch.Default.SearchAsync(query, options: null, token)).ToList();
// If the token is already canceled before scheduling, the task won't start.
results = await Task.Run(() => SearchIndexService.Search(query, token), token);
}
catch (OperationCanceledException)
{
@@ -572,7 +578,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
// Centralized suggestion projection logic used by TextChanged & GotFocus restore.
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingSearchResult> results)
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingEntry> results)
{
results ??= new();
if (results.Count == 0)
@@ -595,17 +601,16 @@ namespace Microsoft.PowerToys.Settings.UI.Views
};
}
var list = results.Take(5).Select(result =>
var list = results.Take(5).Select(e =>
{
var entry = result.Entry;
string subtitle = string.Empty;
if (entry.Type != EntryType.SettingsPage)
if (e.Type != EntryType.SettingsPage)
{
subtitle = SettingsSearch.Default.GetLocalizedPageName(entry.PageTypeName);
subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName);
if (string.IsNullOrEmpty(subtitle))
{
subtitle = SettingsSearch.Default.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == entry.PageTypeName)
subtitle = SearchIndexService.Index
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName)
.Select(x => x.Header)
.FirstOrDefault() ?? string.Empty;
}
@@ -613,15 +618,12 @@ namespace Microsoft.PowerToys.Settings.UI.Views
return new SuggestionItem
{
Header = entry.Header,
Icon = entry.Icon,
PageTypeName = entry.PageTypeName,
ElementName = entry.ElementName,
ParentElementName = entry.ParentElementName,
Header = e.Header,
Icon = e.Icon,
PageTypeName = e.PageTypeName,
ElementName = e.ElementName,
ParentElementName = e.ParentElementName,
Subtitle = subtitle,
Score = result.Score,
MatchKind = result.MatchKind,
MatchSpans = result.MatchSpans ?? Array.Empty<MatchSpan>(),
IsShowAll = false,
};
}).ToList();
@@ -653,7 +655,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
// Prefer cached results (from live search); if empty, perform a fresh search
var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal)
? _lastSearchResults
: (await SettingsSearch.Default.SearchAsync(queryText, options: null)).ToList();
: await Task.Run(() => SearchIndexService.Search(queryText));
var searchParams = new SearchResultsNavigationParams(queryText, matched);
NavigationService.Navigate<SearchResultsPage>(searchParams);

View File

@@ -14,11 +14,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public class SearchResultsViewModel : INotifyPropertyChanged
{
private ObservableCollection<SettingSearchResult> _moduleResults = new();
private ObservableCollection<SettingEntry> _moduleResults = new();
private ObservableCollection<SettingsGroup> _groupedSettingsResults = new();
private bool _hasNoResults;
public ObservableCollection<SettingSearchResult> ModuleResults
public ObservableCollection<SettingEntry> ModuleResults
{
get => _moduleResults;
set
@@ -48,7 +48,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public void SetSearchResults(string query, List<SettingSearchResult> results)
public void SetSearchResults(string query, List<SettingEntry> results)
{
if (results == null || results.Count == 0)
{
@@ -61,8 +61,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
HasNoResults = false;
// Separate modules and settings
var modules = results.Where(r => r.Entry.Type == EntryType.SettingsPage).ToList();
var settings = results.Where(r => r.Entry.Type == EntryType.SettingsCard).ToList();
var modules = results.Where(r => r.Type == EntryType.SettingsPage).ToList();
var settings = results.Where(r => r.Type == EntryType.SettingsCard).ToList();
// Update module results
ModuleResults.Clear();
@@ -73,11 +73,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Group settings by their page/module
var groupedSettings = settings
.GroupBy(s => SettingsSearch.Default.GetLocalizedPageName(s.Entry.PageTypeName))
.GroupBy(s => SearchIndexService.GetLocalizedPageName(s.PageTypeName))
.Select(g => new SettingsGroup
{
GroupName = g.Key,
Settings = new ObservableCollection<SettingSearchResult>(g),
Settings = new ObservableCollection<SettingEntry>(g),
})
.ToList();
@@ -101,7 +101,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
#pragma warning restore SA1402 // File may only contain a single type
{
private string _groupName;
private ObservableCollection<SettingSearchResult> _settings;
private ObservableCollection<SettingEntry> _settings;
public string GroupName
{
@@ -113,7 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public ObservableCollection<SettingSearchResult> Settings
public ObservableCollection<SettingEntry> Settings
{
get => _settings;
set

View File

@@ -2,9 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Common.Search;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public sealed partial class SuggestionItem
@@ -21,12 +18,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public string Subtitle { get; init; }
public double Score { get; init; }
public SearchMatchKind? MatchKind { get; init; }
public IReadOnlyList<MatchSpan> MatchSpans { get; init; }
public bool IsShowAll { get; init; }
public bool IsNoResults { get; init; }

View File

@@ -2,11 +2,6 @@
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="PowerToys.Settings.app"/>
<msix xmlns="urn:schemas-microsoft-com:msix.v1"
packageName="Microsoft.PowerToys.SparseApp"
applicationId="PowerToys.SettingsUI"
publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:

View File

@@ -63,7 +63,7 @@ if (-not $Platform -or $Platform -eq '') {
}
# Ensure solution packages are restored
RestoreThenBuild 'PowerToys.slnx' '' $Platform $Confi.guration $true
RestoreThenBuild 'PowerToys.slnx' '' $Platform $Configuration $true
# Build both runner and settings
$ProjectsToBuild = @(".\src\runner\runner.vcxproj", ".\src\settings-ui\Settings.UI\PowerToys.Settings.csproj")