mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-01 09:56:32 +01:00
Compare commits
21 Commits
tools/Rele
...
dev/migrie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd9f523d5d | ||
|
|
de5fee2ca6 | ||
|
|
8318a40dd4 | ||
|
|
f1f00475d1 | ||
|
|
5da2e74622 | ||
|
|
0d3db48ab1 | ||
|
|
c8486087d8 | ||
|
|
d43a23c745 | ||
|
|
ffae061135 | ||
|
|
1e7c60ec23 | ||
|
|
2a5c61ce1f | ||
|
|
fdfffdc256 | ||
|
|
f249f99694 | ||
|
|
05c700a4cd | ||
|
|
28c0d4a420 | ||
|
|
48b70e0861 | ||
|
|
b0f6e0ae87 | ||
|
|
8cb518b649 | ||
|
|
08dc3fbcef | ||
|
|
eeb84cb621 | ||
|
|
5b2388cd58 |
2
.github/actions/spell-check/allow/names.txt
vendored
2
.github/actions/spell-check/allow/names.txt
vendored
@@ -210,6 +210,7 @@ capturevideosample
|
||||
cmdow
|
||||
Controlz
|
||||
cortana
|
||||
devhints
|
||||
dlnilsson
|
||||
fancymouse
|
||||
firefox
|
||||
@@ -229,6 +230,7 @@ regedit
|
||||
roslyn
|
||||
Skia
|
||||
Spotify
|
||||
tldr
|
||||
Vanara
|
||||
wangyi
|
||||
WEX
|
||||
|
||||
@@ -327,6 +327,12 @@
|
||||
"WinUI3Apps\\ReverseMarkdown.dll",
|
||||
"WinUI3Apps\\SharpCompress.dll",
|
||||
"WinUI3Apps\\ZstdSharp.dll",
|
||||
"CommunityToolkit.WinUI.Controls.MarkdownTextBlock.dll",
|
||||
"WinUI3Apps\\CommunityToolkit.WinUI.Controls.MarkdownTextBlock.dll",
|
||||
"Markdig.dll",
|
||||
"WinUI3Apps\\Markdig.dll",
|
||||
"RomanNumerals.dll",
|
||||
"WinUI3Apps\\RomanNumerals.dll",
|
||||
"TestableIO.System.IO.Abstractions.dll",
|
||||
"WinUI3Apps\\TestableIO.System.IO.Abstractions.dll",
|
||||
"TestableIO.System.IO.Abstractions.Wrappers.dll",
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<PackageVersion Include="UnicodeInformation" Version="2.6.0" />
|
||||
<PackageVersion Include="UnitsNet" Version="5.56.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="WinUIEx" Version="2.2.0" />
|
||||
<PackageVersion Include="WinUIEx" Version="2.8.0" />
|
||||
<PackageVersion Include="WPF-UI" Version="3.0.5" />
|
||||
<PackageVersion Include="WyHash" Version="1.0.5" />
|
||||
<PackageVersion Include="WixToolset.Heat" Version="5.0.2" />
|
||||
|
||||
@@ -50,6 +50,7 @@ Contact the developers of a plugin directly for assistance with a specific plugi
|
||||
| [Hotkeys](https://github.com/ruslanlap/PowerToysRun-Hotkeys) | [ruslanlap](https://github.com/ruslanlap) | Create, manage, and trigger custom keyboard shortcuts directly from PowerToys Run. |
|
||||
| [RandomGen](https://github.com/ruslanlap/PowerToysRun-RandomGen) | [ruslanlap](https://github.com/ruslanlap) | 🎲 Generate random data instantly with a single keystroke. Perfect for developers, testers, designers, and anyone who needs quick access to random data. Features include secure passwords, PINs, names, business data, dates, numbers, GUIDs, color codes, and more. Especially useful for designers who need random color codes and placeholder content. |
|
||||
| [Open With Cursor](https://github.com/VictorNoxx/PowerToys-Run-Cursor/) | [VictorNoxx](https://github.com/VictorNoxx) | Open Visual Studio, VS Code recents with Cursor AI |
|
||||
| [CheatSheets](https://github.com/ruslanlap/PowerToysRun-CheatSheets) | [ruslanlap](https://github.com/ruslanlap) | 📚 Find cheat sheets and command examples instantly from tldr pages, cheat.sh, and devhints.io. Features include favorites system, categories, offline mode, and smart caching. |
|
||||
|
||||
## Extending software plugins
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<CmdPalVersion Condition="'$(CmdPalVersion)'=='' and '$(XES_APPXMANIFESTVERSION)'!=''">$(XES_APPXMANIFESTVERSION)</CmdPalVersion>
|
||||
|
||||
<!-- MIKE: The file you're looking for is src/modules/cmdpal/custom.props -->
|
||||
<CmdPalVersion Condition="'$(CmdPalVersion)'==''">0.0.1.0</CmdPalVersion>
|
||||
|
||||
<DevEnvironment>Local</DevEnvironment>
|
||||
|
||||
<!-- Forcing for every DLL on by default -->
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Services;
|
||||
|
||||
public interface IRunHistoryService
|
||||
@@ -25,3 +23,12 @@ public interface IRunHistoryService
|
||||
/// <param name="item">The run history item to add.</param>
|
||||
void AddRunHistoryItem(string item);
|
||||
}
|
||||
|
||||
public interface ITelemetryService
|
||||
{
|
||||
void LogRunQuery(string query, int resultCount, ulong durationMs);
|
||||
|
||||
void LogRunCommand(string command, bool asAdmin, bool success);
|
||||
|
||||
void LogOpenUri(string uri, bool isWeb, bool success);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.2.0" />
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
|
||||
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
|
||||
@@ -40,10 +40,13 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CommandPalette.Extensions" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWinRT" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" />
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
<PackageReference Include="Shmuelie.WinRTServer" />
|
||||
|
||||
<!-- Needed to enable building an MSIX package -->
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.MSIX">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
@@ -84,6 +87,9 @@
|
||||
<!-- In Release builds, trimming is enabled by default.
|
||||
feel free to disable this if needed -->
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
|
||||
<!-- In release, also ignore the aforementioned ILLink warning -->
|
||||
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -160,7 +160,7 @@ public partial class App : Application
|
||||
|
||||
services.AddSingleton<IRootPageService, PowerToysRootPageService>();
|
||||
services.AddSingleton<IAppHostService, PowerToysAppHostService>();
|
||||
services.AddSingleton(new TelemetryForwarder());
|
||||
services.AddSingleton<ITelemetryService, TelemetryForwarder>();
|
||||
|
||||
// ViewModels
|
||||
services.AddSingleton<ShellViewModel>();
|
||||
|
||||
80
src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs
Normal file
80
src/modules/cmdpal/Microsoft.CmdPal.UI/Events/RunEvents.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.Tracing;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
using Microsoft.PowerToys.Telemetry.Events;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Events;
|
||||
|
||||
// Just put all the run events in one file for simplicity.
|
||||
#pragma warning disable SA1402 // File may only contain a single type
|
||||
#pragma warning disable SA1649 // File name should match first type name
|
||||
|
||||
[EventData]
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||
public class CmdPalRunQuery : EventBase, IEvent
|
||||
{
|
||||
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||
|
||||
public string Query { get; set; }
|
||||
|
||||
public int ResultCount { get; set; }
|
||||
|
||||
public ulong DurationMs { get; set; }
|
||||
|
||||
public CmdPalRunQuery(string query, int resultCount, ulong durationMs)
|
||||
{
|
||||
EventName = "CmdPal_RunQuery";
|
||||
Query = query;
|
||||
ResultCount = resultCount;
|
||||
DurationMs = durationMs;
|
||||
}
|
||||
}
|
||||
|
||||
[EventData]
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||
public class CmdPalRunCommand : EventBase, IEvent
|
||||
{
|
||||
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||
|
||||
public string Command { get; set; }
|
||||
|
||||
public bool AsAdmin { get; set; }
|
||||
|
||||
public bool Success { get; set; }
|
||||
|
||||
public CmdPalRunCommand(string command, bool asAdmin, bool success)
|
||||
{
|
||||
EventName = "CmdPal_RunCommand";
|
||||
Command = command;
|
||||
AsAdmin = asAdmin;
|
||||
Success = success;
|
||||
}
|
||||
}
|
||||
|
||||
[EventData]
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||
public class CmdPalOpenUri : EventBase, IEvent
|
||||
{
|
||||
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||
|
||||
public string Uri { get; set; }
|
||||
|
||||
public bool IsWeb { get; set; }
|
||||
|
||||
public bool Success { get; set; }
|
||||
|
||||
public CmdPalOpenUri(string uri, bool isWeb, bool success)
|
||||
{
|
||||
EventName = "CmdPal_OpenUri";
|
||||
Uri = uri;
|
||||
IsWeb = isWeb;
|
||||
Success = success;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore SA1649 // File name should match first type name
|
||||
#pragma warning restore SA1402 // File may only contain a single type
|
||||
@@ -2,9 +2,17 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Helpers;
|
||||
|
||||
internal static class BindTransformers
|
||||
{
|
||||
public static bool Negate(bool value) => !value;
|
||||
|
||||
public static Visibility EmptyToCollapsed(string? input)
|
||||
=> string.IsNullOrEmpty(input) ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public static Visibility EmptyOrWhitespaceToCollapsed(string? input)
|
||||
=> string.IsNullOrWhiteSpace(input) ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Core.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.Events;
|
||||
using Microsoft.PowerToys.Telemetry;
|
||||
@@ -19,6 +20,7 @@ namespace Microsoft.CmdPal.UI;
|
||||
/// or something similar, but this works for now.
|
||||
/// </summary>
|
||||
internal sealed class TelemetryForwarder :
|
||||
ITelemetryService,
|
||||
IRecipient<BeginInvokeMessage>,
|
||||
IRecipient<CmdPalInvokeResultMessage>
|
||||
{
|
||||
@@ -37,4 +39,19 @@ internal sealed class TelemetryForwarder :
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new BeginInvoke());
|
||||
}
|
||||
|
||||
public void LogRunQuery(string query, int resultCount, ulong durationMs)
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new CmdPalRunQuery(query, resultCount, durationMs));
|
||||
}
|
||||
|
||||
public void LogRunCommand(string command, bool asAdmin, bool success)
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new CmdPalRunCommand(command, asAdmin, success));
|
||||
}
|
||||
|
||||
public void LogOpenUri(string uri, bool isWeb, bool success)
|
||||
{
|
||||
PowerToysTelemetry.Log.WriteEvent(new CmdPalOpenUri(uri, isWeb, success));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,31 @@
|
||||
FalseValue="Visible"
|
||||
TrueValue="Collapsed" />
|
||||
|
||||
<Style
|
||||
x:Key="DetailKeyTextBlockStyle"
|
||||
BasedOn="{StaticResource CaptionTextBlockStyle}"
|
||||
TargetType="TextBlock">
|
||||
<Setter Property="IsTextSelectionEnabled" Value="True" />
|
||||
<Setter Property="TextWrapping" Value="WrapWholeWords" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="SeparatorKeyTextBlockStyle"
|
||||
BasedOn="{StaticResource BodyStrongTextBlockStyle}"
|
||||
TargetType="TextBlock">
|
||||
<Setter Property="IsTextSelectionEnabled" Value="True" />
|
||||
<Setter Property="TextWrapping" Value="WrapWholeWords" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="DetailValueTextBlockStyle"
|
||||
BasedOn="{StaticResource BodyTextBlockStyle}"
|
||||
TargetType="TextBlock">
|
||||
<Setter Property="IsTextSelectionEnabled" Value="True" />
|
||||
<Setter Property="TextWrapping" Value="WrapWholeWords" />
|
||||
</Style>
|
||||
|
||||
<DataTemplate x:Key="TagTemplate" x:DataType="coreViewModels:TagViewModel">
|
||||
<cpcontrols:Tag
|
||||
HorizontalAlignment="Left"
|
||||
@@ -68,7 +93,7 @@
|
||||
Margin="0,3,8,0"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
<TextBlock Text="{x:Bind Name}" />
|
||||
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="{x:Bind Name}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
@@ -76,20 +101,13 @@
|
||||
|
||||
<DataTemplate x:Key="DetailsLinkTemplate" x:DataType="coreViewModels:DetailsLinkViewModel">
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock Style="{StaticResource DetailKeyTextBlockStyle}" Text="{x:Bind Key, Mode=OneWay}" />
|
||||
<TextBlock
|
||||
IsTextSelectionEnabled="True"
|
||||
Text="{x:Bind Key, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock
|
||||
FontSize="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
IsTextSelectionEnabled="True"
|
||||
Style="{StaticResource DetailValueTextBlockStyle}"
|
||||
Text="{x:Bind Text, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords"
|
||||
Visibility="{x:Bind IsText, Mode=OneWay}" />
|
||||
<HyperlinkButton
|
||||
Padding="0"
|
||||
FontSize="12"
|
||||
NavigateUri="{x:Bind Link, Mode=OneWay}"
|
||||
Visibility="{x:Bind IsLink, Mode=OneWay}">
|
||||
<TextBlock Text="{x:Bind Text, Mode=OneWay}" TextWrapping="Wrap" />
|
||||
@@ -98,10 +116,7 @@
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="DetailsCommandsTemplate" x:DataType="coreViewModels:DetailsCommandsViewModel">
|
||||
<StackPanel Orientation="Vertical" Spacing="4">
|
||||
<TextBlock
|
||||
IsTextSelectionEnabled="True"
|
||||
Text="{x:Bind Key, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock Style="{StaticResource DetailKeyTextBlockStyle}" Text="{x:Bind Key, Mode=OneWay}" />
|
||||
<ItemsControl
|
||||
ItemTemplate="{StaticResource CommandTemplate}"
|
||||
ItemsSource="{x:Bind Commands, Mode=OneWay}"
|
||||
@@ -111,24 +126,20 @@
|
||||
<DataTemplate x:Key="DetailsSeparatorTemplate" x:DataType="coreViewModels:DetailsSeparatorViewModel">
|
||||
<StackPanel Margin="0,8,8,0" Orientation="Vertical">
|
||||
<Border
|
||||
Margin="8,0,0,0"
|
||||
BorderBrush="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Margin="0,0,0,0"
|
||||
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,2">
|
||||
<TextBlock
|
||||
Margin="-8,0,0,8"
|
||||
FontWeight="SemiBold"
|
||||
IsTextSelectionEnabled="True"
|
||||
Margin="0,0,0,0"
|
||||
Style="{StaticResource SeparatorKeyTextBlockStyle}"
|
||||
Text="{x:Bind Key, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), FallbackValue=Collapsed}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="DetailsTagsTemplate" x:DataType="coreViewModels:DetailsTagsViewModel">
|
||||
<StackPanel Orientation="Vertical" Spacing="4">
|
||||
<TextBlock
|
||||
IsTextSelectionEnabled="True"
|
||||
Text="{x:Bind Key, Mode=OneWay}"
|
||||
TextWrapping="WrapWholeWords" />
|
||||
<TextBlock Style="{StaticResource DetailKeyTextBlockStyle}" Text="{x:Bind Key, Mode=OneWay}" />
|
||||
<ItemsControl
|
||||
ItemTemplate="{StaticResource TagTemplate}"
|
||||
ItemsSource="{x:Bind Tags, Mode=OneWay}"
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<TitleBar x:Name="TitleBar">
|
||||
<TitleBar x:Name="AppTitleBar" PaneToggleRequested="AppTitleBar_PaneToggleRequested">
|
||||
<!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource -->
|
||||
<TitleBar.LeftHeader>
|
||||
<ImageIcon
|
||||
x:Name="WorkAroundIcon"
|
||||
Height="16"
|
||||
Margin="16,0,0,0"
|
||||
Source="ms-appx:///Assets/icon.svg" />
|
||||
@@ -36,18 +37,19 @@
|
||||
<NavigationView
|
||||
x:Name="NavView"
|
||||
Grid.Row="1"
|
||||
CompactModeThresholdWidth="1007"
|
||||
DisplayModeChanged="NavView_DisplayModeChanged"
|
||||
ExpandedModeThresholdWidth="1007"
|
||||
IsBackButtonVisible="Collapsed"
|
||||
IsPaneToggleButtonVisible="False"
|
||||
IsSettingsVisible="False"
|
||||
ItemInvoked="NavView_ItemInvoked"
|
||||
Loaded="NavView_Loaded"
|
||||
OpenPaneLength="200">
|
||||
Loaded="NavView_Loaded">
|
||||
<NavigationView.Resources>
|
||||
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
|
||||
<SolidColorBrush x:Key="NavigationViewContentGridBorderBrush" Color="Transparent" />
|
||||
<Thickness x:Key="NavigationViewHeaderMargin">15,0,0,0</Thickness>
|
||||
</NavigationView.Resources>
|
||||
|
||||
<NavigationView.MenuItems>
|
||||
<NavigationViewItem
|
||||
x:Uid="Settings_GeneralPage_NavigationViewItem_General"
|
||||
@@ -58,34 +60,32 @@
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
Tag="Extensions" />
|
||||
</NavigationView.MenuItems>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<BreadcrumbBar
|
||||
x:Name="NavigationBreadcrumbBar"
|
||||
Grid.Row="0"
|
||||
MaxWidth="1000"
|
||||
ItemClicked="NavigationBreadcrumbBar_ItemClicked"
|
||||
ItemsSource="{x:Bind BreadCrumbs, Mode=OneWay}">
|
||||
<BreadcrumbBar.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:Crumb">
|
||||
<TextBlock Text="{x:Bind Label, Mode=OneWay}" />
|
||||
</DataTemplate>
|
||||
</BreadcrumbBar.ItemTemplate>
|
||||
<BreadcrumbBar.Resources>
|
||||
<ResourceDictionary>
|
||||
<x:Double x:Key="BreadcrumbBarItemThemeFontSize">28</x:Double>
|
||||
<Thickness x:Key="BreadcrumbBarChevronPadding">7,4,8,0</Thickness>
|
||||
<FontWeight x:Key="BreadcrumbBarItemFontWeight">SemiBold</FontWeight>
|
||||
<x:Double x:Key="BreadcrumbBarChevronFontSize">16</x:Double>
|
||||
</ResourceDictionary>
|
||||
</BreadcrumbBar.Resources>
|
||||
</BreadcrumbBar>
|
||||
|
||||
<Grid Padding="16,0">
|
||||
<BreadcrumbBar
|
||||
x:Name="NavigationBreadcrumbBar"
|
||||
MaxWidth="1000"
|
||||
ItemClicked="NavigationBreadcrumbBar_ItemClicked"
|
||||
ItemsSource="{x:Bind BreadCrumbs, Mode=OneWay}">
|
||||
<BreadcrumbBar.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:Crumb">
|
||||
<TextBlock Text="{x:Bind Label, Mode=OneWay}" />
|
||||
</DataTemplate>
|
||||
</BreadcrumbBar.ItemTemplate>
|
||||
<BreadcrumbBar.Resources>
|
||||
<ResourceDictionary>
|
||||
<x:Double x:Key="BreadcrumbBarItemThemeFontSize">28</x:Double>
|
||||
<Thickness x:Key="BreadcrumbBarChevronPadding">7,4,8,0</Thickness>
|
||||
<FontWeight x:Key="BreadcrumbBarItemFontWeight">SemiBold</FontWeight>
|
||||
<x:Double x:Key="BreadcrumbBarChevronFontSize">16</x:Double>
|
||||
</ResourceDictionary>
|
||||
</BreadcrumbBar.Resources>
|
||||
</BreadcrumbBar>
|
||||
</Grid>
|
||||
<Frame x:Name="NavFrame" Grid.Row="1" />
|
||||
</Grid>
|
||||
</NavigationView>
|
||||
|
||||
@@ -14,6 +14,7 @@ using Microsoft.UI.Xaml.Automation.Peers;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using WinUIEx;
|
||||
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
|
||||
using TitleBar = Microsoft.UI.Xaml.Controls.TitleBar;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Settings;
|
||||
|
||||
@@ -34,7 +35,7 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
var title = RS_.GetString("SettingsWindowTitle");
|
||||
this.AppWindow.Title = title;
|
||||
this.AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall;
|
||||
this.TitleBar.Title = title;
|
||||
this.AppTitleBar.Title = title;
|
||||
PositionCentered();
|
||||
|
||||
WeakReferenceMessenger.Default.Register<NavigateToExtensionSettingsMessage>(this);
|
||||
@@ -142,11 +143,13 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
{
|
||||
if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal)
|
||||
{
|
||||
NavView.IsPaneToggleButtonVisible = false;
|
||||
AppTitleBar.IsPaneToggleButtonVisible = true;
|
||||
WorkAroundIcon.Margin = new Thickness(8, 0, 16, 0); // Required for workaround, see XAML comment
|
||||
}
|
||||
else
|
||||
{
|
||||
NavView.IsPaneToggleButtonVisible = true;
|
||||
AppTitleBar.IsPaneToggleButtonVisible = false;
|
||||
WorkAroundIcon.Margin = new Thickness(16, 0, 0, 0); // Required for workaround, see XAML comment
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +158,11 @@ public sealed partial class SettingsWindow : WindowEx,
|
||||
// This might come in on a background thread
|
||||
DispatcherQueue.TryEnqueue(() => Close());
|
||||
}
|
||||
|
||||
private void AppTitleBar_PaneToggleRequested(TitleBar sender, object args)
|
||||
{
|
||||
NavView.IsPaneOpen = !NavView.IsPaneOpen;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct Crumb
|
||||
|
||||
@@ -23,6 +23,7 @@ public class NormalizeCommandLineTests : CommandPaletteUnitTestBase
|
||||
[DataRow("ping bing.com", "c:\\Windows\\system32\\ping.exe", "bing.com")]
|
||||
[DataRow("curl bing.com", "c:\\Windows\\system32\\curl.exe", "bing.com")]
|
||||
[DataRow("ipconfig /all", "c:\\Windows\\system32\\ipconfig.exe", "/all")]
|
||||
[DataRow("ipconfig a b \"c d\"", "c:\\Windows\\system32\\ipconfig.exe", "a b \"c d\"")]
|
||||
public void NormalizeCommandLineSimple(string input, string expectedExe, string expectedArgs = "")
|
||||
{
|
||||
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||
@@ -46,7 +47,7 @@ public class NormalizeCommandLineTests : CommandPaletteUnitTestBase
|
||||
[TestMethod]
|
||||
[DataRow("cmd --run --test", "C:\\Windows\\System32\\cmd.exe", "--run --test")]
|
||||
[DataRow("cmd --run --test ", "C:\\Windows\\System32\\cmd.exe", "--run --test")]
|
||||
[DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "--run --test --pass")]
|
||||
[DataRow("cmd \"--run --test\" --pass", "C:\\Windows\\System32\\cmd.exe", "\"--run --test\" --pass")]
|
||||
public void NormalizeArgsWithSpaces(string input, string expectedExe, string expectedArgs = "")
|
||||
{
|
||||
NormalizeTestCore(input, expectedExe, expectedArgs);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -83,7 +82,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistory = CreateMockHistoryService();
|
||||
|
||||
var pages = new ShellListPage(settings, mockHistory.Object);
|
||||
var pages = new ShellListPage(settings, mockHistory.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(pages, () =>
|
||||
{
|
||||
@@ -115,7 +114,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryServiceWithCommonCommands();
|
||||
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(pages, () =>
|
||||
{
|
||||
@@ -141,7 +140,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryServiceWithCommonCommands();
|
||||
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object);
|
||||
var pages = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(pages, () =>
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ public class ShellCommandProviderTests
|
||||
{
|
||||
// Setup
|
||||
var mockHistoryService = new Mock<IRunHistoryService>();
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object);
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.DisplayName);
|
||||
@@ -28,7 +28,7 @@ public class ShellCommandProviderTests
|
||||
{
|
||||
// Setup
|
||||
var mockHistoryService = new Mock<IRunHistoryService>();
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object);
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.Icon);
|
||||
@@ -39,7 +39,7 @@ public class ShellCommandProviderTests
|
||||
{
|
||||
// Setup
|
||||
var mockHistoryService = new Mock<IRunHistoryService>();
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object);
|
||||
var provider = new ShellCommandsProvider(mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<XesUseOneStoreVersioning>true</XesUseOneStoreVersioning>
|
||||
<XesBaseYearForStoreVersion>2025</XesBaseYearForStoreVersion>
|
||||
<VersionMajor>0</VersionMajor>
|
||||
<VersionMinor>5</VersionMinor>
|
||||
<VersionMinor>6</VersionMinor>
|
||||
<VersionInfoProductName>Microsoft Command Palette</VersionInfoProductName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Shell.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Shell.Commands;
|
||||
|
||||
internal sealed partial class ExecuteItem : InvokableCommand
|
||||
{
|
||||
private readonly ISettingsInterface _settings;
|
||||
private readonly RunAsType _runas;
|
||||
|
||||
public string Cmd { get; internal set; } = string.Empty;
|
||||
|
||||
private static readonly char[] Separator = [' '];
|
||||
|
||||
public ExecuteItem(string cmd, ISettingsInterface settings, RunAsType type = RunAsType.None)
|
||||
{
|
||||
if (type == RunAsType.Administrator)
|
||||
{
|
||||
Name = Properties.Resources.cmd_run_as_administrator;
|
||||
Icon = Icons.AdminIcon;
|
||||
}
|
||||
else if (type == RunAsType.OtherUser)
|
||||
{
|
||||
Name = Properties.Resources.cmd_run_as_user;
|
||||
Icon = Icons.UserIcon;
|
||||
}
|
||||
else
|
||||
{
|
||||
Name = Properties.Resources.generic_run_command;
|
||||
Icon = Icons.RunV2Icon;
|
||||
}
|
||||
|
||||
Cmd = cmd;
|
||||
_settings = settings;
|
||||
_runas = type;
|
||||
}
|
||||
|
||||
private void Execute(Func<ProcessStartInfo, Process?> startProcess, ProcessStartInfo info)
|
||||
{
|
||||
if (startProcess is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
startProcess(info);
|
||||
}
|
||||
catch (FileNotFoundException e)
|
||||
{
|
||||
var name = "Plugin: " + Properties.Resources.cmd_plugin_name;
|
||||
var message = $"{Properties.Resources.cmd_command_not_found}: {e.Message}";
|
||||
|
||||
// GH TODO #138 -- show this message once that's wired up
|
||||
// _context.API.ShowMsg(name, message);
|
||||
}
|
||||
catch (Win32Exception e)
|
||||
{
|
||||
var name = "Plugin: " + Properties.Resources.cmd_plugin_name;
|
||||
var message = $"{Properties.Resources.cmd_command_failed}: {e.Message}";
|
||||
ExtensionHost.LogMessage(new LogMessage() { Message = name + message });
|
||||
|
||||
// GH TODO #138 -- show this message once that's wired up
|
||||
// _context.API.ShowMsg(name, message);
|
||||
}
|
||||
}
|
||||
|
||||
public static ProcessStartInfo SetProcessStartInfo(string fileName, string workingDirectory = "", string arguments = "", string verb = "")
|
||||
{
|
||||
var info = new ProcessStartInfo
|
||||
{
|
||||
FileName = fileName,
|
||||
WorkingDirectory = workingDirectory,
|
||||
Arguments = arguments,
|
||||
Verb = verb,
|
||||
};
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private ProcessStartInfo PrepareProcessStartInfo(string command, RunAsType runAs = RunAsType.None)
|
||||
{
|
||||
command = Environment.ExpandEnvironmentVariables(command);
|
||||
var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
// Set runAsArg
|
||||
var runAsVerbArg = string.Empty;
|
||||
if (runAs == RunAsType.OtherUser)
|
||||
{
|
||||
runAsVerbArg = "runAsUser";
|
||||
}
|
||||
else if (runAs == RunAsType.Administrator || _settings.RunAsAdministrator)
|
||||
{
|
||||
runAsVerbArg = "runAs";
|
||||
}
|
||||
|
||||
if (Enum.TryParse<ExecutionShell>(_settings.ShellCommandExecution, out var executionShell))
|
||||
{
|
||||
ProcessStartInfo info;
|
||||
if (executionShell == ExecutionShell.Cmd)
|
||||
{
|
||||
var arguments = _settings.LeaveShellOpen ? $"/k \"{command}\"" : $"/c \"{command}\" & pause";
|
||||
|
||||
info = SetProcessStartInfo("cmd.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (executionShell == ExecutionShell.Powershell)
|
||||
{
|
||||
var arguments = _settings.LeaveShellOpen
|
||||
? $"-NoExit \"{command}\""
|
||||
: $"\"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
|
||||
info = SetProcessStartInfo("powershell.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (executionShell == ExecutionShell.PowerShellSeven)
|
||||
{
|
||||
var arguments = _settings.LeaveShellOpen
|
||||
? $"-NoExit -C \"{command}\""
|
||||
: $"-C \"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
|
||||
info = SetProcessStartInfo("pwsh.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (executionShell == ExecutionShell.WindowsTerminalCmd)
|
||||
{
|
||||
var arguments = _settings.LeaveShellOpen ? $"cmd.exe /k \"{command}\"" : $"cmd.exe /c \"{command}\" & pause";
|
||||
info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (executionShell == ExecutionShell.WindowsTerminalPowerShell)
|
||||
{
|
||||
var arguments = _settings.LeaveShellOpen ? $"powershell -NoExit -C \"{command}\"" : $"powershell -C \"{command}\"";
|
||||
info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (executionShell == ExecutionShell.WindowsTerminalPowerShellSeven)
|
||||
{
|
||||
var arguments = _settings.LeaveShellOpen ? $"pwsh.exe -NoExit -C \"{command}\"" : $"pwsh.exe -C \"{command}\"";
|
||||
info = SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (executionShell == ExecutionShell.RunCommand)
|
||||
{
|
||||
// Open explorer if the path is a file or directory
|
||||
if (Directory.Exists(command) || File.Exists(command))
|
||||
{
|
||||
info = SetProcessStartInfo("explorer.exe", arguments: command, verb: runAsVerbArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parts = command.Split(Separator, 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
var filename = parts[0];
|
||||
if (ShellListPageHelpers.FileExistInPath(filename))
|
||||
{
|
||||
var arguments = parts[1];
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
// Wrap the command in a cmd.exe process
|
||||
info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{filename} {arguments}\"", runAsVerbArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
info = SetProcessStartInfo(filename, workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
// Wrap the command in a cmd.exe process
|
||||
info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
info = SetProcessStartInfo(command, verb: runAsVerbArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
// Wrap the command in a cmd.exe process
|
||||
info = SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
info = SetProcessStartInfo(command, verb: runAsVerbArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
info.UseShellExecute = true;
|
||||
|
||||
_settings.AddCmdHistory(command);
|
||||
|
||||
return info;
|
||||
}
|
||||
else
|
||||
{
|
||||
ExtensionHost.LogMessage(new LogMessage() { Message = "Error extracting setting" });
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
Execute(Process.Start, PrepareProcessStartInfo(Cmd, _runas));
|
||||
}
|
||||
catch
|
||||
{
|
||||
ExtensionHost.LogMessage(new LogMessage() { Message = "Error starting the process " });
|
||||
}
|
||||
|
||||
return CommandResult.Dismiss();
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@
|
||||
// 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 Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Shell;
|
||||
@@ -13,18 +11,33 @@ internal sealed partial class OpenUrlWithHistoryCommand : OpenUrlCommand
|
||||
{
|
||||
private readonly Action<string>? _addToHistory;
|
||||
private readonly string _url;
|
||||
private readonly ITelemetryService? _telemetryService;
|
||||
|
||||
public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null)
|
||||
public OpenUrlWithHistoryCommand(string url, Action<string>? addToHistory = null, ITelemetryService? telemetryService = null)
|
||||
: base(url)
|
||||
{
|
||||
_addToHistory = addToHistory;
|
||||
_url = url;
|
||||
_telemetryService = telemetryService;
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
_addToHistory?.Invoke(_url);
|
||||
var result = base.Invoke();
|
||||
return result;
|
||||
|
||||
var success = ShellHelpers.OpenInShell(_url);
|
||||
var isWebUrl = false;
|
||||
|
||||
if (Uri.TryCreate(_url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
isWebUrl = true;
|
||||
}
|
||||
}
|
||||
|
||||
_telemetryService?.LogOpenUri(_url, isWebUrl, success);
|
||||
|
||||
return CommandResult.Dismiss();
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,9 @@
|
||||
// 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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Shell.Pages;
|
||||
using Microsoft.CmdPal.Ext.Shell.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Shell;
|
||||
@@ -19,18 +14,20 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
|
||||
private static readonly char[] _systemDirectoryRoots = ['\\', '/'];
|
||||
|
||||
private readonly Action<string>? _addToHistory;
|
||||
private readonly ITelemetryService _telemetryService;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private Task? _currentUpdateTask;
|
||||
|
||||
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory)
|
||||
public FallbackExecuteItem(SettingsManager settings, Action<string>? addToHistory, ITelemetryService telemetryService)
|
||||
: base(
|
||||
new NoOpCommand() { Id = "com.microsoft.run.fallback" },
|
||||
Resources.shell_command_display_title)
|
||||
ResourceLoaderInstance.GetString("shell_command_display_title"))
|
||||
{
|
||||
Title = string.Empty;
|
||||
Subtitle = Properties.Resources.generic_run_command;
|
||||
Subtitle = ResourceLoaderInstance.GetString("generic_run_command");
|
||||
Icon = Icons.RunV2Icon; // Defined in Icons.cs and contains the execute command icon.
|
||||
_addToHistory = addToHistory;
|
||||
_telemetryService = telemetryService;
|
||||
}
|
||||
|
||||
public override void UpdateQuery(string query)
|
||||
@@ -147,7 +144,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
|
||||
if (exeExists)
|
||||
{
|
||||
// TODO we need to probably get rid of the settings for this provider entirely
|
||||
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory);
|
||||
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, _addToHistory, telemetryService: _telemetryService);
|
||||
Title = exeItem.Title;
|
||||
Subtitle = exeItem.Subtitle;
|
||||
Icon = exeItem.Icon;
|
||||
@@ -156,7 +153,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
|
||||
}
|
||||
else if (pathIsDir)
|
||||
{
|
||||
var pathItem = new PathListItem(exe, query, _addToHistory);
|
||||
var pathItem = new PathListItem(exe, query, _addToHistory, _telemetryService);
|
||||
Command = pathItem.Command;
|
||||
MoreCommands = pathItem.MoreCommands;
|
||||
Title = pathItem.Title;
|
||||
@@ -165,7 +162,7 @@ internal sealed partial class FallbackExecuteItem : FallbackCommandItem, IDispos
|
||||
}
|
||||
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
|
||||
{
|
||||
Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory) { Result = CommandResult.Dismiss() };
|
||||
Command = new OpenUrlWithHistoryCommand(searchText, _addToHistory, _telemetryService) { Result = CommandResult.Dismiss() };
|
||||
Title = searchText;
|
||||
}
|
||||
else
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.Storage.FileSystem;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
|
||||
@@ -19,38 +19,11 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
public static class CommandLineNormalizer
|
||||
{
|
||||
#pragma warning disable SA1310 // Field names should not contain underscore
|
||||
private const int MAX_PATH = 260;
|
||||
private const uint INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF;
|
||||
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
||||
|
||||
private const int MAX_PATH = 260;
|
||||
#pragma warning restore SA1310 // Field names should not contain underscore
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint ExpandEnvironmentStringsW(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string lpSrc,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpDst,
|
||||
uint nSize);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr CommandLineToArgvW(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine,
|
||||
out int pNumArgs);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint SearchPathW(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string? lpPath,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string? lpExtension,
|
||||
uint nBufferLength,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpBuffer,
|
||||
out IntPtr lpFilePart);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern uint GetFileAttributesW(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string lpFileName);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr LocalFree(IntPtr hMem);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a command line string by expanding environment variables, resolving executable paths,
|
||||
/// and standardizing the format for comparison purposes.
|
||||
@@ -129,9 +102,9 @@ public static class CommandLineNormalizer
|
||||
private static string ExpandEnvironmentVariables(string input)
|
||||
{
|
||||
const int initialBufferSize = 1024;
|
||||
var buffer = new StringBuilder(initialBufferSize);
|
||||
var buffer = new char[initialBufferSize];
|
||||
|
||||
var result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity);
|
||||
var result = PInvoke.ExpandEnvironmentStrings(input, buffer);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
@@ -139,11 +112,11 @@ public static class CommandLineNormalizer
|
||||
return input;
|
||||
}
|
||||
|
||||
if (result > buffer.Capacity)
|
||||
if (result > buffer.Length)
|
||||
{
|
||||
// Buffer was too small, resize and try again
|
||||
buffer.Capacity = (int)result;
|
||||
result = ExpandEnvironmentStringsW(input, buffer, (uint)buffer.Capacity);
|
||||
buffer = new char[result];
|
||||
result = PInvoke.ExpandEnvironmentStrings(input, buffer);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
@@ -151,7 +124,7 @@ public static class CommandLineNormalizer
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
return new string(buffer, 0, (int)result - 1); // -1 to exclude null terminator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -159,28 +132,30 @@ public static class CommandLineNormalizer
|
||||
/// </summary>
|
||||
private static string[] ParseCommandLineToArguments(string commandLine)
|
||||
{
|
||||
var argv = CommandLineToArgvW(commandLine, out var argc);
|
||||
|
||||
if (argv == IntPtr.Zero || argc == 0)
|
||||
unsafe
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
var argv = PInvoke.CommandLineToArgv(commandLine, out var argc);
|
||||
|
||||
try
|
||||
{
|
||||
var args = new string[argc];
|
||||
|
||||
for (var i = 0; i < argc; i++)
|
||||
if (argv == null || argc == 0)
|
||||
{
|
||||
var argPtr = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
|
||||
args[i] = Marshal.PtrToStringUni(argPtr) ?? string.Empty;
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalFree(argv);
|
||||
try
|
||||
{
|
||||
var args = new string[argc];
|
||||
|
||||
for (var i = 0; i < argc; i++)
|
||||
{
|
||||
args[i] = new string(argv[i]);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
finally
|
||||
{
|
||||
PInvoke.LocalFree(new HLOCAL(argv));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,39 +202,46 @@ public static class CommandLineNormalizer
|
||||
/// </summary>
|
||||
private static string TryResolveExecutable(string executableName)
|
||||
{
|
||||
var buffer = new StringBuilder(MAX_PATH);
|
||||
var buffer = new char[MAX_PATH];
|
||||
|
||||
var result = SearchPathW(
|
||||
null, // Use default search path
|
||||
executableName,
|
||||
".exe", // Default extension
|
||||
(uint)buffer.Capacity,
|
||||
buffer,
|
||||
out var _); // We don't need the file part
|
||||
|
||||
if (result == 0)
|
||||
unsafe
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
var outParam = default(PWSTR); // ultimately discarded
|
||||
|
||||
if (result > buffer.Capacity)
|
||||
{
|
||||
// Buffer was too small, resize and try again
|
||||
buffer.Capacity = (int)result;
|
||||
result = SearchPathW(null, executableName, ".exe", (uint)buffer.Capacity, buffer, out var _);
|
||||
var result = PInvoke.SearchPath(
|
||||
null, // Use default search path
|
||||
executableName,
|
||||
".exe", // Default extension
|
||||
buffer,
|
||||
&outParam); // We don't need the file part
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (result > buffer.Length)
|
||||
{
|
||||
// Buffer was too small, resize and try again
|
||||
buffer = new char[result];
|
||||
result = PInvoke.SearchPath(null, executableName, ".exe", buffer, &outParam);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedPath = new string(buffer, 0, (int)result);
|
||||
|
||||
// Verify the resolved path exists and is not a directory
|
||||
var attributes = PInvoke.GetFileAttributes(resolvedPath);
|
||||
|
||||
return attributes == INVALID_FILE_ATTRIBUTES ||
|
||||
(attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0 ?
|
||||
string.Empty :
|
||||
resolvedPath;
|
||||
}
|
||||
|
||||
var resolvedPath = buffer.ToString();
|
||||
|
||||
// Verify the resolved path exists and is not a directory
|
||||
var attributes = GetFileAttributesW(resolvedPath);
|
||||
|
||||
return attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 ? string.Empty : resolvedPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
// 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;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Shell.Commands;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Ext.Shell.Pages;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
@@ -16,37 +10,6 @@ namespace Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
|
||||
public class ShellListPageHelpers
|
||||
{
|
||||
private static readonly CompositeFormat CmdHasBeenExecutedTimes = System.Text.CompositeFormat.Parse(Properties.Resources.cmd_has_been_executed_times);
|
||||
private readonly ISettingsInterface _settings;
|
||||
|
||||
public ShellListPageHelpers(ISettingsInterface settings)
|
||||
{
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
private ListItem GetCurrentCmd(string cmd)
|
||||
{
|
||||
var result = new ListItem(new ExecuteItem(cmd, _settings))
|
||||
{
|
||||
Title = cmd,
|
||||
Subtitle = Properties.Resources.cmd_plugin_name + ": " + Properties.Resources.cmd_execute_through_shell,
|
||||
Icon = new IconInfo(string.Empty),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<CommandContextItem> LoadContextMenus(ListItem listItem)
|
||||
{
|
||||
var resultList = new List<CommandContextItem>
|
||||
{
|
||||
new(new ExecuteItem(listItem.Title, _settings, RunAsType.Administrator)),
|
||||
new(new ExecuteItem(listItem.Title, _settings, RunAsType.OtherUser )),
|
||||
};
|
||||
|
||||
return resultList;
|
||||
}
|
||||
|
||||
internal static bool FileExistInPath(string filename)
|
||||
{
|
||||
return FileExistInPath(filename, out var _);
|
||||
@@ -58,7 +21,7 @@ public class ShellListPageHelpers
|
||||
return ShellHelpers.FileExistInPath(filename, out fullPath, token ?? CancellationToken.None);
|
||||
}
|
||||
|
||||
internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory)
|
||||
internal static ListItem? ListItemForCommandString(string query, Action<string>? addToHistory, ITelemetryService? telemetryService)
|
||||
{
|
||||
var li = new ListItem();
|
||||
|
||||
@@ -100,7 +63,7 @@ public class ShellListPageHelpers
|
||||
if (exeExists)
|
||||
{
|
||||
// TODO we need to probably get rid of the settings for this provider entirely
|
||||
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory);
|
||||
var exeItem = ShellListPage.CreateExeItem(exe, args, fullExePath, addToHistory, telemetryService);
|
||||
li.Command = exeItem.Command;
|
||||
li.Title = exeItem.Title;
|
||||
li.Subtitle = exeItem.Subtitle;
|
||||
@@ -109,7 +72,7 @@ public class ShellListPageHelpers
|
||||
}
|
||||
else if (pathIsDir)
|
||||
{
|
||||
var pathItem = new PathListItem(exe, query, addToHistory);
|
||||
var pathItem = new PathListItem(exe, query, addToHistory, telemetryService);
|
||||
li.Command = pathItem.Command;
|
||||
li.Title = pathItem.Title;
|
||||
li.Subtitle = pathItem.Subtitle;
|
||||
@@ -118,7 +81,7 @@ public class ShellListPageHelpers
|
||||
}
|
||||
else if (System.Uri.TryCreate(searchText, UriKind.Absolute, out var uri))
|
||||
{
|
||||
li.Command = new OpenUrlWithHistoryCommand(searchText) { Result = CommandResult.Dismiss() };
|
||||
li.Command = new OpenUrlWithHistoryCommand(searchText, addToHistory, telemetryService) { Result = CommandResult.Dismiss() };
|
||||
li.Title = searchText;
|
||||
}
|
||||
else
|
||||
@@ -157,7 +120,98 @@ public class ShellListPageHelpers
|
||||
executable = segments[0];
|
||||
if (segments.Length > 1)
|
||||
{
|
||||
arguments = string.Join(' ', segments[1..]);
|
||||
arguments = ArgumentBuilder.BuildArguments(segments[1..]);
|
||||
}
|
||||
}
|
||||
|
||||
private static class ArgumentBuilder
|
||||
{
|
||||
internal static string BuildArguments(string[] arguments)
|
||||
{
|
||||
if (arguments.Length <= 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var stringBuilder = new StringBuilder();
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
AppendArgument(stringBuilder, argument);
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendArgument(StringBuilder stringBuilder, string argument)
|
||||
{
|
||||
if (stringBuilder.Length > 0)
|
||||
{
|
||||
stringBuilder.Append(' ');
|
||||
}
|
||||
|
||||
if (argument.Length == 0 || ShouldBeQuoted(argument))
|
||||
{
|
||||
stringBuilder.Append('\"');
|
||||
var index = 0;
|
||||
while (index < argument.Length)
|
||||
{
|
||||
var c = argument[index++];
|
||||
if (c == '\\')
|
||||
{
|
||||
var numBackSlash = 1;
|
||||
while (index < argument.Length && argument[index] == '\\')
|
||||
{
|
||||
index++;
|
||||
numBackSlash++;
|
||||
}
|
||||
|
||||
if (index == argument.Length)
|
||||
{
|
||||
stringBuilder.Append('\\', numBackSlash * 2);
|
||||
}
|
||||
else if (argument[index] == '\"')
|
||||
{
|
||||
stringBuilder.Append('\\', (numBackSlash * 2) + 1);
|
||||
stringBuilder.Append('\"');
|
||||
index++;
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append('\\', numBackSlash);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\"')
|
||||
{
|
||||
stringBuilder.Append('\\');
|
||||
stringBuilder.Append('\"');
|
||||
continue;
|
||||
}
|
||||
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
|
||||
stringBuilder.Append('\"');
|
||||
}
|
||||
else
|
||||
{
|
||||
stringBuilder.Append(argument);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldBeQuoted(string s)
|
||||
{
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (char.IsWhiteSpace(c) || c == '\"')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="..\..\..\..\Common.Dotnet.AotCompatibility.props" />
|
||||
|
||||
<Import Project="..\Common.ExtDependencies.props" />
|
||||
<Import Project="..\..\CoreCommonProps.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Microsoft.CmdPal.Ext.Shell</RootNamespace>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
@@ -16,7 +12,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
|
||||
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://aka.ms/CsWin32.schema.json",
|
||||
"allowMarshaling": false,
|
||||
"comInterop": {
|
||||
"preserveSigMethods": [ "*" ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
GetCurrentPackageFullName
|
||||
SetWindowLong
|
||||
GetWindowLong
|
||||
WINDOW_EX_STYLE
|
||||
SFBS_FLAGS
|
||||
MAX_PATH
|
||||
GetDpiForWindow
|
||||
GetWindowRect
|
||||
GetMonitorInfo
|
||||
SetWindowPos
|
||||
MonitorFromWindow
|
||||
|
||||
SHOW_WINDOW_CMD
|
||||
ShellExecuteEx
|
||||
SEE_MASK_INVOKEIDLIST
|
||||
|
||||
ExpandEnvironmentStringsW
|
||||
CommandLineToArgvW
|
||||
SearchPathW
|
||||
GetFileAttributesW
|
||||
LocalFree
|
||||
FILE_FLAGS_AND_ATTRIBUTES
|
||||
@@ -2,9 +2,8 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.CmdPal.Core.Common.Commands;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.System;
|
||||
@@ -13,13 +12,18 @@ namespace Microsoft.CmdPal.Ext.Shell;
|
||||
|
||||
internal sealed partial class PathListItem : ListItem
|
||||
{
|
||||
private readonly Lazy<IconInfo> _icon;
|
||||
private readonly bool _isDirectory;
|
||||
private readonly Lazy<bool> fetchedIcon;
|
||||
private readonly bool isDirectory;
|
||||
private readonly string path;
|
||||
|
||||
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
|
||||
public override IIconInfo? Icon { get => fetchedIcon.Value ? _icon : _icon; set => base.Icon = value; }
|
||||
|
||||
public PathListItem(string path, string originalDir, Action<string>? addToHistory)
|
||||
: base(new OpenUrlWithHistoryCommand(path, addToHistory))
|
||||
private IIconInfo? _icon;
|
||||
|
||||
internal bool IsDirectory => isDirectory;
|
||||
|
||||
public PathListItem(string path, string originalDir, Action<string>? addToHistory, ITelemetryService? telemetryService = null)
|
||||
: base(new OpenUrlWithHistoryCommand(path, addToHistory, telemetryService))
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
@@ -27,8 +31,8 @@ internal sealed partial class PathListItem : ListItem
|
||||
fileName = Path.GetFileName(Path.GetDirectoryName(path)) ?? string.Empty;
|
||||
}
|
||||
|
||||
_isDirectory = Directory.Exists(path);
|
||||
if (_isDirectory)
|
||||
isDirectory = Directory.Exists(path);
|
||||
if (isDirectory)
|
||||
{
|
||||
if (!path.EndsWith('\\'))
|
||||
{
|
||||
@@ -41,6 +45,8 @@ internal sealed partial class PathListItem : ListItem
|
||||
}
|
||||
}
|
||||
|
||||
this.path = path;
|
||||
|
||||
Title = fileName; // Just the name of the file is the Title
|
||||
Subtitle = path; // What the user typed is the subtitle
|
||||
|
||||
@@ -58,23 +64,35 @@ internal sealed partial class PathListItem : ListItem
|
||||
// wrap it in quotes
|
||||
suggestion = string.Concat("\"", suggestion, "\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
suggestion = path;
|
||||
}
|
||||
|
||||
TextToSuggest = suggestion;
|
||||
|
||||
MoreCommands = [
|
||||
new CommandContextItem(new OpenWithCommand(path)),
|
||||
new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E) },
|
||||
new CommandContextItem(new CopyPathCommand(path) { Name = Properties.Resources.copy_path_command_name }) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) },
|
||||
new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C) },
|
||||
new CommandContextItem(new OpenInConsoleCommand(path)) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R) },
|
||||
new CommandContextItem(new OpenPropertiesCommand(path)),
|
||||
];
|
||||
|
||||
_icon = new Lazy<IconInfo>(() =>
|
||||
fetchedIcon = new Lazy<bool>(() =>
|
||||
{
|
||||
var iconStream = ThumbnailHelper.GetThumbnail(path).Result;
|
||||
var icon = iconStream is not null ? IconInfo.FromStream(iconStream) :
|
||||
_isDirectory ? Icons.FolderIcon : Icons.RunV2Icon;
|
||||
return icon;
|
||||
_ = Task.Run(FetchIconAsync);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task FetchIconAsync()
|
||||
{
|
||||
var iconStream = await ThumbnailHelper.GetThumbnail(path);
|
||||
var icon = iconStream != null ?
|
||||
IconInfo.FromStream(iconStream) :
|
||||
isDirectory ? Icons.FolderIcon : Icons.RunV2Icon;
|
||||
_icon = icon;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Storage.Streams;
|
||||
@@ -15,6 +15,7 @@ internal sealed partial class RunExeItem : ListItem
|
||||
{
|
||||
private readonly Lazy<IconInfo> _icon;
|
||||
private readonly Action<string>? _addToHistory;
|
||||
private readonly ITelemetryService? _telemetryService;
|
||||
|
||||
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
|
||||
|
||||
@@ -26,13 +27,18 @@ internal sealed partial class RunExeItem : ListItem
|
||||
|
||||
private string FullString => string.IsNullOrEmpty(_args) ? Exe : $"{Exe} {_args}";
|
||||
|
||||
public RunExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
|
||||
public RunExeItem(
|
||||
string exe,
|
||||
string args,
|
||||
string fullExePath,
|
||||
Action<string>? addToHistory,
|
||||
ITelemetryService? telemetryService = null)
|
||||
{
|
||||
FullExePath = fullExePath;
|
||||
Exe = exe;
|
||||
var command = new AnonymousCommand(Run)
|
||||
{
|
||||
Name = Properties.Resources.generic_run_command,
|
||||
Name = ResourceLoaderInstance.GetString("generic_run_command"),
|
||||
Result = CommandResult.Dismiss(),
|
||||
};
|
||||
Command = command;
|
||||
@@ -46,6 +52,7 @@ internal sealed partial class RunExeItem : ListItem
|
||||
});
|
||||
|
||||
_addToHistory = addToHistory;
|
||||
_telemetryService = telemetryService;
|
||||
|
||||
UpdateArgs(args);
|
||||
|
||||
@@ -53,13 +60,13 @@ internal sealed partial class RunExeItem : ListItem
|
||||
new CommandContextItem(
|
||||
new AnonymousCommand(RunAsAdmin)
|
||||
{
|
||||
Name = Properties.Resources.cmd_run_as_administrator,
|
||||
Name = ResourceLoaderInstance.GetString("cmd_run_as_administrator"),
|
||||
Icon = Icons.AdminIcon,
|
||||
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter) },
|
||||
new CommandContextItem(
|
||||
new AnonymousCommand(RunAsOther)
|
||||
{
|
||||
Name = Properties.Resources.cmd_run_as_user,
|
||||
Name = ResourceLoaderInstance.GetString("cmd_run_as_user"),
|
||||
Icon = Icons.UserIcon,
|
||||
}) { RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U) },
|
||||
];
|
||||
@@ -97,20 +104,26 @@ internal sealed partial class RunExeItem : ListItem
|
||||
{
|
||||
_addToHistory?.Invoke(FullString);
|
||||
|
||||
ShellHelpers.OpenInShell(FullExePath, _args);
|
||||
var success = ShellHelpers.OpenInShell(FullExePath, _args);
|
||||
|
||||
_telemetryService?.LogRunCommand(FullString, false, success);
|
||||
}
|
||||
|
||||
public void RunAsAdmin()
|
||||
{
|
||||
_addToHistory?.Invoke(FullString);
|
||||
|
||||
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator);
|
||||
var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.Administrator);
|
||||
|
||||
_telemetryService?.LogRunCommand(FullString, true, success);
|
||||
}
|
||||
|
||||
public void RunAsOther()
|
||||
{
|
||||
_addToHistory?.Invoke(FullString);
|
||||
|
||||
ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser);
|
||||
var success = ShellHelpers.OpenInShell(FullExePath, _args, runAs: ShellHelpers.ShellRunAsType.OtherUser);
|
||||
|
||||
_telemetryService?.LogRunCommand(FullString, false, success);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Ext.Shell.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Shell.Properties;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
@@ -18,13 +11,13 @@ namespace Microsoft.CmdPal.Ext.Shell.Pages;
|
||||
|
||||
internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
{
|
||||
private readonly ShellListPageHelpers _helper;
|
||||
|
||||
private readonly List<ListItem> _topLevelItems = [];
|
||||
private readonly Dictionary<string, ListItem> _historyItems = [];
|
||||
private readonly List<ListItem> _currentHistoryItems = [];
|
||||
|
||||
private readonly IRunHistoryService _historyService;
|
||||
private readonly ITelemetryService? _telemetryService;
|
||||
|
||||
private readonly Dictionary<string, ListItem> _currentPathItems = new();
|
||||
|
||||
private ListItem? _exeItem;
|
||||
private List<ListItem> _pathItems = [];
|
||||
@@ -35,27 +28,26 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
|
||||
private bool _loadedInitialHistory;
|
||||
|
||||
public ShellListPage(ISettingsInterface settingsManager, IRunHistoryService runHistoryService, bool addBuiltins = false)
|
||||
private string _currentSubdir = string.Empty;
|
||||
|
||||
public ShellListPage(
|
||||
ISettingsInterface settingsManager,
|
||||
IRunHistoryService runHistoryService,
|
||||
ITelemetryService? telemetryService)
|
||||
{
|
||||
Icon = Icons.RunV2Icon;
|
||||
Id = "com.microsoft.cmdpal.shell";
|
||||
Name = Resources.cmd_plugin_name;
|
||||
PlaceholderText = Resources.list_placeholder_text;
|
||||
_helper = new(settingsManager);
|
||||
Name = ResourceLoaderInstance.GetString("cmd_plugin_name");
|
||||
PlaceholderText = ResourceLoaderInstance.GetString("list_placeholder_text");
|
||||
_historyService = runHistoryService;
|
||||
_telemetryService = telemetryService;
|
||||
|
||||
EmptyContent = new CommandItem()
|
||||
{
|
||||
Title = Resources.cmd_plugin_name,
|
||||
Title = ResourceLoaderInstance.GetString("cmd_plugin_name"),
|
||||
Icon = Icons.RunV2Icon,
|
||||
Subtitle = Resources.list_placeholder_text,
|
||||
Subtitle = ResourceLoaderInstance.GetString("list_placeholder_text"),
|
||||
};
|
||||
|
||||
if (addBuiltins)
|
||||
{
|
||||
// here, we _could_ add built-in providers if we wanted. links to apps, calc, etc.
|
||||
// That would be a truly run-first experience
|
||||
}
|
||||
}
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
@@ -123,8 +115,13 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
|
||||
private async Task BuildListItemsForSearchAsync(string newSearch, CancellationToken cancellationToken)
|
||||
{
|
||||
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
// Check for cancellation at the start
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If the search text is the start of a path to a file (it might be a
|
||||
// UNC path), then we want to list all the files that start with that text:
|
||||
@@ -136,7 +133,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
var expanded = Environment.ExpandEnvironmentVariables(searchText);
|
||||
|
||||
// Check for cancellation after environment expansion
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO we can be smarter about only re-reading the filesystem if the
|
||||
// new search is just the oldSearch+some chars
|
||||
@@ -206,7 +206,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
couldResolvePath = false;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pathItems.Clear();
|
||||
|
||||
@@ -221,7 +224,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
}
|
||||
|
||||
// Check for cancellation before creating exe items
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (couldResolvePath && exeExists)
|
||||
{
|
||||
@@ -278,17 +284,31 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
_currentHistoryItems.AddRange(filteredHistory);
|
||||
|
||||
// Final cancellation check
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
timer.Stop();
|
||||
_telemetryService?.LogRunQuery(newSearch, GetItems().Length, (ulong)timer.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? addToHistory = null)
|
||||
private static ListItem PathToListItem(string path, string originalPath, string args = "", Action<string>? addToHistory = null, ITelemetryService? telemetryService = null)
|
||||
{
|
||||
var pathItem = new PathListItem(path, originalPath, addToHistory);
|
||||
var pathItem = new PathListItem(path, originalPath, addToHistory, telemetryService);
|
||||
|
||||
if (pathItem.IsDirectory)
|
||||
{
|
||||
return pathItem;
|
||||
}
|
||||
|
||||
// Is this path an executable? If so, then make a RunExeItem
|
||||
if (IsExecutable(path))
|
||||
{
|
||||
var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory);
|
||||
var exeItem = new RunExeItem(Path.GetFileName(path), args, path, addToHistory, telemetryService)
|
||||
{
|
||||
TextToSuggest = path,
|
||||
};
|
||||
|
||||
exeItem.MoreCommands = [
|
||||
.. exeItem.MoreCommands,
|
||||
@@ -306,24 +326,22 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
LoadInitialHistory();
|
||||
}
|
||||
|
||||
var filteredTopLevel = ListHelpers.FilterList(_topLevelItems, SearchText);
|
||||
List<ListItem> uriItems = _uriItem is not null ? [_uriItem] : [];
|
||||
List<ListItem> exeItems = _exeItem is not null ? [_exeItem] : [];
|
||||
|
||||
return
|
||||
exeItems
|
||||
.Concat(filteredTopLevel)
|
||||
.Concat(_currentHistoryItems)
|
||||
.Concat(_pathItems)
|
||||
.Concat(uriItems)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory)
|
||||
internal static ListItem CreateExeItem(string exe, string args, string fullExePath, Action<string>? addToHistory, ITelemetryService? telemetryService)
|
||||
{
|
||||
// PathToListItem will return a RunExeItem if it can find a executable.
|
||||
// It will ALSO add the file search commands to the RunExeItem.
|
||||
return PathToListItem(fullExePath, exe, args, addToHistory);
|
||||
return PathToListItem(fullExePath, exe, args, addToHistory, telemetryService);
|
||||
}
|
||||
|
||||
private void CreateAndAddExeItems(string exe, string args, string fullExePath)
|
||||
@@ -335,7 +353,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
_exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory);
|
||||
_exeItem = CreateExeItem(exe, args, fullExePath, AddToHistory, _telemetryService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +407,10 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
}
|
||||
|
||||
// Check for cancellation before directory operations
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dirExists = Directory.Exists(directoryPath);
|
||||
|
||||
@@ -408,30 +429,71 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
if (dirExists)
|
||||
{
|
||||
// Check for cancellation before file system enumeration
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (directoryPath == _currentSubdir)
|
||||
{
|
||||
// Filter the items we already had
|
||||
var fuzzyString = searchPattern.TrimEnd('*');
|
||||
var newMatchedPathItems = new List<ListItem>();
|
||||
|
||||
foreach (var kv in _currentPathItems)
|
||||
{
|
||||
var score = string.IsNullOrEmpty(fuzzyString) ?
|
||||
1 :
|
||||
FuzzyStringMatcher.ScoreFuzzy(fuzzyString, kv.Key);
|
||||
if (score > 0)
|
||||
{
|
||||
newMatchedPathItems.Add(kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
ListHelpers.InPlaceUpdateList(_pathItems, newMatchedPathItems);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all the files in the directory that start with the search text
|
||||
// Run this on a background thread to avoid blocking
|
||||
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath, searchPattern), cancellationToken);
|
||||
|
||||
// Check for cancellation after file enumeration
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length));
|
||||
var originalBeginning = originalPath.Remove(originalPath.Length - searchPathTrailer.Length);
|
||||
var originalBeginning = originalPath.EndsWith(searchPathTrailer, StringComparison.CurrentCultureIgnoreCase) ?
|
||||
originalPath.Remove(originalPath.Length - searchPathTrailer.Length) :
|
||||
originalPath;
|
||||
|
||||
if (isDriveRoot)
|
||||
{
|
||||
originalBeginning = string.Concat(originalBeginning, '\\');
|
||||
}
|
||||
|
||||
// Create a list of commands for each file
|
||||
var commands = files.Select(f => PathToListItem(f, originalBeginning)).ToList();
|
||||
var newPathItems = files
|
||||
.Select(f => PathToListItem(f, originalBeginning))
|
||||
.ToDictionary(item => item.Title, item => item);
|
||||
|
||||
// Final cancellation check before updating results
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the commands to the list
|
||||
_pathItems = commands;
|
||||
_pathItems = newPathItems.Values.ToList();
|
||||
_currentSubdir = directoryPath;
|
||||
_currentPathItems.Clear();
|
||||
foreach ((var k, IListItem v) in newPathItems)
|
||||
{
|
||||
_currentPathItems[k] = (ListItem)v;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -458,7 +520,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
{
|
||||
var hist = _historyService.GetRunHistory();
|
||||
var histItems = hist
|
||||
.Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory)))
|
||||
.Select(h => (h, ShellListPageHelpers.ListItemForCommandString(h, AddToHistory, _telemetryService)))
|
||||
.Where(tuple => tuple.Item2 is not null)
|
||||
.Select(tuple => (tuple.h, tuple.Item2!))
|
||||
.ToList();
|
||||
|
||||
@@ -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 Microsoft.CmdPal.Ext.Shell;
|
||||
|
||||
internal static class ResourceLoaderInstance
|
||||
{
|
||||
public static string GetString(string resourceKey)
|
||||
{
|
||||
return Properties.Resources.ResourceManager.GetString(resourceKey, Properties.Resources.Culture) ?? throw new InvalidOperationException($"Resource key '{resourceKey}' not found.");
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,21 @@ public partial class ShellCommandsProvider : CommandProvider
|
||||
private readonly ShellListPage _shellListPage;
|
||||
private readonly FallbackCommandItem _fallbackItem;
|
||||
private readonly IRunHistoryService _historyService;
|
||||
private readonly ITelemetryService _telemetryService;
|
||||
|
||||
public ShellCommandsProvider(IRunHistoryService runHistoryService)
|
||||
public ShellCommandsProvider(IRunHistoryService runHistoryService, ITelemetryService telemetryService)
|
||||
{
|
||||
_historyService = runHistoryService;
|
||||
_telemetryService = telemetryService;
|
||||
|
||||
Id = "com.microsoft.cmdpal.builtin.run";
|
||||
DisplayName = Resources.cmd_plugin_name;
|
||||
Icon = Icons.RunV2Icon;
|
||||
Settings = _settingsManager.Settings;
|
||||
|
||||
_shellListPage = new ShellListPage(_settingsManager, _historyService);
|
||||
_shellListPage = new ShellListPage(_settingsManager, _historyService, _telemetryService);
|
||||
|
||||
_fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory);
|
||||
_fallbackItem = new FallbackExecuteItem(_settingsManager, _shellListPage.AddToHistory, _telemetryService);
|
||||
|
||||
_shellPageItem = new CommandItem(_shellListPage)
|
||||
{
|
||||
|
||||
@@ -159,15 +159,6 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Open.
|
||||
/// </summary>
|
||||
internal static string Page_Name {
|
||||
get {
|
||||
return ResourceManager.GetString("Page_Name", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Settings.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Activation
|
||||
{
|
||||
// For more information on understanding and extending activation flow see
|
||||
// https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md
|
||||
internal abstract class ActivationHandler
|
||||
{
|
||||
public abstract bool CanHandle(object args);
|
||||
|
||||
public abstract Task HandleAsync(object args);
|
||||
}
|
||||
|
||||
[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "abstract T and abstract")]
|
||||
internal abstract class ActivationHandler<T> : ActivationHandler
|
||||
where T : class
|
||||
{
|
||||
public override async Task HandleAsync(object args)
|
||||
{
|
||||
await HandleInternalAsync(args as T).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public override bool CanHandle(object args)
|
||||
{
|
||||
// CanHandle checks the args is of type you have configured
|
||||
return args is T && CanHandleInternal(args as T);
|
||||
}
|
||||
|
||||
// Override this method to add the activation logic in your activation handler
|
||||
protected abstract Task HandleInternalAsync(T args);
|
||||
|
||||
// You can override this method to add extra validation on activation args
|
||||
// to determine if your ActivationHandler should handle this activation args
|
||||
protected virtual bool CanHandleInternal(T args)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Services;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Activation
|
||||
{
|
||||
internal sealed class DefaultActivationHandler : ActivationHandler<IActivatedEventArgs>
|
||||
{
|
||||
private readonly Type navElement;
|
||||
|
||||
public DefaultActivationHandler(Type navElement)
|
||||
{
|
||||
this.navElement = navElement;
|
||||
}
|
||||
|
||||
protected override async Task HandleInternalAsync(IActivatedEventArgs args)
|
||||
{
|
||||
// When the navigation stack isn't restored, navigate to the first page and configure
|
||||
// the new page by passing required information in the navigation parameter
|
||||
object arguments = null;
|
||||
if (args is LaunchActivatedEventArgs launchArgs)
|
||||
{
|
||||
arguments = launchArgs.Arguments;
|
||||
}
|
||||
|
||||
NavigationService.Navigate(navElement, arguments);
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
protected override bool CanHandleInternal(IActivatedEventArgs args)
|
||||
{
|
||||
// None of the ActivationHandlers has handled the app activation
|
||||
return NavigationService.Frame.Content == null && navElement != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Activation;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
// For more information on understanding and extending activation flow see
|
||||
// https://github.com/Microsoft/WindowsTemplateStudio/blob/master/docs/activation.md
|
||||
internal sealed class ActivationService
|
||||
{
|
||||
private readonly App app;
|
||||
private readonly Type defaultNavItem;
|
||||
private Lazy<UIElement> shell;
|
||||
|
||||
private object lastActivationArgs;
|
||||
|
||||
public ActivationService(App app, Type defaultNavItem, Lazy<UIElement> shell = null)
|
||||
{
|
||||
this.app = app;
|
||||
this.shell = shell;
|
||||
this.defaultNavItem = defaultNavItem;
|
||||
}
|
||||
|
||||
public async Task ActivateAsync(object activationArgs)
|
||||
{
|
||||
if (IsInteractive(activationArgs))
|
||||
{
|
||||
// Initialize services that you need before app activation
|
||||
// take into account that the splash screen is shown while this code runs.
|
||||
await InitializeAsync().ConfigureAwait(false);
|
||||
|
||||
// Do not repeat app initialization when the Window already has content,
|
||||
// just ensure that the window is active
|
||||
if (Window.Current.Content == null)
|
||||
{
|
||||
// Create a Shell or Frame to act as the navigation context
|
||||
Window.Current.Content = shell?.Value ?? new Frame();
|
||||
}
|
||||
}
|
||||
|
||||
// Depending on activationArgs one of ActivationHandlers or DefaultActivationHandler
|
||||
// will navigate to the first page
|
||||
await HandleActivationAsync(activationArgs).ConfigureAwait(false);
|
||||
lastActivationArgs = activationArgs;
|
||||
|
||||
if (IsInteractive(activationArgs))
|
||||
{
|
||||
// Ensure the current window is active
|
||||
Window.Current.Activate();
|
||||
|
||||
// Tasks after activation
|
||||
await StartupAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InitializeAsync()
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleActivationAsync(object activationArgs)
|
||||
{
|
||||
var activationHandler = GetActivationHandlers()
|
||||
.FirstOrDefault(h => h.CanHandle(activationArgs));
|
||||
|
||||
if (activationHandler != null)
|
||||
{
|
||||
await activationHandler.HandleAsync(activationArgs).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (IsInteractive(activationArgs))
|
||||
{
|
||||
var defaultHandler = new DefaultActivationHandler(defaultNavItem);
|
||||
if (defaultHandler.CanHandle(activationArgs))
|
||||
{
|
||||
await defaultHandler.HandleAsync(activationArgs).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task StartupAsync()
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IEnumerable<ActivationHandler> GetActivationHandlers()
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
private static bool IsInteractive(object args)
|
||||
{
|
||||
return args is IActivatedEventArgs;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,6 @@ namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
get
|
||||
{
|
||||
if (frame == null)
|
||||
{
|
||||
frame = Window.Current.Content as Frame;
|
||||
RegisterFrameEvents();
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
## Background
|
||||
This document describes how to collect pull requests for a milestone, request a GitHub Copilot code review for each, and produce release‑notes summaries grouped by label.
|
||||
|
||||
## Agent‑mode execution policy (important)
|
||||
- By default, do NOT run terminal commands or PowerShell scripts beside the ps1 in this folder. Perform all collection, parsing, grouping, and summarization entirely in Agent mode using available files and MCP capabilities.
|
||||
- Only execute existing scripts if the user explicitly asks you to (opt‑in). Otherwise, assume the input artifacts (milestone_prs.json, sorted_prs.csv, grouped_csv/*) are present or will be provided.
|
||||
- Do NOT create new scripts unless requested and justified.
|
||||
|
||||
## Prerequisites
|
||||
- Windows with PowerShell 7+ (pwsh)
|
||||
- GitHub CLI installed and authenticated to the target repo
|
||||
- gh version that supports Copilot review requests
|
||||
- Logged in: gh auth login (ensure repo scope)
|
||||
- Access to the repository configured in the scripts (default: `microsoft/PowerToys`)
|
||||
- GitHub Copilot code review enabled for the org/repo (required for requesting reviews)
|
||||
- 'MCP Server: github-remote' is installed, please find it at [github-mcp-server](https://github.com/github/github-mcp-server)
|
||||
|
||||
## Files in this repo (overview)
|
||||
- `dump-prs-information.ps1`: Fetches PRs for a milestone and outputs `milestone_prs.json` and `sorted_prs.csv`
|
||||
- CSV columns: `Id, Title, Labels, Author, Url, Body, CopilotSummary`
|
||||
- `diff_prs.ps1`: Creates an incremental CSV by diffing two CSVs (in case more PRs cherry pick to stable)
|
||||
- `MemberList.md`: Internal contributors list (used to decide when to add external thanks)
|
||||
- `SampleOutput.md`: Example formatting for summary content
|
||||
|
||||
## Step-by-step
|
||||
1) run `dump-prs-information.ps1` to export PRs for the target milestone (initial run, CopilotSummary likely empty)
|
||||
- Open `dump-prs-information.ps1` and set:
|
||||
- `$repo` (e.g., `microsoft/PowerToys`)
|
||||
- `$milestone` (milestone title exactly as in GitHub, e.g., `PowerToys 0.95`)
|
||||
- run the script in PowerShell; it will generate `milestone_prs.json` and `sorted_prs.csv`.
|
||||
|
||||
2) Request Copilot reviews for each PR listed in the CSV in Agent mode (MUST NOT generate or run any ps1)
|
||||
- Must use MCP tools "MCP Server: github-remote" in current Agent mode to request Copilot reviews for all PR Ids in `sorted_prs.csv`.
|
||||
|
||||
3) run `dump-prs-information.ps1` again
|
||||
- This refresh collects the latest Copilot review body into the `CopilotSummary` column in `sorted_prs.csv`.
|
||||
|
||||
4) run `group-prs-by-label.ps1` to generate `grouped_csv/`
|
||||
|
||||
5) Summarize PRs into per‑label Markdown files in Agent mode (MUST NOT generate or run any script in terminal nor ps1)
|
||||
- Read the the csv files in the folder grouped_csv one by one
|
||||
- For each label group, create a markdown file under a new folder `grouped_md/` (create if missing). File name: sanitized label group name (same pattern as CSV) with `.md` extension. Example: `Area-Build.md`.
|
||||
- Each markdown file content must follow the structure below (two sections) and preserve the PR order from the source CSV.
|
||||
- Do not embed PR numbers in the bullet list lines; only link them in the table.
|
||||
- If re-running, overwrite existing markdown files (idempotent generation).
|
||||
- After generation, you should have a 1:1 correspondence between files in `grouped_csv/` and `grouped_md/` (excluding any intentionally skipped groups—document if skipped).
|
||||
- Generate the summary md file as the following instruction in two parts:
|
||||
1. Markdown list: one concise, user‑facing line per PR (no deep technical jargon). Use "Verbed" + "Scenario" + "Impact" as setence structure. Use `Title`, `Body`, and `CopilotSummary` as sources.
|
||||
- If `Author` is NOT in `**/MemberList.md`, append a "Thanks @handle!" see `**/SampleOutput.md` as example.
|
||||
- Do NOT include PR numbers or IDs in the list line; keep the PR link only in the table mentioned in 2. below, please refer to `**/SampleOutput.md` as example.
|
||||
- If confidence to have enough information for summarization according to guideline above is < 70%, write: `Human Summary Needed: <PR full link>` on that line.
|
||||
2. Three‑column table (in the same PR order):
|
||||
- Column 1: The concise, user‑facing summary (the "cut version")
|
||||
- Column 2: PR link
|
||||
- Column 3: Confidence (e.g., `High/Medium/Low`) and the reasoning if < 70%
|
||||
6) According the generated grouped_md/*.md, update back the repo root's `Readme.md`. Here is the guideline:
|
||||
a. Replace all versioned references in `README.md`:
|
||||
- Bump current release heading (e.g. **Version 0.xx**) by +0.01.
|
||||
- Shift link references: previous `[github-current-release-work]` becomes old version; increment `[github-next-release-work]` to point to the following milestone.
|
||||
- Update download asset filenames (e.g. `PowerToysSetup-0.94.0-...` → `PowerToysSetup-0.95.0-...`).
|
||||
b. Build the What's New content from `grouped_md`:
|
||||
- Combine `Area-Build` and `Area-Tests` entries under a single `Development` subsection (keep bullet order from CSV).
|
||||
- Each other `Product-*` group gets its own subsection titled by the module name.
|
||||
- Order subsections alphabetically by their heading text, with **Highlights** always first and **Development** always last (e.g., Environment Variables, File Locksmith, Find My Mouse, ... , ZoomIt, Development).
|
||||
- Copy bullet lines verbatim from the corresponding `grouped_md` files (preserve punctuation and any trailing `Thanks @handle!`). Do NOT add, remove, or re‑evaluate thanks in the README stage.
|
||||
c. Highlights: choose up to 10 bullets focused on user-visible feature additions or impactful fixes (avoid purely internal refactors). Use pattern: `Module/Feature <past-tense verb> <scenario> <impact>`.
|
||||
d. Keep wording concise (aim 1 line per bullet), no PR numbers, no deep implementation details.
|
||||
e. After updating, verify total highlight count ≤ 10 and that all internal contributors are not thanked.
|
||||
|
||||
## Notes and conventions
|
||||
- Terminal usage: Disabled by default. Do NOT run terminal commands or ps1 scripts unless the user explicitly instructs you to.
|
||||
- Do NOT generate/add new ps1 until instructed (and explain why a new script is needed).
|
||||
- Label filtering in `dump-prs-information.ps1` currently keeps labels matching: `Product-*`, `Area-*`, `Github*`, `*Plugin`, `Issue-*`.
|
||||
- CSV columns are single‑line (line breaks removed) for easier processing.
|
||||
- Keep PRs in the same order as in `sorted_prs.csv` when building summaries.
|
||||
- Sanitize filenames: replace spaces with `-`, strip or replace characters that are invalid on Windows (`<>:"/\\|?*`).
|
||||
@@ -1,26 +0,0 @@
|
||||
cinnamon-msft
|
||||
craigloewen-msft
|
||||
niels9001
|
||||
dhowett
|
||||
yeelam-gordon
|
||||
jamrobot
|
||||
lei9444
|
||||
shuaiyuanxx
|
||||
moooyo
|
||||
haoliuu
|
||||
chenmy77
|
||||
chemwolf6922
|
||||
yaqingmi
|
||||
zhaoqpcn
|
||||
urnotdfs
|
||||
zhaopy536
|
||||
wang563681252
|
||||
vanzue
|
||||
zadjii-msft
|
||||
khmyznikov
|
||||
chatasweetie
|
||||
michaeljolley
|
||||
Jaylyn-Barbee
|
||||
zateutsch
|
||||
crutkas
|
||||
app/copilot-swe-agent
|
||||
@@ -1,9 +0,0 @@
|
||||
- Added mouse button actions so you can choose what left, right, or middle click does. Thanks [@PesBandi](https://github.com/PesBandi)!
|
||||
|
||||
- Aligned window styling with current Windows theme for a cleaner look. Thanks [@sadirano](https://github.com/sadirano)!
|
||||
|
||||
- Ensured screen readers are notified when the selected item in the list changes for better accessibility.
|
||||
|
||||
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours.
|
||||
|
||||
- Fixed Alt+Left Arrow navigation not working when search box contains text. Thanks [@jiripolasek](https://github.com/jiripolasek)!
|
||||
@@ -1,100 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Produce an incremental PR CSV containing rows present in a newer full export but absent from a baseline export.
|
||||
|
||||
.DESCRIPTION
|
||||
Compares two previously generated sorted PR CSV files (same schema). Any row whose key column value
|
||||
(defaults to 'Number') does not exist in the baseline file is emitted to a new incremental CSV, preserving
|
||||
the original column order. If no new rows are found, an empty CSV (with headers when determinable) is written.
|
||||
|
||||
.PARAMETER BaseCsv
|
||||
Path to the baseline (earlier) PR CSV.
|
||||
|
||||
.PARAMETER AllCsv
|
||||
Path to the newer full PR CSV containing superset (or equal set) of rows.
|
||||
|
||||
.PARAMETER OutCsv
|
||||
Path to write the incremental CSV containing only new rows.
|
||||
|
||||
.PARAMETER Key
|
||||
Column name used as unique identifier (defaults to 'Number'). Must exist in both CSVs.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./diff_prs.ps1 -BaseCsv sorted_prs_prev.csv -AllCsv sorted_prs.csv -OutCsv sorted_prs_incremental.csv
|
||||
|
||||
.NOTES
|
||||
Requires: PowerShell 7+, both CSVs with identical column schemas.
|
||||
Exit code 0 on success (even if zero incremental rows). Throws on missing files.
|
||||
#>
|
||||
|
||||
[CmdletBinding()] param(
|
||||
[Parameter(Mandatory=$false)][string]$BaseCsv = "./sorted_prs_93_round1.csv",
|
||||
[Parameter(Mandatory=$false)][string]$AllCsv = "./sorted_prs.csv",
|
||||
[Parameter(Mandatory=$false)][string]$OutCsv = "./sorted_prs_93_incremental.csv",
|
||||
[Parameter(Mandatory=$false)][string]$Key = "Number"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info($m) { Write-Host "[info] $m" -ForegroundColor Cyan }
|
||||
function Write-Warn($m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BaseCsv)) { throw "Base CSV not found: $BaseCsv" }
|
||||
if (-not (Test-Path -LiteralPath $AllCsv)) { throw "All CSV not found: $AllCsv" }
|
||||
|
||||
# Load CSVs
|
||||
$baseRows = Import-Csv -LiteralPath $BaseCsv
|
||||
$allRows = Import-Csv -LiteralPath $AllCsv
|
||||
|
||||
if (-not $baseRows) { Write-Warn "Base CSV has no rows." }
|
||||
if (-not $allRows) { Write-Warn "All CSV has no rows." }
|
||||
|
||||
# Validate key presence
|
||||
if ($baseRows -and -not ($baseRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in base CSV." }
|
||||
if ($allRows -and -not ($allRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in all CSV." }
|
||||
|
||||
# Build a set of existing keys from base
|
||||
$set = New-Object 'System.Collections.Generic.HashSet[string]'
|
||||
foreach ($row in $baseRows) {
|
||||
$val = [string]($row.$Key)
|
||||
if ($null -ne $val) { [void]$set.Add($val) }
|
||||
}
|
||||
|
||||
# Filter rows in AllCsv whose key is not in base (these are the new / incremental rows)
|
||||
$incremental = @()
|
||||
foreach ($row in $allRows) {
|
||||
$val = [string]($row.$Key)
|
||||
if (-not $set.Contains($val)) { $incremental += $row }
|
||||
}
|
||||
|
||||
# Preserve column order from the All CSV
|
||||
$columns = @()
|
||||
if ($allRows.Count -gt 0) {
|
||||
$columns = $allRows[0].PSObject.Properties.Name
|
||||
}
|
||||
|
||||
try {
|
||||
if ($incremental.Count -gt 0) {
|
||||
if ($columns.Count -gt 0) {
|
||||
$incremental | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
|
||||
} else {
|
||||
$incremental | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
|
||||
}
|
||||
} else {
|
||||
# Write an empty CSV with headers if we know them (facilitates downstream tooling expecting header row)
|
||||
if ($columns.Count -gt 0) {
|
||||
$obj = [PSCustomObject]@{}
|
||||
foreach ($c in $columns) { $obj | Add-Member -NotePropertyName $c -NotePropertyValue $null }
|
||||
$obj | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
|
||||
} else {
|
||||
'' | Out-File -LiteralPath $OutCsv -Encoding UTF8
|
||||
}
|
||||
}
|
||||
Write-Info ("Incremental rows: {0}" -f $incremental.Count)
|
||||
Write-Info ("Output: {0}" -f (Resolve-Path -LiteralPath $OutCsv))
|
||||
}
|
||||
catch {
|
||||
Write-Host "[error] Failed writing output CSV: $_" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Export merged pull requests for a milestone into JSON and CSV (sorted) with optional Copilot review summarization.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses the GitHub CLI (gh) to list merged PRs for the specified milestone, captures basic metadata,
|
||||
attempts to obtain a Copilot review summary (choosing the longest Copilot-authored review body),
|
||||
filters labels to a predefined allow-list, and outputs:
|
||||
* Raw JSON list (for traceability)
|
||||
* Sorted CSV (first label alphabetical) used by downstream grouping scripts.
|
||||
|
||||
.PARAMETER Repo
|
||||
GitHub repository in the form 'owner/name'. Default: 'microsoft/PowerToys'.
|
||||
|
||||
.PARAMETER Milestone
|
||||
Exact milestone title (as it appears on GitHub), e.g. 'PowerToys 0.95'.
|
||||
|
||||
.PARAMETER OutputJson
|
||||
Path for raw JSON output. Default: 'milestone_prs.json'.
|
||||
|
||||
.PARAMETER OutputCsv
|
||||
Path for sorted CSV output. Default: 'sorted_prs.csv'.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-information.ps1 -Milestone 'PowerToys 0.95'
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-information.ps1 -Repo microsoft/PowerToys -Milestone 'PowerToys 0.95' -OutputCsv m1.csv
|
||||
|
||||
.NOTES
|
||||
Requires: gh CLI authenticated with repo read access.
|
||||
This script intentionally does NOT use Set-StrictMode (per current repository guidance for release tooling).
|
||||
#>
|
||||
[CmdletBinding()] param(
|
||||
[Parameter(Mandatory=$false)][string]$Repo = 'microsoft/PowerToys',
|
||||
[Parameter(Mandatory=$true)][string]$Milestone,
|
||||
[Parameter(Mandatory=$false)][string]$OutputJson = 'milestone_prs.json',
|
||||
[Parameter(Mandatory=$false)][string]$OutputCsv = 'sorted_prs.csv'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info($m){ Write-Host "[info] $m" -ForegroundColor Cyan }
|
||||
function Write-Warn($m){ Write-Host "[warn] $m" -ForegroundColor Yellow }
|
||||
function Write-Err($m){ Write-Host "[error] $m" -ForegroundColor Red }
|
||||
|
||||
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Err "GitHub CLI 'gh' not found in PATH."; exit 1 }
|
||||
|
||||
Write-Info "Fetching merged PRs for milestone '$Milestone' from $Repo ..."
|
||||
$searchQuery = "milestone:`"$Milestone`""
|
||||
$ghCommand = "gh pr list --repo $Repo --state merged --search '$searchQuery' --json number,title,labels,author,url,body --limit 200"
|
||||
try {
|
||||
Invoke-Expression $ghCommand | Out-File -Encoding UTF8 -FilePath $OutputJson
|
||||
}
|
||||
catch {
|
||||
Write-Err "Failed querying PRs: $_"; exit 1
|
||||
}
|
||||
|
||||
# === STEP 1: Query PRs from GitHub ===
|
||||
if (-not (Test-Path -LiteralPath $OutputJson)) { Write-Err "JSON output not created: $OutputJson"; exit 1 }
|
||||
|
||||
Write-Info "Parsing JSON ..."
|
||||
$prs = Get-Content $OutputJson | ConvertFrom-Json
|
||||
if (-not $prs) { Write-Warn "No PRs returned for milestone '$Milestone'"; exit 0 }
|
||||
$sorted = $prs | Sort-Object { $_.labels[0]?.name }
|
||||
|
||||
Write-Info "Fetching Copilot reviews for each PR (longest Copilot-authored body)."
|
||||
$csvData = $sorted | ForEach-Object {
|
||||
$prNumber = $_.number
|
||||
Write-Info "Processing PR #$prNumber ..."
|
||||
|
||||
# Get Copilot review for this PR
|
||||
$copilotOverview = ""
|
||||
try {
|
||||
$reviewsCommand = "gh pr view $prNumber --repo $repo --json reviews"
|
||||
$reviewsJson = Invoke-Expression $reviewsCommand | ConvertFrom-Json
|
||||
|
||||
# Collect Copilot reviews (match various author logins). Choose the LONGEST body (more content) vs newest.
|
||||
$copilotReviews = $reviewsJson.reviews | Where-Object {
|
||||
($_.author.login -eq "github-copilot[bot]" -or
|
||||
$_.author.login -eq "copilot" -or
|
||||
$_.author.login -eq "github-copilot" -or
|
||||
$_.author.login -like "*copilot*") -and
|
||||
$_.body -and
|
||||
$_.body.Trim() -ne ""
|
||||
}
|
||||
if ($copilotReviews -and $copilotReviews.Count -gt 0) {
|
||||
$longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
|
||||
$copilotOverview = $longest.body.Replace("`r", "").Replace("`n", " ") -replace '\s+', ' '
|
||||
Write-Info " Copilot review selected (author=$($longest.author.login) length=$($longest.body.Length))"
|
||||
} else {
|
||||
Write-Warn " No Copilot reviews found for PR #$prNumber"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warn " Could not fetch reviews for PR #$prNumber"
|
||||
}
|
||||
|
||||
# Filter labels to only include specific patterns
|
||||
$filteredLabels = $_.labels | Where-Object {
|
||||
($_.name -like "Product-*") -or
|
||||
($_.name -like "Area-*") -or
|
||||
($_.name -like "Github*") -or
|
||||
($_.name -like "*Plugin") -or
|
||||
($_.name -like "Issue-*")
|
||||
}
|
||||
|
||||
$labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", "
|
||||
[PSCustomObject]@{
|
||||
Id = $_.number
|
||||
Title = $_.title
|
||||
Labels = $labelNames
|
||||
Author = $_.author.login
|
||||
Url = $_.url
|
||||
Body = $_.body.Replace("`r", "").Replace("`n", " ") -replace '\s+', ' ' # Make body single-line
|
||||
CopilotSummary = $copilotOverview
|
||||
}
|
||||
}
|
||||
|
||||
# === STEP 3: Output CSV ===
|
||||
Write-Info "Saving CSV to $OutputCsv ..."
|
||||
$csvData | Export-Csv $OutputCsv -NoTypeInformation -Encoding UTF8
|
||||
Write-Info "Done. Rows: $($csvData.Count). CSV: $(Resolve-Path -LiteralPath $OutputCsv)"
|
||||
@@ -1,275 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Export merged PR metadata between two commits (exclusive start, inclusive end) to JSON and CSV.
|
||||
|
||||
.DESCRIPTION
|
||||
Identifies merge/squash commits reachable from EndCommit but not StartCommit, extracts PR numbers,
|
||||
queries GitHub for metadata plus (optionally) Copilot review/comment summaries, filters labels, then
|
||||
emits a JSON artifact and a sorted CSV (first label alphabetical) analogous to dump-prs-information.ps1.
|
||||
|
||||
.PARAMETER StartCommit
|
||||
Exclusive starting commit (SHA, tag, or ref). Commits AFTER this one are considered.
|
||||
|
||||
.PARAMETER EndCommit
|
||||
Inclusive ending commit (SHA, tag, or ref). Default: HEAD.
|
||||
|
||||
.PARAMETER Repo
|
||||
GitHub repository (owner/name). Default: microsoft/PowerToys.
|
||||
|
||||
.PARAMETER OutputCsv
|
||||
Destination CSV path. Default: sorted_prs.csv.
|
||||
|
||||
.PARAMETER OutputJson
|
||||
Destination JSON path containing raw PR objects. Default: milestone_prs.json.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv delta.csv
|
||||
|
||||
.NOTES
|
||||
Requires: git, gh (authenticated). No Set-StrictMode to keep parity with existing release scripts.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$StartCommit, # exclusive start (commits AFTER this one)
|
||||
[string]$EndCommit = "HEAD",
|
||||
[string]$Repo = "microsoft/PowerToys",
|
||||
[string]$OutputCsv = "sorted_prs.csv",
|
||||
[string]$OutputJson = "milestone_prs.json"
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dump merged PR information whose merge commits are reachable from EndCommit but not from StartCommit.
|
||||
.DESCRIPTION
|
||||
Uses git rev-list to compute commits in the (StartCommit, EndCommit] range, extracts PR numbers from merge commit messages,
|
||||
queries GitHub (gh CLI) for details, then outputs a CSV similar to dump-prs-information.ps1.
|
||||
|
||||
PR merge commit messages in PowerToys generally contain patterns like:
|
||||
Merge pull request #12345 from ...
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv changes.csv
|
||||
|
||||
.NOTES
|
||||
Requires: gh CLI authenticated; git available in working directory (must be inside PowerToys repo clone).
|
||||
CopilotSummary behavior:
|
||||
- Attempts to locate the latest GitHub Copilot authored review (preferred).
|
||||
- If no review is found, lazily fetches PR comments to look for a Copilot-authored comment.
|
||||
- Normalizes whitespace and strips newlines. Empty when no Copilot activity detected.
|
||||
- Run with -Verbose to see whether the summary came from a 'review' or 'comment' source.
|
||||
#>
|
||||
|
||||
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
|
||||
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
|
||||
function Write-Err($msg) { Write-Host $msg -ForegroundColor Red }
|
||||
function Write-DebugMsg($msg) { if ($PSBoundParameters.ContainsKey('Verbose') -or $VerbosePreference -eq 'Continue') { Write-Host "[VERBOSE] $msg" -ForegroundColor DarkGray } }
|
||||
|
||||
# Validate we are in a git repo
|
||||
#if (-not (Test-Path .git)) {
|
||||
# Write-Err "Current directory does not appear to be the root of a git repository."
|
||||
# exit 1
|
||||
#}
|
||||
|
||||
# Resolve commits
|
||||
try {
|
||||
$startSha = (git rev-parse --verify $StartCommit) 2>$null
|
||||
if (-not $startSha) { throw "StartCommit '$StartCommit' not found" }
|
||||
$endSha = (git rev-parse --verify $EndCommit) 2>$null
|
||||
if (-not $endSha) { throw "EndCommit '$EndCommit' not found" }
|
||||
}
|
||||
catch {
|
||||
Write-Err $_
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)."
|
||||
# Get list of commits reachable from end but not from start.
|
||||
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
|
||||
# `$startSha..$endSha` will expand unexpectedly (often to empty/undesired) instead of passing the literal "sha1..sha2".
|
||||
# Therefore we build the range explicitly as a single string argument.
|
||||
$rangeArg = "$startSha..$endSha"
|
||||
$commitList = git rev-list $rangeArg
|
||||
|
||||
# Normalize list (filter out empty strings)
|
||||
$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' }
|
||||
$commitCount = ($normalizedCommits | Measure-Object).Count
|
||||
Write-DebugMsg ("Raw commitList length (including blanks): {0}" -f (($commitList | Measure-Object).Count))
|
||||
Write-DebugMsg ("Normalized commit count: {0}" -f $commitCount)
|
||||
if ($commitCount -eq 0) {
|
||||
Write-Warn "No commits found in specified range ($startSha..$endSha)."; exit 0
|
||||
}
|
||||
Write-DebugMsg ("First 5 commits: {0}" -f (($normalizedCommits | Select-Object -First 5) -join ', '))
|
||||
|
||||
<#
|
||||
Extract PR numbers from commits.
|
||||
Patterns handled:
|
||||
1. Merge commits: 'Merge pull request #12345 from ...'
|
||||
2. Squash commits: 'Some feature change (#12345)' (GitHub default squash format)
|
||||
We collect both. If a commit matches both (unlikely), it's deduped later.
|
||||
#>
|
||||
# Extract PR numbers from merge or squash commits
|
||||
$mergeCommits = @()
|
||||
foreach ($c in $normalizedCommits) {
|
||||
$subject = git show -s --format=%s $c
|
||||
$matched = $false
|
||||
# Pattern 1: Traditional merge commit
|
||||
if ($subject -match 'Merge pull request #([0-9]+) ') {
|
||||
$prNumber = [int]$matches[1]
|
||||
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber; Subject = $subject; Pattern = 'merge' }
|
||||
Write-DebugMsg "Matched merge PR #$prNumber in commit $c"
|
||||
$matched = $true
|
||||
}
|
||||
# Pattern 2: Squash merge subject line with ' (#12345)' at end (allow possible whitespace before paren)
|
||||
if ($subject -match '\(#([0-9]+)\)$') {
|
||||
$prNumber2 = [int]$matches[1]
|
||||
# Avoid duplicate object if pattern 1 already captured same number for same commit
|
||||
if (-not ($mergeCommits | Where-Object { $_.Sha -eq $c -and $_.Pr -eq $prNumber2 })) {
|
||||
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber2; Subject = $subject; Pattern = 'squash' }
|
||||
Write-DebugMsg "Matched squash PR #$prNumber2 in commit $c"
|
||||
}
|
||||
$matched = $true
|
||||
}
|
||||
if (-not $matched) {
|
||||
Write-DebugMsg "No PR pattern in commit $c : $subject"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $mergeCommits -or $mergeCommits.Count -eq 0) {
|
||||
Write-Warn "No merge commits with PR numbers found in range."; exit 0
|
||||
}
|
||||
|
||||
# Deduplicate PR numbers (in case of revert or merges across branches)
|
||||
$prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Object
|
||||
Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', '))
|
||||
Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count)
|
||||
|
||||
# Query GitHub for each PR
|
||||
$prDetails = @()
|
||||
function Get-CopilotSummaryFromPrJson {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]$PrJson,
|
||||
[switch]$VerboseMode
|
||||
)
|
||||
# Returns a hashtable with Summary and Source keys.
|
||||
$result = @{ Summary = ""; Source = "" }
|
||||
if (-not $PrJson) { return $result }
|
||||
|
||||
$candidateAuthors = @(
|
||||
'github-copilot[bot]', 'github-copilot', 'copilot'
|
||||
)
|
||||
|
||||
# 1. Reviews (preferred) – pick the LONGEST valid Copilot body, not the most recent
|
||||
$reviews = $PrJson.reviews
|
||||
if ($reviews) {
|
||||
$copilotReviews = $reviews | Where-Object {
|
||||
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
|
||||
}
|
||||
if ($copilotReviews) {
|
||||
$longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
|
||||
if ($longest) {
|
||||
$body = $longest.body
|
||||
$norm = ($body -replace "`r", '') -replace "`n", ' '
|
||||
$norm = $norm -replace '\s+', ' '
|
||||
$result.Summary = $norm
|
||||
$result.Source = 'review'
|
||||
if ($VerboseMode) { Write-DebugMsg "Selected Copilot review length=$($body.Length) (longest)." }
|
||||
return $result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Comments fallback (some repos surface Copilot summaries as PR comments rather than review objects)
|
||||
if ($null -eq $PrJson.comments) {
|
||||
try {
|
||||
# Lazy fetch comments only if needed
|
||||
$commentsJson = gh pr view $PrJson.number --repo $Repo --json comments 2>$null | ConvertFrom-Json
|
||||
if ($commentsJson -and $commentsJson.comments) {
|
||||
$PrJson | Add-Member -NotePropertyName comments -NotePropertyValue $commentsJson.comments -Force
|
||||
}
|
||||
} catch {
|
||||
if ($VerboseMode) { Write-DebugMsg "Failed to fetch comments for PR #$($PrJson.number): $_" }
|
||||
}
|
||||
}
|
||||
if ($PrJson.comments) {
|
||||
$copilotComments = $PrJson.comments | Where-Object {
|
||||
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
|
||||
}
|
||||
if ($copilotComments) {
|
||||
$longestC = $copilotComments | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
|
||||
if ($longestC) {
|
||||
$body = $longestC.body
|
||||
$norm = ($body -replace "`r", '') -replace "`n", ' '
|
||||
$norm = $norm -replace '\s+', ' '
|
||||
$result.Summary = $norm
|
||||
$result.Source = 'comment'
|
||||
if ($VerboseMode) { Write-DebugMsg "Selected Copilot comment length=$($body.Length) (longest)." }
|
||||
return $result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
|
||||
foreach ($pr in $prNumbers) {
|
||||
Write-Info "Fetching PR #$pr ..."
|
||||
try {
|
||||
# Include comments only if Verbose asked, otherwise we lazily pull if reviews missing
|
||||
$fields = 'number,title,labels,author,url,body,reviews'
|
||||
if ($PSBoundParameters.ContainsKey('Verbose')) { $fields += ',comments' }
|
||||
$json = gh pr view $pr --repo $Repo --json $fields 2>$null | ConvertFrom-Json
|
||||
if ($null -eq $json) { throw "Empty response" }
|
||||
|
||||
$copilot = Get-CopilotSummaryFromPrJson -PrJson $json -VerboseMode:($PSBoundParameters.ContainsKey('Verbose'))
|
||||
if ($copilot.Summary -and $copilot.Source -and $PSBoundParameters.ContainsKey('Verbose')) {
|
||||
Write-DebugMsg "Copilot summary source=$($copilot.Source) chars=$($copilot.Summary.Length)"
|
||||
} elseif (-not $copilot.Summary) {
|
||||
Write-DebugMsg "No Copilot summary found for PR #$pr"
|
||||
}
|
||||
|
||||
# Filter labels
|
||||
$filteredLabels = $json.labels | Where-Object {
|
||||
($_.name -like "Product-*") -or
|
||||
($_.name -like "Area-*") -or
|
||||
($_.name -like "Github*") -or
|
||||
($_.name -like "*Plugin") -or
|
||||
($_.name -like "Issue-*")
|
||||
}
|
||||
$labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", "
|
||||
|
||||
$bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' }
|
||||
$bodyValue = $bodyValue -replace '\s+', ' '
|
||||
|
||||
$prDetails += [PSCustomObject]@{
|
||||
Id = $json.number
|
||||
Title = $json.title
|
||||
Labels = $labelNames
|
||||
Author = $json.author.login
|
||||
Url = $json.url
|
||||
Body = $bodyValue
|
||||
CopilotSummary = $copilot.Summary
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$err = $_
|
||||
Write-Warn ("Failed to fetch PR #{0}: {1}" -f $pr, $err)
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $prDetails) { Write-Warn "No PR details fetched."; exit 0 }
|
||||
|
||||
# Sort by Labels like original script (first label alphabetical)
|
||||
$sorted = $prDetails | Sort-Object { ($_.Labels -split ',')[0] }
|
||||
|
||||
# Output JSON raw (optional)
|
||||
$sorted | ConvertTo-Json -Depth 6 | Out-File -Encoding UTF8 $OutputJson
|
||||
|
||||
Write-Info "Saving CSV to $OutputCsv ..."
|
||||
$sorted | Export-Csv $OutputCsv -NoTypeInformation
|
||||
Write-Host "✅ Done. Generated $($prDetails.Count) PR rows." -ForegroundColor Green
|
||||
@@ -1,85 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Group PR rows by their Labels column and emit per-label CSV files.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads a milestone PR CSV (usually produced by dump-prs-information / dump-prs-since-commit scripts),
|
||||
splits rows by label list, normalizes/sorts individual labels, and writes one CSV per unique label combination.
|
||||
Each output preserves the original row ordering within that subset and column order from the source.
|
||||
|
||||
.PARAMETER CsvPath
|
||||
Input CSV containing PR rows with a 'Labels' column (comma-separated list).
|
||||
|
||||
.PARAMETER OutDir
|
||||
Output directory to place grouped CSVs (created if missing). Default: 'grouped_csv'.
|
||||
|
||||
.NOTES
|
||||
Label combinations are joined using ' | ' when multiple labels present. Filenames are sanitized (invalid characters,
|
||||
whitespace collapsed) and truncated to <= 120 characters.
|
||||
#>
|
||||
param(
|
||||
[string]$CsvPath = "sorted_prs.csv",
|
||||
[string]$OutDir = "grouped_csv"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-Info($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
|
||||
function Write-Warn($msg) { Write-Host "[warn] $msg" -ForegroundColor Yellow }
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CsvPath)) { throw "CSV not found: $CsvPath" }
|
||||
|
||||
Write-Info "Reading CSV: $CsvPath"
|
||||
$rows = Import-Csv -LiteralPath $CsvPath
|
||||
Write-Info ("Loaded {0} rows" -f $rows.Count)
|
||||
|
||||
function ConvertTo-SafeFileName {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Name
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($Name)) { return 'Unnamed' }
|
||||
$s = $Name -replace '[<>:"/\\|?*]', '-' # invalid path chars
|
||||
$s = $s -replace '\s+', '-' # spaces to dashes
|
||||
$s = $s -replace '-{2,}', '-' # collapse dashes
|
||||
$s = $s.Trim('-')
|
||||
if ($s.Length -gt 120) { $s = $s.Substring(0,120).Trim('-') }
|
||||
if ([string]::IsNullOrWhiteSpace($s)) { return 'Unnamed' }
|
||||
return $s
|
||||
}
|
||||
|
||||
# Build groups keyed by normalized, sorted label combinations. Preserve original CSV row order.
|
||||
$groups = @{}
|
||||
foreach ($row in $rows) {
|
||||
$labelsRaw = $row.Labels
|
||||
if ([string]::IsNullOrWhiteSpace($labelsRaw)) {
|
||||
$labelParts = @('Unlabeled')
|
||||
} else {
|
||||
$parts = $labelsRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
|
||||
if (-not $parts -or $parts.Count -eq 0) { $labelParts = @('Unlabeled') }
|
||||
else { $labelParts = $parts | Sort-Object }
|
||||
}
|
||||
|
||||
$key = ($labelParts -join ' | ')
|
||||
if (-not $groups.ContainsKey($key)) { $groups[$key] = New-Object System.Collections.ArrayList }
|
||||
[void]$groups[$key].Add($row)
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutDir)) {
|
||||
Write-Info "Creating output directory: $OutDir"
|
||||
New-Item -ItemType Directory -Path $OutDir | Out-Null
|
||||
}
|
||||
|
||||
Write-Info ("Generating {0} grouped CSV file(s) into: {1}" -f $groups.Count, $OutDir)
|
||||
|
||||
foreach ($key in $groups.Keys) {
|
||||
$labelParts = if ($key -eq 'Unlabeled') { @('Unlabeled') } else { $key -split '\s\|\s' }
|
||||
$safeName = ($labelParts | ForEach-Object { ConvertTo-SafeFileName -Name $_ }) -join '-'
|
||||
$filePath = Join-Path $OutDir ("$safeName.csv")
|
||||
|
||||
# Keep same columns and order
|
||||
$groups[$key] | Export-Csv -LiteralPath $filePath -NoTypeInformation -Encoding UTF8
|
||||
}
|
||||
|
||||
Write-Info "Done. Sample output files:"
|
||||
Get-ChildItem -LiteralPath $OutDir | Select-Object -First 10 Name | Format-Table -HideTableHeaders
|
||||
Reference in New Issue
Block a user