Compare commits

...

5 Commits

Author SHA1 Message Date
vanzue
8fe9263d99 develop toolbar 2025-10-26 19:12:13 +08:00
Kai Tao (from Dev Box)
49fd8ac0dd dev 2025-10-09 10:18:01 +08:00
Kai Tao (from Dev Box)
304b069c5c dev 2025-10-04 10:45:55 +08:00
vanzue
5915a5c2fc dev 2025-10-01 00:13:04 +08:00
vanzue
3c6d6c998a toolbar dev 2025-09-30 20:38:18 +08:00
156 changed files with 19660 additions and 0 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"dotnet.defaultSolution": "disable"
}

View File

@@ -101,6 +101,7 @@
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
<PackageVersion Include="System.Reactive" Version="6.0.1" />
<PackageVersion Include="System.Runtime.Caching" Version="9.0.8" />
<PackageVersion Include="System.Security.Permissions" Version="9.0.9" />
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="9.0.8" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.8" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />

View File

@@ -0,0 +1,166 @@
# VSCode Debug Setup for TopToolbar
This directory contains VSCode debug configuration for the TopToolbar project.
## Quick Start
### Prerequisites
1. **Install Required Extensions** (VSCode will prompt you)
- C# Dev Kit (ms-dotnettools.csharp)
- .NET Runtime Install Tool (ms-dotnettools.vscode-dotnet-runtime)
2. **Install .NET SDK**
- .NET 8.0 or later
- Download from: https://dotnet.microsoft.com/download
3. **Required Tools**
- Visual Studio 2022 (Insiders or higher for WinUI 3 support)
- Windows SDK for XAML tools
### Setup Steps
1. **Open the TopToolbar folder in VSCode**
```bash
code c:\PowerToys\src\modules\TopToolbar\TopToolbar
```
2. **Install recommended extensions** when prompted
3. **Configure launch settings**
- Press `Ctrl+Shift+D` to open Debug view
- Select configuration from the dropdown:
- "Launch TopToolbar (x64 Debug)" - for x64 architecture
- "Launch TopToolbar (ARM64 Debug)" - for ARM64 architecture
- "Attach to TopToolbar Process" - to attach to running process
## Debug Configurations
### Launch TopToolbar (x64 Debug)
- Builds TopToolbar in Debug mode for x64 architecture
- Runs the executable from `bin/x64/Debug/TopToolbar.exe`
- Places breakpoints and inspects variables
- Recommended for initial debugging
### Attach to TopToolbar Process
- Attaches debugger to running TopToolbar process
- Useful for debugging already-running applications
- VSCode shows list of processes to select from
### Launch TopToolbar (ARM64 Debug)
- Builds TopToolbar in Debug mode for ARM64 architecture
- Runs the executable from `bin/ARM64/Debug/TopToolbar.exe`
- For ARM-based Windows devices
## Build Tasks
Available build tasks (press `Ctrl+Shift+B` to run):
### Default Build Task
- **build-debug-x64**: Debug build for x64 platform
### Other Tasks
- **build-debug-arm64**: Debug build for ARM64 platform
- **build-release-x64**: Release build for x64 platform
- **clean**: Clean build artifacts
- **run-debug-x64**: Run debug build for x64
### Running a Task
1. Press `Ctrl+Shift+B` (default build) or
2. Press `Ctrl+Shift+P` and select "Tasks: Run Task"
3. Choose from available tasks
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `F5` | Start Debugging (Launch) |
| `Ctrl+Shift+D` | Open Debug View |
| `Ctrl+Shift+B` | Run Default Build Task |
| `Ctrl+Shift+P` | Command Palette (run other tasks) |
| `F9` | Toggle Breakpoint |
| `F10` | Step Over |
| `F11` | Step Into |
| `Shift+F11` | Step Out |
| `Ctrl+K Ctrl+I` | Show Hover (Debug) |
## Debugging Workflow
### Setting Breakpoints
1. Click in the gutter (left margin) of code editor
2. Red circle appears indicating breakpoint
3. Launch debugger with F5
4. Execution stops at breakpoint
### Inspecting Variables
- Hover over variables to see current values
- Use Debug Console to evaluate expressions
- Watch panel for complex expressions
### Debug Console
- Press `Ctrl+Shift+Y` or click Debug Console tab
- Execute C# expressions
- View debug output and logs
## Troubleshooting
### "Program not found" error
- Ensure you've built the project successfully
- Check if binary exists in `bin/x64/Debug/` or `bin/ARM64/Debug/`
- Run build task before debugging
### "Cannot attach to process" error
- Ensure TopToolbar process is running
- Check permissions (may need Administrator rights)
- Verify process architecture matches (x64 vs ARM64)
### Breakpoints not hit
- Ensure code is built in Debug mode (not Release)
- Check symbols are loaded (Debug Console shows debug info)
- Verify breakpoint is in actual code path
### Extension not found error
- Install C# Dev Kit from Extensions marketplace
- Reload VSCode window
- Verify installation in Extensions view
## Configuration Files
### launch.json
Defines debug launch configurations:
- Program paths for different architectures
- Pre-launch tasks
- Debug console behavior
### tasks.json
Defines build and run tasks:
- Build commands for different configurations
- Clean task
- Run tasks
### settings.json
VSCode settings specific to this workspace:
- C# analyzer settings
- Code formatting options
- OmniSharp configuration
### extensions.json
Recommended extensions for this project:
- C# extension
- .NET runtime tools
- XAML tools
- Git integration
## Additional Resources
- [VSCode Debugging Guide](https://code.visualstudio.com/docs/editor/debugging)
- [C# in VSCode](https://code.visualstudio.com/docs/languages/csharp)
- [Debug C# Applications](https://code.visualstudio.com/docs/csharp/debugging)
- [TopToolbar Documentation](../README.md)
## Notes
- Ensure Windows Defender or antivirus excludes VSCode and dotnet tools
- For WinUI 3 development, Visual Studio 2022 Insiders recommended
- Debug builds are larger; use Release builds for distribution
- Some features may require Administrator privileges

View File

@@ -0,0 +1,232 @@
# Getting Started with VSCode Debug for TopToolbar
## Step-by-Step Setup
### Step 1: Open TopToolbar in VSCode
```bash
# Navigate to TopToolbar folder
cd c:\PowerToys\src\modules\TopToolbar\TopToolbar
# Open in VSCode
code .
```
### Step 2: Install Recommended Extensions
When VSCode opens, you'll see a notification about recommended extensions:
1. Click "Show Recommendations" or go to Extensions (`Ctrl+Shift+X`)
2. Install the following:
- **C# Dev Kit** by Microsoft (ms-dotnettools.csharp)
- **.NET Runtime Install Tool** (ms-dotnettools.vscode-dotnet-runtime)
- *Optional:* GitLens for version control (eamodio.gitlens)
3. Reload VSCode window when prompted
### Step 3: Verify .NET SDK Installation
```powershell
# Check installed .NET versions
dotnet --list-sdks
# Should show .NET 8.0 or later
# Example output:
# 8.0.x [C:\Program Files\dotnet\sdk\8.0.x]
```
If .NET SDK is not installed:
1. Visit https://dotnet.microsoft.com/download
2. Download .NET 8.0 SDK or later
3. Run installer and follow instructions
4. Restart terminal and verify installation
### Step 4: Build TopToolbar
**Option A: Using VSCode UI**
1. Press `Ctrl+Shift+B` (default build task)
2. Select `build-debug-x64`
3. Wait for compilation to complete
**Option B: Using Terminal**
```powershell
# In VSCode Terminal (Ctrl+`)
dotnet build -c Debug -p:Platform=x64 TopToolbar.csproj
```
### Step 5: Start Debugging
1. Press `F5` or go to Debug view (`Ctrl+Shift+D`)
2. Select debug configuration:
- **"Launch TopToolbar (x64 Debug)"** (recommended for first time)
- Press `F5` or click the green play button
3. App should launch with debugger attached
### Step 6: Set a Breakpoint
1. Open a source file (e.g., `TopToolbarWindow.xaml.cs`)
2. Click in the gutter (left margin of line numbers)
3. Red dot indicates breakpoint is set
4. Interact with the application to trigger breakpoint
5. Execution pauses at breakpoint
6. Inspect variables in Debug panel
## Common Debug Tasks
### Debug Single File
```powershell
# Build only
dotnet build -c Debug TopToolbar.csproj
# Or via UI: Ctrl+Shift+B
```
### Debug Specific Architecture
```powershell
# ARM64 debug
dotnet build -c Debug -p:Platform=ARM64 TopToolbar.csproj
# x64 debug
dotnet build -c Debug -p:Platform=x64 TopToolbar.csproj
```
### Attach to Running Process
1. Start TopToolbar normally
2. Press `F5` in VSCode
3. Select "Attach to TopToolbar Process"
4. VSCode displays list of running processes
5. Select TopToolbar process
6. Debugger attaches
### Clean Build
```powershell
# Clear build artifacts
dotnet clean TopToolbar.csproj
# Or via UI: Ctrl+Shift+P → "Tasks: Run Task" → "clean"
```
## Debugging Features
### Breakpoints
- **Toggle**: Click gutter or press `F9`
- **View**: Debug view → Breakpoints panel
- **Conditional**: Right-click breakpoint → Add breakpoint condition
### Watch Variables
1. Debug view → Watch panel
2. Click "+" to add expression
3. Type variable name or expression
4. Watch updates as execution continues
### Call Stack
- Debug view → Call Stack panel
- Shows execution flow
- Click frame to navigate to code
### Debug Console
1. Press `Ctrl+Shift+Y`
2. Evaluate C# expressions
3. View debug output
4. Exception details displayed here
### Step Operations
- **F10**: Step Over (next line)
- **F11**: Step Into (enter function)
- **Shift+F11**: Step Out (exit function)
- **F5**: Continue execution
## Keyboard Shortcuts Reference
| Shortcut | Action |
|----------|--------|
| `F5` | Start/Continue Debug |
| `Shift+F5` | Stop Debug |
| `Ctrl+Shift+D` | Open Debug View |
| `F9` | Toggle Breakpoint |
| `F10` | Step Over |
| `F11` | Step Into |
| `Shift+F11` | Step Out |
| `Ctrl+Shift+B` | Run Build Task |
| `Ctrl+Shift+P` | Command Palette |
| `Ctrl+\`` | Toggle Terminal |
| `Ctrl+Shift+Y` | Debug Console |
## Troubleshooting
### Issue: "Program not found"
**Solution:**
1. Verify build succeeded (check `bin/x64/Debug/` folder)
2. Run clean build: `dotnet clean && dotnet build -c Debug`
3. Check file path in launch.json
### Issue: "Cannot attach to process"
**Solution:**
1. Ensure application is running
2. May need Administrator privileges
3. Verify architecture matches (x64 vs ARM64)
### Issue: "Breakpoint not hit"
**Solution:**
1. Ensure Debug build (not Release)
2. Check symbols are loaded
3. Verify code path is executed
### Issue: Extension not loading
**Solution:**
1. Go to Extensions (`Ctrl+Shift+X`)
2. Search for "C# Dev Kit"
3. Click Install
4. Reload VSCode (`Ctrl+Shift+P` → "Reload Window")
### Issue: Build fails with errors
**Solution:**
1. Check error output in Terminal
2. Verify .NET SDK version: `dotnet --version`
3. Clean build: `dotnet clean`
4. Check TopToolbar.csproj syntax
## Project Structure
```
TopToolbar/
├── .vscode/ # VSCode configuration
│ ├── launch.json # Debug configurations
│ ├── tasks.json # Build tasks
│ ├── settings.json # Editor settings
│ ├── extensions.json # Recommended extensions
│ ├── DEBUG_SETUP.md # Detailed setup guide
│ └── VSCODE_CONFIG_SUMMARY.md # Configuration summary
├── TopToolbar.csproj # Project file
├── TopToolbarXAML/ # XAML UI code
│ └── ToolbarWindow.xaml.cs
├── Services/ # Services layer
├── Models/ # Data models
├── Providers/ # Provider implementations
└── bin/ # Build output
├── x64/Debug/ # x64 debug build
└── ARM64/Debug/ # ARM64 debug build
```
## Next Steps
1. ✅ Set up VSCode with debug configurations
2. ✅ Build TopToolbar successfully
3. ✅ Set breakpoints and debug code
4. 📖 Read DEBUG_SETUP.md for advanced topics
5. 🚀 Start developing with VSCode
## Additional Resources
- [VSCode Debugging Guide](https://code.visualstudio.com/docs/editor/debugging)
- [C# Development in VSCode](https://code.visualstudio.com/docs/csharp/intro-to-cs)
- [.NET Development Guide](https://learn.microsoft.com/en-us/dotnet/)
- [WinUI 3 Documentation](https://learn.microsoft.com/en-us/windows/apps/winui/)
## Support
For issues or questions:
1. Check DEBUG_SETUP.md troubleshooting section
2. Review VSCode documentation
3. Check TopToolbar project README
4. Consult .NET and C# documentation

View File

@@ -0,0 +1,147 @@
# Installing C# Extension for VSCode Debugging
## What You Need
The error "Configured debug type 'coreclr' is not supported" means the C# debugger extension is not installed.
## Installation Options
### Option 1: Quick Install (Recommended)
Click the button in VSCode error dialog:
1. Click "Install coreclr Extension" button
2. Wait for installation to complete
3. VSCode reloads automatically
4. Press F5 again to start debugging
### Option 2: Install via Extensions Menu
1. Press `Ctrl+Shift+X` to open Extensions
2. Search for: `ms-dotnettools.csharp`
3. Click "Install" button
4. Wait for installation (takes 1-2 minutes)
5. Click "Reload Window" when prompted
### Option 3: Install via Command Palette
1. Press `Ctrl+Shift+P`
2. Type: `Extensions: Install Extensions`
3. Press Enter
4. Search for: `C# Dev Kit`
5. Click Install
## What Gets Installed
Installing C# extension provides:
- ✓ coreclr debugger (for .NET debugging)
- ✓ Code completion and IntelliSense
- ✓ Code formatting
- ✓ Syntax highlighting
- ✓ Error detection
- ✓ Test explorer
- ✓ XAML support
## After Installation
1. **Reload Window** (if not auto-reloaded)
- Press `Ctrl+Shift+P`
- Type: `Developer: Reload Window`
- Press Enter
2. **Start Debugging Again**
- Press `F5`
- Select debug configuration
- Application should launch with debugger
3. **Verify Installation**
- Check Extensions view
- Search for "C#"
- Should show "C# Dev Kit" with checkmark
## Full Extension Recommendations
Install these for complete development experience:
| Extension | Command |
|-----------|---------|
| C# Dev Kit | `ms-dotnettools.csharp` |
| .NET Runtime | `ms-dotnettools.vscode-dotnet-runtime` |
| XAML Tools | `ms-dotnettools.vscode-xaml-tools` |
| GitHub Copilot | `GitHub.copilot` |
| GitLens | `eamodio.gitlens` |
## Troubleshooting Installation
### Slow Installation
- Normal for first install (large download)
- Can take 2-5 minutes
- Don't close VSCode during installation
### Installation Failed
- Check internet connection
- Restart VSCode
- Try manual installation:
1. Go to https://marketplace.visualstudio.com
2. Search "C# Dev Kit"
3. Click "Install" button in browser
4. Select "Open with Visual Studio Code"
### Extension Not Working After Install
- Reload Window: `Ctrl+Shift+P` → "Reload Window"
- Restart VSCode completely
- Check extension is enabled in Extensions view
## After Successful Installation
### Now You Can:
- ✓ Launch debug sessions with F5
- ✓ Set breakpoints with F9
- ✓ Inspect variables
- ✓ Step through code
- ✓ Evaluate expressions in debug console
### Next Steps:
1. Press F5 to start debugging
2. Set breakpoint by clicking gutter
3. Application launches with debugger
4. Breakpoint pauses execution
5. Inspect variables in Debug panel
## Getting Help
If installation still doesn't work:
1. **Check VSCode Version**
- Should be latest stable or insiders
- Update from Help menu if needed
2. **Check .NET SDK**
```powershell
dotnet --version
# Should show 8.0 or later
```
3. **Check Firewall**
- VSCode may need internet access for extensions
- Add VSCode to firewall exceptions if needed
4. **Manual Marketplace Link**
- Direct link: https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp
## Quick Reference
| Action | Steps |
|--------|-------|
| Install C# | Ctrl+Shift+X → Search "C#" → Install |
| Reload | Ctrl+Shift+P → "Reload Window" |
| Debug | F5 → Select config |
| Breakpoint | Click line gutter |
| Step | F10 (over) or F11 (into) |
---
**Status**: After C# extension installation, coreclr debugging will be available.
**Time to Install**: 2-5 minutes
**Next**: Press F5 to start debugging TopToolbar

View File

@@ -0,0 +1,149 @@
# VSCode Debug Configuration Summary
## Overview
Successfully added comprehensive VSCode debug support to the TopToolbar project with proper launch configurations, build tasks, and documentation.
## Files Created
### 1. `.vscode/launch.json`
VSCode debug launch configurations including:
- **Launch TopToolbar (x64 Debug)**: Debug x64 build
- **Attach to TopToolbar Process**: Attach to running process
- **Launch TopToolbar (ARM64 Debug)**: Debug ARM64 build
Features:
- Pre-launch task automation
- Console output to internal console
- Process attachment with picker
### 2. `.vscode/tasks.json`
Build and run tasks for development:
**Build Tasks:**
- `build-debug-x64`: Debug build for x64 (default)
- `build-debug-arm64`: Debug build for ARM64
- `build-release-x64`: Release build for x64
**Utility Tasks:**
- `clean`: Clean build artifacts
- `run-debug-x64`: Build and run debug x64
**Features:**
- Problem matcher for error detection
- Default build task configured
- Full dotnet CLI integration
### 3. `.vscode/settings.json`
VSCode workspace settings:
- C# analyzer configuration
- Code formatting on save
- OmniSharp analyzer settings
- EditorConfig support enabled
### 4. `.vscode/extensions.json`
Recommended extensions:
- C# Dev Kit (ms-dotnettools.csharp)
- .NET Runtime (ms-dotnettools.vscode-dotnet-runtime)
- C# Extension (ms-vscode.csharp)
- XAML Tools (ms-dotnettools.vscode-xaml-tools)
- GitHub Copilot (for AI assistance)
- GitLens (for version control)
### 5. `.vscode/DEBUG_SETUP.md`
Comprehensive debug setup guide including:
- Prerequisites and installation
- Quick start instructions
- Configuration descriptions
- Keyboard shortcuts
- Debugging workflow
- Troubleshooting guide
- Additional resources
## Quick Start
1. **Open folder in VSCode:**
```bash
code .
```
2. **Install recommended extensions** (prompted by VSCode)
3. **Start debugging:**
- Press `F5` to launch
- Select configuration from dropdown
4. **Build project:**
- Press `Ctrl+Shift+B` for default build
- Or use Task menu for specific tasks
## Key Features
✅ Multiple debug configurations (x64, ARM64, attach)
✅ Automated build tasks before debugging
✅ Problem matcher for error detection
✅ Clean code formatting settings
✅ Extension recommendations
✅ Comprehensive documentation
✅ Keyboard shortcuts reference
✅ Troubleshooting guide
## Architecture Support
- **x64 Platform**: Native development platform
- **ARM64 Platform**: For ARM-based Windows devices
- **Debug Configuration**: Full symbols and debug info
- **Release Configuration**: Optimized builds
## Usage Patterns
### Simple Debug Session
```
F5 (Launch) → Code runs → Hit breakpoint → Inspect variables
```
### Build Then Debug
```
Ctrl+Shift+B (Build) → F5 (Launch) → Debug session
```
### Attach to Process
```
F5 → Select "Attach" config → Choose process → Debug
```
### Custom Task
```
Ctrl+Shift+P → "Run Task" → Select task → Executes
```
## Troubleshooting Integration
- Problem Matcher: Captures build errors
- Debug Console: Inspect variables and expressions
- Watch Panel: Track specific variables
- Call Stack: View execution flow
- Breakpoints: Line-by-line debugging
## Integration Points
- Integrates with existing TopToolbar.csproj
- Works with current build structure
- Compatible with x64 and ARM64 binaries
- Supports WinUI 3 XAML debugging
## Next Steps
1. Open the TopToolbar folder in VSCode
2. Extensions will be recommended automatically
3. Install recommended C# development tools
4. Press F5 to start debugging
5. See DEBUG_SETUP.md for detailed instructions
## Notes
- All configurations use dotnet CLI
- Requires .NET 8.0 SDK or later
- WinUI 3 support requires Visual Studio 2022 Insiders
- Administrator rights may be needed for debugging
- Debug builds stored in respective bin directories

View File

@@ -0,0 +1,10 @@
{
"recommendations": [
"ms-dotnettools.csharp",
"ms-dotnettools.vscode-dotnet-runtime",
"ms-vscode.csharp",
"ms-dotnettools.vscode-xaml-tools",
"GitHub.copilot",
"eamodio.gitlens"
]
}

View File

@@ -0,0 +1,42 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch TopToolbar (x64 Debug)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build-debug-x64",
"program": "${workspaceFolder}/bin/x64/Debug/${config:dotnet.targetFramework}/TopToolbar.exe",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "integratedTerminal",
"requireExactSource": false,
"env": {
"DOTNET_ENVIRONMENT": "Development"
}
},
{
"name": "Attach to TopToolbar Process",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickProcess}",
"requireExactSource": false
},
{
"name": "Launch TopToolbar (ARM64 Debug)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build-debug-arm64",
"program": "${workspaceFolder}/bin/ARM64/Debug/${config:dotnet.targetFramework}/TopToolbar.exe",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "integratedTerminal",
"requireExactSource": false,
"env": {
"DOTNET_ENVIRONMENT": "Development"
}
}
]
}

View File

@@ -0,0 +1,19 @@
{
"omnisharp": {
"msbuildProperties": {
"Platform": "x64",
"RuntimeIdentifier": "win-x64"
},
"enableRoslynAnalyzers": true,
"analyzersPath": null,
"organizeImportsOnFormat": true,
"enableEditorConfigSupport": true,
"enableRoslynAnalyzers": true,
"showOmnisharpLogOnError": true,
"loggingLevel": "information"
},
"omnisharp.defaultLaunchSolution": "TopToolbar.csproj",
"omnisharp.useGlobalMono": "never",
"omnisharp.useModernNet": true,
"omnisharp.projectLoadTimeout": 120
}

View File

@@ -0,0 +1,24 @@
{
"dotnet.defaultSolutionOrFolder": "${workspaceFolder}",
"dotnet.targetFramework": "net9.0-windows10.0.19041.0",
"[csharp]": {
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.analyzers": "explicit"
}
},
"omnisharp.path": "latest",
"omnisharp.useGlobalMono": "never",
"omnisharp.useModernNet": true,
"omnisharp.enableEditorConfigSupport": true,
"omnisharp.enableRoslynAnalyzers": true,
"omnisharp.analysisLevel": "latest",
"omnisharp.projectLoadTimeout": 120,
"omnisharp.maxProjectResults": 250,
"omnisharp.showOmnisharpLogOnError": true,
"omnisharp.msbuildProperties": {
"Platform": "x64",
"RuntimeIdentifier": "win-x64"
},
"extensions.ignoreRecommendations": false
}

View File

@@ -0,0 +1,76 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-debug-x64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"-c",
"Debug",
"-p:Platform=x64",
"${workspaceFolder}/TopToolbar.csproj"
],
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "build-debug-arm64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"-c",
"Debug",
"-p:Platform=ARM64",
"${workspaceFolder}/TopToolbar.csproj"
],
"problemMatcher": "$msCompile"
},
{
"label": "build-release-x64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"-c",
"Release",
"-p:Platform=x64",
"${workspaceFolder}/TopToolbar.csproj"
],
"problemMatcher": "$msCompile"
},
{
"label": "clean",
"command": "dotnet",
"type": "process",
"args": [
"clean",
"${workspaceFolder}/TopToolbar.csproj"
],
"problemMatcher": "$msCompile"
},
{
"label": "run-debug-x64",
"command": "dotnet",
"type": "process",
"args": [
"run",
"-c",
"Debug",
"-p:Platform=x64",
"--project",
"${workspaceFolder}/TopToolbar.csproj"
],
"problemMatcher": "$msCompile",
"group": {
"kind": "test",
"isDefault": true
}
}
]
}

View File

@@ -0,0 +1,26 @@
// 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;
namespace TopToolbar.Actions
{
public sealed class ActionContext
{
public string FocusedApp { get; set; } = string.Empty;
public string Selection { get; set; } = string.Empty;
public string WorkspaceId { get; set; } = string.Empty;
public string Zone { get; set; } = string.Empty;
public IDictionary<string, string> EnvironmentVariables { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public string Locale { get; set; } = string.Empty;
public string NowUtcIso { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace TopToolbar.Actions
{
public sealed class ActionDescriptor
{
public string Id { get; set; } = string.Empty;
public string ProviderId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public ActionKind Kind { get; set; } = ActionKind.Command;
public ActionIcon Icon { get; set; } = new ActionIcon();
public string GroupHint { get; set; } = string.Empty;
public double? Order { get; set; }
public IList<string> Keywords { get; } = new List<string>();
public JsonNode ArgsSchema { get; set; }
public JsonNode Preview { get; set; }
public bool? CanExecute { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
// 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 TopToolbar.Actions
{
public sealed class ActionIcon
{
public ActionIconType Type { get; set; } = ActionIconType.Glyph;
public string Value { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,13 @@
// 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 TopToolbar.Actions
{
public enum ActionIconType
{
Glyph,
Bitmap,
Emoji,
}
}

View File

@@ -0,0 +1,16 @@
// 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 TopToolbar.Actions
{
public enum ActionKind
{
Launch,
Toggle,
Command,
Workflow,
Menu,
View,
}
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
namespace TopToolbar.Actions
{
public sealed class ActionOutput
{
public string Type { get; set; } = "text";
public JsonElement Data { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
// 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 TopToolbar.Actions
{
public sealed class ActionProgress
{
public double? Percent { get; set; }
public string Note { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,15 @@
// 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 TopToolbar.Actions
{
public sealed class ActionResult
{
public bool Ok { get; set; }
public string Message { get; set; } = string.Empty;
public ActionOutput Output { get; set; } = new ActionOutput();
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
namespace TopToolbar;
internal static class AppPaths
{
private const string RootFolderName = "TopToolbar";
public static string Root => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), RootFolderName);
public static string Logs => Path.Combine(Root, "Logs");
public static string ConfigFile => Path.Combine(Root, "toolbar.config.json");
public static string ProfilesDirectory => Path.Combine(Root, "Profiles");
public static string ProvidersDirectory => Path.Combine(Root, "Providers");
public static string ConfigDirectory => Path.Combine(Root, "config");
public static string ProviderDefinitionsDirectory => Path.Combine(ConfigDirectory, "providers");
public static string IconsDirectory => Path.Combine(Root, "icons");
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 2 L5 14 H11 L9 22 L19 10 H13 L15 2 Z" />
</svg>

After

Width:  |  Height:  |  Size: 143 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="4" y="5" width="16" height="15" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="4" y1="9" x2="20" y2="9" stroke="currentColor" stroke-width="2" />
<line x1="9" y1="3" x2="9" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="15" y1="3" x2="15" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<rect x="8" y="12" width="3" height="3" fill="currentColor" rx="0.5" />
<rect x="13" y="12" width="3" height="3" fill="currentColor" rx="0.5" />
<rect x="8" y="16" width="3" height="3" fill="currentColor" rx="0.5" />
<rect x="13" y="16" width="3" height="3" fill="currentColor" rx="0.5" />
</svg>

After

Width:  |  Height:  |  Size: 759 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M8 12 L11 15 L16 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="6" y="5" width="12" height="16" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" />
<rect x="9" y="3" width="6" height="4" rx="1" ry="1" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="9" y1="11" x2="15" y2="11" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="9" y1="15" x2="15" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 495 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M17 19H8C5.8 19 4 17.2 4 15C4 13.3 5.1 11.8 6.7 11.3C7.2 8.5 9.7 6.4 12.6 6.4C15.6 6.4 18 8.6 18.3 11.4C20 11.7 21.2 13.2 21.2 14.9C21.2 17 19.5 19 17 19Z" />
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="12" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" />
<rect x="9" y="18" width="6" height="2" rx="1" fill="currentColor" />
<line x1="5" y1="15" x2="19" y2="15" stroke="currentColor" stroke-width="2" />
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="3" width="8" height="8" rx="1.5" fill="currentColor" />
<rect x="13" y="3" width="8" height="8" rx="1.5" fill="currentColor" />
<rect x="3" y="13" width="8" height="8" rx="1.5" fill="currentColor" />
<rect x="13" y="13" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 21C12 21 4 14.5 4 9.5C4 6.5 6.4 4 9.3 4C11 4 12.3 4.9 13 6.1C13.7 4.9 15 4 16.7 4C19.6 4 22 6.5 22 9.5C22 14.5 12 21 12 21Z" />
</svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M8 5 L19 12 L8 19 Z" />
</svg>

After

Width:  |  Height:  |  Size: 122 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 3 C15.3 3 18 5.7 18 9 C18 12.3 16 14.8 12 20 C8 14.8 6 12.3 6 9 C6 5.7 8.7 3 12 3 Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
<circle cx="12" cy="9" r="2.5" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M9 18 L7 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M15 18 L17 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 536 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 9 H9 L13 5 V19 L9 15 H5 Z" fill="currentColor" />
<path d="M16 8 C17.5 9.2 18.3 11 18.3 12 C18.3 13 17.5 14.8 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M18.5 6 C21 8.2 22 10.5 22 12 C22 13.5 21 15.8 18.5 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="7" y="5" width="13" height="2" fill="currentColor" rx="1" />
<rect x="7" y="11" width="13" height="2" fill="currentColor" rx="1" />
<rect x="7" y="17" width="13" height="2" fill="currentColor" rx="1" />
<circle cx="4" cy="6" r="1.5" fill="currentColor" />
<circle cx="4" cy="12" r="1.5" fill="currentColor" />
<circle cx="4" cy="18" r="1.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="16" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M7 9 L11 12 L7 15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<line x1="13" y1="15" x2="17" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M4.5 9 C8 6 16 6 19.5 9" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M7.5 12 C10 10 14 10 16.5 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M10.5 15 C11.7 14.1 12.3 14.1 13.5 15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="12" cy="18.5" r="1.5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 484 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="16" rx="2" ry="2" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="12" y1="4" x2="12" y2="20" stroke="currentColor" stroke-width="2" />
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" />
<rect x="4.5" y="5.5" width="6" height="5" fill="currentColor" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

View File

@@ -0,0 +1,236 @@
# TopToolbar Button Right-Click Delete Feature - Final Implementation
## Feature Overview
Added right-click context menu functionality to all TopToolbar buttons, allowing users to delete buttons with a right-click, and the deletion is immediately persisted to the configuration file.
## Key Findings and Fixes
### Problem (Fixed)
The original implementation had a **critical data synchronization issue**:
- **_vm.Groups** is the true data source of the config file (SaveAsync reads from here)
- **_store.Groups** is the source used by UI (synced from _vm.Groups)
- If deletion only occurs in _store, SaveAsync() cannot see it, so deletion won't be saved to file
### Solution (Correct Implementation)
**Delete from _vm.Groups, then re-sync to _store**
## Implementation Details
### Core Flow (Correct Version)
```
User right-clicks button
OnRightTapped event triggered
MenuFlyout (context menu) displayed
User clicks "Remove Button"
Find corresponding group in _vm.Groups
Remove button from _vm.Groups (true data source!)
Save config via ViewModel: await _vm.SaveAsync()
Config serialized to JSON file ✓
Re-sync Store: SyncStaticGroupsIntoStore()
Rebuild toolbar UI: BuildToolbarFromStore()
Resize window: ResizeToContent()
```
### Key Code (Correct Implementation)
```csharp
void OnRightTapped(object sender, Microsoft.UI.Xaml.Input.RightTappedRoutedEventArgs e)
{
e.Handled = true;
var flyout = new MenuFlyout();
var deleteItem = new MenuFlyoutItem
{
Text = "Remove Button",
Icon = new FontIcon { Glyph = "\uE74D" },
};
deleteItem.Click += async (s, args) =>
{
try
{
// KEY: Find corresponding group in _vm.Groups (true data source)
var vmGroup = _vm.Groups.FirstOrDefault(g =>
string.Equals(g.Id, group.Id, StringComparison.OrdinalIgnoreCase));
if (vmGroup != null)
{
// KEY: Remove from _vm.Groups (changes here will be seen by SaveAsync)
vmGroup.Buttons.Remove(model);
// Save to config file
await _vm.SaveAsync();
// Re-sync Store (keep UI consistent)
SyncStaticGroupsIntoStore();
// Refresh UI
BuildToolbarFromStore();
ResizeToContent();
}
}
catch (Exception ex)
{
AppLogger.LogError($"Failed to delete button '{model.Name}': {ex.Message}");
}
};
flyout.Items.Add(deleteItem);
flyout.ShowAt(button, e.GetPosition(button));
}
```
## Data Architecture
### _vm.Groups vs _store.Groups
| Property | _vm.Groups | _store.Groups |
|----------|-----------|---------------|
| Source | Loaded from config file in LoadAsync | Synced from _vm by SyncStaticGroupsIntoStore |
| Purpose | Config data source (SaveAsync reads from) | UI rendering data source |
| Modified When | At application startup | Real-time sync |
| Modification Impact | Directly affects saved config ✓ | Affects UI display |
### SaveAsync Only Saves _vm.Groups
```csharp
// ToolbarViewModel.SaveAsync()
public async Task SaveAsync()
{
var config = new ToolbarConfig();
foreach (var group in Groups) // ← Iterates _vm.Groups
{
if (group == null || !_staticGroupIds.Contains(group.Id))
continue;
var clone = CloneGroup(group);
// ... Filter out provider-sourced buttons and save
config.Groups.Add(clone);
}
await _configService.SaveAsync(config);
}
```
**This is why deletion must occur in _vm.Groups**
## Configuration File Sync Guarantee
### Configuration Before Deletion
```json
{
"groups": [
{
"id": "group-1",
"buttons": [
{ "id": "btn-1", "name": "Button 1" },
{ "id": "btn-2", "name": "Button 2" },
{ "id": "btn-3", "name": "Button 3" }
]
}
]
}
```
### Configuration After Deleting Button 2
```json
{
"groups": [
{
"id": "group-1",
"buttons": [
{ "id": "btn-1", "name": "Button 1" },
{ "id": "btn-3", "name": "Button 3" }
]
}
]
}
```
✓ Deleted button does not appear in file
✓ On next startup, deleted button will not reappear
## Important Features
### ✅ Complete Persistence Flow
- Deletion removes button from _vm.Groups (data source)
- SaveAsync() reads from updated _vm.Groups
- Configuration file is correctly updated
- On next application startup, deleted button will not appear
### ✅ Only Affects Static Groups
- Only buttons from config file can be deleted
- Buttons from Providers (MCP, Workspace, etc.) are recreated at each startup
### ✅ Clear Data Flow
- _vm.Groups is the only true data source
- SaveAsync() reads from _vm and persists
- SyncStaticGroupsIntoStore() ensures _store and _vm consistency
### ✅ Error Handling
- All exceptions are caught and logged
- Users receive detailed error information
## Usage Flow
1. Open TopToolbar
2. Right-click any button
3. Select "Remove Button"
4. Button immediately disappears
5. Configuration file is automatically updated
6. On app restart, button no longer appears
## Verification Checklist
- [ ] After deletion, UI immediately updates
- [ ] After app restart, deleted button does not appear ✓
- [ ] Deleted button is removed from config file ✓
- [ ] Buttons from Providers still appear at each startup
- [ ] Errors are properly logged ✓
## Files Modified
- `TopToolbarXAML/ToolbarWindow.xaml.cs`:
- Added `using TopToolbar.Logging;`
- Added `OnRightTapped` event handler to `CreateIconButton` method
- Registers `button.RightTapped += OnRightTapped;`
- Properly deletes from _vm.Groups and syncs with _store
## Architecture Diagram
```
Configuration File (JSON)
LoadAsync() / SaveAsync()
_vm.Groups (ToolbarViewModel.Groups) ← SOURCE OF TRUTH
↓ (via SyncStaticGroupsIntoStore)
_store.Groups (ToolbarStore.Groups) ← UI RENDERING DATA
BuildToolbarFromStore()
UI Display
```
When deleting a button:
1. Modify _vm.Groups
2. Call SaveAsync() (writes to file)
3. Call SyncStaticGroupsIntoStore() (updates _store)
4. Call BuildToolbarFromStore() (updates UI)

View File

@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace TopToolbar.Converters
{
public sealed partial class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool b)
{
return b ? Visibility.Visible : Visibility.Collapsed;
}
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
if (value is Visibility v)
{
return v == Visibility.Visible;
}
return false;
}
}
}

View File

@@ -0,0 +1,18 @@
// 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.Text.Json.Serialization;
namespace TopToolbar.Extensions
{
public sealed class ExtensionContributions
{
[JsonPropertyName("providers")]
public List<string> Providers { get; set; } = new List<string>();
[JsonPropertyName("actions")]
public List<StaticActionContribution> Actions { get; set; } = new List<StaticActionContribution>();
}
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace TopToolbar.Extensions
{
public sealed class ExtensionManifest
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("runtime")]
public string Runtime { get; set; } = string.Empty;
[JsonPropertyName("entry")]
public string Entry { get; set; } = string.Empty;
[JsonPropertyName("contributes")]
public ExtensionContributions Contributes { get; set; } = new ExtensionContributions();
[JsonPropertyName("permissions")]
public System.Collections.Generic.List<string> Permissions { get; set; } = new System.Collections.Generic.List<string>();
}
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
namespace TopToolbar.Extensions
{
public static class ExtensionManifestLoader
{
private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
};
public static async Task<ExtensionManifest> LoadAsync(string manifestPath)
{
if (string.IsNullOrWhiteSpace(manifestPath))
{
throw new ArgumentException("Manifest path must be provided.", nameof(manifestPath));
}
await using var stream = File.OpenRead(manifestPath);
var manifest = await JsonSerializer.DeserializeAsync<ExtensionManifest>(stream, Options).ConfigureAwait(false);
return manifest ?? new ExtensionManifest();
}
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace TopToolbar.Extensions
{
public sealed class StaticActionContribution
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("exec")]
public string Exec { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,263 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace TopToolbar.Logging
{
internal static class AppLogger
{
private static readonly object InitLock = new object();
private static readonly string AssemblyVersion = typeof(AppLogger).Assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "Unknown";
private static ILoggerFactory _loggerFactory = NullLoggerFactory.Instance;
private static ILogger _logger = NullLogger.Instance;
private static bool _initialized;
public static void Initialize(string logsRoot)
{
if (_initialized)
{
return;
}
lock (InitLock)
{
if (_initialized)
{
return;
}
try
{
string targetDirectory = PrepareLogDirectory(logsRoot);
var fileProvider = new FileLoggerProvider(targetDirectory);
_loggerFactory = LoggerFactory.Create(builder =>
{
builder.ClearProviders();
builder.SetMinimumLevel(LogLevel.Information);
builder.AddProvider(fileProvider);
});
_logger = _loggerFactory.CreateLogger("TopToolbar");
_initialized = true;
}
catch
{
_loggerFactory = NullLoggerFactory.Instance;
_logger = NullLogger.Instance;
}
}
}
public static void LogInfo(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log(LogLevel.Information, message, null, memberName, sourceFilePath, sourceLineNumber);
}
public static void LogWarning(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log(LogLevel.Warning, message, null, memberName, sourceFilePath, sourceLineNumber);
}
public static void LogError(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log(LogLevel.Error, message, null, memberName, sourceFilePath, sourceLineNumber);
}
public static void LogError(string message, Exception exception, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log(LogLevel.Error, message, exception, memberName, sourceFilePath, sourceLineNumber);
}
public static void LogDebug(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log(LogLevel.Debug, message, null, memberName, sourceFilePath, sourceLineNumber);
}
public static void LogTrace([CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log(LogLevel.Trace, string.Empty, null, memberName, sourceFilePath, sourceLineNumber);
}
private static void Log(LogLevel level, string message, Exception exception, string memberName, string sourceFilePath, int sourceLineNumber)
{
string caller = FormatCaller(memberName, sourceFilePath, sourceLineNumber);
string payload = string.IsNullOrEmpty(message) ? caller : string.Concat(caller, " - ", message);
_logger.Log(level, 0, payload, exception, static (state, ex) => state);
}
private static string PrepareLogDirectory(string logsRoot)
{
if (string.IsNullOrWhiteSpace(logsRoot))
{
throw new ArgumentException("Log directory must be provided.", nameof(logsRoot));
}
Directory.CreateDirectory(logsRoot);
string versionedDirectory = Path.Combine(logsRoot, AssemblyVersion);
Directory.CreateDirectory(versionedDirectory);
Task.Run(() => CleanOldVersionFolders(logsRoot, versionedDirectory));
return versionedDirectory;
}
private static void CleanOldVersionFolders(string basePath, string currentVersionPath)
{
try
{
if (!Directory.Exists(basePath))
{
return;
}
var directories = new DirectoryInfo(basePath)
.EnumerateDirectories()
.Where(d => !string.Equals(d.FullName, currentVersionPath, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(d => d.CreationTimeUtc)
.Skip(3);
foreach (var directory in directories)
{
try
{
directory.Delete(true);
}
catch
{
}
}
}
catch
{
}
}
private static string FormatCaller(string memberName, string sourceFilePath, int sourceLineNumber)
{
string fileName = string.Empty;
try
{
fileName = Path.GetFileName(sourceFilePath);
}
catch
{
fileName = string.Empty;
}
if (string.IsNullOrEmpty(fileName))
{
return string.Concat(memberName, ":", sourceLineNumber.ToString(CultureInfo.InvariantCulture));
}
return string.Concat(fileName, "::", memberName, "::", sourceLineNumber.ToString(CultureInfo.InvariantCulture));
}
private sealed class FileLoggerProvider : ILoggerProvider
{
private readonly string _directory;
private readonly object _lock = new object();
private bool _disposed;
internal FileLoggerProvider(string directory)
{
_directory = directory ?? throw new ArgumentNullException(nameof(directory));
}
public ILogger CreateLogger(string categoryName)
{
ObjectDisposedException.ThrowIf(_disposed, nameof(FileLoggerProvider));
return new FileLogger(this, categoryName);
}
public void Dispose()
{
_disposed = true;
}
internal void WriteMessage(string categoryName, LogLevel level, string message, Exception exception)
{
if (_disposed)
{
return;
}
string logFilePath = GetLogFilePath();
string timestamp = DateTime.Now.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture);
string line = string.Concat("[", timestamp, "] [", level.ToString(), "] [", categoryName, "] ", message ?? string.Empty);
if (exception != null)
{
line = string.Concat(line, Environment.NewLine, exception.ToString());
}
lock (_lock)
{
Directory.CreateDirectory(_directory);
File.AppendAllText(logFilePath, line + Environment.NewLine);
}
}
private string GetLogFilePath()
{
string fileName = "Log_" + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log";
return Path.Combine(_directory, fileName);
}
private sealed class FileLogger : ILogger
{
private readonly FileLoggerProvider _provider;
private readonly string _categoryName;
internal FileLogger(FileLoggerProvider provider, string categoryName)
{
_provider = provider;
_categoryName = string.IsNullOrEmpty(categoryName) ? "TopToolbar" : categoryName;
}
public IDisposable BeginScope<TState>(TState state)
{
return NullScope.Instance;
}
public bool IsEnabled(LogLevel logLevel)
{
return logLevel != LogLevel.None;
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
{
if (!IsEnabled(logLevel))
{
return;
}
string message = formatter != null ? formatter(state, exception) : state != null ? state.ToString() : string.Empty;
_provider.WriteMessage(_categoryName, logLevel, message, exception);
}
}
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new NullScope();
private NullScope()
{
}
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace TopToolbar.Models.Abstractions
{
public interface IProfile
{
string Id { get; }
string Name { get; }
IReadOnlyList<IProfileGroup> Groups { get; }
IEnumerable<IProfileGroup> GetActiveGroups();
}
public interface IProfileGroup
{
string Id { get; }
string Name { get; }
bool IsEnabled { get; }
int SortOrder { get; }
IReadOnlyList<IProfileAction> Actions { get; }
IEnumerable<IProfileAction> GetActiveActions();
}
public interface IProfileAction
{
string Id { get; }
string Name { get; }
string Description { get; }
bool IsEnabled { get; }
string IconGlyph { get; }
}
}

View File

@@ -0,0 +1,170 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace TopToolbar.Models
{
public class ButtonGroup : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
private string _id = System.Guid.NewGuid().ToString();
public string Id
{
get => _id;
set
{
if (_id != value)
{
_id = value;
OnPropertyChanged();
}
}
}
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value;
OnPropertyChanged();
}
}
}
private string _description = string.Empty;
public string Description
{
get => _description;
set
{
if (_description != value)
{
_description = value;
OnPropertyChanged();
}
}
}
private bool _isEnabled = true;
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
private ToolbarGroupLayout _layout = new ToolbarGroupLayout();
public ToolbarGroupLayout Layout
{
get => _layout;
set
{
if (!Equals(_layout, value))
{
_layout = value ?? new ToolbarGroupLayout();
OnPropertyChanged();
}
}
}
private ObservableCollection<string> _providers = new ObservableCollection<string>();
public ObservableCollection<string> Providers
{
get => _providers;
set
{
if (!ReferenceEquals(_providers, value))
{
_providers = value ?? new ObservableCollection<string>();
OnPropertyChanged();
}
}
}
private ObservableCollection<string> _staticActions = new ObservableCollection<string>();
public ObservableCollection<string> StaticActions
{
get => _staticActions;
set
{
if (!ReferenceEquals(_staticActions, value))
{
_staticActions = value ?? new ObservableCollection<string>();
OnPropertyChanged();
}
}
}
private string _filter = string.Empty;
public string Filter
{
get => _filter;
set
{
if (_filter != value)
{
_filter = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private bool _autoRefresh;
public bool AutoRefresh
{
get => _autoRefresh;
set
{
if (_autoRefresh != value)
{
_autoRefresh = value;
OnPropertyChanged();
}
}
}
private ObservableCollection<ToolbarButton> _buttons = new ObservableCollection<ToolbarButton>();
public ObservableCollection<ToolbarButton> Buttons
{
get => _buttons;
set
{
if (!ReferenceEquals(_buttons, value))
{
_buttons = value ?? new ObservableCollection<ToolbarButton>();
OnPropertyChanged();
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
// 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;
namespace TopToolbar.Models
{
public sealed class IconCatalogEntry
{
public IconCatalogEntry(
string id,
string displayName,
string category,
Uri resourceUri = null,
IReadOnlyList<string> keywords = null,
string description = "",
string glyph = "",
string fontFamily = "")
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentNullException(nameof(id));
}
if (string.IsNullOrWhiteSpace(displayName))
{
throw new ArgumentNullException(nameof(displayName));
}
Id = id;
DisplayName = displayName;
Category = category ?? string.Empty;
ResourceUri = resourceUri;
Glyph = glyph?.Trim() ?? string.Empty;
FontFamily = fontFamily?.Trim() ?? string.Empty;
Keywords = keywords ?? Array.Empty<string>();
Description = description ?? string.Empty;
}
public string Id { get; }
public string DisplayName { get; }
public string Category { get; }
public Uri ResourceUri { get; }
public string Glyph { get; }
public string FontFamily { get; }
public IReadOnlyList<string> Keywords { get; }
public string Description { get; }
public bool HasGlyph => !string.IsNullOrWhiteSpace(Glyph);
public bool HasImage => ResourceUri != null;
}
}

View File

@@ -0,0 +1,159 @@
// 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.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using TopToolbar.Models.Abstractions;
namespace TopToolbar.Models;
/// <summary>
/// A complete profile containing metadata and its own actions
/// </summary>
public class Profile : INotifyPropertyChanged, IProfile
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _id = string.Empty;
public string Id
{
get => _id;
set
{
if (_id != value)
{
_id = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _description = string.Empty;
public string Description
{
get => _description;
set
{
if (_description != value)
{
_description = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private DateTime _createdAt = DateTime.UtcNow;
public DateTime CreatedAt
{
get => _createdAt;
set
{
if (_createdAt != value)
{
_createdAt = value;
OnPropertyChanged();
}
}
}
private DateTime _modifiedAt = DateTime.UtcNow;
public DateTime ModifiedAt
{
get => _modifiedAt;
set
{
if (_modifiedAt != value)
{
_modifiedAt = value;
OnPropertyChanged();
}
}
}
private List<ProfileGroup> _groups = new();
public List<ProfileGroup> Groups
{
get => _groups;
set
{
if (_groups != value)
{
_groups = value ?? new List<ProfileGroup>();
OnPropertyChanged();
}
}
}
IReadOnlyList<IProfileGroup> IProfile.Groups => _groups.Cast<IProfileGroup>().ToList();
/// <summary>
/// Returns a new list of groups that are currently considered "active" for rendering/execution.
/// Active definition (v1): Group.IsEnabled == true AND there is at least one enabled action inside it.
/// Ordering: by SortOrder ascending, then by Name (stable deterministic ordering for UI diffing).
/// This keeps filtering logic in the data model layer instead of scattering IsEnabled checks in UI code.
/// </summary>
public List<ProfileGroup> GetActiveGroups()
{
// Defensive: treat null or mutated list gracefully; never throw.
IEnumerable<ProfileGroup> source = Groups ?? new List<ProfileGroup>();
// Filter groups first.
var active = new List<ProfileGroup>();
foreach (var g in source)
{
if (g == null || !g.IsEnabled)
{
continue;
}
// Evaluate enabled actions; if none, skip group.
var enabledActions = g.Actions?.Where(a => a != null && a.IsEnabled).ToList() ?? new List<ProfileAction>();
if (enabledActions.Count == 0)
{
continue;
}
active.Add(g);
}
// Order groups deterministically.
return active
.OrderBy(g => g.SortOrder)
.ThenBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// Helper to get enabled actions for a given group id (returns empty list if group missing or has none).
/// Provided for callers that need quick access without re-filtering all groups.
/// </summary>
// Explicit interface for active groups
IEnumerable<IProfileGroup> IProfile.GetActiveGroups() => GetActiveGroups().Cast<IProfileGroup>();
}

View File

@@ -0,0 +1,142 @@
// 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.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using TopToolbar.Models.Abstractions;
namespace TopToolbar.Models;
/// <summary>
/// An action within a profile group
/// </summary>
public class ProfileAction : INotifyPropertyChanged, IProfileAction
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _id = Guid.NewGuid().ToString();
public string Id
{
get => _id;
set
{
if (_id != value)
{
_id = value ?? Guid.NewGuid().ToString();
OnPropertyChanged();
}
}
}
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _description = string.Empty;
public string Description
{
get => _description;
set
{
if (_description != value)
{
_description = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private bool _isEnabled = true;
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
private int _sortOrder;
public int SortOrder
{
get => _sortOrder;
set
{
if (_sortOrder != value)
{
_sortOrder = value;
OnPropertyChanged();
}
}
}
// ActionType removed from minimal contract (execution layer can extend via pattern matching)
private string _iconGlyph = "\uE8EF";
public string IconGlyph
{
get => _iconGlyph;
set
{
if (_iconGlyph != value)
{
_iconGlyph = value ?? "\uE8EF";
OnPropertyChanged();
}
}
}
// Removed IconPath/IconType for minimal glyph-only representation
// Command Line Action Properties
// Removed command-line execution fields from minimal model
// Provider Action Properties
// Removed provider action fields
// Chat Action Properties (for future chat integration)
// Removed chat config
// Removed type helper properties
[JsonIgnore]
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? "Untitled Action" : Name;
public ProfileAction Clone()
{
return new ProfileAction
{
Id = Id,
Name = Name,
Description = Description,
IsEnabled = IsEnabled,
SortOrder = SortOrder,
IconGlyph = IconGlyph,
};
}
}

View File

@@ -0,0 +1,21 @@
// 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 TopToolbar.Models;
/// <summary>
/// Icon display types for profile actions
/// </summary>
public enum ProfileActionIcon
{
/// <summary>
/// Use a font glyph icon
/// </summary>
Glyph,
/// <summary>
/// Use an image file
/// </summary>
Image,
}

View File

@@ -0,0 +1,26 @@
// 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 TopToolbar.Models;
/// <summary>
/// Types of actions that can be executed by profiles
/// </summary>
public enum ProfileActionType
{
/// <summary>
/// Execute a command line application
/// </summary>
CommandLine,
/// <summary>
/// Call an action provider
/// </summary>
Provider,
/// <summary>
/// Chat-based action (future)
/// </summary>
Chat,
}

View File

@@ -0,0 +1,75 @@
// 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.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
namespace TopToolbar.Models;
/// <summary>
/// Configuration for chat-based actions (future feature)
/// </summary>
public class ProfileChatActionConfig : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _prompt = string.Empty;
public string Prompt
{
get => _prompt;
set
{
if (_prompt != value)
{
_prompt = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _model = string.Empty;
public string Model
{
get => _model;
set
{
if (_model != value)
{
_model = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private bool _useContext = true;
public bool UseContext
{
get => _useContext;
set
{
if (_useContext != value)
{
_useContext = value;
OnPropertyChanged();
}
}
}
public ProfileChatActionConfig Clone()
{
return new ProfileChatActionConfig
{
Prompt = Prompt,
Model = Model,
UseContext = UseContext,
};
}
}

View File

@@ -0,0 +1,157 @@
// 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.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
namespace TopToolbar.Models;
/// <summary>
/// Complete profile configuration containing all actions and settings for a specific profile
/// </summary>
public class ProfileConfig : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _id = string.Empty;
public string Id
{
get => _id;
set
{
if (_id != value)
{
_id = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _description = string.Empty;
public string Description
{
get => _description;
set
{
if (_description != value)
{
_description = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private DateTime _createdAt = DateTime.UtcNow;
public DateTime CreatedAt
{
get => _createdAt;
set
{
if (_createdAt != value)
{
_createdAt = value;
OnPropertyChanged();
}
}
}
private DateTime _lastModified = DateTime.UtcNow;
public DateTime LastModified
{
get => _lastModified;
set
{
if (_lastModified != value)
{
_lastModified = value;
OnPropertyChanged();
}
}
}
private List<ProfileGroup> _groups = new();
public List<ProfileGroup> Groups
{
get => _groups;
set
{
if (_groups != value)
{
_groups = value ?? new List<ProfileGroup>();
OnPropertyChanged();
}
}
}
private ProfileChatActionConfig _chatConfig = new();
public ProfileChatActionConfig ChatConfig
{
get => _chatConfig;
set
{
if (_chatConfig != value)
{
_chatConfig = value ?? new ProfileChatActionConfig();
OnPropertyChanged();
}
}
}
private Dictionary<string, object> _metadata = new();
public Dictionary<string, object> Metadata
{
get => _metadata;
set
{
if (_metadata != value)
{
_metadata = value ?? new Dictionary<string, object>();
OnPropertyChanged();
}
}
}
public ProfileConfig Clone()
{
return new ProfileConfig
{
Id = Id,
Name = Name,
Description = Description,
CreatedAt = CreatedAt,
LastModified = DateTime.UtcNow,
Groups = Groups.ConvertAll(g => g.Clone()),
ChatConfig = ChatConfig.Clone(),
Metadata = new Dictionary<string, object>(Metadata),
};
}
}

View File

@@ -0,0 +1,139 @@
// 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.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using TopToolbar.Models.Abstractions;
namespace TopToolbar.Models;
/// <summary>
/// A group of actions within a profile
/// </summary>
public class ProfileGroup : INotifyPropertyChanged, IProfileGroup
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _id = Guid.NewGuid().ToString();
public string Id
{
get => _id;
set
{
if (_id != value)
{
_id = value ?? Guid.NewGuid().ToString();
OnPropertyChanged();
}
}
}
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _description = string.Empty;
public string Description
{
get => _description;
set
{
if (_description != value)
{
_description = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private bool _isEnabled = true;
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
private int _sortOrder;
public int SortOrder
{
get => _sortOrder;
set
{
if (_sortOrder != value)
{
_sortOrder = value;
OnPropertyChanged();
}
}
}
private List<ProfileAction> _actions = new();
public List<ProfileAction> Actions
{
get => _actions;
set
{
if (_actions != value)
{
_actions = value ?? new List<ProfileAction>();
OnPropertyChanged();
}
}
}
IReadOnlyList<IProfileAction> IProfileGroup.Actions => _actions.Cast<IProfileAction>().ToList();
public IEnumerable<ProfileAction> GetActiveActions()
{
return (Actions ?? new List<ProfileAction>())
.Where(a => a != null && a.IsEnabled)
.OrderBy(a => a.SortOrder)
.ThenBy(a => a.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
IEnumerable<IProfileAction> IProfileGroup.GetActiveActions() => GetActiveActions().Cast<IProfileAction>();
public ProfileGroup Clone()
{
return new ProfileGroup
{
Id = Id,
Name = Name,
Description = Description,
IsEnabled = IsEnabled,
SortOrder = SortOrder,
Actions = Actions.ConvertAll(a => a.Clone()),
};
}
}

View File

@@ -0,0 +1,91 @@
// 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.ComponentModel;
using System.Runtime.CompilerServices;
namespace TopToolbar.Models;
/// <summary>
/// Layout configuration for a profile group
/// </summary>
public class ProfileGroupLayout : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _style = "horizontal";
public string Style
{
get => _style;
set
{
if (_style != value)
{
_style = value ?? "horizontal";
OnPropertyChanged();
}
}
}
private string _overflow = "wrap";
public string Overflow
{
get => _overflow;
set
{
if (_overflow != value)
{
_overflow = value ?? "wrap";
OnPropertyChanged();
}
}
}
private int _maxInline = 6;
public int MaxInline
{
get => _maxInline;
set
{
if (_maxInline != value)
{
_maxInline = value;
OnPropertyChanged();
}
}
}
private bool _showLabels = true;
public bool ShowLabels
{
get => _showLabels;
set
{
if (_showLabels != value)
{
_showLabels = value;
OnPropertyChanged();
}
}
}
public ProfileGroupLayout Clone()
{
return new ProfileGroupLayout
{
Style = Style,
Overflow = Overflow,
MaxInline = MaxInline,
ShowLabels = ShowLabels,
};
}
}

View File

@@ -0,0 +1,155 @@
// 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.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
namespace TopToolbar.Models;
public class ToolbarAction : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private ToolbarActionType _type = ToolbarActionType.CommandLine;
public ToolbarActionType Type
{
get => _type;
set
{
if (_type != value)
{
_type = value;
OnPropertyChanged();
}
}
}
private string _command;
public string Command
{
get => _command;
set
{
if (_command != value)
{
_command = value;
OnPropertyChanged();
}
}
}
private string _arguments;
public string Arguments
{
get => _arguments;
set
{
if (_arguments != value)
{
_arguments = value;
OnPropertyChanged();
}
}
}
private string _workingDirectory;
public string WorkingDirectory
{
get => _workingDirectory;
set
{
if (_workingDirectory != value)
{
_workingDirectory = value;
OnPropertyChanged();
}
}
}
private bool _runAsAdmin;
public bool RunAsAdmin
{
get => _runAsAdmin;
set
{
if (_runAsAdmin != value)
{
_runAsAdmin = value;
OnPropertyChanged();
}
}
}
private string _providerId = string.Empty;
public string ProviderId
{
get => _providerId;
set
{
if (_providerId != value)
{
_providerId = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _providerActionId = string.Empty;
public string ProviderActionId
{
get => _providerActionId;
set
{
if (_providerActionId != value)
{
_providerActionId = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private string _providerArgumentsJson;
public string ProviderArgumentsJson
{
get => _providerArgumentsJson;
set
{
if (_providerArgumentsJson != value)
{
_providerArgumentsJson = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
public bool IsProviderAction => Type == ToolbarActionType.Provider;
public ToolbarAction Clone()
{
return new ToolbarAction
{
Type = Type,
Command = Command,
Arguments = Arguments,
WorkingDirectory = WorkingDirectory,
RunAsAdmin = RunAsAdmin,
ProviderId = ProviderId,
ProviderActionId = ProviderActionId,
ProviderArgumentsJson = ProviderArgumentsJson,
};
}
}

View File

@@ -0,0 +1,12 @@
// 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 TopToolbar.Models
{
public enum ToolbarActionType
{
CommandLine = 0,
Provider = 1,
}
}

View File

@@ -0,0 +1,326 @@
// 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.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using TopToolbar.Services;
namespace TopToolbar.Models;
public partial class ToolbarButton : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
private string _id = Guid.NewGuid().ToString();
public string Id
{
get => _id;
set
{
if (_id != value)
{
_id = value;
OnPropertyChanged();
}
}
}
private string _name = string.Empty;
public string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value ?? string.Empty;
OnPropertyChanged();
// Also notify DisplayName since it depends on Name
OnPropertyChanged(nameof(DisplayName));
}
}
}
private string _description = string.Empty;
public string Description
{
get => _description;
set
{
if (_description != value)
{
_description = value ?? string.Empty;
OnPropertyChanged();
OnPropertyChanged(nameof(HasDescription));
}
}
}
private ToolbarIconType _iconType = ToolbarIconType.Catalog;
public ToolbarIconType IconType
{
get => _iconType;
set
{
var normalized = value == ToolbarIconType.Glyph ? ToolbarIconType.Catalog : value;
if (_iconType != normalized)
{
_iconType = normalized;
OnPropertyChanged();
OnPropertyChanged(nameof(IconTypeIndex));
OnPropertyChanged(nameof(IsImageIcon));
OnPropertyChanged(nameof(IsCatalogIcon));
}
}
}
public int IconTypeIndex
{
get => IconType == ToolbarIconType.Image ? 1 : 0;
set
{
var newType = value == 1 ? ToolbarIconType.Image : ToolbarIconType.Catalog;
IconType = newType;
}
}
private string _iconGlyph = "\uE10F";
public string IconGlyph
{
get => _iconGlyph;
set
{
var normalized = NormalizeGlyph(value);
if (_iconGlyph != normalized)
{
_iconGlyph = normalized;
OnPropertyChanged();
}
}
}
private static string NormalizeGlyph(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return string.Empty;
}
var s = input.Trim();
try
{
if (s.StartsWith("\\u", StringComparison.OrdinalIgnoreCase) && s.Length >= 6)
{
s = s.Substring(2);
}
else if (s.StartsWith("U+", StringComparison.OrdinalIgnoreCase) && s.Length >= 5)
{
s = s.Substring(2);
}
else if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && s.Length >= 4)
{
s = s.Substring(2);
}
else if (s.StartsWith("&#x", StringComparison.OrdinalIgnoreCase) && s.EndsWith(';'))
{
s = s.Substring(3, s.Length - 4);
}
bool isHex = true;
foreach (var c in s)
{
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
{
isHex = false;
break;
}
}
if (isHex && s.Length >= 4 && s.Length <= 6)
{
var codePoint = Convert.ToInt32(s, 16);
return char.ConvertFromUtf32(codePoint);
}
}
catch
{
}
return input;
}
private string _iconPath = string.Empty;
public string IconPath
{
get => _iconPath;
set
{
if (_iconPath != value)
{
_iconPath = value ?? string.Empty;
OnPropertyChanged();
}
}
}
private bool _isEnabled = true;
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
private bool _isExecuting;
[JsonIgnore]
public bool IsExecuting
{
get => _isExecuting;
set
{
if (_isExecuting != value)
{
_isExecuting = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
private double? _progressValue;
[JsonIgnore]
public double? ProgressValue
{
get => _progressValue;
set
{
if (_progressValue != value)
{
_progressValue = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
private string _progressMessage = string.Empty;
[JsonIgnore]
public string ProgressMessage
{
get => _progressMessage;
set
{
if (_progressMessage != value)
{
_progressMessage = value ?? string.Empty;
OnPropertyChanged();
}
}
}
[JsonIgnore]
private string _statusMessage = string.Empty;
[JsonIgnore]
public string StatusMessage
{
get => _statusMessage;
set
{
if (_statusMessage != value)
{
_statusMessage = value ?? string.Empty;
OnPropertyChanged();
}
}
}
[JsonIgnore]
private double? _sortOrder;
[JsonIgnore]
public double? SortOrder
{
get => _sortOrder;
set
{
if (_sortOrder != value)
{
_sortOrder = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
public bool IsImageIcon => IconType == ToolbarIconType.Image;
[JsonIgnore]
public bool IsCatalogIcon => IconType == ToolbarIconType.Catalog;
private ToolbarAction _action = new();
public ToolbarAction Action
{
get => _action;
set
{
if (!Equals(_action, value))
{
_action = value ?? new ToolbarAction();
OnPropertyChanged();
}
}
}
public ToolbarButton Clone()
{
var clone = new ToolbarButton
{
Id = Id,
Name = Name,
Description = Description,
IconType = IconType,
IconGlyph = IconGlyph,
IconPath = IconPath,
IsEnabled = IsEnabled,
Action = Action?.Clone() ?? new ToolbarAction(),
};
clone.SortOrder = SortOrder;
return clone;
}
// Computed / convenience properties (not serialized)
[JsonIgnore]
public string DisplayName => string.IsNullOrWhiteSpace(Name) ? "New Button" : Name;
[JsonIgnore]
public bool HasDescription => !string.IsNullOrWhiteSpace(Description);
}

View File

@@ -0,0 +1,15 @@
// 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;
namespace TopToolbar.Models;
public class ToolbarConfig
{
public List<ButtonGroup> Groups { get; set; } = new();
public Dictionary<string, string> Bindings { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,70 @@
// 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.ComponentModel;
using System.Runtime.CompilerServices;
namespace TopToolbar.Models
{
public enum ToolbarGroupLayoutStyle
{
Capsule,
Icon,
Text,
Mixed,
}
public enum ToolbarGroupOverflowMode
{
Menu,
Wrap,
Hidden,
}
public class ToolbarGroupLayout : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private ToolbarGroupLayoutStyle _style = ToolbarGroupLayoutStyle.Capsule;
public ToolbarGroupLayoutStyle Style
{
get => _style;
set => SetProperty(ref _style, value);
}
private ToolbarGroupOverflowMode _overflow = ToolbarGroupOverflowMode.Menu;
public ToolbarGroupOverflowMode Overflow
{
get => _overflow;
set => SetProperty(ref _overflow, value);
}
private int? _maxInline;
public int? MaxInline
{
get => _maxInline;
set => SetProperty(ref _maxInline, value);
}
private bool _showLabels = true;
public bool ShowLabels
{
get => _showLabels;
set => SetProperty(ref _showLabels, value);
}
private void SetProperty<T>(ref T storage, T value, [CallerMemberName] string name = null)
{
if (!Equals(storage, value))
{
storage = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
}
}

View File

@@ -0,0 +1,12 @@
// 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 TopToolbar.Models;
public enum ToolbarIconType
{
Glyph = 0,
Image = 1,
Catalog = 2,
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.UI.Xaml;
using TopToolbar.Logging;
namespace TopToolbar
{
public class Program
{
[STAThread]
public static void Main(string[] args)
{
try
{
AppLogger.Initialize(AppPaths.Logs);
EnsureAppDirectories();
AppLogger.LogInfo($"Logger initialized. Logs directory: {AppPaths.Logs}");
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
try
{
var message = $"AppDomain unhandled exception (IsTerminating={e.IsTerminating})";
if (e.ExceptionObject is Exception exception)
{
AppLogger.LogError(message, exception);
}
else
{
AppLogger.LogError($"{message} - {e.ExceptionObject}");
}
}
catch
{
}
};
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
try
{
AppLogger.LogError("Unobserved task exception", e.Exception);
e.SetObserved();
}
catch
{
}
};
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"AppLogger init failed: {ex.Message}");
}
Application.Start(args =>
{
_ = new App();
});
}
private static void EnsureAppDirectories()
{
try
{
Directory.CreateDirectory(AppPaths.Root);
Directory.CreateDirectory(AppPaths.IconsDirectory);
Directory.CreateDirectory(AppPaths.ProfilesDirectory);
Directory.CreateDirectory(AppPaths.ProvidersDirectory);
Directory.CreateDirectory(AppPaths.ConfigDirectory);
}
catch (Exception ex)
{
AppLogger.LogError("Failed to ensure data directories", ex);
}
}
}
}

View File

@@ -0,0 +1,187 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Actions;
using TopToolbar.Models;
namespace TopToolbar.Providers
{
public sealed class ActionProviderRuntime
{
private readonly Dictionary<string, IActionProvider> _providers;
private readonly Dictionary<string, ProviderInfo> _infoCache;
private readonly Dictionary<string, IToolbarGroupProvider> _groupProviders;
private long _changeVersion;
/// <summary>
/// Raised when any provider reports a change. Carries semantic detail for selective refresh.
/// </summary>
public event EventHandler<ProviderChangedEventArgs> ProvidersChanged;
public ActionProviderRuntime()
{
_providers = new Dictionary<string, IActionProvider>(StringComparer.OrdinalIgnoreCase);
_infoCache = new Dictionary<string, ProviderInfo>(StringComparer.OrdinalIgnoreCase);
_groupProviders = new Dictionary<string, IToolbarGroupProvider>(StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyCollection<string> RegisteredProviderIds => _providers.Keys;
public IReadOnlyCollection<string> RegisteredGroupProviderIds => _groupProviders.Keys;
public void RegisterProvider(IActionProvider provider)
{
ArgumentNullException.ThrowIfNull(provider);
if (string.IsNullOrWhiteSpace(provider.Id))
{
throw new InvalidOperationException("Provider must expose a non-empty Id.");
}
_providers[provider.Id] = provider;
if (provider is IToolbarGroupProvider groupProvider)
{
_groupProviders[provider.Id] = groupProvider;
}
else
{
_groupProviders.Remove(provider.Id);
}
if (provider is IChangeNotifyingActionProvider notifying)
{
notifying.ProviderChanged += (_, args) =>
{
if (args == null)
{
return;
}
try
{
// Assign version (thread-safe increment)
var version = System.Threading.Interlocked.Increment(ref _changeVersion);
args.Version = version;
ProvidersChanged?.Invoke(this, args);
}
catch
{
}
};
}
}
public bool TryGetProvider(string providerId, out IActionProvider provider)
{
return _providers.TryGetValue(providerId, out provider);
}
public async Task<ProviderInfo> GetInfoAsync(string providerId, CancellationToken cancellationToken)
{
if (!_providers.TryGetValue(providerId, out var provider))
{
throw new InvalidOperationException($"Provider '{providerId}' is not registered.");
}
if (_infoCache.TryGetValue(providerId, out var cached))
{
return cached;
}
var info = await provider.GetInfoAsync(cancellationToken).ConfigureAwait(false);
if (info == null)
{
info = new ProviderInfo(providerId, string.Empty);
}
_infoCache[providerId] = info;
return info;
}
public async Task<ButtonGroup> CreateGroupAsync(string providerId, ActionContext context, CancellationToken cancellationToken)
{
if (!_groupProviders.TryGetValue(providerId, out var provider))
{
throw new InvalidOperationException("Provider '{providerId}' does not support toolbar groups.");
}
var ctx = context ?? new ActionContext();
return await provider.CreateGroupAsync(ctx, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<ActionDescriptor>> DiscoverAsync(
IEnumerable<string> providerIds,
ActionContext context,
CancellationToken cancellationToken)
{
var results = new List<ActionDescriptor>();
var ctx = context ?? new ActionContext();
foreach (var providerId in providerIds)
{
if (!_providers.TryGetValue(providerId, out var provider))
{
continue;
}
await foreach (var descriptor in provider.DiscoverAsync(ctx, cancellationToken).ConfigureAwait(false))
{
if (descriptor != null)
{
if (string.IsNullOrEmpty(descriptor.ProviderId))
{
descriptor.ProviderId = providerId;
}
results.Add(descriptor);
}
}
}
return results;
}
public async Task<ActionResult> InvokeAsync(
string providerId,
string actionId,
JsonElement? args,
ActionContext context,
IProgress<ActionProgress> progress,
CancellationToken cancellationToken)
{
if (!_providers.TryGetValue(providerId, out var provider))
{
throw new InvalidOperationException($"Provider '{providerId}' is not registered.");
}
var ctx = context ?? new ActionContext();
var progressSink = progress ?? new Progress<ActionProgress>(_ => { });
try
{
var result = await provider.InvokeAsync(actionId, args, ctx, progressSink, cancellationToken).ConfigureAwait(false);
return result ?? new ActionResult { Ok = false, Message = "Provider returned no result." };
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new ActionResult
{
Ok = false,
Message = ex.Message,
Output = new ActionOutput { Type = "text", Data = JsonDocument.Parse("\"" + ex.Message + "\"").RootElement },
};
}
}
}
}

View File

@@ -0,0 +1,341 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Actions;
using TopToolbar.Logging;
using TopToolbar.Models;
using TopToolbar.Providers.Configuration;
using TopToolbar.Providers.External.Mcp;
using TopToolbar.Services;
namespace TopToolbar.Providers
{
/// <summary>
/// Built-in provider that manages workspace and MCP providers automatically.
/// This replaces individual provider registration in the main toolbar class.
/// </summary>
public sealed class BuiltinProvider : IDisposable
{
private readonly List<IActionProvider> _providers = new();
private readonly List<IDisposable> _disposables = new();
private bool _disposed;
/// <summary>
/// Gets all registered built-in providers
/// </summary>
public IReadOnlyList<IActionProvider> Providers => _providers.AsReadOnly();
/// <summary>
/// Initializes and loads all built-in providers (workspace and MCP providers)
/// </summary>
public void Initialize()
{
ObjectDisposedException.ThrowIf(_disposed, nameof(BuiltinProvider));
// Clear any existing providers
DisposeProviders();
LoadWorkspaceProvider();
LoadMcpProviders();
}
/// <summary>
/// Registers all loaded providers to the ActionProviderRuntime
/// </summary>
public void RegisterProvidersTo(ActionProviderRuntime runtime)
{
ArgumentNullException.ThrowIfNull(runtime);
foreach (var provider in _providers)
{
try
{
runtime.RegisterProvider(provider);
}
catch (Exception ex)
{
// Log error but continue with other providers
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to register provider '{provider.Id}': {ex.Message}");
}
catch
{
// Ignore logging errors
}
}
}
}
/// <summary>
/// Gets default profile groups for all built-in providers
/// </summary>
public async Task<List<ProfileGroup>> GetDefaultProfileGroupsAsync()
{
var groups = new List<ProfileGroup>();
// Get workspace groups
try
{
var workspaceGroups = await WorkspaceProvider.GetDefaultWorkspaceGroupsAsync();
groups.AddRange(workspaceGroups);
}
catch (Exception ex)
{
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to get workspace groups: {ex.Message}");
}
catch
{
// Ignore logging errors
}
}
// Get MCP provider groups
try
{
var mcpGroups = await GetDynamicMcpProviderGroupsAsync();
groups.AddRange(mcpGroups);
}
catch (Exception ex)
{
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to get MCP provider groups: {ex.Message}");
}
catch
{
// Ignore logging errors
}
}
return groups;
}
/// <summary>
/// Loads the workspace provider
/// </summary>
private void LoadWorkspaceProvider()
{
try
{
var workspaceProvider = new WorkspaceProvider();
_providers.Add(workspaceProvider);
_disposables.Add(workspaceProvider);
}
catch (Exception ex)
{
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to load workspace provider: {ex.Message}");
}
catch
{
// Ignore logging errors
}
}
}
/// <summary>
/// Loads all MCP providers from configuration files
/// </summary>
private void LoadMcpProviders()
{
try
{
var configService = new ProviderConfigService();
var configs = configService.LoadConfigs();
foreach (var config in configs)
{
if (config == null || string.IsNullOrWhiteSpace(config.Id))
{
continue;
}
try
{
var provider = CreateProvider(config);
_providers.Add(provider);
if (provider is IDisposable disposable)
{
_disposables.Add(disposable);
}
}
catch (Exception ex)
{
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to create provider '{config.Id}': {ex.Message}");
}
catch
{
// Ignore logging errors
}
}
}
}
catch (Exception ex)
{
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to load MCP providers: {ex.Message}");
}
catch
{
// Ignore logging errors
}
}
}
/// <summary>
/// Creates a provider instance from configuration
/// </summary>
private static IActionProvider CreateProvider(ProviderConfig config)
{
if (config?.External?.Type == ExternalProviderType.Mcp)
{
return new McpActionProvider(config);
}
return new ConfiguredActionProvider(config);
}
/// <summary>
/// Gets dynamic MCP provider groups by discovering MCP configurations and creating profile groups with default enabled actions
/// </summary>
private async Task<List<ProfileGroup>> GetDynamicMcpProviderGroupsAsync()
{
var groups = new List<ProfileGroup>();
try
{
var configService = new ProviderConfigService();
var configs = configService.LoadConfigs();
// Filter for MCP providers
var mcpConfigs = configs.Where(c => c?.External?.Type == ExternalProviderType.Mcp).ToList();
foreach (var config in mcpConfigs)
{
if (string.IsNullOrWhiteSpace(config.Id))
{
continue;
}
// Check if a group with this ID already exists
if (groups.Any(g => string.Equals(g.Id, config.Id, StringComparison.OrdinalIgnoreCase)))
{
continue; // Skip duplicates
}
try
{
// Create MCP provider instance to get actual tools
using var mcpProvider = new McpActionProvider(config);
var context = new ActionContext();
// Get actual group with real MCP tools
var mcpButtonGroup = await mcpProvider.CreateGroupAsync(context, CancellationToken.None);
// Convert ButtonGroup to ProfileGroup with all actions enabled by default
var profileGroup = new ProfileGroup
{
Id = mcpButtonGroup.Id,
Name = mcpButtonGroup.Name,
Description = mcpButtonGroup.Description,
IsEnabled = true, // Default enabled for new MCP provider groups
SortOrder = groups.Count + 10, // Place after workspace groups
Actions = new List<ProfileAction>(),
};
// Convert each ToolbarButton to ProfileAction (all enabled by default)
foreach (var button in mcpButtonGroup.Buttons)
{
var profileAction = new ProfileAction
{
Id = button.Id,
Name = button.Name,
Description = button.Description,
IsEnabled = true, // Default enabled for new MCP actions
IconGlyph = button.IconGlyph,
};
profileGroup.Actions.Add(profileAction);
}
groups.Add(profileGroup);
}
catch (Exception ex)
{
// Log error for individual MCP provider but continue with others
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to create group for MCP provider '{config.Id}': {ex.Message}");
}
catch
{
// Ignore logging errors
}
}
}
}
catch (Exception ex)
{
// Log general error but don't throw - let caller handle
try
{
AppLogger.LogWarning($"BuiltinProvider: Failed to load dynamic MCP provider groups: {ex.Message}");
}
catch
{
// Ignore logging errors
}
throw; // Re-throw for caller to handle
}
return groups;
}
/// <summary>
/// Disposes all loaded providers
/// </summary>
private void DisposeProviders()
{
foreach (var disposable in _disposables)
{
try
{
disposable?.Dispose();
}
catch
{
// Ignore disposal errors
}
}
_disposables.Clear();
_providers.Clear();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
DisposeProviders();
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using TopToolbar.Models;
namespace TopToolbar.Providers.Configuration;
public sealed class ExternalProviderConfig
{
public ExternalProviderType Type { get; set; } = ExternalProviderType.None;
public string ExecutablePath { get; set; } = string.Empty;
public string Arguments { get; set; } = string.Empty;
public string WorkingDirectory { get; set; } = string.Empty;
public Dictionary<string, string> Environment { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int? StartupTimeoutSeconds { get; set; }
}

View File

@@ -0,0 +1,174 @@
// 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.
#pragma warning disable SA1402 // File may only contain a single type
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace TopToolbar.Providers.Configuration;
/// <summary>
/// Modern MCP (Model Context Protocol) provider configuration schema v2.0
/// This replaces the legacy mixed static/dynamic action approach with pure MCP discovery
/// </summary>
public sealed class McpProviderConfig
{
public string Version { get; set; } = "2.0";
public string Type { get; set; } = "mcp";
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public McpGroupLayout GroupLayout { get; set; } = new();
public McpConnection Connection { get; set; } = new();
public McpDiscovery Discovery { get; set; } = new();
public McpFallback Fallback { get; set; } = new();
}
/// <summary>
/// Layout configuration for the MCP provider group
/// </summary>
public sealed class McpGroupLayout
{
public string Style { get; set; } = "capsule";
public string Overflow { get; set; } = "menu";
public int MaxInline { get; set; } = 8;
public bool ShowLabels { get; set; } = true;
}
/// <summary>
/// MCP server connection configuration
/// </summary>
public sealed class McpConnection
{
public string Type { get; set; } = "stdio"; // "stdio" or "http"
public McpExecutable Executable { get; set; } = new();
public McpHttpEndpoint Http { get; set; }
public McpTimeouts Timeouts { get; set; } = new();
}
/// <summary>
/// Stdio-based MCP server executable configuration
/// </summary>
public sealed class McpExecutable
{
public string Path { get; set; } = string.Empty;
public List<string> Args { get; set; } = new();
public string WorkingDirectory { get; set; } = string.Empty;
public Dictionary<string, string> Environment { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// HTTP-based MCP server endpoint configuration (for future use)
/// </summary>
public sealed class McpHttpEndpoint
{
public string BaseUrl { get; set; } = string.Empty;
public Dictionary<string, string> Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public McpAuthentication Authentication { get; set; }
}
/// <summary>
/// HTTP authentication configuration (for future use)
/// </summary>
public sealed class McpAuthentication
{
public string Type { get; set; } = "none"; // "none", "bearer", "basic", "apikey"
public string Token { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public string ApiKeyHeader { get; set; } = "X-API-Key";
}
/// <summary>
/// Timeout configuration for MCP operations
/// </summary>
public sealed class McpTimeouts
{
public int StartupSeconds { get; set; } = 10;
public int RequestSeconds { get; set; } = 30;
public int ShutdownSeconds { get; set; } = 5;
}
/// <summary>
/// MCP tool discovery configuration
/// </summary>
public sealed class McpDiscovery
{
public string Mode { get; set; } = "dynamic"; // "dynamic", "static", "hybrid"
public int RefreshIntervalMinutes { get; set; } = 5;
public int CacheToolsForSeconds { get; set; } = 15;
}
/// <summary>
/// Fallback actions when MCP server is unavailable
/// </summary>
public sealed class McpFallback
{
public bool Enabled { get; set; } = true;
public List<McpFallbackAction> Actions { get; set; } = new();
}
/// <summary>
/// Individual fallback action definition
/// </summary>
public sealed class McpFallbackAction
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string IconGlyph { get; set; } = "\\uECAA";
public int SortOrder { get; set; }
public Dictionary<string, McpParameter> Parameters { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Parameter definition for MCP actions
/// </summary>
public sealed class McpParameter
{
public string Type { get; set; } = "string"; // "string", "number", "boolean", "object", "array"
public bool Required { get; set; }
public string Description { get; set; } = string.Empty;
public object DefaultValue { get; set; }
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using TopToolbar.Models;
namespace TopToolbar.Providers.Configuration;
public sealed class ProviderActionConfig
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ToolbarIconType? IconType { get; set; }
public string IconGlyph { get; set; } = string.Empty;
public string IconPath { get; set; } = string.Empty;
public double? SortOrder { get; set; }
public bool? IsEnabled { get; set; }
public ToolbarAction Action { get; set; } = new();
public ProviderActionInputConfig Input { get; set; } = new();
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using TopToolbar.Models;
namespace TopToolbar.Providers.Configuration;
public sealed class ProviderActionDynamicInputConfig
{
public string SourceTool { get; set; } = string.Empty;
public string ItemsPath { get; set; } = "content[0].data";
public int? CacheSeconds { get; set; }
public int? MaxItems { get; set; }
public string LabelField { get; set; } = string.Empty;
public string LabelTemplate { get; set; } = string.Empty;
public string DescriptionField { get; set; } = string.Empty;
public string DescriptionTemplate { get; set; } = string.Empty;
public string ValueField { get; set; } = string.Empty;
public JsonNode ArgsTemplate { get; set; }
public string IconGlyphField { get; set; } = string.Empty;
public string IconPathField { get; set; } = string.Empty;
public ToolbarIconType? IconType { get; set; }
public string SortField { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using TopToolbar.Models;
namespace TopToolbar.Providers.Configuration;
public sealed class ProviderActionEnumOption
{
public string Label { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public double? SortOrder { get; set; }
public JsonNode Args { get; set; }
public string IconGlyph { get; set; } = string.Empty;
public string IconPath { get; set; } = string.Empty;
public ToolbarIconType? IconType { get; set; }
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using TopToolbar.Models;
namespace TopToolbar.Providers.Configuration;
public sealed class ProviderActionInputConfig
{
public bool? HideBaseAction { get; set; }
public List<ProviderActionEnumOption> Enum { get; set; } = new();
public ProviderActionDynamicInputConfig Dynamic { get; set; }
public JsonNode ArgsTemplate { get; set; }
public string LabelTemplate { get; set; } = string.Empty;
public string DescriptionTemplate { get; set; } = string.Empty;
public string IconGlyph { get; set; } = string.Empty;
public string IconPath { get; set; } = string.Empty;
public ToolbarIconType? IconType { get; set; }
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace TopToolbar.Providers.Configuration;
public sealed class ProviderConfig
{
public string Id { get; set; } = string.Empty;
public string GroupName { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ProviderLayoutConfig Layout { get; set; } = new();
public List<ProviderActionConfig> Actions { get; set; } = new();
public ExternalProviderConfig External { get; set; } = new();
}
public enum ExternalProviderType
{
None = 0,
Mcp = 1,
}

View File

@@ -0,0 +1,18 @@
// 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 TopToolbar.Models;
namespace TopToolbar.Providers.Configuration;
public sealed class ProviderLayoutConfig
{
public ToolbarGroupLayoutStyle? Style { get; set; }
public ToolbarGroupOverflowMode? Overflow { get; set; }
public int? MaxInline { get; set; }
public bool? ShowLabels { get; set; }
}

View File

@@ -0,0 +1,254 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Actions;
using TopToolbar.Models;
using TopToolbar.Providers.Configuration;
namespace TopToolbar.Providers;
public sealed class ConfiguredActionProvider : IActionProvider, IToolbarGroupProvider
{
private readonly ProviderConfig _config;
private readonly Dictionary<string, ProviderActionConfig> _actionMap;
private readonly List<(ProviderActionConfig Config, double Order, int Index)> _orderedActions;
public ConfiguredActionProvider(ProviderConfig config)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
(_actionMap, _orderedActions) = BuildActionIndex(config.Actions);
}
public string Id => _config.Id;
public Task<ProviderInfo> GetInfoAsync(CancellationToken cancellationToken)
{
var displayName = string.IsNullOrWhiteSpace(_config.GroupName) ? _config.Id : _config.GroupName;
return Task.FromResult(new ProviderInfo(displayName, string.Empty));
}
public async IAsyncEnumerable<ActionDescriptor> DiscoverAsync(ActionContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();
foreach (var entry in _orderedActions)
{
cancellationToken.ThrowIfCancellationRequested();
yield return CreateDescriptor(entry.Config);
}
}
public Task<ButtonGroup> CreateGroupAsync(ActionContext context, CancellationToken cancellationToken)
{
var group = new ButtonGroup
{
Id = _config.Id,
Name = string.IsNullOrWhiteSpace(_config.GroupName) ? _config.Id : _config.GroupName,
Description = _config.Description,
Layout = BuildLayout(_config.Layout),
};
foreach (var entry in _orderedActions)
{
var button = BuildButton(entry.Config);
group.Buttons.Add(button);
}
return Task.FromResult(group);
}
public Task<ActionResult> InvokeAsync(
string actionId,
JsonElement? args,
ActionContext context,
IProgress<ActionProgress> progress,
CancellationToken cancellationToken)
{
return Task.FromResult(new ActionResult
{
Ok = false,
Message = "Configured provider actions execute directly.",
});
}
private static (Dictionary<string, ProviderActionConfig> Map, List<(ProviderActionConfig Config, double Order, int Index)> Ordered) BuildActionIndex(IEnumerable<ProviderActionConfig> actions)
{
var map = new Dictionary<string, ProviderActionConfig>(StringComparer.OrdinalIgnoreCase);
var ordered = new List<(ProviderActionConfig Config, double Order, int Index)>();
if (actions == null)
{
return (map, ordered);
}
var index = 0;
foreach (var action in actions)
{
if (action == null)
{
index++;
continue;
}
if (action.IsEnabled.HasValue && !action.IsEnabled.Value)
{
index++;
continue;
}
var key = string.IsNullOrWhiteSpace(action.Id) ? Guid.NewGuid().ToString() : action.Id;
if (map.TryGetValue(key, out _))
{
map[key] = action;
for (var i = 0; i < ordered.Count; i++)
{
if (!string.Equals(ordered[i].Config?.Id, key, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var current = ordered[i];
ordered[i] = (Config: action, Order: action.SortOrder ?? double.MaxValue, Index: current.Index);
break;
}
}
else
{
map[key] = action;
ordered.Add((Config: action, Order: action.SortOrder ?? double.MaxValue, Index: index));
}
index++;
}
ordered.Sort((left, right) =>
{
var orderCompare = left.Order.CompareTo(right.Order);
if (orderCompare != 0)
{
return orderCompare;
}
return left.Index.CompareTo(right.Index);
});
return (map, ordered);
}
private static ToolbarGroupLayout BuildLayout(ProviderLayoutConfig layout)
{
var result = new ToolbarGroupLayout();
if (layout == null)
{
return result;
}
if (layout.Style.HasValue)
{
result.Style = layout.Style.Value;
}
if (layout.Overflow.HasValue)
{
result.Overflow = layout.Overflow.Value;
}
if (layout.MaxInline.HasValue)
{
result.MaxInline = layout.MaxInline;
}
if (layout.ShowLabels.HasValue)
{
result.ShowLabels = layout.ShowLabels.Value;
}
return result;
}
private static ToolbarButton BuildButton(ProviderActionConfig action)
{
var button = new ToolbarButton
{
Id = string.IsNullOrWhiteSpace(action.Id) ? Guid.NewGuid().ToString() : action.Id,
Name = action.Name,
Description = action.Description,
IconGlyph = action.IconGlyph,
IconPath = action.IconPath,
Action = action.Action?.Clone() ?? new ToolbarAction(),
};
if (action.IconType.HasValue)
{
button.IconType = action.IconType.Value;
}
else if (!string.IsNullOrWhiteSpace(action.IconPath))
{
button.IconType = ToolbarIconType.Image;
}
else
{
button.IconType = ToolbarIconType.Glyph;
}
if (action.SortOrder.HasValue)
{
button.SortOrder = action.SortOrder;
}
return button;
}
private ActionDescriptor CreateDescriptor(ProviderActionConfig action)
{
var descriptorId = string.IsNullOrWhiteSpace(action.Id) ? Guid.NewGuid().ToString() : action.Id;
var descriptor = new ActionDescriptor
{
Id = descriptorId,
ProviderId = Id,
Title = string.IsNullOrWhiteSpace(action.Name) ? descriptorId : action.Name,
Subtitle = action.Description,
Kind = ActionKind.Command,
Icon = BuildActionIcon(action),
Order = action.SortOrder,
CanExecute = true,
};
if (!string.IsNullOrWhiteSpace(action.Name))
{
descriptor.Keywords.Add(action.Name);
}
if (!string.IsNullOrWhiteSpace(action.Description))
{
descriptor.Keywords.Add(action.Description);
}
return descriptor;
}
private static ActionIcon BuildActionIcon(ProviderActionConfig action)
{
if (!string.IsNullOrWhiteSpace(action.IconGlyph))
{
return new ActionIcon { Type = ActionIconType.Glyph, Value = action.IconGlyph };
}
if (!string.IsNullOrWhiteSpace(action.IconPath))
{
return new ActionIcon { Type = ActionIconType.Bitmap, Value = action.IconPath };
}
return new ActionIcon { Type = ActionIconType.Glyph, Value = string.Empty };
}
}

View File

@@ -0,0 +1,387 @@
// 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.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Logging;
namespace TopToolbar.Providers.External
{
public sealed class ExternalActionProviderHost : IDisposable
{
private readonly object _sync = new();
private readonly string _providerId;
private readonly string _executablePath;
private readonly string _arguments;
private readonly string _workingDirectory;
private readonly IReadOnlyDictionary<string, string> _environment;
private readonly TimeSpan _shutdownTimeout;
private Process _process;
private StreamWriter _stdin;
private StreamReader _stdout;
private StreamReader _stderr;
private CancellationTokenSource _stderrCts;
private Task _stderrPump;
private bool _disposed;
public ExternalActionProviderHost(
string providerId,
string executablePath,
string arguments = "",
string workingDirectory = "",
IReadOnlyDictionary<string, string> environment = null,
TimeSpan? shutdownTimeout = null)
{
_providerId = providerId ?? string.Empty;
_executablePath = ResolveExecutable(executablePath);
_arguments = arguments ?? string.Empty;
_workingDirectory = ResolveWorkingDirectory(workingDirectory, _executablePath);
_environment = environment ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_shutdownTimeout = shutdownTimeout ?? TimeSpan.FromSeconds(3);
}
public bool IsRunning
{
get
{
lock (_sync)
{
return _process != null && !_process.HasExited;
}
}
}
public StreamWriter StandardInput
{
get
{
lock (_sync)
{
return _stdin ?? throw new InvalidOperationException("External provider host is not started.");
}
}
}
public StreamReader StandardOutput
{
get
{
lock (_sync)
{
return _stdout ?? throw new InvalidOperationException("External provider host is not started.");
}
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
lock (_sync)
{
ThrowIfDisposed();
if (_process != null && !_process.HasExited)
{
return Task.CompletedTask;
}
CleanupResources();
var startInfo = new ProcessStartInfo
{
FileName = _executablePath,
Arguments = _arguments,
WorkingDirectory = _workingDirectory,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
CreateNoWindow = true,
};
foreach (var pair in _environment)
{
if (string.IsNullOrWhiteSpace(pair.Key))
{
continue;
}
startInfo.Environment[pair.Key] = pair.Value ?? string.Empty;
}
_process = new Process
{
StartInfo = startInfo,
EnableRaisingEvents = true,
};
_process.Exited += OnProcessExited;
try
{
if (!_process.Start())
{
throw new InvalidOperationException($"Failed to start external provider '{_providerId}'.");
}
}
catch
{
_process.Exited -= OnProcessExited;
_process.Dispose();
_process = null;
throw;
}
_stdin = _process.StandardInput;
_stdin.AutoFlush = true;
_stdout = _process.StandardOutput;
_stderr = _process.StandardError;
_stderrCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_stderrPump = Task.Run(() => PumpStandardErrorAsync(_stderrCts.Token), CancellationToken.None);
if (_process.HasExited)
{
throw new InvalidOperationException($"External provider '{_providerId}' exited immediately with code {_process.ExitCode}.");
}
}
return Task.CompletedTask;
}
public Task StopAsync()
{
lock (_sync)
{
if (_process == null)
{
return Task.CompletedTask;
}
}
return Task.Run(() => ShutdownProcess(killIfNeeded: false));
}
private void OnProcessExited(object sender, EventArgs e)
{
lock (_sync)
{
AppLogger.LogInfo($"ExternalActionProviderHost: provider '{_providerId}' exited with code {_process?.ExitCode ?? -1}.");
CleanupResources();
}
}
private void ShutdownProcess(bool killIfNeeded)
{
Process process;
StreamWriter stdin;
StreamReader stdout;
StreamReader stderr;
CancellationTokenSource stderrCts;
Task stderrPump;
lock (_sync)
{
process = _process;
stdin = _stdin;
stdout = _stdout;
stderr = _stderr;
stderrCts = _stderrCts;
stderrPump = _stderrPump;
_process = null;
_stdin = null;
_stdout = null;
_stderr = null;
_stderrCts = null;
_stderrPump = null;
}
try
{
stderrCts?.Cancel();
stderr?.Close();
stdout?.Close();
if (stdin != null)
{
try
{
stdin.Close();
}
catch
{
// Ignore errors while closing input
}
}
if (stderrPump != null)
{
try
{
stderrPump.Wait(_shutdownTimeout);
}
catch
{
}
}
if (process != null && !process.HasExited)
{
var exited = process.WaitForExit((int)_shutdownTimeout.TotalMilliseconds);
if (!exited && killIfNeeded)
{
try
{
process.Kill(true);
exited = true;
}
catch
{
}
}
if (killIfNeeded && !process.HasExited)
{
process.WaitForExit();
}
}
}
finally
{
stderrCts?.Dispose();
stderrPump?.Dispose();
process?.Dispose();
stdin?.Dispose();
stdout?.Dispose();
stderr?.Dispose();
}
}
private async Task PumpStandardErrorAsync(CancellationToken token)
{
try
{
StreamReader reader;
lock (_sync)
{
reader = _stderr;
}
if (reader == null)
{
return;
}
while (!token.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(token).ConfigureAwait(false);
if (line == null)
{
break;
}
if (!string.IsNullOrWhiteSpace(line))
{
AppLogger.LogInfo($"ExternalActionProviderHost[{_providerId}]: {line}");
}
}
}
catch (ObjectDisposedException)
{
// Expected when shutting down
}
catch (Exception ex)
{
AppLogger.LogWarning($"ExternalActionProviderHost: stderr pump for '{_providerId}' failed - {ex.Message}.");
}
}
private void CleanupResources()
{
_stdin?.Dispose();
_stdout?.Dispose();
_stderr?.Dispose();
_stderrCts?.Cancel();
_stderrCts?.Dispose();
_stderrPump?.Dispose();
_stdin = null;
_stdout = null;
_stderr = null;
_stderrCts = null;
_stderrPump = null;
}
private static string ResolveExecutable(string executablePath)
{
var raw = executablePath?.Trim();
if (string.IsNullOrWhiteSpace(raw))
{
throw new InvalidOperationException("Executable path must be provided for external providers.");
}
var expanded = Environment.ExpandEnvironmentVariables(raw);
if (Path.IsPathRooted(expanded) && File.Exists(expanded))
{
return expanded;
}
var baseDir = AppContext.BaseDirectory;
if (!string.IsNullOrEmpty(baseDir))
{
var candidate = Path.GetFullPath(Path.Combine(baseDir, expanded));
if (File.Exists(candidate))
{
return candidate;
}
}
if (File.Exists(expanded))
{
return Path.GetFullPath(expanded);
}
throw new FileNotFoundException($"External provider executable '{raw}' was not found.", expanded);
}
private static string ResolveWorkingDirectory(string workingDirectory, string executablePath)
{
var raw = workingDirectory?.Trim();
if (!string.IsNullOrWhiteSpace(raw))
{
var expanded = Environment.ExpandEnvironmentVariables(raw);
return Path.IsPathRooted(expanded)
? expanded
: Path.GetFullPath(expanded);
}
return Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, nameof(ExternalActionProviderHost));
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
ShutdownProcess(killIfNeeded: true);
GC.SuppressFinalize(this);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
// 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.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Logging;
namespace TopToolbar.Providers.External.Mcp;
internal sealed class McpClient : IDisposable
{
private readonly ExternalActionProviderHost _host;
private readonly JsonSerializerOptions _jsonOptions;
private readonly ConcurrentDictionary<long, TaskCompletionSource<JsonElement>> _pending;
private readonly SemaphoreSlim _connectLock = new(1, 1);
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly CancellationTokenSource _lifetimeCts = new();
private StreamWriter _writer;
private StreamReader _reader;
private CancellationTokenSource _readerCts;
private Task _readerTask;
private long _nextId;
private bool _initialized;
private bool _disposed;
public McpClient(ExternalActionProviderHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_pending = new ConcurrentDictionary<long, TaskCompletionSource<JsonElement>>();
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
ThrowIfDisposed();
await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
if (_initialized)
{
return;
}
var initParams = new
{
protocolVersion = "2024-11-05",
capabilities = new { tools = new { }, resources = new { } },
clientInfo = new { name = "TopToolbar", version = "1.0.0" },
};
await SendRequestAsync("initialize", initParams, cancellationToken).ConfigureAwait(false);
await SendNotificationAsync("notifications/initialized", new { }, cancellationToken).ConfigureAwait(false);
_initialized = true;
}
public async Task<IReadOnlyList<McpToolInfo>> ListToolsAsync(CancellationToken cancellationToken)
{
ThrowIfDisposed();
var result = await SendRequestAsync("tools/list", null, cancellationToken).ConfigureAwait(false);
var tools = new List<McpToolInfo>();
if (result.TryGetProperty("tools", out var toolsElement) && toolsElement.ValueKind == JsonValueKind.Array)
{
foreach (var toolElement in toolsElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
if (!toolElement.TryGetProperty("name", out var nameElement))
{
continue;
}
var name = nameElement.GetString();
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
string description = string.Empty;
if (toolElement.TryGetProperty("description", out var descriptionElement))
{
description = descriptionElement.GetString() ?? string.Empty;
}
JsonElement inputSchema = default;
if (toolElement.TryGetProperty("inputSchema", out var schemaElement))
{
inputSchema = schemaElement.Clone();
}
tools.Add(new McpToolInfo(name.Trim(), description.Trim(), inputSchema));
}
}
return tools;
}
public Task<JsonElement> CallToolAsync(string name, JsonElement? arguments, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Tool name must be provided.", nameof(name));
}
ThrowIfDisposed();
object payload = new
{
name,
arguments = arguments.HasValue ? (object)arguments.Value : new JsonObject(),
};
return SendRequestAsync("tools/call", payload, cancellationToken);
}
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
{
if (_writer != null && _host.IsRunning)
{
return;
}
await _connectLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_writer != null && _host.IsRunning)
{
return;
}
await _host.StartAsync(cancellationToken).ConfigureAwait(false);
_writer = _host.StandardInput;
_reader = _host.StandardOutput;
_readerCts?.Cancel();
_readerCts?.Dispose();
_readerCts = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCts.Token);
_readerTask = Task.Run(() => ReadLoopAsync(_readerCts.Token), CancellationToken.None);
}
finally
{
_connectLock.Release();
}
}
private async Task SendNotificationAsync(string method, object parameters, CancellationToken cancellationToken)
{
await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var notification = new JsonRpcRequest
{
JsonRpc = "2.0",
Id = null,
Method = method,
Params = parameters,
};
var json = JsonSerializer.Serialize(notification, _jsonOptions);
await _writer.WriteLineAsync(json).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
private async Task<JsonElement> SendRequestAsync(string method, object parameters, CancellationToken cancellationToken)
{
await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
var id = Interlocked.Increment(ref _nextId);
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[id] = tcs;
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var request = new JsonRpcRequest
{
JsonRpc = "2.0",
Id = id,
Method = method,
Params = parameters,
};
var json = JsonSerializer.Serialize(request, _jsonOptions);
await _writer.WriteLineAsync(json).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
{
return await tcs.Task.ConfigureAwait(false);
}
}
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line == null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
try
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
if (!root.TryGetProperty("id", out var idElement) || idElement.ValueKind == JsonValueKind.Null)
{
continue;
}
if (!TryParseId(idElement, out var id))
{
continue;
}
if (!_pending.TryRemove(id, out var tcs))
{
continue;
}
if (root.TryGetProperty("error", out var errorElement) && errorElement.ValueKind != JsonValueKind.Null)
{
var error = McpError.FromJson(errorElement);
tcs.TrySetException(new McpException(error.Code, error.Message, errorElement.Clone()));
}
else if (root.TryGetProperty("result", out var resultElement))
{
tcs.TrySetResult(resultElement.Clone());
}
else
{
tcs.TrySetResult(default);
}
}
catch (JsonException ex)
{
AppLogger.LogWarning($"McpClient: failed to parse MCP response - {ex.Message}.");
}
}
}
catch (ObjectDisposedException)
{
// Shutdown in progress
}
catch (Exception ex)
{
AppLogger.LogWarning($"McpClient: read loop failed - {ex.Message}.");
}
finally
{
foreach (var pending in _pending)
{
if (_pending.TryRemove(pending.Key, out var tcs))
{
tcs.TrySetException(new IOException("MCP connection closed."));
}
}
_writer = null;
_reader = null;
_initialized = false;
}
}
private static bool TryParseId(JsonElement element, out long id)
{
switch (element.ValueKind)
{
case JsonValueKind.Number:
if (element.TryGetInt64(out var value))
{
id = value;
return true;
}
break;
case JsonValueKind.String:
if (long.TryParse(element.GetString(), out var parsed))
{
id = parsed;
return true;
}
break;
}
id = 0;
return false;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, nameof(McpClient));
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_lifetimeCts.Cancel();
try
{
_readerCts?.Cancel();
_readerCts?.Dispose();
}
catch
{
}
try
{
_readerTask?.Wait(TimeSpan.FromSeconds(1));
}
catch
{
}
_writeLock.Dispose();
_connectLock.Dispose();
_lifetimeCts.Dispose();
}
private sealed class JsonRpcRequest
{
public string JsonRpc { get; set; }
public long? Id { get; set; }
public string Method { get; set; }
public object Params { get; set; }
}
}

View File

@@ -0,0 +1,45 @@
// 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.Text.Json;
namespace TopToolbar.Providers.External.Mcp;
internal sealed class McpError
{
private McpError(int code, string message, JsonElement data)
{
Code = code;
Message = message;
Data = data;
}
public int Code { get; }
public string Message { get; }
public JsonElement Data { get; }
public static McpError FromJson(JsonElement element)
{
var code = element.TryGetProperty("code", out var codeElement) && codeElement.TryGetInt32(out var parsedCode)
? parsedCode
: 0;
string message = "MCP error.";
if (element.TryGetProperty("message", out var messageElement))
{
message = messageElement.GetString() ?? message;
}
JsonElement data = default;
if (element.TryGetProperty("data", out var dataElement))
{
data = dataElement.Clone();
}
return new McpError(code, message, data);
}
}

View File

@@ -0,0 +1,22 @@
// 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.Text.Json;
namespace TopToolbar.Providers.External.Mcp;
internal sealed class McpException : Exception
{
public McpException(int code, string message, JsonElement details)
: base(string.IsNullOrWhiteSpace(message) ? $"MCP error ({code})." : message)
{
ErrorCode = code;
ErrorDetails = details;
}
public int ErrorCode { get; }
public JsonElement ErrorDetails { get; }
}

View File

@@ -0,0 +1,24 @@
// 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.Text.Json;
namespace TopToolbar.Providers.External.Mcp;
internal sealed class McpToolInfo
{
public McpToolInfo(string name, string description, JsonElement inputSchema)
{
Name = name ?? string.Empty;
Description = description ?? string.Empty;
InputSchema = inputSchema;
}
public string Name { get; }
public string Description { get; }
public JsonElement InputSchema { get; }
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Actions;
namespace TopToolbar.Providers
{
public interface IActionProvider
{
string Id { get; }
Task<ProviderInfo> GetInfoAsync(CancellationToken cancellationToken);
IAsyncEnumerable<ActionDescriptor> DiscoverAsync(ActionContext context, CancellationToken cancellationToken);
Task<ActionResult> InvokeAsync(
string actionId,
JsonElement? args,
ActionContext context,
IProgress<ActionProgress> progress,
CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace TopToolbar.Providers
{
/// <summary>
/// Optional interface a provider can implement to publish fine-grained change notifications.
/// Implement this when the provider's underlying data can change at runtime (file watch, external service, user edits)
/// and you want the toolbar/UI to refresh incrementally instead of forcing a full rebuild.
/// <para>
/// Guidance:
/// <list type="bullet">
/// <item><description>Use <see cref="ProviderChangeKind.ActionsUpdated"/> when existing actions' metadata (title, enabled state, icon) changed but ids are stable.</description></item>
/// <item><description>Use <see cref="ProviderChangeKind.ActionsAdded"/> / <see cref="ProviderChangeKind.ActionsRemoved"/> for structural additions/removals.</description></item>
/// <item><description>Use <see cref="ProviderChangeKind.GroupUpdated"/> when group level properties (name, layout) changed without removing all buttons.</description></item>
/// <item><description>Use <see cref="ProviderChangeKind.BulkRefresh"/> only when you cannot compute a diff; UI will rebuild all groups/actions for this provider.</description></item>
/// <item><description>Use <see cref="ProviderChangeKind.Reset"/> for catastrophic changes (e.g. provider lost state) runtime should drop caches then rediscover.</description></item>
/// </list>
/// </para>
/// <para>
/// Threading: Events may be raised from background threads; the runtime assigns a monotonically increasing version and forwards on a thread-pool thread.
/// UI code must marshal to the UI thread (the window already does this). Providers should avoid long blocking work inside event invocation.
/// </para>
/// <para>
/// Factory helpers on <see cref="ProviderChangedEventArgs"/> (<c>ActionsUpdated</c>, <c>ActionsAdded</c>, etc.) cover common patterns; use a custom instance only when you need a payload.
/// </para>
/// </summary>
public interface IChangeNotifyingActionProvider
{
/// <summary>
/// Raised whenever the provider's exposed groups or actions change.
/// Provide only the impacted ids to enable minimal UI refresh. Null lists mean "unspecified"; empty lists mean "known none".
/// </summary>
event EventHandler<ProviderChangedEventArgs> ProviderChanged;
}
}

View File

@@ -0,0 +1,18 @@
// 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.Threading;
using System.Threading.Tasks;
using TopToolbar.Actions;
using TopToolbar.Models;
namespace TopToolbar.Providers
{
public interface IToolbarGroupProvider
{
string Id { get; }
Task<ButtonGroup> CreateGroupAsync(ActionContext context, CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace TopToolbar.Providers
{
/// <summary>
/// Describes the semantic kind of change a provider is reporting. UI can use this to perform minimal refresh.
/// Values are intentionally sparse to allow future insertion without breaking binary consumers.
/// </summary>
public enum ProviderChangeKind
{
Unknown = 0,
ProviderRegistered = 1, // Provider became available (runtime may create initial group)
ProviderUnregistered = 2, // Provider removed/unloaded
GroupAdded = 10, // A new logical group is now available
GroupRemoved = 11, // A previously exposed group should be removed
GroupUpdated = 12, // Non-structural updates (name/layout/filter)
ActionsAdded = 20, // One or more new actions appended/inserted
ActionsRemoved = 21, // One or more actions removed
ActionsUpdated = 22, // Metadata or enabled state changed
ActionExecutionState = 30, // Execution started/stopped/succeeded/failed
ActionProgress = 31, // Long running action progress update
BulkRefresh = 90, // Provider cannot express finegrained diff; UI should rebuild provider output
Reset = 99, // Hard reset: discard cached state then rediscover fully
}
}

View File

@@ -0,0 +1,69 @@
// 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;
namespace TopToolbar.Providers
{
/// <summary>
/// Event payload describing a provider data change.
/// Provider implementations should prefer factory helpers (e.g. <see cref="ActionsUpdated"/>)
/// for common scenarios; custom payloads can be supplied via <see cref="Payload"/>.
/// </summary>
public sealed class ProviderChangedEventArgs : EventArgs
{
/// <summary>Gets the stable provider identifier emitting the change.</summary>
public string ProviderId { get; }
/// <summary>Gets the semantic change category.</summary>
public ProviderChangeKind Kind { get; }
/// <summary>Gets the groups impacted (null = not specified, empty = known none).</summary>
public IReadOnlyList<string> AffectedGroupIds { get; }
/// <summary>Gets the actions impacted (null = not specified, empty = known none).</summary>
public IReadOnlyList<string> AffectedActionIds { get; }
/// <summary>Gets the monotonic version assigned by runtime when dispatching.</summary>
public long Version { get; internal set; }
/// <summary>Gets the UTC timestamp of creation (provider side).</summary>
public DateTimeOffset Timestamp { get; } = DateTimeOffset.UtcNow;
/// <summary>Gets the optional strongly typed payload (execution state, progress, etc.).</summary>
public object Payload { get; }
/// <summary>Gets the optional lightweight metadata bag (string/object pairs) for extensions.</summary>
public IReadOnlyDictionary<string, object> Metadata { get; }
public ProviderChangedEventArgs(
string providerId,
ProviderChangeKind kind,
IReadOnlyList<string> affectedGroupIds = null,
IReadOnlyList<string> affectedActionIds = null,
object payload = null,
IReadOnlyDictionary<string, object> metadata = null)
{
ProviderId = providerId ?? string.Empty;
Kind = kind;
AffectedGroupIds = affectedGroupIds;
AffectedActionIds = affectedActionIds;
Payload = payload;
Metadata = metadata;
}
public static ProviderChangedEventArgs Bulk(string providerId) => new(providerId, ProviderChangeKind.BulkRefresh);
public static ProviderChangedEventArgs ResetAll(string providerId) => new(providerId, ProviderChangeKind.Reset);
public static ProviderChangedEventArgs ActionsUpdated(string providerId, IReadOnlyList<string> actionIds) => new(providerId, ProviderChangeKind.ActionsUpdated, affectedActionIds: actionIds);
public static ProviderChangedEventArgs ActionsAdded(string providerId, IReadOnlyList<string> actionIds) => new(providerId, ProviderChangeKind.ActionsAdded, affectedActionIds: actionIds);
public static ProviderChangedEventArgs ActionsRemoved(string providerId, IReadOnlyList<string> actionIds) => new(providerId, ProviderChangeKind.ActionsRemoved, affectedActionIds: actionIds);
public static ProviderChangedEventArgs GroupUpdated(string providerId, IReadOnlyList<string> groupIds) => new(providerId, ProviderChangeKind.GroupUpdated, affectedGroupIds: groupIds);
}
}

View File

@@ -0,0 +1,19 @@
// 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 TopToolbar.Providers
{
public sealed class ProviderInfo
{
public ProviderInfo(string displayName, string version)
{
DisplayName = displayName ?? string.Empty;
Version = version ?? string.Empty;
}
public string DisplayName { get; }
public string Version { get; }
}
}

View File

@@ -0,0 +1,687 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Actions;
using TopToolbar.Logging;
using TopToolbar.Models;
using TopToolbar.Services.Profiles;
using TopToolbar.Services.Workspaces;
namespace TopToolbar.Providers
{
public sealed class WorkspaceProvider : IActionProvider, IToolbarGroupProvider, IDisposable, IChangeNotifyingActionProvider
{
private const string WorkspacePrefix = "workspace.launch:";
private readonly string _workspacesPath;
private readonly WorkspacesRuntimeService _workspacesService;
private readonly ProfileFileService _profileFileService;
// Caching + watcher fields
private readonly object _cacheLock = new();
private List<WorkspaceRecord> _cached = new();
private bool _cacheLoaded;
private int _version;
private FileSystemWatcher _watcher;
private System.Timers.Timer _debounceTimer;
private bool _disposed;
// Local event (UI or tests can hook) - optional
public event EventHandler WorkspacesChanged;
// Typed provider change event consumed by runtime
public event EventHandler<ProviderChangedEventArgs> ProviderChanged;
public WorkspaceProvider(string workspacesPath = null, ProfileFileService profileFileService = null)
{
_workspacesPath = string.IsNullOrWhiteSpace(workspacesPath)
? WorkspaceStoragePaths.GetDefaultWorkspacesPath()
: workspacesPath;
_workspacesService = new WorkspacesRuntimeService(_workspacesPath);
_profileFileService = profileFileService ?? new ProfileFileService();
StartWatcher();
}
private void StartWatcher()
{
try
{
var dir = Path.GetDirectoryName(_workspacesPath);
var file = Path.GetFileName(_workspacesPath);
if (string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(file))
{
return;
}
_debounceTimer = new System.Timers.Timer(250) { AutoReset = false };
_debounceTimer.Elapsed += async (_, __) =>
{
try
{
if (await ReloadIfChangedAsync().ConfigureAwait(false))
{
WorkspacesChanged?.Invoke(this, EventArgs.Empty);
}
}
catch (Exception)
{
// Swallow, optional: add logging later
}
};
_watcher = new FileSystemWatcher(dir, file)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName | NotifyFilters.CreationTime,
IncludeSubdirectories = false,
EnableRaisingEvents = true,
};
FileSystemEventHandler handler = (_, __) => RestartDebounce();
RenamedEventHandler renamedHandler = (_, __) => RestartDebounce();
_watcher.Changed += handler;
_watcher.Created += handler;
_watcher.Deleted += handler;
_watcher.Renamed += renamedHandler;
}
catch (Exception)
{
// Ignore watcher setup failures
}
}
private void RestartDebounce()
{
if (_debounceTimer == null)
{
return;
}
try
{
_debounceTimer.Stop();
_debounceTimer.Start();
}
catch
{
}
}
private async Task<bool> ReloadIfChangedAsync()
{
var newList = await ReadWorkspacesFileAsync(CancellationToken.None).ConfigureAwait(false);
bool changed;
List<WorkspaceRecord> oldList;
lock (_cacheLock)
{
oldList = new List<WorkspaceRecord>(_cached);
if (!HasChanged(_cached, newList))
{
return false;
}
_cached = new List<WorkspaceRecord>(newList);
_cacheLoaded = true;
_version++;
changed = true;
}
if (changed)
{
try
{
// Synchronize workspace changes to all profiles
SyncWorkspacesToAllProfiles(oldList, newList);
WorkspacesChanged?.Invoke(this, EventArgs.Empty);
// Use ActionsUpdated with the set of current workspace action ids
var actionIds = new List<string>();
foreach (var ws in newList)
{
actionIds.Add("workspace::" + ws.Id);
}
ProviderChanged?.Invoke(this, ProviderChangedEventArgs.ActionsUpdated(Id, actionIds));
}
catch
{
}
}
return true;
}
private static bool HasChanged(List<WorkspaceRecord> oldList, IReadOnlyList<WorkspaceRecord> newList)
{
if (oldList.Count != newList.Count
)
{
return true;
}
for (int i = 0; i < oldList.Count; i++)
{
var o = oldList[i];
var n = newList[i];
if (!string.Equals(o.Id, n.Id, StringComparison.Ordinal) || !string.Equals(o.Name, n.Name, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private async Task<IReadOnlyList<WorkspaceRecord>> GetWorkspacesAsync(CancellationToken cancellationToken)
{
if (_cacheLoaded)
{
lock (_cacheLock)
{
return _cached;
}
}
var list = await ReadWorkspacesFileAsync(cancellationToken).ConfigureAwait(false);
lock (_cacheLock)
{
if (!_cacheLoaded)
{
_cached = new List<WorkspaceRecord>(list);
_cacheLoaded = true;
_version = 1;
}
return _cached;
}
}
internal async Task<WorkspaceDefinition> SnapshotAsync(string workspaceName, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, nameof(WorkspaceProvider));
var workspace = await _workspacesService.SnapshotAsync(workspaceName, cancellationToken).ConfigureAwait(false);
if (workspace != null)
{
try
{
await ReloadIfChangedAsync().ConfigureAwait(false);
}
catch
{
}
}
return workspace;
}
public string Id => "WorkspaceProvider";
public Task<ProviderInfo> GetInfoAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new ProviderInfo("Workspaces", "1.0"));
}
public async IAsyncEnumerable<ActionDescriptor> DiscoverAsync(ActionContext context, [EnumeratorCancellation] CancellationToken cancellationToken)
{
var workspaces = await GetWorkspacesAsync(cancellationToken).ConfigureAwait(false);
var order = 0d;
foreach (var workspace in workspaces)
{
cancellationToken.ThrowIfCancellationRequested();
var displayName = string.IsNullOrWhiteSpace(workspace.Name) ? workspace.Id : workspace.Name;
var descriptor = new ActionDescriptor
{
Id = WorkspacePrefix + workspace.Id,
ProviderId = Id,
Title = displayName,
Subtitle = workspace.Id,
Kind = ActionKind.Launch,
GroupHint = "workspaces",
Order = order++,
Icon = new ActionIcon { Type = ActionIconType.Glyph, Value = "\uE7F1" },
CanExecute = true,
};
if (!string.IsNullOrWhiteSpace(workspace.Name))
{
descriptor.Keywords.Add(workspace.Name);
}
descriptor.Keywords.Add(workspace.Id);
yield return descriptor;
}
}
public async Task<ButtonGroup> CreateGroupAsync(ActionContext context, CancellationToken cancellationToken)
{
var group = new ButtonGroup
{
Id = "workspaces",
Name = "Workspaces",
Description = "Saved workspace layouts",
Layout = new ToolbarGroupLayout
{
Style = ToolbarGroupLayoutStyle.Capsule,
Overflow = ToolbarGroupOverflowMode.Menu,
MaxInline = 8,
},
};
var workspaces = await GetWorkspacesAsync(cancellationToken).ConfigureAwait(false);
foreach (var workspace in workspaces)
{
cancellationToken.ThrowIfCancellationRequested();
var displayName = string.IsNullOrWhiteSpace(workspace.Name) ? workspace.Id : workspace.Name;
var button = new ToolbarButton
{
Id = $"workspace::{workspace.Id}",
Name = displayName,
Description = workspace.Id,
IconGlyph = "\uE7F1",
Action = new ToolbarAction
{
Type = ToolbarActionType.Provider,
ProviderId = Id,
ProviderActionId = WorkspacePrefix + workspace.Id,
},
};
group.Buttons.Add(button);
}
return group;
}
public async Task<ActionResult> InvokeAsync(
string actionId,
JsonElement? args,
ActionContext context,
IProgress<ActionProgress> progress,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(actionId) || !actionId.StartsWith(WorkspacePrefix, StringComparison.OrdinalIgnoreCase))
{
return new ActionResult
{
Ok = false,
Message = "Invalid workspace action id.",
};
}
var workspaceId = actionId.Substring(WorkspacePrefix.Length).Trim();
if (string.IsNullOrWhiteSpace(workspaceId))
{
return new ActionResult
{
Ok = false,
Message = "Workspace identifier is empty.",
};
}
try
{
var exitCode = await RunLauncherAsync(workspaceId, cancellationToken).ConfigureAwait(false);
var ok = exitCode == 0;
return new ActionResult
{
Ok = ok,
Message = ok ? string.Empty : $"Launcher exit code {exitCode}.",
};
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// TODO: Add proper logging once Logger reference is resolved
return new ActionResult
{
Ok = false,
Message = ex.Message,
};
}
}
private async Task<int> RunLauncherAsync(string workspaceId, CancellationToken cancellationToken)
{
try
{
var success = await _workspacesService.LaunchWorkspaceAsync(workspaceId, cancellationToken).ConfigureAwait(false);
return success ? 0 : 1;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.LogWarning($"WorkspaceProvider: failed to launch workspace '{workspaceId}' - {ex.Message}");
return 1;
}
}
private async Task<IReadOnlyList<WorkspaceRecord>> ReadWorkspacesFileAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_workspacesPath))
{
return Array.Empty<WorkspaceRecord>();
}
try
{
await using var stream = new FileStream(_workspacesPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!document.RootElement.TryGetProperty("workspaces", out var workspacesElement) || workspacesElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<WorkspaceRecord>();
}
var list = new List<WorkspaceRecord>();
foreach (var item in workspacesElement.EnumerateArray())
{
cancellationToken.ThrowIfCancellationRequested();
if (!item.TryGetProperty("id", out var idElement))
{
continue;
}
var id = idElement.GetString();
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
string name = null;
if (item.TryGetProperty("name", out var nameElement))
{
name = nameElement.GetString();
}
list.Add(new WorkspaceRecord(id.Trim(), name?.Trim()));
}
return list;
}
catch (JsonException)
{
// TODO: Add proper logging once Logger reference is resolved
// Logger.LogWarning($"WorkspaceProvider: failed to parse '{_workspacesPath} - {ex.Message}'.");
}
catch (IOException)
{
// TODO: Add proper logging once Logger reference is resolved
// Logger.LogWarning($"WorkspaceProvider: unable to read '{_workspacesPath} - {ex.Message}'.");
}
return Array.Empty<WorkspaceRecord>();
}
/// <summary>
/// Synchronizes workspace changes to all profiles.
/// New workspaces are added as enabled by default.
/// Deleted workspaces are removed from all profiles.
/// </summary>
private void SyncWorkspacesToAllProfiles(IReadOnlyList<WorkspaceRecord> oldWorkspaces, IReadOnlyList<WorkspaceRecord> newWorkspaces)
{
try
{
if (_profileFileService == null)
{
return;
}
// Get all profiles
var profiles = _profileFileService.GetAllProfiles();
if (profiles == null || profiles.Count == 0)
{
return;
}
// Find added workspaces (new ones that weren't in old list)
var addedWorkspaces = newWorkspaces
.Where(nw => !oldWorkspaces.Any(ow => string.Equals(ow.Id, nw.Id, StringComparison.Ordinal)))
.ToList();
// Find removed workspaces (old ones that aren't in new list)
var removedWorkspaces = oldWorkspaces
.Where(ow => !newWorkspaces.Any(nw => string.Equals(nw.Id, ow.Id, StringComparison.Ordinal)))
.ToList();
// Update each profile
foreach (var profile in profiles)
{
var modified = false;
// Find or create the workspaces group
var workspacesGroup = profile.Groups?.FirstOrDefault(g =>
string.Equals(g.Id, "workspaces", StringComparison.OrdinalIgnoreCase));
if (workspacesGroup == null)
{
// Create new workspaces group if it doesn't exist
workspacesGroup = new ProfileGroup
{
Id = "workspaces",
Name = "Workspaces",
Description = "Saved workspace layouts",
IsEnabled = true,
SortOrder = 0,
Actions = new List<ProfileAction>(),
};
profile.Groups ??= new List<ProfileGroup>();
profile.Groups.Add(workspacesGroup);
modified = true;
}
// Add new workspaces as enabled actions
foreach (var addedWorkspace in addedWorkspaces)
{
var actionId = $"workspace::{addedWorkspace.Id}";
var existingAction = workspacesGroup.Actions?.FirstOrDefault(a =>
string.Equals(a.Id, actionId, StringComparison.Ordinal));
if (existingAction == null)
{
var displayName = string.IsNullOrWhiteSpace(addedWorkspace.Name) ? addedWorkspace.Id : addedWorkspace.Name;
var newAction = new ProfileAction
{
Id = actionId,
Name = displayName,
Description = addedWorkspace.Id,
IsEnabled = true, // Enable new workspaces by default
IconGlyph = "\uE7F1",
};
workspacesGroup.Actions ??= new List<ProfileAction>();
workspacesGroup.Actions.Add(newAction);
modified = true;
}
}
// Remove deleted workspaces
if (workspacesGroup.Actions != null)
{
foreach (var removedWorkspace in removedWorkspaces)
{
var actionId = $"workspace::{removedWorkspace.Id}";
var actionToRemove = workspacesGroup.Actions.FirstOrDefault(a =>
string.Equals(a.Id, actionId, StringComparison.Ordinal));
if (actionToRemove != null)
{
workspacesGroup.Actions.Remove(actionToRemove);
modified = true;
}
}
}
// Save profile if modified
if (modified)
{
_profileFileService.SaveProfile(profile);
}
}
}
catch (Exception ex)
{
// Log error but don't fail the workspace reload
System.Diagnostics.Debug.WriteLine($"WorkspaceProvider: Failed to sync workspaces to profiles: {ex.Message}");
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
try
{
try
{
_debounceTimer?.Stop();
}
catch
{
}
try
{
_debounceTimer?.Dispose();
}
catch
{
}
_debounceTimer = null;
try
{
if (_watcher != null)
{
_watcher.EnableRaisingEvents = false;
_watcher.Dispose();
}
}
catch
{
}
_watcher = null;
try
{
_workspacesService?.Dispose();
}
catch
{
}
try
{
_profileFileService?.Dispose();
}
catch
{
}
lock (_cacheLock)
{
_cached.Clear();
_cacheLoaded = false;
_version = 0;
}
// Release any external subscribers
WorkspacesChanged = null;
ProviderChanged = null;
}
finally
{
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Gets default workspace groups that should be added to profiles if they don't exist.
/// This provides the standard workspace and MCP server groups with default enabled actions.
/// </summary>
public static async Task<List<TopToolbar.Models.ProfileGroup>> GetDefaultWorkspaceGroupsAsync()
{
var groups = new List<TopToolbar.Models.ProfileGroup>();
// Create workspace provider instance to get real workspace data
using var workspaceProvider = new WorkspaceProvider();
try
{
// Get actual workspace group with real workspace data
var context = new Actions.ActionContext();
var workspaceButtonGroup = await workspaceProvider.CreateGroupAsync(context, CancellationToken.None);
// Convert ButtonGroup to ProfileGroup
var workspacesGroup = new TopToolbar.Models.ProfileGroup
{
Id = workspaceButtonGroup.Id,
Name = workspaceButtonGroup.Name,
Description = workspaceButtonGroup.Description,
IsEnabled = true,
SortOrder = 0,
Actions = new List<TopToolbar.Models.ProfileAction>(),
};
// Convert each ToolbarButton to ProfileAction
foreach (var button in workspaceButtonGroup.Buttons)
{
var profileAction = new TopToolbar.Models.ProfileAction
{
Id = button.Id,
Name = button.Name,
Description = button.Description,
IsEnabled = true,
IconGlyph = button.IconGlyph,
};
workspacesGroup.Actions.Add(profileAction);
}
groups.Add(workspacesGroup);
}
catch (Exception)
{
// If workspace provider fails, add a minimal fallback group
var fallbackGroup = new TopToolbar.Models.ProfileGroup
{
Id = "workspaces",
Name = "Workspaces",
Description = "Saved workspace layouts",
IsEnabled = true,
SortOrder = 0,
Actions = new List<TopToolbar.Models.ProfileAction>(),
};
groups.Add(fallbackGroup);
}
return groups;
}
private sealed record WorkspaceRecord(string Id, string Name);
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Models;
using TopToolbar.Providers;
using TopToolbar.Services.Profiles;
namespace TopToolbar.Test
{
internal sealed class WorkspaceProviderTest
{
/// <summary>
/// Simple test method to verify workspace-to-profile synchronization.
/// This demonstrates the enhanced functionality.
/// </summary>
public static async Task TestWorkspaceProfileSyncAsync()
{
try
{
// Create a temporary workspace file for testing
var tempDir = Path.GetTempPath();
var testWorkspacePath = Path.Combine(tempDir, "test_workspaces.json");
var testProfileDir = Path.Combine(tempDir, "test_profiles");
Directory.CreateDirectory(testProfileDir);
// Create initial workspace data
var initialWorkspaces = new
{
workspaces = new[]
{
new { id = "workspace1", name = "Development" },
new { id = "workspace2", name = "Research" },
},
};
await File.WriteAllTextAsync(testWorkspacePath, JsonSerializer.Serialize(initialWorkspaces));
// Create test profile service
var profileFileService = new ProfileFileService(testProfileDir);
// Create test profile
var testProfile = profileFileService.CreateEmptyProfile("test-profile", "Test Profile");
profileFileService.SaveProfile(testProfile);
// Create WorkspaceProvider with test dependencies
using var workspaceProvider = new WorkspaceProvider(testWorkspacePath, profileFileService);
// Wait a moment for initial load
await Task.Delay(100);
// Simulate workspace changes - add a new workspace
var updatedWorkspaces = new
{
workspaces = new[]
{
new { id = "workspace1", name = "Development" },
new { id = "workspace2", name = "Research" },
new { id = "workspace3", name = "Testing" }, // New workspace
},
};
await File.WriteAllTextAsync(testWorkspacePath, JsonSerializer.Serialize(updatedWorkspaces));
// Wait for file watcher to trigger
await Task.Delay(500);
// Verify that the profile was updated
var updatedProfile = profileFileService.GetProfile("test-profile");
if (updatedProfile != null)
{
var workspacesGroup = updatedProfile.Groups?.Find(g => g.Id == "workspaces");
if (workspacesGroup?.Actions != null)
{
var workspace3Action = workspacesGroup.Actions.Find(a => a.Id == "workspace::workspace3");
if (workspace3Action != null && workspace3Action.IsEnabled)
{
Console.WriteLine("Γ£ô Success: New workspace was automatically added to profile and enabled");
}
else
{
Console.WriteLine("Γ£ù Failed: New workspace was not properly added to profile");
}
}
else
{
Console.WriteLine("Γ£ù Failed: Workspaces group not found in profile");
}
}
else
{
Console.WriteLine("Γ£ù Failed: Could not retrieve updated profile");
}
// Clean up
try
{
File.Delete(testWorkspacePath);
Directory.Delete(testProfileDir, true);
}
catch
{
// Ignore cleanup errors
}
}
catch (Exception ex)
{
Console.WriteLine($"Test failed with exception: {ex.Message}");
}
}
}
}

View File

@@ -0,0 +1,89 @@
# TopToolbar Right-Click Button Delete Feature
## Summary
Successfully implemented right-click context menu functionality for TopToolbar buttons that allows users to delete buttons with immediate persistence to the configuration file.
## What Was Implemented
### Code Changes
**File: `TopToolbarXAML/ToolbarWindow.xaml.cs`**
1. Added import: `using TopToolbar.Logging;`
2. Added `OnRightTapped` event handler to the `CreateIconButton(ButtonGroup group, ToolbarButton model)` method:
- Displays MenuFlyout (context menu) with "Remove Button" option
- Proper trash icon (\uE74D)
- Clean error handling
3. Event handler properly implements the delete flow:
- Finds the corresponding group in `_vm.Groups` (the source of truth)
- Removes button from `_vm.Groups`
- Calls `_vm.SaveAsync()` to persist to config file
- Re-syncs Store via `SyncStaticGroupsIntoStore()`
- Rebuilds UI with `BuildToolbarFromStore()`
- Resizes window appropriately
### Key Architecture Insight
Fixed a critical synchronization issue by recognizing:
- **_vm.Groups** = true data source (config file origin)
- **_store.Groups** = UI rendering source (synced from _vm)
- **SaveAsync()** only reads from _vm.Groups
Therefore, deletion must occur in _vm.Groups to be persisted.
## Correct Deletion Flow
```csharp
// 1. Find group in _vm (source of truth)
var vmGroup = _vm.Groups.FirstOrDefault(g =>
string.Equals(g.Id, group.Id, StringComparison.OrdinalIgnoreCase));
// 2. Delete from _vm
vmGroup.Buttons.Remove(model);
// 3. Save to file (reads from _vm)
await _vm.SaveAsync();
// 4. Sync to Store (updates UI source)
SyncStaticGroupsIntoStore();
// 5. Rebuild UI
BuildToolbarFromStore();
ResizeToContent();
```
## Features
✅ Right-click context menu on all buttons
✅ Instant UI update after deletion
✅ Persistent storage in config file
✅ Proper error handling and logging
✅ Only affects static buttons (from config file)
✅ Provider-based buttons unaffected
✅ All code and documentation in English
## Testing Verification
- [ ] Delete button - UI immediately updates
- [ ] Restart app - deleted button does not appear
- [ ] Config file - deleted button entry removed
- [ ] Provider buttons - reappear after restart
- [ ] Error cases - logged appropriately
## Documentation
Complete implementation details available in:
`BUTTON_DELETE_IMPLEMENTATION.md`
Contains:
- Feature overview
- Problem analysis and solution
- Code implementation
- Data architecture explanation
- Configuration file examples
- Usage instructions
- Verification checklist

View File

@@ -0,0 +1,65 @@
// 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;
using System.Globalization;
using TopToolbar.Actions;
using TopToolbar.Models;
namespace TopToolbar.Services
{
public sealed class ActionContextFactory
{
public ActionContext CreateForDiscovery(ButtonGroup group)
{
var context = CreateBaseContext();
if (group != null)
{
context.EnvironmentVariables["TOPTOOLBAR_GROUP_ID"] = group.Id ?? string.Empty;
context.EnvironmentVariables["TOPTOOLBAR_GROUP_NAME"] = group.Name ?? string.Empty;
context.EnvironmentVariables["TOPTOOLBAR_GROUP_FILTER"] = group.Filter ?? string.Empty;
}
return context;
}
public ActionContext CreateForInvocation(ButtonGroup group, ToolbarButton button)
{
var context = CreateForDiscovery(group);
if (button != null)
{
context.EnvironmentVariables["TOPTOOLBAR_BUTTON_ID"] = button.Id ?? string.Empty;
context.EnvironmentVariables["TOPTOOLBAR_BUTTON_NAME"] = button.Name ?? string.Empty;
context.EnvironmentVariables["TOPTOOLBAR_BUTTON_DESCRIPTION"] = button.Description ?? string.Empty;
}
return context;
}
private static ActionContext CreateBaseContext()
{
var context = new ActionContext
{
Locale = CultureInfo.CurrentUICulture?.Name ?? string.Empty,
NowUtcIso = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture),
};
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
{
var key = entry.Key?.ToString();
if (string.IsNullOrEmpty(key) || context.EnvironmentVariables.ContainsKey(key))
{
continue;
}
context.EnvironmentVariables[key] = entry.Value?.ToString() ?? string.Empty;
}
return context;
}
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TopToolbar.Actions;
using TopToolbar.Models;
using TopToolbar.Providers;
namespace TopToolbar.Services
{
public sealed class ActionProviderService
{
private readonly ActionProviderRuntime _runtime;
public ActionProviderService(ActionProviderRuntime runtime)
{
_runtime = runtime ?? new ActionProviderRuntime();
}
public IReadOnlyCollection<string> RegisteredProviderIds => _runtime.RegisteredProviderIds;
public IReadOnlyCollection<string> RegisteredGroupProviderIds => _runtime.RegisteredGroupProviderIds;
public void RegisterProvider(IActionProvider provider)
{
_runtime.RegisterProvider(provider);
}
public Task<ProviderInfo> GetInfoAsync(string providerId, CancellationToken cancellationToken)
{
return _runtime.GetInfoAsync(providerId, cancellationToken);
}
public Task<ButtonGroup> CreateGroupAsync(string providerId, ActionContext context, CancellationToken cancellationToken)
{
return _runtime.CreateGroupAsync(providerId, context, cancellationToken);
}
public Task<IReadOnlyList<ActionDescriptor>> DiscoverAsync(
IEnumerable<string> providerIds,
ActionContext context,
CancellationToken cancellationToken)
{
return _runtime.DiscoverAsync(providerIds, context, cancellationToken);
}
public Task<ActionResult> InvokeAsync(
string providerId,
string actionId,
JsonElement? args,
ActionContext context,
IProgress<ActionProgress> progress,
CancellationToken cancellationToken)
{
return _runtime.InvokeAsync(providerId, actionId, args, context, progress, cancellationToken);
}
public bool TryGetProvider(string providerId, out IActionProvider provider)
{
return _runtime.TryGetProvider(providerId, out provider);
}
}
}

View File

@@ -0,0 +1,234 @@
// 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.ObjectModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.UI.Xaml.Controls;
using TopToolbar.Models;
namespace TopToolbar.Services
{
internal static class IconCatalogService
{
public const string CatalogScheme = "catalog";
private static readonly IReadOnlyList<IconCatalogEntry> Catalog;
private static readonly IReadOnlyDictionary<string, IconCatalogEntry> CatalogMap;
static IconCatalogService()
{
var list = new List<IconCatalogEntry>
{
CreateSvg("display", "Display", "System", "display", "monitor", "screen", "desktop"),
CreateSvg("speaker", "Audio", "System", "speaker", "sound", "volume", "music"),
CreateSvg("wifi", "Network", "Connectivity", "wifi", "wireless", "internet", "connection"),
CreateSvg("bolt", "Lightning", "Quick launch", "bolt", "power", "flash", "script"),
CreateSvg("check-circle", "Check", "Monitoring", "check-circle", "confirm", "status", "success"),
CreateSvg("cloud", "Cloud", "Web", "cloud", "web", "internet", "sync"),
CreateSvg("terminal", "Terminal", "Developer", "terminal", "console", "cli", "shell"),
CreateSvg("calendar", "Calendar", "Time", "calendar", "schedule", "events", "date"),
CreateSvg("clipboard", "Clipboard", "Utilities", "clipboard", "notes", "copy", "task"),
CreateSvg("grid", "Grid", "Productivity", "grid", "layout", "apps", "matrix"),
CreateSvg("heart", "Favorite", "Personal", "heart", "like", "love", "pin"),
CreateSvg("play", "Play", "Media", "play", "run", "start", "begin"),
CreateSvg("workspace", "Workspace", "Layouts", "workspace", "snap", "arrange", "desktop"),
CreateSvg("tasks", "Tasks", "Productivity", "tasks", "todo", "list", "organize"),
CreateSvg("rocket", "Rocket", "Automation", "rocket", "deploy", "launch", "boost"),
};
foreach (var glyphEntry in BuildGlyphCatalogEntries())
{
if (!list.Any(existing => string.Equals(existing.Id, glyphEntry.Id, StringComparison.OrdinalIgnoreCase)))
{
list.Add(glyphEntry);
}
}
Catalog = new ReadOnlyCollection<IconCatalogEntry>(list);
CatalogMap = Catalog.ToDictionary(i => i.Id, StringComparer.OrdinalIgnoreCase);
}
public static IconCatalogEntry GetDefault()
{
return Catalog.Count > 0 ? Catalog[0] : null;
}
public static IReadOnlyList<IconCatalogEntry> GetAll()
{
return Catalog;
}
public static bool TryGetById(string id, out IconCatalogEntry entry)
{
if (string.IsNullOrWhiteSpace(id))
{
entry = null;
return false;
}
return CatalogMap.TryGetValue(id.Trim(), out entry);
}
public static IconCatalogEntry ResolveFromPath(string iconPath)
{
return TryParseCatalogId(iconPath, out var id) && CatalogMap.TryGetValue(id, out var entry) ? entry : null;
}
public static string BuildCatalogPath(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("Icon id cannot be null or whitespace.", nameof(id));
}
return string.Concat(CatalogScheme, ":", id.Trim());
}
public static bool TryParseCatalogId(string iconPath, out string id)
{
id = string.Empty;
if (string.IsNullOrWhiteSpace(iconPath))
{
return false;
}
var trimmed = iconPath.Trim();
string candidateId = string.Empty;
if (trimmed.StartsWith(CatalogScheme + ":", StringComparison.OrdinalIgnoreCase))
{
candidateId = trimmed.Substring(CatalogScheme.Length + 1);
}
else if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))
{
if (string.Equals(uri.Scheme, CatalogScheme, StringComparison.OrdinalIgnoreCase))
{
candidateId = uri.AbsolutePath.Trim('/');
}
else
{
var fromPath = Path.GetFileNameWithoutExtension(uri.AbsolutePath);
candidateId = fromPath ?? string.Empty;
}
if (!string.IsNullOrWhiteSpace(candidateId))
{
candidateId = Path.GetFileNameWithoutExtension(candidateId);
}
}
else
{
candidateId = Path.GetFileNameWithoutExtension(trimmed);
}
if (string.IsNullOrWhiteSpace(candidateId))
{
id = string.Empty;
return false;
}
candidateId = candidateId.Trim();
if (CatalogMap.ContainsKey(candidateId))
{
id = candidateId;
return true;
}
id = string.Empty;
return false;
}
private static IconCatalogEntry CreateSvg(string id, string name, string category, string fileName, params string[] keywords)
{
var uri = new Uri($"ms-appx:///Assets/Icons/{fileName}.svg", UriKind.Absolute);
return new IconCatalogEntry(id, name, category, uri, keywords ?? Array.Empty<string>());
}
private static IEnumerable<IconCatalogEntry> BuildGlyphCatalogEntries()
{
foreach (Symbol symbol in Enum.GetValues(typeof(Symbol)))
{
var codepoint = (int)symbol;
if (codepoint <= 0)
{
continue;
}
string glyph;
try
{
glyph = char.ConvertFromUtf32(codepoint);
}
catch
{
continue;
}
var displayName = ToFriendlyName(symbol.ToString());
if (string.IsNullOrWhiteSpace(displayName))
{
displayName = symbol.ToString();
}
var keywords = new List<string>
{
displayName,
displayName.Replace(" ", string.Empty),
FormatCodepoint(codepoint),
glyph,
symbol.ToString(),
};
yield return new IconCatalogEntry(
id: $"glyph-{codepoint:X4}",
displayName: displayName,
category: "Segoe Fluent Icons",
resourceUri: null,
keywords: keywords,
glyph: glyph,
fontFamily: "Segoe Fluent Icons,Segoe MDL2 Assets");
}
}
private static string FormatCodepoint(int codepoint)
{
return string.Concat("U+", codepoint.ToString("X4", CultureInfo.InvariantCulture));
}
private static string ToFriendlyName(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var builder = new StringBuilder(value.Length + 8);
builder.Append(value[0]);
for (int i = 1; i < value.Length; i++)
{
var current = value[i];
var previous = value[i - 1];
if (char.IsUpper(current) && !char.IsUpper(previous))
{
builder.Append(' ');
}
else if (char.IsDigit(current) && !char.IsDigit(previous))
{
builder.Append(' ');
}
builder.Append(current);
}
return builder.ToString();
}
}
}

View File

@@ -0,0 +1,69 @@
// 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.Drawing;
using System.Drawing.Imaging;
using System.IO;
namespace TopToolbar.Services
{
public static class IconExtractionService
{
public static bool TryExtractExeIconToPng(string exePath, string targetPngPath)
{
try
{
if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath) ||
!exePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
return false;
}
Directory.CreateDirectory(Path.GetDirectoryName(targetPngPath)!);
using var icon = Icon.ExtractAssociatedIcon(exePath);
if (icon == null)
{
return false;
}
using var bmp = icon.ToBitmap();
bmp.Save(targetPngPath, ImageFormat.Png);
return true;
}
catch
{
return false;
}
}
public static bool TryExtractFileIconToPng(string filePath, string targetPngPath)
{
try
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
return false;
}
Directory.CreateDirectory(Path.GetDirectoryName(targetPngPath)!);
using var icon = Icon.ExtractAssociatedIcon(filePath);
if (icon == null)
{
return false;
}
using var bmp = icon.ToBitmap();
bmp.Save(targetPngPath, ImageFormat.Png);
return true;
}
catch
{
return false;
}
}
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace TopToolbar.Services
{
internal static class IconStorageService
{
public static string SaveIconFromFile(string sourcePath, string preferredName = "")
{
if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath))
{
throw new FileNotFoundException("Icon source file not found.", sourcePath);
}
var extension = Path.GetExtension(sourcePath);
if (string.IsNullOrWhiteSpace(extension))
{
extension = ".png";
}
using var stream = File.OpenRead(sourcePath);
return SaveIconFromStream(stream, extension, string.IsNullOrWhiteSpace(preferredName) ? Path.GetFileNameWithoutExtension(sourcePath) : preferredName);
}
public static string SaveIconFromStream(Stream sourceStream, string extension, string preferredName = "")
{
ArgumentNullException.ThrowIfNull(sourceStream);
extension = NormalizeExtension(extension);
var safeName = SanitizeName(string.IsNullOrWhiteSpace(preferredName) ? "icon" : preferredName.Trim());
var fileName = string.Concat(safeName, "_", DateTime.UtcNow.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture), extension);
Directory.CreateDirectory(AppPaths.IconsDirectory);
var targetPath = Path.Combine(AppPaths.IconsDirectory, fileName);
using var target = File.Create(targetPath);
if (sourceStream.CanSeek)
{
sourceStream.Position = 0;
}
sourceStream.CopyTo(target);
return targetPath;
}
public static string SaveSvg(string svgContent, string preferredName = "")
{
if (string.IsNullOrWhiteSpace(svgContent))
{
throw new ArgumentException("SVG content cannot be empty.", nameof(svgContent));
}
var bytes = Encoding.UTF8.GetBytes(svgContent);
using var memory = new MemoryStream(bytes, writable: false);
return SaveIconFromStream(memory, ".svg", preferredName);
}
private static string NormalizeExtension(string extension)
{
if (string.IsNullOrWhiteSpace(extension))
{
return ".png";
}
var ext = extension.Trim();
if (!ext.StartsWith('.'))
{
ext = "." + ext;
}
return ext.ToLowerInvariant();
}
private static string SanitizeName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var safeChars = name.Select(c => invalid.Contains(c) ? '-' : c).ToArray();
var sanitized = new string(safeChars);
return string.IsNullOrWhiteSpace(sanitized) ? "icon" : sanitized;
}
}
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace TopToolbar.Services.Profiles;
public sealed class EffectiveAction
{
public string ActionId { get; set; } = string.Empty;
public bool Enabled { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string IconGlyph { get; set; } = string.Empty;
public string IconPath { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace TopToolbar.Services.Profiles;
public sealed class EffectiveGroup
{
public string GroupId { get; set; } = string.Empty;
public bool Enabled { get; set; }
public List<EffectiveAction> Actions { get; set; } = new();
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using TopToolbar.Services.Profiles.Models;
namespace TopToolbar.Services.Profiles;
/// <summary>
/// Merges provider definitions with a profile's overrides to produce an effective enabled model.
/// </summary>
public sealed class EffectiveModelBuilder : IEffectiveModelBuilder
{
public IReadOnlyList<EffectiveProviderModel> Build(ProfileOverridesFile profile, IReadOnlyDictionary<string, ProviderDefinitionFile> providers)
{
profile ??= new ProfileOverridesFile { ProfileId = "default", Overrides = new ProfileOverrides() };
providers ??= new Dictionary<string, ProviderDefinitionFile>(StringComparer.OrdinalIgnoreCase);
var results = new List<EffectiveProviderModel>();
foreach (var def in providers.Values.OrderBy(p => p.ProviderId, StringComparer.OrdinalIgnoreCase))
{
if (def.Groups == null || def.Groups.Count == 0)
{
continue;
}
var effProvider = new EffectiveProviderModel
{
ProviderId = def.ProviderId,
DisplayName = string.IsNullOrWhiteSpace(def.DisplayName) ? def.ProviderId : def.DisplayName,
};
foreach (var group in def.Groups)
{
if (group == null || string.IsNullOrWhiteSpace(group.GroupId))
{
continue;
}
var groupEnabled = ResolveGroupEnabled(group, profile.Overrides);
if (!groupEnabled)
{
// Even if group disabled we still record it (so UI can show disabled group if needed?)
// For now we skip disabled groups entirely to minimize render cost.
continue;
}
var effGroup = new EffectiveGroup
{
GroupId = group.GroupId,
Enabled = groupEnabled,
};
foreach (var action in group.Actions ?? Enumerable.Empty<ProviderActionDef>())
{
if (action == null || string.IsNullOrWhiteSpace(action.Name))
{
continue;
}
var actionId = BuildActionId(group.GroupId, action.Name);
var actionEnabled = ResolveActionEnabled(actionId, action, profile.Overrides);
if (!actionEnabled)
{
continue;
}
effGroup.Actions.Add(new EffectiveAction
{
ActionId = actionId,
Enabled = true,
DisplayName = string.IsNullOrWhiteSpace(action.DisplayName) ? action.Name : action.DisplayName,
IconGlyph = action.IconGlyph,
IconPath = action.IconPath,
});
}
if (effGroup.Actions.Count > 0)
{
effProvider.Groups.Add(effGroup);
}
}
if (effProvider.Groups.Count > 0)
{
results.Add(effProvider);
}
}
return results;
}
private static bool ResolveGroupEnabled(ProviderGroupDef g, ProfileOverrides ov)
{
ov ??= new ProfileOverrides();
if (ov.Groups.TryGetValue(g.GroupId, out var v))
{
return v;
}
return g.DefaultEnabled ?? true;
}
private static bool ResolveActionEnabled(string actionId, ProviderActionDef a, ProfileOverrides ov)
{
ov ??= new ProfileOverrides();
if (ov.Actions.TryGetValue(actionId, out var v))
{
return v;
}
return a.DefaultEnabled ?? true;
}
private static string BuildActionId(string groupId, string actionName) => $"{groupId}->{actionName}";
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace TopToolbar.Services.Profiles;
public sealed class EffectiveProviderModel
{
public string ProviderId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public List<EffectiveGroup> Groups { get; set; } = new();
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using TopToolbar.Logging;
using TopToolbar.Services.Profiles.Models;
namespace TopToolbar.Services.Profiles;
public sealed class FileProfileRegistry : IProfileRegistry
{
private readonly string _registryPath;
private readonly JsonSerializerOptions _jsonOptions;
public FileProfileRegistry(string configRoot = null)
{
var root = string.IsNullOrWhiteSpace(configRoot)
? AppPaths.ProfilesDirectory
: configRoot;
Directory.CreateDirectory(root);
_registryPath = Path.Combine(root, "profiles.json");
_jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
_jsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
}
public ProfilesRegistry Load()
{
try
{
if (!File.Exists(_registryPath))
{
var reg = CreateDefault();
Save(reg);
return reg;
}
using var stream = File.OpenRead(_registryPath);
var loaded = JsonSerializer.Deserialize<ProfilesRegistry>(stream, _jsonOptions) ?? CreateDefault();
Normalize(loaded);
var needsSave = false;
if (!loaded.Profiles.Any(p => string.Equals(p.Id, loaded.ActiveProfileId, StringComparison.OrdinalIgnoreCase)))
{
loaded.ActiveProfileId = "default";
needsSave = true;
}
if (!loaded.Profiles.Any(p => p.Id.Equals("default", StringComparison.OrdinalIgnoreCase)))
{
loaded.Profiles.Insert(0, new ProfileMeta { Id = "default", Name = "Default" });
needsSave = true;
}
// Only save if we made changes
if (needsSave)
{
Save(loaded);
}
return loaded;
}
catch (Exception ex)
{
Console.WriteLine($"ProfileRegistry: load failed - {ex.Message}; recreating.");
var reg = CreateDefault();
Save(reg);
return reg;
}
}
public void Save(ProfilesRegistry registry)
{
Normalize(registry);
// Create a temporary file first, then move to avoid corruption
var tempPath = _registryPath + ".tmp";
try
{
using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.Read))
{
JsonSerializer.Serialize(stream, registry, _jsonOptions);
}
// Atomic move operation
if (File.Exists(_registryPath))
{
File.Delete(_registryPath);
}
File.Move(tempPath, _registryPath);
}
catch
{
// Clean up temp file if something goes wrong
if (File.Exists(tempPath))
{
try
{
File.Delete(tempPath);
}
catch
{
// Ignore cleanup errors
}
}
throw;
}
}
public void SetActive(string profileId)
{
var reg = Load();
if (!reg.Profiles.Any(p => string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)))
{
return; // silently ignore invalid id
}
reg.ActiveProfileId = profileId;
Save(reg);
}
private static ProfilesRegistry CreateDefault()
{
return new ProfilesRegistry
{
ActiveProfileId = "default",
Profiles = new List<ProfileMeta>
{
new ProfileMeta { Id = "default", Name = "Default" },
},
};
}
private static void Normalize(ProfilesRegistry reg)
{
reg.ActiveProfileId = reg.ActiveProfileId?.Trim() ?? "default";
reg.Profiles ??= new List<ProfileMeta>();
foreach (var p in reg.Profiles)
{
if (p == null)
{
continue;
}
p.Id = p.Id?.Trim() ?? string.Empty;
p.Name = p.Name?.Trim() ?? p.Id;
}
reg.Profiles = reg.Profiles.Where(p => !string.IsNullOrWhiteSpace(p.Id)).DistinctBy(p => p.Id, StringComparer.OrdinalIgnoreCase).ToList();
}
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using TopToolbar.Logging;
using TopToolbar.Services.Profiles.Models;
namespace TopToolbar.Services.Profiles;
public sealed class FileProfileStore : IProfileStore
{
private readonly string _profilesDirectory;
private readonly JsonSerializerOptions _jsonOptions;
public FileProfileStore(string configRoot = null)
{
var root = string.IsNullOrWhiteSpace(configRoot)
? AppPaths.ConfigDirectory
: configRoot;
_profilesDirectory = Path.Combine(root, "profiles");
Directory.CreateDirectory(_profilesDirectory);
_jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
_jsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
}
public ProfileOverridesFile Load(string profileId)
{
profileId = (profileId ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(profileId))
{
profileId = "default";
}
var path = GetProfilePath(profileId);
try
{
if (!File.Exists(path))
{
var created = CreateEmpty(profileId);
Save(created);
return created;
}
using var stream = File.OpenRead(path);
var loaded = JsonSerializer.Deserialize<ProfileOverridesFile>(stream, _jsonOptions) ?? CreateEmpty(profileId);
Normalize(loaded);
loaded.ProfileId = profileId; // enforce
return loaded;
}
catch (Exception ex)
{
Console.WriteLine($"ProfileStore: failed to load '{profileId}' - {ex.Message}; recreating.");
var recreated = CreateEmpty(profileId);
Save(recreated);
return recreated;
}
}
public void Save(ProfileOverridesFile file)
{
Normalize(file);
var path = GetProfilePath(file.ProfileId);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
// Create a temporary file first, then move to avoid corruption
var tempPath = path + ".tmp";
try
{
using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.Read))
{
JsonSerializer.Serialize(stream, file, _jsonOptions);
}
// Atomic move operation
if (File.Exists(path))
{
File.Delete(path);
}
File.Move(tempPath, path);
}
catch
{
// Clean up temp file if something goes wrong
if (File.Exists(tempPath))
{
try
{
File.Delete(tempPath);
}
catch
{
// Ignore cleanup errors
}
}
throw;
}
}
private string GetProfilePath(string profileId) => Path.Combine(_profilesDirectory, profileId + ".json");
private static ProfileOverridesFile CreateEmpty(string id) => new() { ProfileId = id, Overrides = new ProfileOverrides() };
private static void Normalize(ProfileOverridesFile file)
{
file.ProfileId = file.ProfileId?.Trim() ?? string.Empty;
file.Overrides ??= new ProfileOverrides();
file.Overrides.Groups ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
file.Overrides.Actions ??= new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
// Remove empty/whitespace keys
foreach (var k in file.Overrides.Groups.Keys.Where(k => string.IsNullOrWhiteSpace(k)).ToList())
{
file.Overrides.Groups.Remove(k);
}
foreach (var k in file.Overrides.Actions.Keys.Where(k => string.IsNullOrWhiteSpace(k)).ToList())
{
file.Overrides.Actions.Remove(k);
}
}
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using TopToolbar.Logging;
using TopToolbar.Services.Profiles.Models;
namespace TopToolbar.Services.Profiles;
/// <summary>
/// File-based implementation of <see cref="IProviderDefinitionCatalog"/>.
/// Scans a providers directory for *.json definitions.
/// </summary>
public sealed class FileProviderDefinitionCatalog : IProviderDefinitionCatalog
{
private readonly string _providersDirectory;
private readonly JsonSerializerOptions _jsonOptions;
public FileProviderDefinitionCatalog(string providersDirectory = null)
{
_providersDirectory = string.IsNullOrWhiteSpace(providersDirectory)
? AppPaths.ProviderDefinitionsDirectory
: providersDirectory;
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
_jsonOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
try
{
Directory.CreateDirectory(_providersDirectory);
}
catch
{
// Ignore directory creation failures; LoadAll will surface issues.
}
}
public IReadOnlyDictionary<string, ProviderDefinitionFile> LoadAll()
{
var result = new Dictionary<string, ProviderDefinitionFile>(StringComparer.OrdinalIgnoreCase);
if (!Directory.Exists(_providersDirectory))
{
return result;
}
IEnumerable<string> files;
try
{
files = Directory.EnumerateFiles(_providersDirectory, "*.json", SearchOption.TopDirectoryOnly);
}
catch (Exception ex)
{
AppLogger.LogWarning($"ProviderCatalog: enumerate failed - {ex.Message}.");
return result;
}
foreach (var file in files)
{
try
{
using var stream = File.OpenRead(file);
var def = JsonSerializer.Deserialize<ProviderDefinitionFile>(stream, _jsonOptions);
if (def == null || string.IsNullOrWhiteSpace(def.ProviderId))
{
continue;
}
Normalize(def);
if (result.ContainsKey(def.ProviderId))
{
AppLogger.LogWarning($"ProviderCatalog: duplicate providerId '{def.ProviderId}' in '{file}', ignoring.");
continue;
}
result[def.ProviderId] = def;
}
catch (Exception ex)
{
AppLogger.LogWarning($"ProviderCatalog: failed to load '{file}' - {ex.Message}.");
}
}
return result;
}
private static void Normalize(ProviderDefinitionFile def)
{
def.ProviderId = def.ProviderId?.Trim() ?? string.Empty;
def.DisplayName = def.DisplayName?.Trim() ?? def.ProviderId;
def.Description = def.Description?.Trim() ?? string.Empty;
def.Groups ??= new List<ProviderGroupDef>();
foreach (var g in def.Groups.ToList())
{
if (g == null)
{
def.Groups.Remove(g);
continue;
}
g.GroupId = g.GroupId?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(g.GroupId))
{
def.Groups.Remove(g);
continue;
}
g.DisplayName = g.DisplayName?.Trim() ?? g.GroupId;
g.Actions ??= new List<ProviderActionDef>();
foreach (var a in g.Actions.ToList())
{
if (a == null)
{
g.Actions.Remove(a);
continue;
}
a.Name = a.Name?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(a.Name))
{
g.Actions.Remove(a);
continue;
}
a.DisplayName = a.DisplayName?.Trim() ?? a.Name;
a.IconGlyph = a.IconGlyph?.Trim() ?? string.Empty;
a.IconPath = a.IconPath?.Trim() ?? string.Empty;
}
}
}
}

View File

@@ -0,0 +1,13 @@
// 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 TopToolbar.Services.Profiles.Models;
namespace TopToolbar.Services.Profiles;
public interface IEffectiveModelBuilder
{
IReadOnlyList<EffectiveProviderModel> Build(ProfileOverridesFile profile, IReadOnlyDictionary<string, ProviderDefinitionFile> providers);
}

View File

@@ -0,0 +1,36 @@
// 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 TopToolbar.Services.Profiles.Models;
namespace TopToolbar.Services.Profiles;
public interface IProfileManager
{
event EventHandler ActiveProfileChanged;
event EventHandler ProfilesChanged;
event EventHandler OverridesChanged;
string ActiveProfileId { get; }
IReadOnlyList<ProfileMeta> GetProfiles();
ProfileOverridesFile GetActiveOverrides();
void SwitchProfile(string profileId);
void CreateProfile(string newProfileId, string name, bool cloneCurrent);
void RenameProfile(string profileId, string newName);
void DeleteProfile(string profileId);
void UpdateGroup(string groupId, bool? enabled); // null = remove override
void UpdateAction(string actionId, bool? enabled); // null = remove override
}

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