Compare commits

...

8 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
df45e56511 WIP: AdvancedPaste ViewModel and test updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 15:57:44 +08:00
Muyuan Li (from Dev Box)
f8bca48db3 Settings UX: enable toggle, Load/Refresh button, greyed-out Apply
- Add master toggle to enable/disable Python scripts feature entirely
  (IsPythonScriptsEnabled in settings model, ViewModel, and runtime)
- Button shows 'Load scripts' initially, changes to 'Refresh scripts'
  after first load
- Edit dialog 'Apply changes' button is greyed out until a field changes
  (tracks initial snapshot vs current values for all fields)
- Runtime BuildPythonScriptFormats respects IsPythonScriptsEnabled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:56:08 +08:00
Muyuan Li (from Dev Box)
5104d0846c Auto-apply on edit dialog and enable toggle, write-lock error fallback
- Remove Apply Changes button from parent expander
- Edit dialog now applies changes to file directly on confirm
- Enable toggle auto-applies changes immediately
- On write-lock (IOException), show error dialog with copyable header
  text and 'Copy header' button for manual paste fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:39:03 +08:00
Muyuan Li (from Dev Box)
8ca6c4d2ec Add open-folder button, fix duplicate header tags on apply
- Add button to open scripts folder in File Explorer
- Fix WriteScriptHeader: duplicate tag lines (e.g. multiple desc) are now
  deduplicated — only the first occurrence is updated, extras are removed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:13:54 +08:00
Muyuan Li (from Dev Box)
574aca841b Auto-refresh scripts, fix event handler leak, deep clone HotkeySettings, remove dead code
- Auto-refresh discovered scripts when ScriptsFolder changes
- ApplyPythonScriptChanges now refreshes after saving
- Fix event handler leak in EditPythonScript_Click (Toggled += lambda)
- Deep clone HotkeySettings in AdvancedPastePythonScriptAction.Clone()
- Remove unused WriteMetadata() and FormatFlagsToString() from PythonScriptService

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:48:55 +08:00
Muyuan Li (from Dev Box)
0833b68907 Advanced Paste: error details popup, enabled tag, and script settings page
- Error display: moved error UI from PromptBox to MainPage with Show Details
  button that opens a ContentDialog with the full error traceback
- Python scripts: added @advancedpaste:enabled header tag to control script
  visibility in the paste menu (default: true)
- Settings page: added Discovered Scripts section with Refresh/Apply buttons,
  per-script enable toggle, and edit dialog for name, description, platform,
  supported formats, and dependency detection mode (auto/manual)
- Model: extended AdvancedPastePythonScriptAction with IsEnabled, Platform,
  Formats, Requires, and RequiresAutoDetect properties
- ViewModel: added RefreshPythonScripts() to scan folder and read headers,
  ApplyPythonScriptChanges() to write metadata back to script files
- Build: added vswhere -all fallback in build-common.ps1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:49:02 +08:00
Muyuan Li
2051c13bf9 Improve Python script error handling and prevent freezes
- Extract script name, line, and column from Python tracebacks in error summaries
- Add ParsePipInstallError to show concise pip errors with full stderr in details tooltip
- Add timeouts to pip install (Windows/WSL) and import-check subprocesses to prevent UI freezes
- Add PythonPackageInstallTimeout resource string
- Add unit tests for all new parsing logic
2026-04-15 16:10:49 +08:00
Muyuan Li
4c7bf3df79 [AdvancedPaste] Python scripts: docs, custom folder, auto-import detection, better errors
1. Script header documentation (doc/devdocs/modules/advancedpaste-python-scripts.md)
   - Complete reference for all @advancedpaste: header tags
   - Windows and WSL/Linux execution mode protocols
   - Declaring dependencies, security trust model, error handling
   - Example scripts for both platforms

2. Custom scripts folder setting in Settings UI
   - Added ScriptsFolder property to AdvancedPasteViewModel
   - Added SettingsCard with TextBox + Browse folder dialog in XAML
   - Added localization strings for the new setting

3. Auto-detect missing Python modules from import statements
   - Scans script body for import/from-import statements
   - Filters Python stdlib modules (CPython 3.12 set)
   - Well-known import-to-pip mapping table (pywin32, Pillow, opencv-python, etc.)
   - Merges auto-detected imports with explicit @advancedpaste:requires entries
   - Explicit requires always take precedence

4. Better error messages for Python script failures
   - Parses stderr to extract the final Python exception line
   - User-friendly summaries for ModuleNotFoundError, SyntaxError, etc.
   - ModuleNotFoundError includes pip install hint from the mapping table
   - Full traceback available in Details section of the error UI

Added 12 unit tests for MergeWithAutoDetectedImports and ParsePythonError.
Fixed IntegrationTestUserSettings mock to implement IUserSettings Python members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 16:56:42 +08:00
20 changed files with 2229 additions and 88 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,6 +144,7 @@
</Page.KeyboardAccelerators>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -299,7 +300,57 @@
</StackPanel>
</controls:PromptBox.Footer>
</controls:PromptBox>
<ScrollViewer Grid.Row="2">
<Grid
x:Name="ErrorMessageGrid"
Grid.Row="2"
Margin="20,4,20,0"
ColumnSpacing="8"
Visibility="{x:Bind ViewModel.PasteActionError.HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
Grid.Column="1"
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
MaxLines="2"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
<HyperlinkButton
x:Name="ShowErrorDetailsBtn"
x:Uid="ShowErrorDetailsBtn"
Grid.Column="2"
Margin="4,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Click="ShowErrorDetailsBtn_Click"
FontSize="12"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="3"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
</Grid>
<ScrollViewer Grid.Row="3">
<Grid RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />

View File

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

View File

@@ -29,6 +29,8 @@ namespace AdvancedPaste.Settings
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
public bool IsPythonScriptsEnabled { get; }
public string PythonScriptsFolder { get; }
public string PythonExecutablePath { get; }

View File

@@ -57,6 +57,8 @@ namespace AdvancedPaste.Settings
public string PythonScriptsFolder { get; private set; }
public bool IsPythonScriptsEnabled { get; private set; }
public string PythonExecutablePath { get; private set; }
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
@@ -159,6 +161,7 @@ namespace AdvancedPaste.Settings
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
IsPythonScriptsEnabled = pythonScripts.IsEnabled;
PythonScriptsFolder = string.IsNullOrWhiteSpace(pythonScripts.ScriptsFolder)
? GetDefaultScriptsFolder()
: pythonScripts.ScriptsFolder;

View File

@@ -15,4 +15,5 @@ public sealed record PythonScriptMetadata(
ClipboardFormat SupportedFormats,
string Platform,
string Version,
bool IsEnabled,
IReadOnlyList<PythonRequirement> Requirements);

View File

@@ -10,6 +10,7 @@ using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -64,6 +65,9 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"),
new InvalidOperationException("Failed to start Python process."));
// Start reading stderr immediately to avoid truncation and deadlocks.
var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
int timeoutMs = _userSettings.PythonScriptTimeoutSeconds * 1000;
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeoutMs);
@@ -83,11 +87,12 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
new TimeoutException());
}
var stderr = await stderrTask;
if (process.ExitCode != 0)
{
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
var errorMsg = stderr.Length > 1500 ? stderr[..1500] : stderr;
throw new PasteActionException(errorMsg, new InvalidOperationException($"Script exited with code {process.ExitCode}."));
var (summary, details) = ParsePythonError(stderr);
throw new PasteActionException(summary, new InvalidOperationException($"Script exited with code {process.ExitCode}."), aiServiceMessage: details);
}
}
finally
@@ -181,8 +186,8 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
if (process.ExitCode != 0)
{
var errorMsg = stderr.Length > 1500 ? stderr[..1500] : stderr;
throw new PasteActionException(errorMsg, new InvalidOperationException($"WSL script exited with code {process.ExitCode}."));
var (summary, details) = ParsePythonError(stderr);
throw new PasteActionException(summary, new InvalidOperationException($"WSL script exited with code {process.ExitCode}."), aiServiceMessage: details);
}
return await ParseWslOutputAsync(stdout, workDir, cancellationToken);
@@ -210,7 +215,9 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
ClipboardFormat.Text | ClipboardFormat.Html |
ClipboardFormat.Image | ClipboardFormat.Audio |
ClipboardFormat.Video | ClipboardFormat.File;
var requirements = new List<PythonRequirement>();
var explicitRequirements = new List<PythonRequirement>();
var allLines = new List<string>();
var isEnabled = true;
try
{
@@ -226,54 +233,71 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
}
lineCount++;
line = line.Trim();
allLines.Add(line);
var trimmed = line.Trim();
if (!line.StartsWith('#'))
if (!trimmed.StartsWith('#'))
{
continue;
}
var tag = ParseTag(line, "@advancedpaste:name");
var tag = ParseTag(trimmed, "@advancedpaste:name");
if (tag != null)
{
name = tag;
continue;
}
tag = ParseTag(line, "@advancedpaste:desc");
tag = ParseTag(trimmed, "@advancedpaste:desc");
if (tag != null)
{
description = tag;
continue;
}
tag = ParseTag(line, "@advancedpaste:platform");
tag = ParseTag(trimmed, "@advancedpaste:platform");
if (tag != null)
{
platform = tag.ToLowerInvariant();
continue;
}
tag = ParseTag(line, "@advancedpaste:version");
tag = ParseTag(trimmed, "@advancedpaste:version");
if (tag != null)
{
version = tag;
continue;
}
tag = ParseTag(line, "@advancedpaste:formats");
tag = ParseTag(trimmed, "@advancedpaste:formats");
if (tag != null)
{
supportedFormats = ParseFormats(tag);
continue;
}
tag = ParseTag(line, "@advancedpaste:requires");
tag = ParseTag(trimmed, "@advancedpaste:requires");
if (tag != null)
{
requirements.AddRange(ParseRequirements(tag));
explicitRequirements.AddRange(ParseRequirements(tag));
continue;
}
tag = ParseTag(trimmed, "@advancedpaste:enabled");
if (tag != null)
{
isEnabled = !string.Equals(tag, "false", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(tag, "0", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(tag, "no", StringComparison.OrdinalIgnoreCase);
continue;
}
}
// Read remaining lines for import scanning
string remainingLine;
while ((remainingLine = reader.ReadLine()) is not null)
{
allLines.Add(remainingLine);
}
}
catch (Exception ex)
@@ -281,7 +305,9 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
Logger.LogError($"Failed to read metadata from {scriptPath}", ex);
}
return new PythonScriptMetadata(scriptPath, name, description, supportedFormats, platform, version, requirements);
var mergedRequirements = MergeWithAutoDetectedImports(allLines, explicitRequirements);
return new PythonScriptMetadata(scriptPath, name, description, supportedFormats, platform, version, isEnabled, mergedRequirements);
}
/// <summary>
@@ -305,6 +331,193 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
}
}
/// <summary>
/// Common mappings from Python import names to pip package names where they differ.
/// </summary>
private static readonly Dictionary<string, string> WellKnownImportToPip = new(StringComparer.OrdinalIgnoreCase)
{
["PIL"] = "Pillow",
["cv2"] = "opencv-python",
["sklearn"] = "scikit-learn",
["skimage"] = "scikit-image",
["yaml"] = "PyYAML",
["bs4"] = "beautifulsoup4",
["gi"] = "PyGObject",
["attr"] = "attrs",
["dateutil"] = "python-dateutil",
["dotenv"] = "python-dotenv",
["git"] = "GitPython",
["serial"] = "pyserial",
["usb"] = "pyusb",
["wx"] = "wxPython",
["Crypto"] = "pycryptodome",
["lxml"] = "lxml",
["magic"] = "python-magic",
["docx"] = "python-docx",
["pptx"] = "python-pptx",
["win32clipboard"] = "pywin32",
["win32com"] = "pywin32",
["win32api"] = "pywin32",
["win32gui"] = "pywin32",
["win32con"] = "pywin32",
["win32print"] = "pywin32",
["win32security"] = "pywin32",
["win32service"] = "pywin32",
["win32event"] = "pywin32",
["win32file"] = "pywin32",
["win32pipe"] = "pywin32",
["win32process"] = "pywin32",
["win32ts"] = "pywin32",
["pywintypes"] = "pywin32",
["pythoncom"] = "pywin32",
["wmi"] = "WMI",
["llama_cpp"] = "llama-cpp-python",
};
/// <summary>
/// Python standard library module names (CPython 3.12). Imports in this set are never
/// treated as third-party requirements.
/// </summary>
private static readonly HashSet<string> PythonStdlib = new(StringComparer.Ordinal)
{
"__future__", "_thread", "abc", "aifc", "argparse", "array", "ast", "asynchat",
"asyncio", "asyncore", "atexit", "audioop", "base64", "bdb", "binascii", "binhex",
"bisect", "builtins", "bz2", "calendar", "cgi", "cgitb", "chunk", "cmath", "cmd",
"code", "codecs", "codeop", "collections", "colorsys", "compileall", "concurrent",
"configparser", "contextlib", "contextvars", "copy", "copyreg", "cProfile", "crypt",
"csv", "ctypes", "curses", "dataclasses", "datetime", "dbm", "decimal", "difflib",
"dis", "distutils", "doctest", "email", "encodings", "enum", "errno", "faulthandler",
"fcntl", "filecmp", "fileinput", "fnmatch", "fractions", "ftplib", "functools", "gc",
"getopt", "getpass", "gettext", "glob", "grp", "gzip", "hashlib", "heapq", "hmac",
"html", "http", "idlelib", "imaplib", "imghdr", "imp", "importlib", "inspect", "io",
"ipaddress", "itertools", "json", "keyword", "lib2to3", "linecache", "locale",
"logging", "lzma", "mailbox", "mailcap", "marshal", "math", "mimetypes", "mmap",
"modulefinder", "multiprocessing", "netrc", "nis", "nntplib", "numbers", "operator",
"optparse", "os", "ossaudiodev", "pathlib", "pdb", "pickle", "pickletools", "pipes",
"pkgutil", "platform", "plistlib", "poplib", "posix", "posixpath", "pprint",
"profile", "pstats", "pty", "pwd", "py_compile", "pyclbr", "pydoc", "queue",
"quopri", "random", "re", "readline", "reprlib", "resource", "rlcompleter",
"runpy", "sched", "secrets", "select", "selectors", "shelve", "shlex", "shutil",
"signal", "site", "smtpd", "smtplib", "sndhdr", "socket", "socketserver", "spwd",
"sqlite3", "ssl", "stat", "statistics", "string", "stringprep", "struct",
"subprocess", "sunau", "symtable", "sys", "sysconfig", "syslog", "tabnanny",
"tarfile", "telnetlib", "tempfile", "termios", "test", "textwrap", "threading",
"time", "timeit", "tkinter", "token", "tokenize", "tomllib", "trace", "traceback",
"tracemalloc", "tty", "turtle", "turtledemo", "types", "typing", "unicodedata",
"unittest", "urllib", "uu", "uuid", "venv", "warnings", "wave", "weakref",
"webbrowser", "winreg", "winsound", "wsgiref", "xdrlib", "xml", "xmlrpc",
"zipapp", "zipfile", "zipimport", "zlib", "_io", "_collections_abc",
"typing_extensions", "ntpath", "posixpath", "genericpath", "stat",
};
/// <summary>
/// Scans all lines in the script for top-level <c>import X</c> and <c>from X import Y</c>
/// statements, then merges auto-detected third-party imports with the explicit
/// <c>@advancedpaste:requires</c> entries. Explicit entries always take precedence.
/// </summary>
internal static IReadOnlyList<PythonRequirement> MergeWithAutoDetectedImports(
IReadOnlyList<string> lines,
IReadOnlyList<PythonRequirement> explicitRequirements)
{
// Build a set of import names already covered by explicit requirements.
var explicitImports = new HashSet<string>(
explicitRequirements.Select(r => r.ImportName),
StringComparer.OrdinalIgnoreCase);
var autoDetected = new Dictionary<string, PythonRequirement>(StringComparer.OrdinalIgnoreCase);
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
// Skip comments and blank lines
if (line.Length == 0 || line[0] == '#')
{
continue;
}
// Match "import X", "import X as Y", "import X, Y, Z"
if (line.StartsWith("import ", StringComparison.Ordinal))
{
var rest = line["import ".Length..].Trim();
// Stop at inline comment
var commentIdx = rest.IndexOf('#');
if (commentIdx >= 0)
{
rest = rest[..commentIdx].Trim();
}
foreach (var segment in rest.Split(','))
{
var moduleName = segment.Trim().Split(' ')[0].Split('.')[0]; // "X as Y" → "X"; "X.sub" → "X"
TryAddAutoImport(moduleName, explicitImports, autoDetected);
}
continue;
}
// Match "from X import Y"
if (line.StartsWith("from ", StringComparison.Ordinal))
{
var rest = line["from ".Length..].Trim();
var importIdx = rest.IndexOf(" import ", StringComparison.Ordinal);
if (importIdx < 0)
{
continue;
}
var moduleName = rest[..importIdx].Trim().Split('.')[0]; // "X.sub" → "X"
TryAddAutoImport(moduleName, explicitImports, autoDetected);
continue;
}
}
// Merge: explicit first, then auto-detected
var merged = new List<PythonRequirement>(explicitRequirements);
merged.AddRange(autoDetected.Values);
return merged;
}
private static void TryAddAutoImport(
string moduleName,
HashSet<string> explicitImports,
Dictionary<string, PythonRequirement> autoDetected)
{
if (string.IsNullOrWhiteSpace(moduleName))
{
return;
}
// Skip relative imports (leading dot)
if (moduleName[0] == '.')
{
return;
}
// Skip stdlib modules
if (PythonStdlib.Contains(moduleName))
{
return;
}
// Skip if already covered by explicit requires
if (explicitImports.Contains(moduleName))
{
return;
}
// Skip if already auto-detected
if (autoDetected.ContainsKey(moduleName))
{
return;
}
// Look up well-known import→pip mapping
var pipPackage = WellKnownImportToPip.TryGetValue(moduleName, out var mapped) ? mapped : moduleName;
autoDetected[moduleName] = new PythonRequirement(moduleName, pipPackage);
}
private static string ParseTag(string line, string tag)
{
var idx = line.IndexOf(tag, StringComparison.OrdinalIgnoreCase);
@@ -439,7 +652,19 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
return false;
}
await process.WaitForExitAsync(cancellationToken);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(15));
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
process.Kill(entireProcessTree: true);
return false;
}
return process.ExitCode == 0;
}
@@ -457,7 +682,19 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
return false;
}
await process.WaitForExitAsync(cancellationToken);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(15));
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
process.Kill(entireProcessTree: true);
return false;
}
return process.ExitCode == 0;
}
@@ -484,23 +721,45 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"),
new InvalidOperationException("Failed to start pip."));
await process.WaitForExitAsync(cancellationToken);
int timeoutMs = _userSettings.PythonScriptTimeoutSeconds * 1000;
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeoutMs);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
process.Kill(entireProcessTree: true);
throw new PasteActionException(
string.Format(
System.Globalization.CultureInfo.CurrentCulture,
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallTimeout"),
packages,
_userSettings.PythonScriptTimeoutSeconds),
new TimeoutException());
}
if (process.ExitCode != 0)
{
var stderr = await process.StandardError.ReadToEndAsync(cancellationToken);
var (summary, _) = ParsePipInstallError(stderr);
throw new PasteActionException(
string.Format(
System.Globalization.CultureInfo.CurrentCulture,
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallFailed"),
packages,
stderr.Length > 300 ? stderr[..300] : stderr),
new InvalidOperationException($"pip exited with code {process.ExitCode}."));
summary),
new InvalidOperationException($"pip exited with code {process.ExitCode}."),
aiServiceMessage: stderr.Length > 3000 ? stderr[^3000..] : stderr);
}
}
private static async Task InstallInWslAsync(string packages, CancellationToken cancellationToken)
private async Task InstallInWslAsync(string packages, CancellationToken cancellationToken)
{
int timeoutMs = _userSettings.PythonScriptTimeoutSeconds * 1000;
// Try plain pip3 first; if the environment is managed (PEP 668), retry with --break-system-packages.
foreach (var extraArgs in (string[])[string.Empty, "--break-system-packages"])
{
@@ -521,7 +780,24 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
continue;
}
await process.WaitForExitAsync(cancellationToken);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeoutMs);
try
{
await process.WaitForExitAsync(timeoutCts.Token);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
process.Kill(entireProcessTree: true);
throw new PasteActionException(
string.Format(
System.Globalization.CultureInfo.CurrentCulture,
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallTimeout"),
packages,
_userSettings.PythonScriptTimeoutSeconds),
new TimeoutException());
}
if (process.ExitCode == 0)
{
@@ -533,13 +809,15 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
// If it failed for a reason OTHER than externally-managed-environment, throw immediately.
if (!stderr.Contains("externally-managed-environment", StringComparison.OrdinalIgnoreCase))
{
var (summary, _) = ParsePipInstallError(stderr);
throw new PasteActionException(
string.Format(
System.Globalization.CultureInfo.CurrentCulture,
ResourceLoaderInstance.ResourceLoader.GetString("PythonPackageInstallFailed"),
packages,
stderr.Length > 300 ? stderr[..300] : stderr),
new InvalidOperationException($"pip3 exited with code {process.ExitCode}."));
summary),
new InvalidOperationException($"pip3 exited with code {process.ExitCode}."),
aiServiceMessage: stderr.Length > 3000 ? stderr[^3000..] : stderr);
}
}
@@ -1070,4 +1348,203 @@ public sealed class PythonScriptService(IUserSettings userSettings) : IPythonScr
new FileNotFoundException("Script file not found.", scriptPath));
}
}
// Matches Python traceback lines: File "path/to/file.py", line 10
private static readonly Regex TracebackFileLineRegex = new(
@"^\s*File\s+""(.+?)"",\s*line\s+(\d+)",
RegexOptions.Compiled);
/// <summary>
/// Parses Python stderr output and returns a user-friendly summary plus the full traceback
/// as details. Identifies common error types (ModuleNotFoundError, SyntaxError, etc.)
/// for clearer messaging. Extracts script name, line, and column from the traceback
/// so the user can quickly locate the failure.
/// </summary>
internal static (string Summary, string Details) ParsePythonError(string stderr)
{
if (string.IsNullOrWhiteSpace(stderr))
{
return (ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"), string.Empty);
}
var fullDetails = stderr.Length > 3000 ? stderr[^3000..] : stderr;
// Find the last exception line — Python tracebacks end with "ErrorType: message"
var lines = stderr.Split('\n');
string lastExceptionLine = null;
for (int i = lines.Length - 1; i >= 0; i--)
{
var trimmed = lines[i].Trim();
if (trimmed.Length == 0)
{
continue;
}
// Python exception lines look like "ModuleNotFoundError: No module named 'xyz'"
// or "SyntaxError: invalid syntax"
if (trimmed.Contains("Error:") || trimmed.Contains("Exception:"))
{
lastExceptionLine = trimmed;
break;
}
// If the very last non-blank line doesn't have "Error:", use it anyway
// (some scripts just print an error message to stderr)
lastExceptionLine ??= trimmed;
}
if (lastExceptionLine is null)
{
return (ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptFailed"), fullDetails);
}
// Extract location (script, line, column) from the traceback
var location = ExtractLastTracebackLocation(lines);
// Build a user-friendly summary for well-known error types
var errorMessage = lastExceptionLine switch
{
string s when s.StartsWith("ModuleNotFoundError:", StringComparison.Ordinal) =>
FormatModuleNotFoundSummary(s),
string s when s.StartsWith("SyntaxError:", StringComparison.Ordinal) =>
$"Python syntax error: {s["SyntaxError:".Length..].Trim()}",
string s when s.StartsWith("FileNotFoundError:", StringComparison.Ordinal) =>
$"File not found: {s["FileNotFoundError:".Length..].Trim()}",
string s when s.StartsWith("PermissionError:", StringComparison.Ordinal) =>
$"Permission denied: {s["PermissionError:".Length..].Trim()}",
string s when s.StartsWith("TypeError:", StringComparison.Ordinal) =>
$"Type error: {s["TypeError:".Length..].Trim()}",
string s when s.StartsWith("ValueError:", StringComparison.Ordinal) =>
$"Value error: {s["ValueError:".Length..].Trim()}",
string s when s.StartsWith("KeyError:", StringComparison.Ordinal) =>
$"Key error: {s["KeyError:".Length..].Trim()}",
_ => lastExceptionLine.Length > 200 ? lastExceptionLine[..200] + "…" : lastExceptionLine,
};
// Prepend location info so the user can quickly find the failure
var summary = FormatErrorWithLocation(location, errorMessage);
return (summary, fullDetails);
}
/// <summary>
/// Scans the traceback lines for the last <c>File "...", line N</c> reference and
/// optionally extracts the column from a caret (<c>^</c>) indicator line.
/// </summary>
internal static (string FileName, int Line, int? Column)? ExtractLastTracebackLocation(string[] lines)
{
string lastFile = null;
int lastLine = 0;
int lastFileLineIndex = -1;
for (int i = 0; i < lines.Length; i++)
{
var match = TracebackFileLineRegex.Match(lines[i]);
if (match.Success)
{
lastFile = match.Groups[1].Value;
lastLine = int.Parse(match.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture);
lastFileLineIndex = i;
}
}
if (lastFile is null)
{
return null;
}
// Try to extract column from a caret line. Python tracebacks show:
// File "test.py", line 5
// def foo(
// ^
// The caret line (index lastFileLineIndex+2) marks the error column.
int? column = null;
int caretIdx = lastFileLineIndex + 2;
if (caretIdx < lines.Length)
{
var caretLine = lines[caretIdx];
var caretPos = caretLine.IndexOf('^');
if (caretPos >= 0 && caretLine.Trim().Replace("^", string.Empty, StringComparison.Ordinal).Trim().Length == 0)
{
// The code line sits at lastFileLineIndex+1; compute column relative to code indent
var codeLine = lines[lastFileLineIndex + 1];
var codeIndent = codeLine.Length - codeLine.TrimStart().Length;
var col = caretPos - codeIndent + 1; // 1-based
if (col >= 1)
{
column = col;
}
}
}
return (Path.GetFileName(lastFile), lastLine, column);
}
private static string FormatErrorWithLocation((string FileName, int Line, int? Column)? location, string errorMessage)
{
if (location is not { } loc)
{
return errorMessage;
}
var locationText = loc.Column is { } col
? $"{loc.FileName}, line {loc.Line}, col {col}"
: $"{loc.FileName}, line {loc.Line}";
return $"{locationText}: {errorMessage}";
}
private static string FormatModuleNotFoundSummary(string errorLine)
{
// Extract module name from: "ModuleNotFoundError: No module named 'xyz'"
var singleQuoteStart = errorLine.IndexOf('\'');
var singleQuoteEnd = singleQuoteStart >= 0 ? errorLine.IndexOf('\'', singleQuoteStart + 1) : -1;
if (singleQuoteStart >= 0 && singleQuoteEnd > singleQuoteStart)
{
var moduleName = errorLine[(singleQuoteStart + 1)..singleQuoteEnd];
var pipHint = WellKnownImportToPip.TryGetValue(moduleName, out var pipName) ? pipName : moduleName;
return $"Module '{moduleName}' not found. Try: pip install {pipHint}";
}
return errorLine;
}
/// <summary>
/// Extracts a concise summary from pip install stderr output.
/// pip typically ends with "ERROR: ..." lines; we grab the last one.
/// </summary>
internal static (string Summary, string FullStderr) ParsePipInstallError(string stderr)
{
if (string.IsNullOrWhiteSpace(stderr))
{
return ("unknown error", string.Empty);
}
var lines = stderr.Split('\n');
// Find the last line starting with "ERROR:" — pip's standard error prefix.
for (int i = lines.Length - 1; i >= 0; i--)
{
var trimmed = lines[i].Trim();
if (trimmed.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase))
{
var msg = trimmed["ERROR:".Length..].Trim();
return (msg.Length > 300 ? msg[..300] + "…" : msg, stderr);
}
}
// Fallback: use the last non-empty line.
for (int i = lines.Length - 1; i >= 0; i--)
{
var trimmed = lines[i].Trim();
if (trimmed.Length > 0)
{
return (trimmed.Length > 300 ? trimmed[..300] + "…" : trimmed, stderr);
}
}
return ("unknown error", stderr);
}
}

View File

@@ -428,4 +428,17 @@ Install them now?</value>
<value>Failed to install package(s) "{0}": {1}</value>
<comment>{0} = pip package names, {1} = error detail. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallTimeout" xml:space="preserve">
<value>Package installation for "{0}" timed out ({1} seconds).</value>
<comment>{0} = pip package names, {1} = timeout in seconds. Do not translate {0} or {1}.</comment>
</data>
<data name="ShowErrorDetailsBtn.Content" xml:space="preserve">
<value>Show details</value>
</data>
<data name="ErrorDetailsDialogTitle" xml:space="preserve">
<value>Error Details</value>
</data>
<data name="ErrorDetailsDialogClose" xml:space="preserve">
<value>Close</value>
</data>
</root>

View File

@@ -433,6 +433,11 @@ namespace AdvancedPaste.ViewModels
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
{
if (!_userSettings.IsPythonScriptsEnabled)
{
yield break;
}
var folder = _userSettings.PythonScriptsFolder;
if (string.IsNullOrWhiteSpace(folder))
{
@@ -449,7 +454,7 @@ namespace AdvancedPaste.ViewModels
foreach (var meta in discoveredScripts)
{
if (hiddenPaths.Contains(meta.ScriptPath))
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
{
continue;
}

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
@@ -15,6 +16,11 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
private string _name = string.Empty;
private string _description = string.Empty;
private bool _isShown = true;
private bool _isEnabled = true;
private string _platform = "windows";
private string _formats = "any";
private string _requires = string.Empty;
private bool _requiresAutoDetect = true;
private HotkeySettings _shortcut = new();
[JsonPropertyName("scriptPath")]
@@ -45,6 +51,69 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
set => Set(ref _isShown, value);
}
[JsonPropertyName("isEnabled")]
public bool IsEnabled
{
get => _isEnabled;
set => Set(ref _isEnabled, value);
}
[JsonPropertyName("platform")]
public string Platform
{
get => _platform;
set => Set(ref _platform, value ?? "windows");
}
[JsonPropertyName("formats")]
public string Formats
{
get => _formats;
set => Set(ref _formats, value ?? "any");
}
/// <summary>
/// Space-separated requires entries, e.g. "cv2=opencv-python-headless numpy requests".
/// Only written to header when RequiresAutoDetect is false (manual mode).
/// </summary>
[JsonPropertyName("requires")]
public string Requires
{
get => _requires;
set => Set(ref _requires, value ?? string.Empty);
}
/// <summary>
/// When true, dependencies are auto-detected from import statements.
/// When false, the manual <see cref="Requires"/> value is used.
/// </summary>
[JsonPropertyName("requiresAutoDetect")]
public bool RequiresAutoDetect
{
get => _requiresAutoDetect;
set => Set(ref _requiresAutoDetect, value);
}
/// <summary>
/// Inverted view of RequiresAutoDetect for UI binding.
/// Uses a separate field to avoid circular property change notifications.
/// </summary>
[JsonIgnore]
public bool IsRequiresManual
{
get => !_requiresAutoDetect;
set
{
var newAuto = !value;
if (_requiresAutoDetect != newAuto)
{
_requiresAutoDetect = newAuto;
OnPropertyChanged(nameof(RequiresAutoDetect));
OnPropertyChanged(nameof(IsRequiresManual));
}
}
}
[JsonPropertyName("shortcut")]
public HotkeySettings Shortcut
{
@@ -62,6 +131,104 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
[JsonIgnore]
public IEnumerable<IAdvancedPasteAction> SubActions => [];
// Convenience properties for format checkboxes
[JsonIgnore]
public bool SupportsText
{
get => FormatContains("text");
set => ToggleFormat("text", value);
}
[JsonIgnore]
public bool SupportsHtml
{
get => FormatContains("html");
set => ToggleFormat("html", value);
}
[JsonIgnore]
public bool SupportsImage
{
get => FormatContains("image");
set => ToggleFormat("image", value);
}
[JsonIgnore]
public bool SupportsAudio
{
get => FormatContains("audio");
set => ToggleFormat("audio", value);
}
[JsonIgnore]
public bool SupportsVideo
{
get => FormatContains("video");
set => ToggleFormat("video", value);
}
[JsonIgnore]
public bool SupportsFiles
{
get => FormatContains("files") || FormatContains("file");
set => ToggleFormat("files", value);
}
private bool FormatContains(string format)
{
if (string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase))
{
return true;
}
return Formats.Split(',', StringSplitOptions.TrimEntries)
.Any(f => string.Equals(f, format, StringComparison.OrdinalIgnoreCase));
}
private bool _isTogglingFormat;
private void ToggleFormat(string format, bool include)
{
if (_isTogglingFormat)
{
return;
}
_isTogglingFormat = true;
try
{
var currentFormats = string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase)
? new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(Formats.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase);
// Normalize file/files
currentFormats.Remove("file");
if (include)
{
currentFormats.Add(format);
}
else
{
currentFormats.Remove(format);
}
var allFormats = new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase);
Formats = currentFormats.SetEquals(allFormats) ? "any" : string.Join(", ", currentFormats);
OnPropertyChanged(nameof(SupportsText));
OnPropertyChanged(nameof(SupportsHtml));
OnPropertyChanged(nameof(SupportsImage));
OnPropertyChanged(nameof(SupportsAudio));
OnPropertyChanged(nameof(SupportsVideo));
OnPropertyChanged(nameof(SupportsFiles));
}
finally
{
_isTogglingFormat = false;
}
}
public object Clone()
{
return new AdvancedPastePythonScriptAction
@@ -70,7 +237,12 @@ public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPaste
Name = Name,
Description = Description,
IsShown = IsShown,
Shortcut = Shortcut,
IsEnabled = IsEnabled,
Platform = Platform,
Formats = Formats,
Requires = Requires,
RequiresAutoDetect = RequiresAutoDetect,
Shortcut = Shortcut != null ? new HotkeySettings(Shortcut.Win, Shortcut.Ctrl, Shortcut.Alt, Shortcut.Shift, Shortcut.Code) : null,
};
}
}

View File

@@ -9,6 +9,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class AdvancedPastePythonScriptSettings
{
[JsonPropertyName("isEnabled")]
public bool IsEnabled { get; set; }
[JsonPropertyName("scriptsFolder")]
public string ScriptsFolder { get; set; } = string.Empty;

View File

@@ -0,0 +1,71 @@
// 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.Linq;
using System.Reflection;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ViewModelTests
{
[TestClass]
public class AdvancedPaste
{
[TestMethod]
public void WriteScriptHeader_OverwritesExistingBlankDescriptionTag()
{
var scriptPath = Path.Combine(Path.GetTempPath(), $"AdvancedPaste-{Guid.NewGuid():N}.py");
try
{
File.WriteAllLines(
scriptPath,
[
"# @advancedpaste:name reverse text",
"# @advancedpaste:formats text",
"# @advancedpaste:platform windows",
"# @advancedpaste:desc ",
"# @advancedpaste:enabled true",
"print('hello')",
]);
var action = new AdvancedPastePythonScriptAction
{
ScriptPath = scriptPath,
Name = "reverse text",
Description = "Updated description",
Platform = "windows",
Formats = "text",
IsEnabled = true,
RequiresAutoDetect = true,
};
var writeScriptHeader = typeof(AdvancedPasteViewModel)
.GetMethod("WriteScriptHeader", BindingFlags.NonPublic | BindingFlags.Static);
Assert.IsNotNull(writeScriptHeader);
writeScriptHeader.Invoke(null, [action]);
var descLines = File.ReadAllLines(scriptPath)
.Where(line => line.Contains("@advancedpaste:desc", StringComparison.OrdinalIgnoreCase))
.ToArray();
Assert.AreEqual(1, descLines.Length);
Assert.AreEqual("# @advancedpaste:desc Updated description", descLines[0]);
}
finally
{
if (File.Exists(scriptPath))
{
File.Delete(scriptPath);
}
}
}
}
}

View File

@@ -392,7 +392,41 @@
</controls:SettingsGroup>
<controls:SettingsGroup x:Uid="AdvancedPaste_PythonScripts_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard">
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_EnableCard">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE943;" />
</tkcontrols:SettingsCard.HeaderIcon>
<ToggleSwitch IsOn="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_ScriptsFolder_SettingsCard"
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE8B7;" />
</tkcontrols:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox
x:Name="ScriptsFolderTextBox"
x:Uid="AdvancedPaste_ScriptsFolder_TextBox"
MinWidth="300"
HorizontalAlignment="Stretch"
Text="{x:Bind ViewModel.ScriptsFolder, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
<Button
VerticalAlignment="Bottom"
Click="BrowseScriptsFolder_Click"
Content="{ui:FontIcon Glyph=&#xE8DA;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
<Button
VerticalAlignment="Bottom"
Click="OpenScriptsFolder_Click"
Content="{ui:FontIcon Glyph=&#xE838;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}"
ToolTipService.ToolTip="Open folder" />
</StackPanel>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard"
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE943;" />
</tkcontrols:SettingsCard.HeaderIcon>
@@ -411,6 +445,50 @@
Style="{StaticResource SubtleButtonStyle}" />
</StackPanel>
</tkcontrols:SettingsCard>
<!-- Discovered Python Scripts -->
<tkcontrols:SettingsExpander
x:Name="PythonScriptListExpander"
x:Uid="AdvancedPaste_PythonScriptList"
HeaderIcon="{ui:FontIcon Glyph=&#xE756;}"
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}"
IsExpanded="True"
ItemsSource="{x:Bind ViewModel.PythonScriptActions, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
x:Name="RefreshScriptsButton"
Click="RefreshPythonScripts_Click"
Style="{ThemeResource AccentButtonStyle}"
Content="Load scripts" />
</StackPanel>
<tkcontrols:SettingsExpander.ItemTemplate>
<DataTemplate x:DataType="models:AdvancedPastePythonScriptAction">
<tkcontrols:SettingsCard
Margin="0,0,0,2"
Header="{x:Bind Name, Mode=OneWay}"
Description="{x:Bind ScriptPath, Mode=OneWay}"
IsActionIconVisible="False">
<StackPanel Orientation="Horizontal" Spacing="8">
<ToggleSwitch
IsOn="{x:Bind IsEnabled, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Toggled="ScriptEnabledToggle_Toggled"
OffContent=""
OnContent="" />
<Button
Content="&#xE70F;"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Click="EditPythonScript_Click"
Style="{StaticResource SubtleButtonStyle}"
Tag="{x:Bind}">
<ToolTipService.ToolTip>
<TextBlock Text="Edit script settings" />
</ToolTipService.ToolTip>
</Button>
</StackPanel>
</tkcontrols:SettingsCard>
</DataTemplate>
</tkcontrols:SettingsExpander.ItemTemplate>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
@@ -450,7 +528,74 @@
</StackPanel>
</ContentDialog>
<!-- Paste AI provider dialog -->
<!-- Python Script Edit Dialog -->
<ContentDialog
x:Name="PythonScriptEditDialog"
Title="Edit script settings"
CloseButtonText="Cancel"
PrimaryButtonText="Apply changes"
PrimaryButtonStyle="{ThemeResource AccentButtonStyle}">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMaxWidth">600</x:Double>
</ContentDialog.Resources>
<StackPanel Spacing="14" MinWidth="400">
<TextBlock
x:Name="ScriptEditFilePath"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
IsTextSelectionEnabled="True" />
<TextBox
x:Name="ScriptEditName"
Header="Name"
Width="380"
HorizontalAlignment="Left" />
<TextBox
x:Name="ScriptEditDescription"
Header="Description"
Width="380"
HorizontalAlignment="Left" />
<ComboBox
x:Name="ScriptEditPlatform"
Header="Platform"
MinWidth="160">
<x:String>windows</x:String>
<x:String>linux</x:String>
<x:String>any</x:String>
</ComboBox>
<TextBlock
Text="Supported formats"
Style="{StaticResource BodyTextBlockStyle}"
Margin="0,4,0,0" />
<Grid ColumnSpacing="12" RowSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<CheckBox x:Name="ScriptEditText" Content="Text" Grid.Row="0" Grid.Column="0" />
<CheckBox x:Name="ScriptEditHtml" Content="HTML" Grid.Row="0" Grid.Column="1" />
<CheckBox x:Name="ScriptEditImage" Content="Image" Grid.Row="0" Grid.Column="2" />
<CheckBox x:Name="ScriptEditAudio" Content="Audio" Grid.Row="1" Grid.Column="0" />
<CheckBox x:Name="ScriptEditVideo" Content="Video" Grid.Row="1" Grid.Column="1" />
<CheckBox x:Name="ScriptEditFiles" Content="Files" Grid.Row="1" Grid.Column="2" />
</Grid>
<ToggleSwitch
x:Name="ScriptEditAutoDetectDeps"
Header="Dependencies"
OnContent="Auto-detect from imports"
OffContent="Manual" />
<TextBox
x:Name="ScriptEditRequires"
Header="Required packages (space-separated)"
Width="380"
HorizontalAlignment="Left"
PlaceholderText="e.g. numpy requests cv2=opencv-python-headless" />
</StackPanel>
</ContentDialog>
<ContentDialog
x:Name="PasteAIProviderConfigurationDialog"
x:Uid="AdvancedPaste_EndpointDialog"

View File

@@ -21,6 +21,7 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
namespace Microsoft.PowerToys.Settings.UI.Views
{
@@ -280,6 +281,231 @@ namespace Microsoft.PowerToys.Settings.UI.Views
}
}
private void BrowseScriptsFolder_Click(object sender, RoutedEventArgs e)
{
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
string selectedFolder = ShellGetFolder.GetFolderDialogWithFlags(
windowHandle,
ShellGetFolder.FolderDialogFlags._BIF_NEWDIALOGSTYLE);
if (!string.IsNullOrEmpty(selectedFolder))
{
ScriptsFolderTextBox.Text = selectedFolder;
if (ViewModel is not null)
{
ViewModel.ScriptsFolder = selectedFolder;
}
}
}
private void OpenScriptsFolder_Click(object sender, RoutedEventArgs e)
{
var folder = ViewModel?.ScriptsFolder;
if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = folder,
UseShellExecute = true,
});
}
}
private void RefreshPythonScripts_Click(object sender, RoutedEventArgs e)
{
ViewModel?.RefreshPythonScripts();
RefreshScriptsButton.Content = "Refresh scripts";
}
private async void EditPythonScript_Click(object sender, RoutedEventArgs e)
{
if (sender is not FrameworkElement { Tag: AdvancedPastePythonScriptAction action })
{
return;
}
// Snapshot initial values to detect changes
var initialName = action.Name;
var initialDesc = action.Description;
var initialPlatform = action.Platform;
var initialText = action.SupportsText;
var initialHtml = action.SupportsHtml;
var initialImage = action.SupportsImage;
var initialAudio = action.SupportsAudio;
var initialVideo = action.SupportsVideo;
var initialFiles = action.SupportsFiles;
var initialAutoDetect = action.RequiresAutoDetect;
var initialRequires = action.Requires;
// Populate dialog fields from the action
ScriptEditFilePath.Text = action.ScriptPath;
ScriptEditName.Text = action.Name;
ScriptEditDescription.Text = action.Description;
ScriptEditPlatform.SelectedItem = action.Platform;
ScriptEditText.IsChecked = action.SupportsText;
ScriptEditHtml.IsChecked = action.SupportsHtml;
ScriptEditImage.IsChecked = action.SupportsImage;
ScriptEditAudio.IsChecked = action.SupportsAudio;
ScriptEditVideo.IsChecked = action.SupportsVideo;
ScriptEditFiles.IsChecked = action.SupportsFiles;
ScriptEditAutoDetectDeps.IsOn = action.RequiresAutoDetect;
ScriptEditRequires.Text = action.Requires;
ScriptEditRequires.IsEnabled = !action.RequiresAutoDetect;
void OnDepsToggled(object s, RoutedEventArgs args) =>
ScriptEditRequires.IsEnabled = !ScriptEditAutoDetectDeps.IsOn;
// Change detection: disable Apply button when nothing has changed
PythonScriptEditDialog.IsPrimaryButtonEnabled = false;
void CheckForChanges(object s, object args)
{
bool hasChanges =
ScriptEditName.Text != initialName ||
ScriptEditDescription.Text != initialDesc ||
(ScriptEditPlatform.SelectedItem as string ?? "windows") != initialPlatform ||
(ScriptEditText.IsChecked == true) != initialText ||
(ScriptEditHtml.IsChecked == true) != initialHtml ||
(ScriptEditImage.IsChecked == true) != initialImage ||
(ScriptEditAudio.IsChecked == true) != initialAudio ||
(ScriptEditVideo.IsChecked == true) != initialVideo ||
(ScriptEditFiles.IsChecked == true) != initialFiles ||
ScriptEditAutoDetectDeps.IsOn != initialAutoDetect ||
ScriptEditRequires.Text != initialRequires;
PythonScriptEditDialog.IsPrimaryButtonEnabled = hasChanges;
}
void OnTextChanged(object s, TextChangedEventArgs args) => CheckForChanges(s, args);
void OnChecked(object s, RoutedEventArgs args) => CheckForChanges(s, args);
void OnToggled(object s, RoutedEventArgs args)
{
OnDepsToggled(s, args);
CheckForChanges(s, args);
}
void OnSelectionChanged(object s, SelectionChangedEventArgs args) => CheckForChanges(s, args);
ScriptEditName.TextChanged += OnTextChanged;
ScriptEditDescription.TextChanged += OnTextChanged;
ScriptEditRequires.TextChanged += OnTextChanged;
ScriptEditPlatform.SelectionChanged += OnSelectionChanged;
ScriptEditText.Checked += OnChecked;
ScriptEditText.Unchecked += OnChecked;
ScriptEditHtml.Checked += OnChecked;
ScriptEditHtml.Unchecked += OnChecked;
ScriptEditImage.Checked += OnChecked;
ScriptEditImage.Unchecked += OnChecked;
ScriptEditAudio.Checked += OnChecked;
ScriptEditAudio.Unchecked += OnChecked;
ScriptEditVideo.Checked += OnChecked;
ScriptEditVideo.Unchecked += OnChecked;
ScriptEditFiles.Checked += OnChecked;
ScriptEditFiles.Unchecked += OnChecked;
ScriptEditAutoDetectDeps.Toggled += OnToggled;
try
{
PythonScriptEditDialog.XamlRoot = Content.XamlRoot;
var result = await PythonScriptEditDialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// Write dialog values back to the action
action.Name = ScriptEditName.Text;
action.Description = ScriptEditDescription.Text;
action.Platform = ScriptEditPlatform.SelectedItem as string ?? "windows";
action.SupportsText = ScriptEditText.IsChecked == true;
action.SupportsHtml = ScriptEditHtml.IsChecked == true;
action.SupportsImage = ScriptEditImage.IsChecked == true;
action.SupportsAudio = ScriptEditAudio.IsChecked == true;
action.SupportsVideo = ScriptEditVideo.IsChecked == true;
action.SupportsFiles = ScriptEditFiles.IsChecked == true;
action.RequiresAutoDetect = ScriptEditAutoDetectDeps.IsOn;
action.Requires = ScriptEditRequires.Text;
await ApplySingleActionAsync(action);
}
}
finally
{
ScriptEditName.TextChanged -= OnTextChanged;
ScriptEditDescription.TextChanged -= OnTextChanged;
ScriptEditRequires.TextChanged -= OnTextChanged;
ScriptEditPlatform.SelectionChanged -= OnSelectionChanged;
ScriptEditText.Checked -= OnChecked;
ScriptEditText.Unchecked -= OnChecked;
ScriptEditHtml.Checked -= OnChecked;
ScriptEditHtml.Unchecked -= OnChecked;
ScriptEditImage.Checked -= OnChecked;
ScriptEditImage.Unchecked -= OnChecked;
ScriptEditAudio.Checked -= OnChecked;
ScriptEditAudio.Unchecked -= OnChecked;
ScriptEditVideo.Checked -= OnChecked;
ScriptEditVideo.Unchecked -= OnChecked;
ScriptEditFiles.Checked -= OnChecked;
ScriptEditFiles.Unchecked -= OnChecked;
ScriptEditAutoDetectDeps.Toggled -= OnToggled;
}
}
private async void ScriptEnabledToggle_Toggled(object sender, RoutedEventArgs e)
{
if (sender is ToggleSwitch toggle && toggle.DataContext is AdvancedPastePythonScriptAction action)
{
action.IsEnabled = toggle.IsOn;
await ApplySingleActionAsync(action);
}
}
private async Task ApplySingleActionAsync(AdvancedPastePythonScriptAction action)
{
try
{
ViewModel?.ApplySingleScriptChange(action);
}
catch (System.IO.IOException)
{
var headerText = ViewModels.AdvancedPasteViewModel.GenerateHeaderText(action);
var errorDialog = new ContentDialog
{
XamlRoot = Content.XamlRoot,
Title = "Unable to write to file",
PrimaryButtonText = "Copy header",
CloseButtonText = "OK",
PrimaryButtonStyle = (Style)Application.Current.Resources["AccentButtonStyle"],
Content = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = $"The file is locked by another process and cannot be updated:\n{action.ScriptPath}\n\nCopy the header below and paste it at the top of the file manually:",
TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap,
},
new TextBox
{
Text = headerText,
IsReadOnly = true,
AcceptsReturn = true,
TextWrapping = Microsoft.UI.Xaml.TextWrapping.Wrap,
FontFamily = new FontFamily("Consolas"),
MaxHeight = 200,
},
},
},
};
var dialogResult = await errorDialog.ShowAsync();
if (dialogResult == ContentDialogResult.Primary)
{
var dataPackage = new DataPackage();
dataPackage.SetText(headerText);
Clipboard.SetContent(dataPackage);
}
}
}
private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0)
{
// Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions

View File

@@ -601,6 +601,21 @@ opera.exe</value>
<data name="AdvancedPaste_PythonScripts_GroupSettings.Header" xml:space="preserve">
<value>Python scripts</value>
</data>
<data name="AdvancedPaste_PythonScripts_EnableCard.Header" xml:space="preserve">
<value>Enable Python scripts</value>
</data>
<data name="AdvancedPaste_PythonScripts_EnableCard.Description" xml:space="preserve">
<value>Run custom Python scripts from the Advanced Paste menu</value>
</data>
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Header" xml:space="preserve">
<value>Scripts folder</value>
</data>
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Description" xml:space="preserve">
<value>Folder to scan for Python scripts (.py files). Leave blank to use the default location.</value>
</data>
<data name="AdvancedPaste_ScriptsFolder_TextBox.PlaceholderText" xml:space="preserve">
<value>Default (%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts)</value>
</data>
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Header" xml:space="preserve">
<value>Python interpreter</value>
</data>
@@ -610,6 +625,18 @@ opera.exe</value>
<data name="AdvancedPaste_PythonExecutablePath_TextBox.PlaceholderText" xml:space="preserve">
<value>Auto-detect (e.g. C:\Users\&lt;user&gt;\anaconda3\python.exe)</value>
</data>
<data name="AdvancedPaste_PythonScriptList.Header" xml:space="preserve">
<value>Discovered scripts</value>
</data>
<data name="AdvancedPaste_PythonScriptList.Description" xml:space="preserve">
<value>Python scripts found in the scripts folder. Changes to settings are written back to the script file headers.</value>
</data>
<data name="AdvancedPaste_PythonScript_RefreshScripts.Content" xml:space="preserve">
<value>Refresh scripts</value>
</data>
<data name="AdvancedPaste_PythonScript_ApplyChanges.Content" xml:space="preserve">
<value>Apply changes</value>
</data>
<data name="AdvancedPaste_FoundryLocal_LegalDescription" xml:space="preserve">
<value>You're running local models directly on your device. Their behavior may vary or be unpredictable.</value>
</data>

View File

@@ -293,6 +293,28 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
public bool IsPythonScriptsEnabled
{
get
{
var scripts = _advancedPasteSettings.Properties.PythonScripts;
return scripts?.IsEnabled ?? false;
}
set
{
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
if (scripts.IsEnabled != value)
{
scripts.IsEnabled = value;
OnPropertyChanged(nameof(IsPythonScriptsEnabled));
SaveAndNotifySettings();
}
}
}
public bool ScriptsDiscovered => _scriptsDiscovered;
public string PythonExecutablePath
{
get => _advancedPasteSettings.Properties.PythonScripts?.PythonExecutablePath ?? string.Empty;
@@ -308,6 +330,349 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public string ScriptsFolder
{
get => _advancedPasteSettings.Properties.PythonScripts?.ScriptsFolder ?? string.Empty;
set
{
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
if (!string.Equals(scripts.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
{
scripts.ScriptsFolder = value ?? string.Empty;
OnPropertyChanged(nameof(ScriptsFolder));
SaveAndNotifySettings();
if (_scriptsDiscovered)
{
RefreshPythonScripts();
}
}
}
}
private ObservableCollection<AdvancedPastePythonScriptAction> _pythonScriptActions = [];
private bool _scriptsDiscovered;
public ObservableCollection<AdvancedPastePythonScriptAction> PythonScriptActions
{
get => _pythonScriptActions;
set
{
_pythonScriptActions = value;
OnPropertyChanged(nameof(PythonScriptActions));
}
}
/// <summary>
/// Scans the scripts folder for .py files and populates PythonScriptActions
/// with metadata from their headers. Existing settings (hotkeys) are preserved.
/// </summary>
public void RefreshPythonScripts()
{
_scriptsDiscovered = true;
OnPropertyChanged(nameof(ScriptsDiscovered));
var folder = ScriptsFolder;
if (string.IsNullOrWhiteSpace(folder) || !System.IO.Directory.Exists(folder))
{
PythonScriptActions = [];
return;
}
var scripts = new ObservableCollection<AdvancedPastePythonScriptAction>();
var savedActions = _advancedPasteSettings.Properties.PythonScripts?.Value ?? [];
foreach (var file in System.IO.Directory.EnumerateFiles(folder, "*.py", System.IO.SearchOption.TopDirectoryOnly))
{
try
{
var action = CreateActionFromScript(file, savedActions);
scripts.Add(action);
}
catch
{
// Skip scripts that can't be read
}
}
PythonScriptActions = scripts;
}
private static AdvancedPastePythonScriptAction CreateActionFromScript(
string filePath,
List<AdvancedPastePythonScriptAction> savedActions)
{
// Read header metadata from the .py file
var name = System.IO.Path.GetFileNameWithoutExtension(filePath);
var description = string.Empty;
var platform = "windows";
var formats = "any";
var enabled = true;
var requires = string.Empty;
var hasExplicitRequires = false;
using var reader = new System.IO.StreamReader(filePath, System.Text.Encoding.UTF8);
int lineCount = 0;
while (lineCount < 50)
{
var line = reader.ReadLine();
if (line is null)
{
break;
}
lineCount++;
var trimmed = line.Trim();
if (!trimmed.StartsWith('#'))
{
continue;
}
if (TryParseTag(trimmed, "@advancedpaste:name", out var val))
{
name = val;
}
else if (TryParseTag(trimmed, "@advancedpaste:desc", out val))
{
description = val;
}
else if (TryParseTag(trimmed, "@advancedpaste:platform", out val))
{
platform = val.ToLowerInvariant();
}
else if (TryParseTag(trimmed, "@advancedpaste:formats", out val))
{
formats = val;
}
else if (TryParseTag(trimmed, "@advancedpaste:enabled", out val))
{
enabled = !string.Equals(val, "false", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(val, "0", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(val, "no", StringComparison.OrdinalIgnoreCase);
}
else if (TryParseTag(trimmed, "@advancedpaste:requires", out val))
{
// Accumulate multiple requires tags
requires = string.IsNullOrEmpty(requires) ? val : $"{requires} {val}";
hasExplicitRequires = true;
}
}
// Preserve existing saved settings (hotkeys, IsShown)
var saved = savedActions?.FirstOrDefault(a =>
string.Equals(a.ScriptPath, filePath, StringComparison.OrdinalIgnoreCase));
return new AdvancedPastePythonScriptAction
{
ScriptPath = filePath,
Name = name,
Description = description,
Platform = platform,
Formats = formats,
IsEnabled = enabled,
Requires = requires,
RequiresAutoDetect = !hasExplicitRequires,
IsShown = saved?.IsShown ?? true,
Shortcut = saved?.Shortcut ?? new HotkeySettings(),
};
}
private static bool TryParseTag(string line, string tag, out string value)
{
var idx = line.IndexOf(tag, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
value = null;
return false;
}
value = line[(idx + tag.Length)..].Trim();
return value.Length > 0;
}
private static bool ContainsTag(string line, string tag)
{
return line.IndexOf(tag, StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Writes changed metadata back to each script's header and saves settings.
/// </summary>
public void ApplyPythonScriptChanges()
{
foreach (var action in PythonScriptActions)
{
try
{
WriteScriptHeader(action);
}
catch
{
// Skip files that can't be written
}
}
// Save the actions to settings
var pythonSettings = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
pythonSettings.Value = [.. PythonScriptActions.Select(a => (AdvancedPastePythonScriptAction)a.Clone())];
SaveAndNotifySettings();
RefreshPythonScripts();
}
/// <summary>
/// Applies changes for a single script action. Writes the header to file and saves settings.
/// Throws IOException if the file is locked.
/// </summary>
public void ApplySingleScriptChange(AdvancedPastePythonScriptAction action)
{
WriteScriptHeader(action);
var pythonSettings = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
pythonSettings.Value = [.. PythonScriptActions.Select(a => (AdvancedPastePythonScriptAction)a.Clone())];
SaveAndNotifySettings();
}
/// <summary>
/// Generates the full header text for a script action, for manual copy/paste fallback.
/// </summary>
public static string GenerateHeaderText(AdvancedPastePythonScriptAction action)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine(string.Concat("# @advancedpaste:name ", action.Name));
sb.AppendLine(string.Concat("# @advancedpaste:desc ", action.Description));
sb.AppendLine(string.Concat("# @advancedpaste:platform ", action.Platform));
sb.AppendLine(string.Concat("# @advancedpaste:formats ", action.Formats));
sb.AppendLine(string.Concat("# @advancedpaste:enabled ", action.IsEnabled ? "true" : "false"));
if (!action.RequiresAutoDetect && !string.IsNullOrWhiteSpace(action.Requires))
{
sb.AppendLine(string.Concat("# @advancedpaste:requires ", action.Requires));
}
return sb.ToString().TrimEnd();
}
private static void WriteScriptHeader(AdvancedPastePythonScriptAction action)
{
if (!System.IO.File.Exists(action.ScriptPath))
{
return;
}
var lines = System.IO.File.ReadAllLines(action.ScriptPath, System.Text.Encoding.UTF8).ToList();
var tagUpdates = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["@advancedpaste:name"] = action.Name,
["@advancedpaste:desc"] = action.Description,
["@advancedpaste:platform"] = action.Platform,
["@advancedpaste:formats"] = action.Formats,
["@advancedpaste:enabled"] = action.IsEnabled ? "true" : "false",
};
// Only write requires tag in manual mode
if (!action.RequiresAutoDetect && !string.IsNullOrWhiteSpace(action.Requires))
{
tagUpdates["@advancedpaste:requires"] = action.Requires;
}
var updatedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int lastTagLine = -1;
// Collect indices of duplicate tag lines and requires lines to remove
var linesToRemove = new HashSet<int>();
for (int i = 0; i < Math.Min(lines.Count, 50); i++)
{
var trimmed = lines[i].Trim();
if (!trimmed.StartsWith('#'))
{
continue;
}
// Handle requires tag separately (may need full removal in auto mode)
if (ContainsTag(trimmed, "@advancedpaste:requires"))
{
lastTagLine = i;
if (action.RequiresAutoDetect)
{
// Auto-detect mode: remove all requires lines
linesToRemove.Add(i);
}
else if (!updatedTags.Contains("@advancedpaste:requires"))
{
// Manual mode: update first occurrence
if (tagUpdates.ContainsKey("@advancedpaste:requires"))
{
lines[i] = $"# @advancedpaste:requires {action.Requires}";
updatedTags.Add("@advancedpaste:requires");
}
}
else
{
// Duplicate requires line: remove
linesToRemove.Add(i);
}
continue;
}
foreach (var (tag, newValue) in tagUpdates)
{
if (ContainsTag(trimmed, tag))
{
if (!updatedTags.Contains(tag))
{
// First occurrence: update in place
lines[i] = $"# {tag} {newValue}";
updatedTags.Add(tag);
lastTagLine = i;
}
else
{
// Duplicate: mark for removal
linesToRemove.Add(i);
}
break;
}
}
if (trimmed.Contains("@advancedpaste:"))
{
lastTagLine = i;
}
}
// Remove duplicate/unwanted lines in reverse order to preserve indices
foreach (var idx in linesToRemove.OrderByDescending(x => x))
{
lines.RemoveAt(idx);
}
// Recalculate insertion point after removals
var removedBeforeLastTag = linesToRemove.Count(idx => idx <= lastTagLine);
var insertionPoint = lastTagLine >= 0 ? lastTagLine + 1 - removedBeforeLastTag : 0;
var newLines = new List<string>();
foreach (var (tag, newValue) in tagUpdates)
{
if (!updatedTags.Contains(tag))
{
newLines.Add($"# {tag} {newValue}");
}
}
if (newLines.Count > 0)
{
insertionPoint = Math.Min(insertionPoint, lines.Count);
lines.InsertRange(insertionPoint, newLines);
}
System.IO.File.WriteAllLines(action.ScriptPath, lines, new System.Text.UTF8Encoding(false));
}
public static IEnumerable<AIServiceTypeMetadata> AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes();
/// <summary>

View File

@@ -201,6 +201,10 @@ function Ensure-VsDevEnvironment {
if (-not $instPaths) {
try { $p2 = & $vswhere -latest -products * -property installationPath 2>$null; if ($p2) { $instPaths += $p2 } } catch {}
}
# Fallback: include incomplete installations (e.g. pending reboot after component install)
if (-not $instPaths) {
try { $p3 = & $vswhere -latest -products * -all -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null; if ($p3) { $instPaths += $p3 } } catch {}
}
}
# Add explicit common year-based candidates as a last resort