mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-08 05:17:03 +01:00
Compare commits
13 Commits
jay/ls-ui-
...
jay/lsv2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
794b26d8dd | ||
|
|
0d5220561d | ||
|
|
ccc31c13ae | ||
|
|
233ca4c05b | ||
|
|
f42d6dbc3d | ||
|
|
466a94eb40 | ||
|
|
26ec8c6bd5 | ||
|
|
8a218860d4 | ||
|
|
e748f31593 | ||
|
|
b6944b432c | ||
|
|
8ce4b635cf | ||
|
|
87af08630a | ||
|
|
55f0bcc441 |
6
.github/actions/spell-check/expect.txt
vendored
6
.github/actions/spell-check/expect.txt
vendored
@@ -368,6 +368,7 @@ desktopshorcutinstalled
|
||||
DESKTOPVERTRES
|
||||
devblogs
|
||||
devdocs
|
||||
devenv
|
||||
devmgmt
|
||||
DEVMODE
|
||||
DEVMODEW
|
||||
@@ -826,6 +827,7 @@ killrunner
|
||||
kmph
|
||||
kvp
|
||||
Kybd
|
||||
LARGEICON
|
||||
lastcodeanalysissucceeded
|
||||
LASTEXITCODE
|
||||
LAYOUTRTL
|
||||
@@ -1209,8 +1211,10 @@ PACL
|
||||
PAINTSTRUCT
|
||||
PALETTEWINDOW
|
||||
PARENTNOTIFY
|
||||
PARENTRELATIVE
|
||||
PARENTRELATIVEEDITING
|
||||
PARENTRELATIVEFORADDRESSBAR
|
||||
PARENTRELATIVEFORUI
|
||||
PARENTRELATIVEPARSING
|
||||
parray
|
||||
PARTIALCONFIRMATIONDIALOGTITLE
|
||||
@@ -1266,6 +1270,7 @@ pgp
|
||||
pguid
|
||||
phbm
|
||||
phbmp
|
||||
phicon
|
||||
phwnd
|
||||
pici
|
||||
pidl
|
||||
@@ -1274,6 +1279,7 @@ pinfo
|
||||
pinvoke
|
||||
pipename
|
||||
PKBDLLHOOKSTRUCT
|
||||
pkgfamily
|
||||
plib
|
||||
ploc
|
||||
ploca
|
||||
|
||||
@@ -44,6 +44,9 @@ foreach ($csprojFile in $csprojFilesArray) {
|
||||
if ($csprojFile -like '*Microsoft.CmdPal.Core.*.csproj') {
|
||||
continue
|
||||
}
|
||||
if ($csprojFile -like '*Microsoft.CmdPal.Ext.Shell.csproj') {
|
||||
continue
|
||||
}
|
||||
|
||||
$importExists = Test-ImportSharedCsWinRTProps -filePath $csprojFile
|
||||
if (!$importExists) {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.250402" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.250910-build.2249" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.251002-build.2316" />
|
||||
<PackageVersion Include="ControlzEx" Version="6.0.0" />
|
||||
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
|
||||
<PackageVersion Include="HelixToolkit.Core.Wpf" Version="2.24.0" />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.PowerToys.UITest;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace LightSwitch.UITests
|
||||
{
|
||||
[TestClass]
|
||||
public class TestUserSelectedLocation : UITestBase
|
||||
{
|
||||
public TestUserSelectedLocation()
|
||||
: base(PowerToysModule.PowerToysSettings, WindowSize.Large)
|
||||
{
|
||||
}
|
||||
|
||||
[TestMethod("LightSwitch.UserSelectedLocation")]
|
||||
[TestCategory("Location")]
|
||||
public void TestUserSelectedLocationUpdate()
|
||||
{
|
||||
TestHelper.InitializeTest(this, "user selected location test");
|
||||
TestHelper.PerformUserSelectedLocationTest(this);
|
||||
TestHelper.CleanupTest(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace Microsoft.CmdPal.Core.Common.Helpers;
|
||||
/// If ExecuteAsync is called while already executing, it cancels the current execution
|
||||
/// and starts the operation again (superseding behavior).
|
||||
/// </summary>
|
||||
public partial class SupersedingAsyncGate : IDisposable
|
||||
public sealed partial class SupersedingAsyncGate : IDisposable
|
||||
{
|
||||
private readonly Func<CancellationToken, Task> _action;
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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.Core.Common.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// An async gate that ensures only one value computation runs at a time.
|
||||
/// If ExecuteAsync is called while already executing, it cancels the current computation
|
||||
/// and starts the operation again (superseding behavior).
|
||||
/// Once a value is successfully computed, it is applied (via the provided <see cref="Action{T}"/>).
|
||||
/// The apply step uses its own lock so that long-running apply logic does not block the
|
||||
/// computation / superseding pipeline, while still remaining serialized with respect to
|
||||
/// other apply calls.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the computed value.</typeparam>
|
||||
public sealed partial class SupersedingAsyncValueGate<T> : IDisposable
|
||||
{
|
||||
private readonly Func<CancellationToken, Task<T>> _valueFactory;
|
||||
private readonly Action<T> _apply;
|
||||
private readonly Lock _lock = new(); // Controls scheduling / superseding
|
||||
private readonly Lock _applyLock = new(); // Serializes application of results
|
||||
private int _callId;
|
||||
private TaskCompletionSource<T>? _currentTcs;
|
||||
private CancellationTokenSource? _currentCancellationSource;
|
||||
private Task? _executingTask;
|
||||
|
||||
public SupersedingAsyncValueGate(
|
||||
Func<CancellationToken, Task<T>> valueFactory,
|
||||
Action<T> apply)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(valueFactory);
|
||||
ArgumentNullException.ThrowIfNull(apply);
|
||||
_valueFactory = valueFactory;
|
||||
_apply = apply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the configured value computation. If another execution is running, this call will
|
||||
/// cancel the current execution and restart the computation. The returned task completes when
|
||||
/// (and only if) the computation associated with this invocation completes (or is canceled / superseded).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Optional external cancellation token.</param>
|
||||
/// <returns>The computed value for this invocation.</returns>
|
||||
public async Task<T> ExecuteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
TaskCompletionSource<T> tcs;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Supersede any in-flight computation.
|
||||
_currentCancellationSource?.Cancel();
|
||||
_currentTcs?.TrySetException(new OperationCanceledException("Superseded by newer call"));
|
||||
|
||||
tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_currentTcs = tcs;
|
||||
_callId++;
|
||||
|
||||
if (_executingTask is null)
|
||||
{
|
||||
_executingTask = Task.Run(ExecuteLoop, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
using var ctr = cancellationToken.Register(state => ((TaskCompletionSource<T>)state!).TrySetCanceled(cancellationToken), tcs);
|
||||
return await tcs.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ExecuteLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
TaskCompletionSource<T>? currentTcs;
|
||||
CancellationTokenSource? currentCts;
|
||||
int currentCallId;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
currentTcs = _currentTcs;
|
||||
currentCallId = _callId;
|
||||
|
||||
if (currentTcs is null)
|
||||
{
|
||||
break; // Nothing pending.
|
||||
}
|
||||
|
||||
_currentCancellationSource?.Dispose();
|
||||
_currentCancellationSource = new();
|
||||
currentCts = _currentCancellationSource;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var value = await _valueFactory(currentCts.Token).ConfigureAwait(false);
|
||||
CompleteSuccessIfCurrent(currentTcs, currentCallId, value);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetCanceled(currentCts.Token));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CompleteIfCurrent(currentTcs, currentCallId, t => t.TrySetException(ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentTcs = null;
|
||||
_currentCancellationSource?.Dispose();
|
||||
_currentCancellationSource = null;
|
||||
_executingTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteSuccessIfCurrent(TaskCompletionSource<T> candidate, int id, T value)
|
||||
{
|
||||
var shouldApply = false;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentTcs == candidate && _callId == id)
|
||||
{
|
||||
// Mark as consumed so a new computation can start immediately.
|
||||
_currentTcs = null;
|
||||
shouldApply = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApply)
|
||||
{
|
||||
return; // Superseded meanwhile.
|
||||
}
|
||||
|
||||
Exception? applyException = null;
|
||||
try
|
||||
{
|
||||
lock (_applyLock)
|
||||
{
|
||||
_apply(value);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
applyException = ex;
|
||||
}
|
||||
|
||||
if (applyException is null)
|
||||
{
|
||||
candidate.TrySetResult(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
candidate.TrySetException(applyException);
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteIfCurrent(
|
||||
TaskCompletionSource<T> candidate,
|
||||
int id,
|
||||
Action<TaskCompletionSource<T>> complete)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentTcs == candidate && _callId == id)
|
||||
{
|
||||
complete(candidate);
|
||||
_currentTcs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentCancellationSource?.Cancel();
|
||||
_currentCancellationSource?.Dispose();
|
||||
_currentTcs?.TrySetException(new ObjectDisposedException(nameof(SupersedingAsyncValueGate<T>)));
|
||||
_currentTcs = null;
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
public ExtensionObject<ICommandItem> Model => _commandItemModel;
|
||||
|
||||
private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null);
|
||||
private CommandContextItemViewModel? _defaultCommandContextItem;
|
||||
private CommandContextItemViewModel? _defaultCommandContextItemViewModel;
|
||||
|
||||
internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized;
|
||||
|
||||
@@ -43,9 +43,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
public string Subtitle { get; private set; } = string.Empty;
|
||||
|
||||
private IconInfoViewModel _listItemIcon = new(null);
|
||||
private IconInfoViewModel _icon = new(null);
|
||||
|
||||
public IconInfoViewModel Icon => _listItemIcon.IsSet ? _listItemIcon : Command.Icon;
|
||||
public IconInfoViewModel Icon => _icon.IsSet ? _icon : Command.Icon;
|
||||
|
||||
public CommandViewModel Command { get; private set; }
|
||||
|
||||
@@ -69,9 +69,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
{
|
||||
get
|
||||
{
|
||||
List<IContextItemViewModel> l = _defaultCommandContextItem is null ?
|
||||
List<IContextItemViewModel> l = _defaultCommandContextItemViewModel is null ?
|
||||
new() :
|
||||
[_defaultCommandContextItem];
|
||||
[_defaultCommandContextItemViewModel];
|
||||
|
||||
l.AddRange(MoreCommands);
|
||||
return l;
|
||||
@@ -136,11 +136,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
Command.InitializeProperties();
|
||||
|
||||
var listIcon = model.Icon;
|
||||
if (listIcon is not null)
|
||||
var icon = model.Icon;
|
||||
if (icon is not null)
|
||||
{
|
||||
_listItemIcon = new(listIcon);
|
||||
_listItemIcon.InitializeProperties();
|
||||
_icon = new(icon);
|
||||
_icon.InitializeProperties();
|
||||
}
|
||||
|
||||
// TODO: Do these need to go into FastInit?
|
||||
@@ -201,21 +201,19 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
if (!string.IsNullOrEmpty(model.Command?.Name))
|
||||
{
|
||||
_defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext)
|
||||
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext)
|
||||
{
|
||||
_itemTitle = Name,
|
||||
Subtitle = Subtitle,
|
||||
Command = Command,
|
||||
|
||||
// TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever
|
||||
// Anything we set manually here must stay in sync with the corresponding properties on CommandItemViewModel.
|
||||
};
|
||||
|
||||
// Only set the icon on the context item for us if our command didn't
|
||||
// have its own icon
|
||||
if (!Command.HasIcon)
|
||||
{
|
||||
_defaultCommandContextItem._listItemIcon = _listItemIcon;
|
||||
}
|
||||
UpdateDefaultContextItemIcon();
|
||||
}
|
||||
|
||||
Initialized |= InitializedState.SelectionInitialized;
|
||||
@@ -238,7 +236,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
_itemTitle = "Error";
|
||||
Subtitle = "Item failed to load";
|
||||
MoreCommands = [];
|
||||
_listItemIcon = _errorIcon;
|
||||
_icon = _errorIcon;
|
||||
Initialized |= InitializedState.Error;
|
||||
}
|
||||
|
||||
@@ -275,7 +273,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
_itemTitle = "Error";
|
||||
Subtitle = "Item failed to load";
|
||||
MoreCommands = [];
|
||||
_listItemIcon = _errorIcon;
|
||||
_icon = _errorIcon;
|
||||
Initialized |= InitializedState.Error;
|
||||
}
|
||||
|
||||
@@ -305,17 +303,18 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
switch (propertyName)
|
||||
{
|
||||
case nameof(Command):
|
||||
if (Command is not null)
|
||||
{
|
||||
Command.PropertyChanged -= Command_PropertyChanged;
|
||||
}
|
||||
|
||||
Command.PropertyChanged -= Command_PropertyChanged;
|
||||
Command = new(model.Command, PageContext);
|
||||
Command.InitializeProperties();
|
||||
|
||||
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
|
||||
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
|
||||
_itemTitle = model.Title;
|
||||
|
||||
_defaultCommandContextItemViewModel?.Command = Command;
|
||||
_defaultCommandContextItemViewModel?.UpdateTitle(_itemTitle);
|
||||
UpdateDefaultContextItemIcon();
|
||||
|
||||
UpdateProperty(nameof(Name));
|
||||
UpdateProperty(nameof(Title));
|
||||
UpdateProperty(nameof(Icon));
|
||||
@@ -326,12 +325,22 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
break;
|
||||
|
||||
case nameof(Subtitle):
|
||||
this.Subtitle = model.Subtitle;
|
||||
var modelSubtitle = model.Subtitle;
|
||||
this.Subtitle = modelSubtitle;
|
||||
_defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
|
||||
break;
|
||||
|
||||
case nameof(Icon):
|
||||
_listItemIcon = new(model.Icon);
|
||||
_listItemIcon.InitializeProperties();
|
||||
var oldIcon = _icon;
|
||||
_icon = new(model.Icon);
|
||||
_icon.InitializeProperties();
|
||||
if (oldIcon.IsSet || _icon.IsSet)
|
||||
{
|
||||
UpdateProperty(nameof(Icon));
|
||||
}
|
||||
|
||||
UpdateDefaultContextItemIcon();
|
||||
|
||||
break;
|
||||
|
||||
case nameof(model.MoreCommands):
|
||||
@@ -378,26 +387,49 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
private void Command_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
var propertyName = e.PropertyName;
|
||||
var model = _commandItemModel.Unsafe;
|
||||
if (model is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case nameof(Command.Name):
|
||||
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
|
||||
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
|
||||
var model = _commandItemModel.Unsafe;
|
||||
if (model is not null)
|
||||
{
|
||||
_itemTitle = model.Title;
|
||||
}
|
||||
_itemTitle = model.Title;
|
||||
UpdateProperty(nameof(Title), nameof(Name));
|
||||
|
||||
UpdateProperty(nameof(Title));
|
||||
UpdateProperty(nameof(Name));
|
||||
_defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name);
|
||||
break;
|
||||
|
||||
case nameof(Command.Icon):
|
||||
UpdateDefaultContextItemIcon();
|
||||
UpdateProperty(nameof(Icon));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDefaultContextItemIcon()
|
||||
{
|
||||
// Command icon takes precedence over our icon on the primary command
|
||||
_defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon);
|
||||
}
|
||||
|
||||
private void UpdateTitle(string? title)
|
||||
{
|
||||
_itemTitle = title ?? string.Empty;
|
||||
UpdateProperty(nameof(Title));
|
||||
}
|
||||
|
||||
private void UpdateIcon(IIconInfo? iconInfo)
|
||||
{
|
||||
_icon = new(iconInfo);
|
||||
_icon.InitializeProperties();
|
||||
UpdateProperty(nameof(Icon));
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
{
|
||||
base.UnsafeCleanup();
|
||||
@@ -411,10 +443,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
}
|
||||
|
||||
// _listItemIcon.SafeCleanup();
|
||||
_listItemIcon = new(null); // necessary?
|
||||
_icon = new(null); // necessary?
|
||||
|
||||
_defaultCommandContextItem?.SafeCleanup();
|
||||
_defaultCommandContextItem = null;
|
||||
_defaultCommandContextItemViewModel?.SafeCleanup();
|
||||
_defaultCommandContextItemViewModel = null;
|
||||
|
||||
Command.PropertyChanged -= Command_PropertyChanged;
|
||||
Command.SafeCleanup();
|
||||
|
||||
@@ -52,6 +52,8 @@ public partial class SettingsModel : ObservableObject
|
||||
|
||||
public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse;
|
||||
|
||||
public bool DisableAnimations { get; set; } = true;
|
||||
|
||||
// END SETTINGS
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
@@ -128,6 +128,16 @@ public partial class SettingsViewModel : INotifyPropertyChanged
|
||||
}
|
||||
}
|
||||
|
||||
public bool DisableAnimations
|
||||
{
|
||||
get => _settings.DisableAnimations;
|
||||
set
|
||||
{
|
||||
_settings.DisableAnimations = value;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = [];
|
||||
|
||||
public SettingsViewModel(SettingsModel settings, IServiceProvider serviceProvider, TaskScheduler scheduler)
|
||||
|
||||
@@ -114,7 +114,7 @@ public partial class App : Application
|
||||
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
|
||||
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
|
||||
services.AddSingleton<ICommandProvider>(files);
|
||||
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>();
|
||||
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>(_ => BookmarksCommandProvider.CreateWithDefaultStore());
|
||||
|
||||
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
|
||||
services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>();
|
||||
@@ -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>();
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Template for context items in the context item menu -->
|
||||
<DataTemplate x:Key="DefaultContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
|
||||
<Grid AutomationProperties.Name="{x:Bind Title}">
|
||||
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="32" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -42,7 +42,7 @@
|
||||
Height="16"
|
||||
Margin="4,0,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
SourceKey="{x:Bind Icon}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
<TextBlock
|
||||
x:Name="TitleTextBlock"
|
||||
@@ -51,11 +51,11 @@
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{x:Bind Title}"
|
||||
Text="{x:Bind Title, Mode=OneWay}"
|
||||
TextTrimming="WordEllipsis"
|
||||
TextWrapping="NoWrap">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip Content="{x:Bind Title}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</ToolTipService.ToolTip>
|
||||
</TextBlock>
|
||||
<TextBlock
|
||||
@@ -65,13 +65,13 @@
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource MenuFlyoutItemKeyboardAcceleratorTextForeground}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind RequestedShortcut, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Template for context items flagged as critical -->
|
||||
<DataTemplate x:Key="CriticalContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
|
||||
<Grid AutomationProperties.Name="{x:Bind Title}">
|
||||
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="32" />
|
||||
<ColumnDefinition Width="*" />
|
||||
@@ -83,7 +83,7 @@
|
||||
Margin="4,0,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
|
||||
SourceKey="{x:Bind Icon}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
<TextBlock
|
||||
x:Name="TitleTextBlock"
|
||||
@@ -93,11 +93,11 @@
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Style="{StaticResource ContextItemTitleTextBlockCriticalStyle}"
|
||||
Text="{x:Bind Title}"
|
||||
Text="{x:Bind Title, Mode=OneWay}"
|
||||
TextTrimming="WordEllipsis"
|
||||
TextWrapping="NoWrap">
|
||||
<ToolTipService.ToolTip>
|
||||
<ToolTip Content="{x:Bind Title}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
<ToolTip Content="{x:Bind Title, Mode=OneWay}" Visibility="{Binding IsTextTrimmed, ElementName=TitleTextBlock, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
|
||||
</ToolTipService.ToolTip>
|
||||
</TextBlock>
|
||||
<TextBlock
|
||||
@@ -106,7 +106,7 @@
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ContextItemCaptionTextBlockCriticalStyle}"
|
||||
Text="{x:Bind RequestedShortcut, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
Text="{x:Bind RequestedShortcut, Mode=OneWay, Converter={StaticResource KeyChordToStringConverter}}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
|
||||
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
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,33 +360,51 @@ public sealed partial class MainWindow : WindowEx,
|
||||
private void HideWindow()
|
||||
{
|
||||
// Cloak our HWND to avoid all animations.
|
||||
Cloak();
|
||||
var cloaked = Cloak();
|
||||
|
||||
// Then hide our HWND, to make sure that the OS gives the FG / focus back to another app
|
||||
// (there's no way for us to guess what the right hwnd might be, only the OS can do it right)
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_HIDE);
|
||||
|
||||
// TRICKY: show our HWND again. This will trick XAML into painting our
|
||||
// HWND again, so that we avoid the "flicker" caused by a WinUI3 app
|
||||
// window being first shown
|
||||
// SW_SHOWNA will prevent us for trying to fight the focus back
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNA);
|
||||
if (cloaked)
|
||||
{
|
||||
// TRICKY: show our HWND again. This will trick XAML into painting our
|
||||
// HWND again, so that we avoid the "flicker" caused by a WinUI3 app
|
||||
// window being first shown
|
||||
// SW_SHOWNA will prevent us for trying to fight the focus back
|
||||
PInvoke.ShowWindow(_hwnd, SHOW_WINDOW_CMD.SW_SHOWNA);
|
||||
|
||||
// Intentionally leave the window cloaked. So our window is "visible",
|
||||
// but also cloaked, so you can't see it.
|
||||
// Intentionally leave the window cloaked. So our window is "visible",
|
||||
// but also cloaked, so you can't see it.
|
||||
|
||||
// If the window was not cloaked, then leave it hidden.
|
||||
// Sure, it's not ideal, but at least it's not visible.
|
||||
}
|
||||
}
|
||||
|
||||
private void Cloak()
|
||||
private bool Cloak()
|
||||
{
|
||||
bool wasCloaked;
|
||||
unsafe
|
||||
{
|
||||
BOOL value = true;
|
||||
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
|
||||
var hr = PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
|
||||
if (hr.Failed)
|
||||
{
|
||||
Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}.");
|
||||
}
|
||||
|
||||
wasCloaked = hr.Succeeded;
|
||||
}
|
||||
|
||||
// Because we're only cloaking the window, bury it at the bottom in case something can
|
||||
// see it - e.g. some accessibility helper (note: this also removes the top-most status).
|
||||
PInvoke.SetWindowPos(_hwnd, HWND.HWND_BOTTOM, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
if (wasCloaked)
|
||||
{
|
||||
// Because we're only cloaking the window, bury it at the bottom in case something can
|
||||
// see it - e.g. some accessibility helper (note: this also removes the top-most status).
|
||||
PInvoke.SetWindowPos(_hwnd, HWND.HWND_BOTTOM, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
|
||||
}
|
||||
|
||||
return wasCloaked;
|
||||
}
|
||||
|
||||
private void Uncloak()
|
||||
|
||||
@@ -101,6 +101,18 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
_pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default page animation, depending on the settings
|
||||
/// </summary>
|
||||
private NavigationTransitionInfo DefaultPageAnimation
|
||||
{
|
||||
get
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>()!;
|
||||
return settings.DisableAnimations ? _noAnimation : _slideRightTransition;
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(NavigateBackMessage message)
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>()!;
|
||||
@@ -142,7 +154,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
_ => throw new NotSupportedException(),
|
||||
},
|
||||
message.Page,
|
||||
message.WithAnimation ? _slideRightTransition : _noAnimation);
|
||||
message.WithAnimation ? DefaultPageAnimation : _noAnimation);
|
||||
|
||||
PowerToysTelemetry.Log.WriteEvent(new OpenPage(RootFrame.BackStackDepth));
|
||||
|
||||
@@ -549,19 +561,25 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
private static void ShellPage_OnPreviewKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (e.Key == VirtualKey.Left && e.KeyStatus.IsMenuKeyDown)
|
||||
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
|
||||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
|
||||
|
||||
var onlyAlt = altPressed && !ctrlPressed && !shiftPressed && !winPressed;
|
||||
if (e.Key == VirtualKey.Left && onlyAlt)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == VirtualKey.Home && onlyAlt)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<GoHomeMessage>(new(WithAnimation: false));
|
||||
e.Handled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
|
||||
var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) ||
|
||||
InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down);
|
||||
|
||||
// The CommandBar is responsible for handling all the item keybindings,
|
||||
// since the bound context item may need to then show another
|
||||
// context menu
|
||||
|
||||
@@ -88,6 +88,10 @@
|
||||
<ToggleSwitch IsOn="{x:Bind viewModel.ShowSystemTrayIcon, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<controls:SettingsCard x:Uid="Settings_GeneralPage_DisableAnimations_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ToggleSwitch IsOn="{x:Bind viewModel.DisableAnimations, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<!-- 'For Developers' section -->
|
||||
|
||||
<TextBlock x:Uid="ForDevelopersSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
|
||||
@@ -407,6 +407,12 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="Settings_GeneralPage_ShowSystemTrayIcon_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Choose if Command Palette is visible in the system tray</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_DisableAnimations_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Disable animations</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_DisableAnimations_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Disable animations when switching between pages</value>
|
||||
</data>
|
||||
<data name="BackButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Back</value>
|
||||
</data>
|
||||
|
||||
@@ -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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class BookmarkDataTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void BookmarkDataWebUrlDetection()
|
||||
{
|
||||
// Act
|
||||
var webBookmark = new BookmarkData
|
||||
{
|
||||
Name = "Test Site",
|
||||
Bookmark = "https://test.com",
|
||||
};
|
||||
|
||||
var nonWebBookmark = new BookmarkData
|
||||
{
|
||||
Name = "Local File",
|
||||
Bookmark = "C:\\temp\\file.txt",
|
||||
};
|
||||
|
||||
var placeholderBookmark = new BookmarkData
|
||||
{
|
||||
Name = "Placeholder",
|
||||
Bookmark = "{Placeholder}",
|
||||
};
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(webBookmark.IsWebUrl());
|
||||
Assert.IsFalse(webBookmark.IsPlaceholder);
|
||||
Assert.IsFalse(nonWebBookmark.IsWebUrl());
|
||||
Assert.IsFalse(nonWebBookmark.IsPlaceholder);
|
||||
|
||||
Assert.IsTrue(placeholderBookmark.IsPlaceholder);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
@@ -191,7 +193,7 @@ public class BookmarkJsonParserTests
|
||||
public void SerializeBookmarks_ValidBookmarks_ReturnsJsonString()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarks = new Bookmarks
|
||||
var bookmarks = new BookmarksData
|
||||
{
|
||||
Data = new List<BookmarkData>
|
||||
{
|
||||
@@ -216,7 +218,7 @@ public class BookmarkJsonParserTests
|
||||
public void SerializeBookmarks_EmptyBookmarks_ReturnsValidJson()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarks = new Bookmarks();
|
||||
var bookmarks = new BookmarksData();
|
||||
|
||||
// Act
|
||||
var result = _parser.SerializeBookmarks(bookmarks);
|
||||
@@ -241,7 +243,7 @@ public class BookmarkJsonParserTests
|
||||
public void ParseBookmarks_RoundTripSerialization_PreservesData()
|
||||
{
|
||||
// Arrange
|
||||
var originalBookmarks = new Bookmarks
|
||||
var originalBookmarks = new BookmarksData
|
||||
{
|
||||
Data = new List<BookmarkData>
|
||||
{
|
||||
@@ -263,7 +265,6 @@ public class BookmarkJsonParserTests
|
||||
{
|
||||
Assert.AreEqual(originalBookmarks.Data[i].Name, parsedBookmarks.Data[i].Name);
|
||||
Assert.AreEqual(originalBookmarks.Data[i].Bookmark, parsedBookmarks.Data[i].Bookmark);
|
||||
Assert.AreEqual(originalBookmarks.Data[i].IsPlaceholder, parsedBookmarks.Data[i].IsPlaceholder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,70 +297,6 @@ public class BookmarkJsonParserTests
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(3, result.Data.Count);
|
||||
|
||||
Assert.IsFalse(result.Data[0].IsPlaceholder);
|
||||
Assert.IsTrue(result.Data[1].IsPlaceholder);
|
||||
Assert.IsTrue(result.Data[2].IsPlaceholder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParseBookmarks_IsWebUrl_CorrectlyIdentifiesWebUrls()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"Data": [
|
||||
{
|
||||
"Name": "HTTPS Website",
|
||||
"Bookmark": "https://www.google.com"
|
||||
},
|
||||
{
|
||||
"Name": "HTTP Website",
|
||||
"Bookmark": "http://example.com"
|
||||
},
|
||||
{
|
||||
"Name": "Website without protocol",
|
||||
"Bookmark": "www.github.com"
|
||||
},
|
||||
{
|
||||
"Name": "Local File Path",
|
||||
"Bookmark": "C:\\Users\\test\\Documents\\file.txt"
|
||||
},
|
||||
{
|
||||
"Name": "Network Path",
|
||||
"Bookmark": "\\\\server\\share\\file.txt"
|
||||
},
|
||||
{
|
||||
"Name": "Executable",
|
||||
"Bookmark": "notepad.exe"
|
||||
},
|
||||
{
|
||||
"Name": "File URI",
|
||||
"Bookmark": "file:///C:/temp/file.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.ParseBookmarks(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(7, result.Data.Count);
|
||||
|
||||
// Web URLs should return true
|
||||
Assert.IsTrue(result.Data[0].IsWebUrl(), "HTTPS URL should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTP URL should be identified as web URL");
|
||||
|
||||
// This case will fail. We need to consider if we need to support pure domain value in bookmark.
|
||||
// Assert.IsTrue(result.Data[2].IsWebUrl(), "Domain without protocol should be identified as web URL");
|
||||
|
||||
// Non-web URLs should return false
|
||||
Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file path should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[4].IsWebUrl(), "Network path should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[5].IsWebUrl(), "Executable should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[6].IsWebUrl(), "File URI should not be identified as web URL");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -415,23 +352,10 @@ public class BookmarkJsonParserTests
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(9, result.Data.Count);
|
||||
|
||||
// Should be identified as placeholders
|
||||
Assert.IsTrue(result.Data[0].IsPlaceholder, "Simple placeholder should be identified");
|
||||
Assert.IsTrue(result.Data[1].IsPlaceholder, "Multiple placeholders should be identified");
|
||||
Assert.IsTrue(result.Data[2].IsPlaceholder, "Web URL with placeholder should be identified");
|
||||
Assert.IsTrue(result.Data[3].IsPlaceholder, "Complex placeholder should be identified");
|
||||
Assert.IsTrue(result.Data[8].IsPlaceholder, "Empty placeholder should be identified");
|
||||
|
||||
// Should NOT be identified as placeholders
|
||||
Assert.IsFalse(result.Data[4].IsPlaceholder, "Regular URL should not be placeholder");
|
||||
Assert.IsFalse(result.Data[5].IsPlaceholder, "Local file should not be placeholder");
|
||||
Assert.IsFalse(result.Data[6].IsPlaceholder, "Only opening brace should not be placeholder");
|
||||
Assert.IsFalse(result.Data[7].IsPlaceholder, "Only closing brace should not be placeholder");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesBothWebUrlAndPlaceholder()
|
||||
public void ParseBookmarks_MixedProperties_CorrectlyIdentifiesPlaceholder()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
@@ -463,73 +387,5 @@ public class BookmarkJsonParserTests
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(4, result.Data.Count);
|
||||
|
||||
// Web URL with placeholder
|
||||
Assert.IsTrue(result.Data[0].IsWebUrl(), "Web URL with placeholder should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[0].IsPlaceholder, "Web URL with placeholder should be identified as placeholder");
|
||||
|
||||
// Web URL without placeholder
|
||||
Assert.IsTrue(result.Data[1].IsWebUrl(), "Web URL without placeholder should be identified as web URL");
|
||||
Assert.IsFalse(result.Data[1].IsPlaceholder, "Web URL without placeholder should not be identified as placeholder");
|
||||
|
||||
// Local file with placeholder
|
||||
Assert.IsFalse(result.Data[2].IsWebUrl(), "Local file with placeholder should not be identified as web URL");
|
||||
Assert.IsTrue(result.Data[2].IsPlaceholder, "Local file with placeholder should be identified as placeholder");
|
||||
|
||||
// Local file without placeholder
|
||||
Assert.IsFalse(result.Data[3].IsWebUrl(), "Local file without placeholder should not be identified as web URL");
|
||||
Assert.IsFalse(result.Data[3].IsPlaceholder, "Local file without placeholder should not be identified as placeholder");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParseBookmarks_EdgeCaseUrls_CorrectlyIdentifiesWebUrls()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"Data": [
|
||||
{
|
||||
"Name": "FTP URL",
|
||||
"Bookmark": "ftp://files.example.com"
|
||||
},
|
||||
{
|
||||
"Name": "HTTPS with port",
|
||||
"Bookmark": "https://localhost:8080"
|
||||
},
|
||||
{
|
||||
"Name": "IP Address",
|
||||
"Bookmark": "http://192.168.1.1"
|
||||
},
|
||||
{
|
||||
"Name": "Subdomain",
|
||||
"Bookmark": "https://api.github.com"
|
||||
},
|
||||
{
|
||||
"Name": "Domain only",
|
||||
"Bookmark": "example.com"
|
||||
},
|
||||
{
|
||||
"Name": "Not a URL - no dots",
|
||||
"Bookmark": "localhost"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _parser.ParseBookmarks(json);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(6, result.Data.Count);
|
||||
|
||||
Assert.IsFalse(result.Data[0].IsWebUrl(), "FTP URL should not be identified as web URL");
|
||||
Assert.IsTrue(result.Data[1].IsWebUrl(), "HTTPS with port should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[2].IsWebUrl(), "IP Address with HTTP should be identified as web URL");
|
||||
Assert.IsTrue(result.Data[3].IsWebUrl(), "Subdomain should be identified as web URL");
|
||||
|
||||
// This case will fail. We need to consider if we need to support pure domain value in bookmark.
|
||||
// Assert.IsTrue(result.Data[4].IsWebUrl(), "Domain only should be identified as web URL");
|
||||
Assert.IsFalse(result.Data[5].IsWebUrl(), "Single word without dots should not be identified as web URL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// 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.Linq;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class BookmarkManagerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void BookmarkManager_CanBeInstantiated()
|
||||
{
|
||||
// Arrange & Act
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarkManager);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitialBookmarksEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(0, bookmarks.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitialBookmarksCorruptedData()
|
||||
{
|
||||
// Arrange
|
||||
var json = "@*>$ß Corrupted data. Hey, this is not JSON!";
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json));
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(0, bookmarks.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitializeWithExistingData()
|
||||
{
|
||||
// Arrange
|
||||
const string json = """
|
||||
{
|
||||
"Data":[
|
||||
{"Id":"d290f1ee-6c54-4b01-90e6-d701748f0851","Name":"Bookmark1","Bookmark":"C:\\Path1"},
|
||||
{"Id":"c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a","Name":"Bookmark2","Bookmark":"D:\\Path2"}
|
||||
]
|
||||
}
|
||||
""";
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json));
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks?.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(2, bookmarks.Count);
|
||||
|
||||
Assert.AreEqual("Bookmark1", bookmarks[0].Name);
|
||||
Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark);
|
||||
Assert.AreEqual(Guid.Parse("d290f1ee-6c54-4b01-90e6-d701748f0851"), bookmarks[0].Id);
|
||||
|
||||
Assert.AreEqual("Bookmark2", bookmarks[1].Name);
|
||||
Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark);
|
||||
Assert.AreEqual(Guid.Parse("c4a760a4-5b63-4c9e-b8b3-2c3f5f3e6f7a"), bookmarks[1].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_InitializeWithLegacyData_GeneratesIds()
|
||||
{
|
||||
// Arrange
|
||||
const string json = """
|
||||
{
|
||||
"Data":
|
||||
[
|
||||
{ "Name":"Bookmark1", "Bookmark":"C:\\Path1" },
|
||||
{ "Name":"Bookmark2", "Bookmark":"D:\\Path2" }
|
||||
]
|
||||
}
|
||||
""";
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource(json));
|
||||
|
||||
// Act
|
||||
var bookmarks = bookmarkManager.Bookmarks?.ToList();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(bookmarks);
|
||||
Assert.AreEqual(2, bookmarks.Count);
|
||||
|
||||
Assert.AreEqual("Bookmark1", bookmarks[0].Name);
|
||||
Assert.AreEqual("C:\\Path1", bookmarks[0].Bookmark);
|
||||
Assert.AreNotEqual(Guid.Empty, bookmarks[0].Id);
|
||||
|
||||
Assert.AreEqual("Bookmark2", bookmarks[1].Name);
|
||||
Assert.AreEqual("D:\\Path2", bookmarks[1].Bookmark);
|
||||
Assert.AreNotEqual(Guid.Empty, bookmarks[1].Id);
|
||||
|
||||
Assert.AreNotEqual(bookmarks[0].Id, bookmarks[1].Id);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_AddBookmark_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
var bookmarkAddedEventFired = false;
|
||||
bookmarkManager.BookmarkAdded += (bookmark) =>
|
||||
{
|
||||
bookmarkAddedEventFired = true;
|
||||
Assert.AreEqual("TestBookmark", bookmark.Name);
|
||||
Assert.AreEqual("C:\\TestPath", bookmark.Bookmark);
|
||||
};
|
||||
|
||||
// Act
|
||||
var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath");
|
||||
|
||||
// Assert
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
Assert.AreEqual(1, bookmarks.Count);
|
||||
Assert.AreEqual(addedBookmark, bookmarks.First());
|
||||
Assert.IsTrue(bookmarkAddedEventFired);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_RemoveBookmark_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath");
|
||||
var bookmarkRemovedEventFired = false;
|
||||
bookmarkManager.BookmarkRemoved += (bookmark) =>
|
||||
{
|
||||
bookmarkRemovedEventFired = true;
|
||||
Assert.AreEqual(addedBookmark, bookmark);
|
||||
};
|
||||
|
||||
// Act
|
||||
var removeResult = bookmarkManager.Remove(addedBookmark.Id);
|
||||
|
||||
// Assert
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
Assert.IsTrue(removeResult);
|
||||
Assert.AreEqual(0, bookmarks.Count);
|
||||
Assert.IsTrue(bookmarkRemovedEventFired);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BookmarkManager_UpdateBookmark_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var bookmarkManager = new BookmarksManager(new MockBookmarkDataSource());
|
||||
var addedBookmark = bookmarkManager.Add("TestBookmark", "C:\\TestPath");
|
||||
var bookmarkUpdatedEventFired = false;
|
||||
bookmarkManager.BookmarkUpdated += (data, bookmarkData) =>
|
||||
{
|
||||
bookmarkUpdatedEventFired = true;
|
||||
Assert.AreEqual(addedBookmark, data);
|
||||
Assert.AreEqual("UpdatedBookmark", bookmarkData.Name);
|
||||
Assert.AreEqual("D:\\UpdatedPath", bookmarkData.Bookmark);
|
||||
};
|
||||
|
||||
// Act
|
||||
var updatedBookmark = bookmarkManager.Update(addedBookmark.Id, "UpdatedBookmark", "D:\\UpdatedPath");
|
||||
|
||||
// Assert
|
||||
var bookmarks = bookmarkManager.Bookmarks;
|
||||
Assert.IsNotNull(updatedBookmark);
|
||||
Assert.AreEqual(1, bookmarks.Count);
|
||||
Assert.AreEqual(updatedBookmark, bookmarks.First());
|
||||
Assert.AreEqual("UpdatedBookmark", updatedBookmark.Name);
|
||||
Assert.AreEqual("D:\\UpdatedPath", updatedBookmark.Bookmark);
|
||||
Assert.IsTrue(bookmarkUpdatedEventFired);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(CommonClassificationData.CommonCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateCommonClassification(PlaceholderClassificationCase c) => await RunShared(c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(CommonClassificationData.UwpAumidCases), typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateUwpAumidClassification(PlaceholderClassificationCase c) => await RunShared(c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedRelativePaths), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateUnquotedRelativePathScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(CommonClassificationData.UnquotedShellProtocol), dynamicDataDeclaringType: typeof(CommonClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Common_ValidateUnquotedShellProtocolScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
private static class CommonClassificationData
|
||||
{
|
||||
public static IEnumerable<object[]> CommonCases()
|
||||
{
|
||||
return
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "HTTPS URL",
|
||||
Input: "https://microsoft.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://microsoft.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "WWW URL without scheme",
|
||||
Input: "www.example.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://www.example.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "HTTP URL with query",
|
||||
Input: "http://yahoo.com?p=search",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "http://yahoo.com?p=search",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Mailto protocol",
|
||||
Input: "mailto:user@example.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "mailto:user@example.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "MS-Settings protocol",
|
||||
Input: "ms-settings:display",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "ms-settings:display",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Custom protocol",
|
||||
Input: "myapp:doit",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "myapp:doit",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Not really a valid protocol",
|
||||
Input: "this is not really a protocol myapp: doit",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "this",
|
||||
ExpectedArguments: "is not really a protocol myapp: doit",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Drive",
|
||||
Input: "C:",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: "C:\\",
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Non-existing path with extension",
|
||||
Input: "C:\\this-folder-should-not-exist-12345\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\this-folder-should-not-exist-12345\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unknown fallback",
|
||||
Input: "some_unlikely_command_name_12345",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "some_unlikely_command_name_12345",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
|
||||
[new PlaceholderClassificationCase(
|
||||
Name: "Simple unquoted executable path",
|
||||
Input: "C:\\Windows\\System32\\notepad.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Windows\\System32\\notepad.exe",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted document path (non existed file)",
|
||||
Input: "C:\\Users\\John\\Documents\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Users\\John\\Documents\\file.txt",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> UwpAumidCases() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID with AppsFolder prefix",
|
||||
Input: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID with AppsFolder prefix and argument (Trap)",
|
||||
Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App --maximized",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID via AppsFolder",
|
||||
Input: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> UnquotedShellProtocol() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell for This PC (shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D})",
|
||||
Input: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell for This PC (::{20D04FE0-3AEA-1069-A2D8-08002B30309D})",
|
||||
Input: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for My Documents (shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
Input: "shell:::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for My Documents (::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
Input: "::{450D8FBA-AD25-11D0-98A8-0800361B1103}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for AppData (shell:appdata)",
|
||||
Input: "shell:appdata",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
|
||||
// let's pray this works on all systems
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell protocol for AppData + subpath (shell:appdata\\microsoft)",
|
||||
Input: "shell:appdata\\microsoft",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Microsoft"),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> UnquotedRelativePaths() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted relative current path",
|
||||
Input: ".\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
#if CMDPAL_ENABLE_UNSAFE_TESTS
|
||||
It's not really a good idea blindly write to directory out of user profile
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted relative parent path",
|
||||
Input: "..\\parent folder\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
#endif // CMDPAL_ENABLE_UNSAFE_TESTS
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unquoted relative home folder",
|
||||
Input: $"~\\{_testDirName}\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: Path.Combine(_testDirPath, "app.exe"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(PlaceholderClassificationData.PlaceholderCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Placeholders_ValidatePlaceholderClassification(PlaceholderClassificationCase c) => await RunShared(c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(nameof(PlaceholderClassificationData.EdgeCases), typeof(PlaceholderClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Placeholders_ValidatePlaceholderEdgeCases(PlaceholderClassificationCase c)
|
||||
{
|
||||
// Arrange
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
|
||||
// Act & Assert - Should not throw exceptions
|
||||
var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None);
|
||||
|
||||
Assert.IsNotNull(classification);
|
||||
Assert.AreEqual(c.ExpectSuccess, classification.Success);
|
||||
|
||||
if (c.ExpectSuccess && classification.Result != null)
|
||||
{
|
||||
Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder);
|
||||
Assert.AreEqual(c.Input, classification.Result.Input, "OriginalInput should be preserved");
|
||||
}
|
||||
}
|
||||
|
||||
private static class PlaceholderClassificationData
|
||||
{
|
||||
public static IEnumerable<object[]> PlaceholderCases()
|
||||
{
|
||||
// UWP/AUMID with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UWP AUMID with package placeholder",
|
||||
Input: "shell:AppsFolder\\{packageFamily}!{appId}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\{packageFamily}!{appId}",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
|
||||
// Expects no special handling
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Bare UWP AUMID with placeholders",
|
||||
Input: "{packageFamily}!{appId}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{packageFamily}!{appId}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Web URLs with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "HTTPS URL with domain placeholder",
|
||||
Input: "https://{domain}/path",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://{domain}/path",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "WWW URL with site placeholder",
|
||||
Input: "www.{site}.com",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "https://www.{site}.com",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "WWW URL - Yahoo with Search",
|
||||
Input: "http://yahoo.com?p={search}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.WebUrl,
|
||||
ExpectedTarget: "http://yahoo.com?p={search}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Protocol URLs with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Mailto protocol with email placeholder",
|
||||
Input: "mailto:{email}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "mailto:{email}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "MS-Settings protocol with category placeholder",
|
||||
Input: "ms-settings:{category}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Protocol,
|
||||
ExpectedTarget: "ms-settings:{category}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// File executables with placeholders - These might classify as Unknown currently
|
||||
// due to nonexistent paths, but should preserve placeholder flag
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Executable with profile path placeholder",
|
||||
Input: "{userProfile}\\Documents\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist
|
||||
ExpectedTarget: "{userProfile}\\Documents\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Executable with program files placeholder",
|
||||
Input: "{programFiles}\\MyApp\\tool.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // May be Unknown if path doesn't exist
|
||||
ExpectedTarget: "{programFiles}\\MyApp\\tool.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Commands with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Command with placeholder and arguments",
|
||||
Input: "{editor} {filename}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown, // Likely Unknown since command won't be found in PATH
|
||||
ExpectedTarget: "{editor}",
|
||||
ExpectedArguments: "{filename}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Directory paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Directory with user profile placeholder",
|
||||
Input: "{userProfile}\\Documents",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown, // May be Unknown if path doesn't exist during classification
|
||||
ExpectedTarget: "{userProfile}\\Documents",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Complex quoted paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted executable path with placeholders and args",
|
||||
Input: "\"{programFiles}\\{appName}\\{executable}.exe\" --verbose",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // Likely Unknown due to nonexistent path
|
||||
ExpectedTarget: "{programFiles}\\{appName}\\{executable}.exe",
|
||||
ExpectedArguments: "--verbose",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Shell paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell folder with placeholder",
|
||||
Input: "shell:{folder}\\{filename}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "shell:{folder}\\{filename}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Shell paths with placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell folder with placeholder",
|
||||
Input: "shell:knownFolder\\{filename}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.VirtualShellItem,
|
||||
ExpectedTarget: "shell:knownFolder\\{filename}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
yield return
|
||||
[
|
||||
|
||||
// cmd /K {param1}
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Command with braces in arguments",
|
||||
Input: "cmd /K {param1}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE",
|
||||
ExpectedArguments: "/K {param1}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Mixed literal and placeholder paths
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Mixed literal and placeholder path",
|
||||
Input: "C:\\{folder}\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable, // Behavior depends on partial path resolution
|
||||
ExpectedTarget: "C:\\{folder}\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Multiple placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Multiple placeholders in path",
|
||||
Input: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{drive}\\{folder}\\{subfolder}\\{file}.{ext}",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> EdgeCases()
|
||||
{
|
||||
// Empty and malformed placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Empty placeholder",
|
||||
Input: "{} file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{} file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unclosed placeholder",
|
||||
Input: "{unclosed file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{unclosed file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Placeholder with spaces",
|
||||
Input: "{with spaces}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{with spaces}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Nested placeholders",
|
||||
Input: "{outer{inner}}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{outer{inner}}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Only closing brace",
|
||||
Input: "file} something",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "file} something",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
];
|
||||
|
||||
// Very long placeholder names
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Very long placeholder name",
|
||||
Input: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{thisIsVeryLongPlaceholderNameThatShouldStillWorkProperly}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
// Special characters in placeholders
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Placeholder with underscores",
|
||||
Input: "{user_profile}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{user_profile}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
|
||||
yield return
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Placeholder with numbers",
|
||||
Input: "{path123}\\file.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "{path123}\\file.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: true)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.MixedQuotesScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateMixedQuotesScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EscapedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateEscapedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.PartialMalformedQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidatePartialMalformedQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EnvironmentVariablesWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateEnvironmentVariablesWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.ShellProtocolPathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateShellProtocolPathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.CommandFlagsAndOptions), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateCommandFlagsAndOptions(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.NetworkPathsUnc), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateNetworkPathsUnc(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RelativePathsWithQuotes), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateRelativePathsWithQuotes(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.EmptyAndWhitespaceCases), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateEmptyAndWhitespaceCases(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.RealWorldCommandScenarios), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateRealWorldCommandScenarios(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.SpecialCharactersInPaths), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateSpecialCharactersInPaths(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsCurrentlyBroken), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateQuotedPathsCurrentlyBroken(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedPathsInCommands), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateQuotedPathsInCommands(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
[DataTestMethod]
|
||||
[DynamicData(dynamicDataSourceName: nameof(QuotedClassificationData.QuotedAumid), dynamicDataDeclaringType: typeof(QuotedClassificationData), DynamicDataDisplayName = nameof(FromCase))]
|
||||
public async Task Quoted_ValidateQuotedUwpAppAumidCommands(PlaceholderClassificationCase c) => await RunShared(c: c);
|
||||
|
||||
public static class QuotedClassificationData
|
||||
{
|
||||
public static IEnumerable<object[]> MixedQuotesScenarios() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Executable with quoted argument",
|
||||
Input: "C:\\Windows\\notepad.exe \"C:\\my file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Windows\\notepad.exe",
|
||||
ExpectedArguments: "\"C:\\my file.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "App with quoted argument containing spaces",
|
||||
Input: "app.exe \"argument with spaces\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "app.exe",
|
||||
ExpectedArguments: "\"argument with spaces\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Tool with input flag and quoted file",
|
||||
Input: "C:\\tool.exe -input \"data file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\tool.exe",
|
||||
ExpectedArguments: "-input \"data file.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Multiple quoted arguments after path",
|
||||
Input: "\"C:\\Program Files\\app.exe\" -file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\app.exe",
|
||||
ExpectedArguments: "-file \"C:\\data\\input.txt\" -output \"C:\\results\\output.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Command with two quoted paths",
|
||||
Input: "cmd /c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE",
|
||||
ExpectedArguments: "/c \"C:\\First Path\\tool.exe\" \"C:\\Second Path\\file.txt\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> EscapedQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Path with escaped quotes in folder name",
|
||||
Input: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "C:\\Windows\\\\\\\"System32\\\\\\\"CatRoot\\\\",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with trailing escaped quote",
|
||||
Input: "\"C:\\Windows\\\\\\\"\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: "C:\\Windows\\",
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> PartialMalformedQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unclosed quote at start",
|
||||
Input: "\"C:\\Program Files\\app.exe",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quote in middle of unquoted path",
|
||||
Input: "C:\\Some\\\"Path\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Some\\\"Path\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Unclosed quote - never ends",
|
||||
Input: "\"Starts quoted but never ends",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "Starts quoted but never ends",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> EnvironmentVariablesWithQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted environment variable path with spaces",
|
||||
Input: "\"%ProgramFiles%\\MyApp\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "MyApp", "app.exe"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted USERPROFILE with document path",
|
||||
Input: "\"%USERPROFILE%\\Documents\\file with spaces.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "file with spaces.txt"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Environment variable with trailing args",
|
||||
Input: "\"%ProgramFiles%\\App\" with args",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"),
|
||||
ExpectedArguments: "with args",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Environment variable with trailing args",
|
||||
Input: "%ProgramFiles%\\App with args",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "App"),
|
||||
ExpectedArguments: "with args",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> ShellProtocolPathsWithQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted shell:Downloads",
|
||||
Input: "\"shell:Downloads\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted shell:Downloads with subpath",
|
||||
Input: "\"shell:Downloads\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "file.txt"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Shell Desktop with subpath",
|
||||
Input: "shell:Desktop\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "file.txt"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted shell path with trailing text",
|
||||
Input: "\"shell:Programs\" extra",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Directory,
|
||||
ExpectedTarget: Path.Combine(paths: Environment.GetFolderPath(Environment.SpecialFolder.Programs)),
|
||||
ExpectedLaunch: LaunchMethod.ExplorerOpen,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> CommandFlagsAndOptions() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Path followed by flag with quoted value",
|
||||
Input: "C:\\app.exe -flag \"value\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\app.exe",
|
||||
ExpectedArguments: "-flag \"value\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted tool with equals-style flag",
|
||||
Input: "\"C:\\Program Files\\tool.exe\" --input=file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\tool.exe",
|
||||
ExpectedArguments: "--input=file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Path with slash option and quoted value",
|
||||
Input: "C:\\tool.exe /option \"quoted value\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\tool.exe",
|
||||
ExpectedArguments: "/option \"quoted value\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Flag before quoted path",
|
||||
Input: "--path \"C:\\Program Files\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "--path",
|
||||
ExpectedArguments: "\"C:\\Program Files\\app.exe\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> NetworkPathsUnc() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UNC path unquoted",
|
||||
Input: "\\\\server\\share\\folder\\file.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "\\\\server\\share\\folder\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UNC path with spaces",
|
||||
Input: "\"\\\\server\\share with spaces\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "\\\\server\\share with spaces\\file.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "UNC path with trailing args",
|
||||
Input: "\"\\\\server\\share\\\" with args",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: "\\\\server\\share\\",
|
||||
ExpectedArguments: "with args",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UNC app with flag",
|
||||
Input: "\"\\\\server\\My Share\\app.exe\" --flag",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "\\\\server\\My Share\\app.exe",
|
||||
ExpectedArguments: "--flag",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> RelativePathsWithQuotes() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted relative current path",
|
||||
Input: "\".\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted relative parent path",
|
||||
Input: "\"..\\parent folder\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "..", "parent folder", "file.txt")),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted relative home folder",
|
||||
Input: "\"~\\current folder\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "current folder\\app.exe"),
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> EmptyAndWhitespaceCases() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Empty string",
|
||||
Input: string.Empty,
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Only whitespace",
|
||||
Input: " ",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: " ",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Just empty quotes",
|
||||
Input: "\"\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted single space",
|
||||
Input: "\" \"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Unknown,
|
||||
ExpectedTarget: " ",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> RealWorldCommandScenarios() =>
|
||||
[
|
||||
#if CMDPAL_ENABLE_UNSAFE_TESTS
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Git clone command with full exe path with quoted path",
|
||||
Input: "\"C:\\Program Files\\Git\\bin\\git.exe\" clone repo",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\Git\\bin\\git.exe",
|
||||
ExpectedArguments: "clone repo",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Git clone command with quoted path",
|
||||
Input: "git clone repo",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Program Files\\Git\\cmd\\git.EXE",
|
||||
ExpectedArguments: "clone repo",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Visual Studio devenv with solution",
|
||||
Input: "\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe\" solution.sln",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\Microsoft Visual Studio\\2022\\Preview\\Common7\\IDE\\devenv.exe",
|
||||
ExpectedArguments: "solution.sln",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Double-quoted Windows cmd pattern",
|
||||
Input: "cmd /c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.EXE",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedArguments: "/c \"\"C:\\Program Files\\app.exe\" arg1 arg2\"",
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
#endif
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "PowerShell script with execution policy",
|
||||
Input: "powershell -ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\WINDOWS\\system32\\WindowsPowerShell\\v1.0\\PowerShell.exe",
|
||||
ExpectedArguments: "-ExecutionPolicy Bypass -File \"C:\\Scripts\\My Script.ps1\" -param \"value\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> SpecialCharactersInPaths() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with square brackets",
|
||||
Input: "\"C:\\Path\\file[1].txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Path\\file[1].txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with parentheses",
|
||||
Input: "\"C:\\Folder (2)\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Folder (2)\\app.exe",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with hyphens and underscores",
|
||||
Input: "\"C:\\Path\\file_name-123.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Path\\file_name-123.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> QuotedPathsCurrentlyBroken() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with spaces - complete path",
|
||||
Input: "\"C:\\Program Files\\MyApp\\app.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\MyApp\\app.exe",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with spaces in user folder",
|
||||
Input: "\"C:\\Users\\John Doe\\Documents\\file.txt\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\Users\\John Doe\\Documents\\file.txt",
|
||||
ExpectedArguments: string.Empty,
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with trailing arguments",
|
||||
Input: "\"C:\\Program Files\\app.exe\" --flag",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Program Files\\app.exe",
|
||||
ExpectedArguments: "--flag",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with multiple arguments",
|
||||
Input: "\"C:\\My Documents\\file.txt\" -output result.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileDocument,
|
||||
ExpectedTarget: "C:\\My Documents\\file.txt",
|
||||
ExpectedArguments: "-output result.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted path with trailing flag and value",
|
||||
Input: "\"C:\\Tools\\converter.exe\" input.txt output.txt",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.FileExecutable,
|
||||
ExpectedTarget: "C:\\Tools\\converter.exe",
|
||||
ExpectedArguments: "input.txt output.txt",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> QuotedPathsInCommands() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "cmd /c with quoted path",
|
||||
Input: "cmd /c \"C:\\Program Files\\tool.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\cmd.exe",
|
||||
ExpectedArguments: "/c \"C:\\Program Files\\tool.exe\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "PowerShell with quoted script path",
|
||||
Input: "powershell -File \"C:\\Scripts\\my script.ps1\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: Path.Combine(path1: Environment.GetFolderPath(Environment.SpecialFolder.System), "WindowsPowerShell", "v1.0", path4: "powershell.exe"),
|
||||
ExpectedArguments: "-File \"C:\\Scripts\\my script.ps1\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "runas with quoted executable",
|
||||
Input: "runas /user:admin \"C:\\Windows\\System32\\cmd.exe\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.PathCommand,
|
||||
ExpectedTarget: "C:\\Windows\\system32\\runas.exe",
|
||||
ExpectedArguments: "/user:admin \"C:\\Windows\\System32\\cmd.exe\"",
|
||||
ExpectedLaunch: LaunchMethod.ShellExecute,
|
||||
ExpectedIsPlaceholder: false)
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> QuotedAumid() =>
|
||||
[
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UWP AUMID via AppsFolder",
|
||||
Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\"",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
[
|
||||
new PlaceholderClassificationCase(
|
||||
Name: "Quoted UWP AUMID with AppsFolder prefix and argument",
|
||||
Input: "\"shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App\" --maximized",
|
||||
ExpectSuccess: true,
|
||||
ExpectedKind: CommandKind.Aumid,
|
||||
ExpectedTarget: "shell:AppsFolder\\Microsoft.WindowsTerminal_8wekyb3d8bbwe!App",
|
||||
ExpectedArguments: "--maximized",
|
||||
ExpectedLaunch: LaunchMethod.ActivateAppId,
|
||||
ExpectedIsPlaceholder: false),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public partial class BookmarkResolverTests
|
||||
{
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
private static string _testDirPath;
|
||||
private static string _userHomeDirPath;
|
||||
private static string _testDirName;
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
[ClassInitialize]
|
||||
public static void ClassSetup(TestContext context)
|
||||
{
|
||||
_userHomeDirPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
_testDirName = "CmdPalBookmarkTests" + Guid.NewGuid().ToString("N");
|
||||
_testDirPath = Path.Combine(_userHomeDirPath, _testDirName);
|
||||
Directory.CreateDirectory(_testDirPath);
|
||||
|
||||
// test files in user home
|
||||
File.WriteAllText(Path.Combine(_userHomeDirPath, "file.txt"), "This is a test text file.");
|
||||
|
||||
// test files in test dir
|
||||
File.WriteAllText(Path.Combine(_testDirPath, "file.txt"), "This is a test text file.");
|
||||
File.WriteAllText(Path.Combine(_testDirPath, "app.exe"), "This is a test text file.");
|
||||
}
|
||||
|
||||
[ClassCleanup]
|
||||
public static void ClassCleanup()
|
||||
{
|
||||
if (Directory.Exists(_testDirPath))
|
||||
{
|
||||
Directory.Delete(_testDirPath, true);
|
||||
}
|
||||
|
||||
if (File.Exists(Path.Combine(_userHomeDirPath, "file.txt")))
|
||||
{
|
||||
File.Delete(Path.Combine(_userHomeDirPath, "file.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
// must be public static to be used as DataTestMethod data source
|
||||
public static string FromCase(MethodInfo method, object[] data)
|
||||
=> data is [PlaceholderClassificationCase c]
|
||||
? c.Name
|
||||
: $"{method.Name}({string.Join(", ", data.Select(row => row.ToString()))})";
|
||||
|
||||
private static async Task RunShared(PlaceholderClassificationCase c)
|
||||
{
|
||||
// Arrange
|
||||
IBookmarkResolver resolver = new BookmarkResolver(new PlaceholderParser());
|
||||
|
||||
// Act
|
||||
var classification = await resolver.TryClassifyAsync(c.Input, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(classification);
|
||||
Assert.AreEqual(c.ExpectSuccess, classification.Success, "Success flag mismatch.");
|
||||
|
||||
if (c.ExpectSuccess)
|
||||
{
|
||||
Assert.IsNotNull(classification.Result, "Result should not be null for successful classification.");
|
||||
Assert.AreEqual(c.ExpectedKind, classification.Result.Kind, $"CommandKind mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedTarget, classification.Result.Target, StringComparer.OrdinalIgnoreCase, $"Target mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedLaunch, classification.Result.Launch, $"LaunchMethod mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedArguments, classification.Result.Arguments, $"Arguments mismatch for input: {c.Input}");
|
||||
Assert.AreEqual(c.ExpectedIsPlaceholder, classification.Result.IsPlaceholder, $"IsPlaceholder mismatch for input: {c.Input}");
|
||||
|
||||
if (c.ExpectedDisplayName != null)
|
||||
{
|
||||
Assert.AreEqual(c.ExpectedDisplayName, classification.Result.DisplayName, $"DisplayName mismatch for input: {c.Input}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlaceholderClassificationCase(
|
||||
string Name, // Friendly name for Test Explorer
|
||||
string Input, // Input string passed to classifier
|
||||
bool ExpectSuccess, // Expected Success flag
|
||||
CommandKind ExpectedKind, // Expected Result.Kind
|
||||
string ExpectedTarget, // Expected Result.Target (normalized)
|
||||
LaunchMethod ExpectedLaunch, // Expected Result.Launch
|
||||
bool ExpectedIsPlaceholder, // Expected Result.IsPlaceholder
|
||||
string ExpectedArguments = "", // Expected Result.Arguments
|
||||
string? ExpectedDisplayName = null // Expected Result.DisplayName
|
||||
);
|
||||
}
|
||||
@@ -2,9 +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.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
@@ -16,8 +16,8 @@ public class BookmarksCommandProviderTests
|
||||
public void ProviderHasCorrectId()
|
||||
{
|
||||
// Setup
|
||||
var mockDataSource = new MockBookmarkDataSource();
|
||||
var provider = new BookmarksCommandProvider(mockDataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Bookmarks", provider.Id);
|
||||
@@ -27,8 +27,8 @@ public class BookmarksCommandProviderTests
|
||||
public void ProviderHasDisplayName()
|
||||
{
|
||||
// Setup
|
||||
var mockDataSource = new MockBookmarkDataSource();
|
||||
var provider = new BookmarksCommandProvider(mockDataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.DisplayName);
|
||||
@@ -39,7 +39,8 @@ public class BookmarksCommandProviderTests
|
||||
public void ProviderHasIcon()
|
||||
{
|
||||
// Setup
|
||||
var provider = new BookmarksCommandProvider();
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(provider.Icon);
|
||||
@@ -49,7 +50,8 @@ public class BookmarksCommandProviderTests
|
||||
public void TopLevelCommandsNotEmpty()
|
||||
{
|
||||
// Setup
|
||||
var provider = new BookmarksCommandProvider();
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
@@ -60,47 +62,40 @@ public class BookmarksCommandProviderTests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ProviderWithMockData_LoadsBookmarksCorrectly()
|
||||
[Timeout(5000)]
|
||||
public async Task ProviderWithMockData_LoadsBookmarksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var jsonData = @"{
|
||||
""Data"": [
|
||||
{
|
||||
""Name"": ""Test Bookmark"",
|
||||
""Bookmark"": ""https://test.com""
|
||||
},
|
||||
{
|
||||
""Name"": ""Another Bookmark"",
|
||||
""Bookmark"": ""https://another.com""
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
var dataSource = new MockBookmarkDataSource(jsonData);
|
||||
var provider = new BookmarksCommandProvider(dataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager(
|
||||
new BookmarkData("Test Bookmark", "http://test.com"),
|
||||
new BookmarkData("Another Bookmark", "http://another.com"));
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(commands);
|
||||
|
||||
var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault();
|
||||
var testBookmark = commands.Where(c => c.Title.Contains("Test Bookmark")).FirstOrDefault();
|
||||
Assert.IsNotNull(commands, "commands != null");
|
||||
|
||||
// Should have three commands:Add + two custom bookmarks
|
||||
Assert.AreEqual(3, commands.Length);
|
||||
|
||||
Assert.IsNotNull(addCommand);
|
||||
Assert.IsNotNull(testBookmark);
|
||||
// Wait until all BookmarkListItem commands are initialized
|
||||
await Task.WhenAll(commands.OfType<Pages.BookmarkListItem>().Select(t => t.IsInitialized));
|
||||
|
||||
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
|
||||
var testBookmark = commands.FirstOrDefault(c => c.Title.Contains("Test Bookmark"));
|
||||
|
||||
Assert.IsNotNull(addCommand, "addCommand != null");
|
||||
Assert.IsNotNull(testBookmark, "testBookmark != null");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ProviderWithEmptyData_HasOnlyAddCommand()
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = new MockBookmarkDataSource(@"{ ""Data"": [] }");
|
||||
var provider = new BookmarksCommandProvider(dataSource);
|
||||
var mockBookmarkManager = new MockBookmarkManager();
|
||||
var provider = new BookmarksCommandProvider(mockBookmarkManager);
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
@@ -111,7 +106,7 @@ public class BookmarksCommandProviderTests
|
||||
// Only have Add command
|
||||
Assert.AreEqual(1, commands.Length);
|
||||
|
||||
var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault();
|
||||
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
|
||||
Assert.IsNotNull(addCommand);
|
||||
}
|
||||
|
||||
@@ -120,7 +115,7 @@ public class BookmarksCommandProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var dataSource = new MockBookmarkDataSource("invalid json");
|
||||
var provider = new BookmarksCommandProvider(dataSource);
|
||||
var provider = new BookmarksCommandProvider(new MockBookmarkManager());
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
@@ -131,7 +126,7 @@ public class BookmarksCommandProviderTests
|
||||
// Only have one command. Will ignore json parse error.
|
||||
Assert.AreEqual(1, commands.Length);
|
||||
|
||||
var addCommand = commands.Where(c => c.Title.Contains("Add bookmark")).FirstOrDefault();
|
||||
var addCommand = commands.FirstOrDefault(c => c.Title.Contains("Add bookmark"));
|
||||
Assert.IsNotNull(addCommand);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class CommandLineHelperTests
|
||||
{
|
||||
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
private static string _tempTestDir;
|
||||
|
||||
private static string _tempTestFile;
|
||||
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
||||
|
||||
[ClassInitialize]
|
||||
public static void ClassSetup(TestContext context)
|
||||
{
|
||||
// Create temporary test directory and file
|
||||
_tempTestDir = Path.Combine(Path.GetTempPath(), "CommandLineHelperTests_" + Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_tempTestDir);
|
||||
|
||||
_tempTestFile = Path.Combine(_tempTestDir, "testfile.txt");
|
||||
File.WriteAllText(_tempTestFile, "test");
|
||||
}
|
||||
|
||||
[ClassCleanup]
|
||||
public static void ClassCleanup()
|
||||
{
|
||||
// Clean up test directory
|
||||
if (Directory.Exists(_tempTestDir))
|
||||
{
|
||||
Directory.Delete(_tempTestDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("%TEMP%", false, true, DisplayName = "Expands TEMP environment variable")]
|
||||
[DataRow("%USERPROFILE%", false, true, DisplayName = "Expands USERPROFILE environment variable")]
|
||||
[DataRow("%SystemRoot%", false, true, DisplayName = "Expands SystemRoot environment variable")]
|
||||
public void Expand_WithEnvironmentVariables_ExpandsCorrectly(string input, bool expandShell, bool shouldExist)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(shouldExist, result, $"Expected result {shouldExist} for input '{input}'");
|
||||
if (shouldExist)
|
||||
{
|
||||
Assert.IsFalse(full.Contains('%'), "Output should not contain % symbols after expansion");
|
||||
Assert.IsTrue(Path.Exists(full), $"Expanded path '{full}' should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("shell:Downloads", true, DisplayName = "Expands shell:Downloads when expandShell is true")]
|
||||
[DataRow("shell:Desktop", true, DisplayName = "Expands shell:Desktop when expandShell is true")]
|
||||
[DataRow("shell:Documents", true, DisplayName = "Expands shell:Documents when expandShell is true")]
|
||||
public void Expand_WithShellPaths_ExpandsWhenFlagIsTrue(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
if (result)
|
||||
{
|
||||
Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved");
|
||||
Assert.IsTrue(Path.Exists(full), $"Expanded shell path '{full}' should exist");
|
||||
}
|
||||
|
||||
// Note: Result may be false if ShellNames.TryGetFileSystemPath fails
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("shell:Personal", false, DisplayName = "Does not expand shell: when expandShell is false")]
|
||||
public void Expand_WithShellPaths_DoesNotExpandWhenFlagIsFalse(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert - shell: paths won't exist as literal paths
|
||||
Assert.IsFalse(result, "Should return false for unexpanded shell path");
|
||||
Assert.AreEqual(input, full, "Output should match input when not expanding shell paths");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("shell:Personal\\subfolder", true, "\\subfolder", DisplayName = "Expands shell path with subfolder")]
|
||||
[DataRow("shell:Desktop\\test.txt", true, "\\test.txt", DisplayName = "Expands shell path with file")]
|
||||
public void Expand_WithShellPathsAndSubpaths_CombinesCorrectly(string input, bool expandShell, string expectedEnding)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Note: Result depends on whether the combined path exists
|
||||
if (result)
|
||||
{
|
||||
Assert.IsFalse(full.StartsWith("shell:", StringComparison.OrdinalIgnoreCase), "Shell prefix should be resolved");
|
||||
Assert.IsTrue(full.EndsWith(expectedEnding, StringComparison.OrdinalIgnoreCase), "Output should end with the subpath");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Expand_WithExistingDirectory_ReturnsFullPath()
|
||||
{
|
||||
// Arrange
|
||||
var input = _tempTestDir;
|
||||
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result, "Should return true for existing directory");
|
||||
Assert.AreEqual(Path.GetFullPath(_tempTestDir), full, "Should return full path");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Expand_WithExistingFile_ReturnsFullPath()
|
||||
{
|
||||
// Arrange
|
||||
var input = _tempTestFile;
|
||||
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, false, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result, "Should return true for existing file");
|
||||
Assert.AreEqual(Path.GetFullPath(_tempTestFile), full, "Should return full path");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", false, "C:\\NonExistent\\Path\\That\\Does\\Not\\Exist", DisplayName = "Nonexistent absolute path")]
|
||||
[DataRow("NonExistentFile.txt", false, "NonExistentFile.txt", DisplayName = "Nonexistent relative path")]
|
||||
public void Expand_WithNonExistentPath_ReturnsFalse(string input, bool expandShell, string expectedFull)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result, "Should return false for nonexistent path");
|
||||
Assert.AreEqual(expectedFull, full, "Output should be empty string");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("", false, DisplayName = "Empty string")]
|
||||
[DataRow(" ", false, DisplayName = "Whitespace only")]
|
||||
public void Expand_WithEmptyOrWhitespace_ReturnsFalse(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result, "Should return false for empty/whitespace input");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("%TEMP%\\testsubdir", false, DisplayName = "Env var with subdirectory")]
|
||||
[DataRow("%USERPROFILE%\\Desktop", false, DisplayName = "USERPROFILE with Desktop")]
|
||||
public void Expand_WithEnvironmentVariableAndSubpath_ExpandsCorrectly(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Result depends on whether the path exists
|
||||
if (result)
|
||||
{
|
||||
Assert.IsFalse(full.Contains('%'), "Should expand environment variables");
|
||||
Assert.IsTrue(Path.Exists(full), "Expanded path should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Expand_WithRelativePath_ConvertsToAbsoluteWhenExists()
|
||||
{
|
||||
// Arrange
|
||||
var relativePath = Path.GetRelativePath(Environment.CurrentDirectory, _tempTestDir);
|
||||
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(relativePath, false, out var full);
|
||||
|
||||
// Assert
|
||||
if (result)
|
||||
{
|
||||
Assert.IsTrue(Path.IsPathRooted(full), "Output should be absolute path");
|
||||
Assert.IsTrue(Path.Exists(full), "Expanded path should exist");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("InvalidShell:Path", true, DisplayName = "Invalid shell path format")]
|
||||
public void Expand_WithInvalidShellPath_ReturnsFalse(string input, bool expandShell)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.ExpandPathToPhysicalFile(input, expandShell, out var full);
|
||||
|
||||
// Assert
|
||||
// If ShellNames.TryGetFileSystemPath returns false, method returns false
|
||||
Assert.IsFalse(result || Path.Exists(full), "Should return false or path should not exist");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
|
||||
// basic
|
||||
[DataRow("cmd ping", "cmd", "ping")]
|
||||
[DataRow("cmd ping pong", "cmd", "ping pong")]
|
||||
[DataRow("cmd \"ping pong\"", "cmd", "\"ping pong\"")]
|
||||
|
||||
// no tail / trailing whitespace after head
|
||||
[DataRow("cmd", "cmd", "")]
|
||||
[DataRow("cmd ", "cmd", "")]
|
||||
|
||||
// spacing & tabs between args should be preserved in tail
|
||||
[DataRow("cmd ping pong", "cmd", "ping pong")]
|
||||
[DataRow("cmd\tping\tpong", "cmd", "ping\tpong")]
|
||||
|
||||
// leading whitespace before head
|
||||
[DataRow(" cmd ping", "", "cmd ping")]
|
||||
[DataRow("\t cmd ping", "", "cmd ping")]
|
||||
|
||||
// quoted tail variants
|
||||
[DataRow("cmd \"\"", "cmd", "\"\"")]
|
||||
[DataRow("cmd \"a \\\"quoted\\\" arg\" b", "cmd", "\"a \\\"quoted\\\" arg\" b")]
|
||||
|
||||
// quoted head (spaces in path)
|
||||
[DataRow(@"""C:\Program Files\nodejs\node.exe"" -v", @"C:\Program Files\nodejs\node.exe", "-v")]
|
||||
[DataRow(@"""C:\Program Files\Git\bin\bash.exe""", @"C:\Program Files\Git\bin\bash.exe", "")]
|
||||
[DataRow(@" ""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""", @"", @"""C:\Program Files\Git\bin\bash.exe"" -lc ""hi""")]
|
||||
[DataRow(@"""C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe"" Test.sln", @"C:\Program Files (x86)\MSBuild\Current\Bin\MSBuild.exe", "Test.sln")]
|
||||
|
||||
// quoted simple head (still should strip quotes for head)
|
||||
[DataRow(@"""cmd"" ping", "cmd", "ping")]
|
||||
|
||||
// common CLI shapes
|
||||
[DataRow("git --version", "git", "--version")]
|
||||
[DataRow("dotnet build -c Release", "dotnet", "build -c Release")]
|
||||
|
||||
// UNC paths
|
||||
[DataRow("\"\\\\server\\share\\\" with args", "\\\\server\\share\\", "with args")]
|
||||
public void SplitHeadAndArgs(string input, string expectedHead, string expectedTail)
|
||||
{
|
||||
// Act
|
||||
var result = CommandLineHelper.SplitHeadAndArgs(input);
|
||||
|
||||
// Assert
|
||||
// If ShellNames.TryGetFileSystemPath returns false, method returns false
|
||||
Assert.AreEqual(expectedHead, result.Head);
|
||||
Assert.AreEqual(expectedTail, result.Tail);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(@"C:\program files\myapp\app.exe -param ""1"" -param 2", @"C:\program files\myapp\app.exe -param", @"""1"" -param 2")]
|
||||
[DataRow(@"git commit -m test", "git commit -m test", "")]
|
||||
[DataRow(@"""C:\Program Files\App\app.exe"" -v", "", @"""C:\Program Files\App\app.exe"" -v")]
|
||||
[DataRow(@"tool a\\\""b c ""d e"" f", @"tool a\\\""b c", @"""d e"" f")] // escaped quote before first real one
|
||||
[DataRow("C:\\Some\\\"Path\\file.txt", "C:\\Some\\\"Path\\file.txt", "")]
|
||||
[DataRow(@" ""C:\p\app.exe"" -v", "", @"""C:\p\app.exe"" -v")] // first token is quoted
|
||||
public void SplitLongestHeadBeforeQuotedArg_Tests(string input, string expectedHead, string expectedTail)
|
||||
{
|
||||
var (head, tail) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input);
|
||||
Assert.AreEqual(expectedHead, head);
|
||||
Assert.AreEqual(expectedTail, tail);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
internal sealed class MockBookmarkDataSource : IBookmarkDataSource
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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 Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
#pragma warning disable CS0067
|
||||
|
||||
internal sealed class MockBookmarkManager : IBookmarksManager
|
||||
{
|
||||
private readonly List<BookmarkData> _bookmarks;
|
||||
|
||||
public event Action<BookmarkData> BookmarkAdded;
|
||||
|
||||
public event Action<BookmarkData, BookmarkData> BookmarkUpdated;
|
||||
|
||||
public event Action<BookmarkData> BookmarkRemoved;
|
||||
|
||||
public IReadOnlyCollection<BookmarkData> Bookmarks => _bookmarks;
|
||||
|
||||
public BookmarkData Add(string name, string bookmark) => throw new NotImplementedException();
|
||||
|
||||
public bool Remove(Guid id) => throw new NotImplementedException();
|
||||
|
||||
public BookmarkData Update(Guid id, string name, string bookmark) => throw new NotImplementedException();
|
||||
|
||||
public MockBookmarkManager(params IEnumerable<BookmarkData> bookmarks)
|
||||
{
|
||||
_bookmarks = [.. bookmarks];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// 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 Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class PlaceholderInfoNameEqualityComparerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Equals_BothNull_ReturnsTrue()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
|
||||
var result = comparer.Equals(null, null);
|
||||
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_OneNull_ReturnsFalse()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p = new PlaceholderInfo("name", 0);
|
||||
|
||||
Assert.IsFalse(comparer.Equals(p, null));
|
||||
Assert.IsFalse(comparer.Equals(null, p));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_SameNameDifferentIndex_ReturnsTrue()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("name", 0);
|
||||
var p2 = new PlaceholderInfo("name", 10);
|
||||
|
||||
Assert.IsTrue(comparer.Equals(p1, p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_DifferentNameSameIndex_ReturnsFalse()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("first", 3);
|
||||
var p2 = new PlaceholderInfo("second", 3);
|
||||
|
||||
Assert.IsFalse(comparer.Equals(p1, p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_CaseInsensitive_ReturnsTrue()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("Name", 0);
|
||||
var p2 = new PlaceholderInfo("name", 5);
|
||||
|
||||
Assert.IsTrue(comparer.Equals(p1, p2));
|
||||
Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetHashCode_SameNameDifferentIndex_SameHash()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var p1 = new PlaceholderInfo("same", 1);
|
||||
var p2 = new PlaceholderInfo("same", 99);
|
||||
|
||||
Assert.AreEqual(comparer.GetHashCode(p1), comparer.GetHashCode(p2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetHashCode_Null_ThrowsArgumentNullException()
|
||||
{
|
||||
var comparer = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
Assert.ThrowsException<ArgumentNullException>(() => comparer.GetHashCode(null!));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Instance_ReturnsSingleton()
|
||||
{
|
||||
var a = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
var b = PlaceholderInfoNameEqualityComparer.Instance;
|
||||
|
||||
Assert.IsNotNull(a);
|
||||
Assert.AreSame(a, b);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HashSet_UsesNameEquality_IgnoresIndex()
|
||||
{
|
||||
var set = new HashSet<PlaceholderInfo>(PlaceholderInfoNameEqualityComparer.Instance)
|
||||
{
|
||||
new("dup", 0),
|
||||
new("DUP", 10),
|
||||
new("unique", 0),
|
||||
};
|
||||
|
||||
Assert.AreEqual(2, set.Count);
|
||||
Assert.IsTrue(set.Contains(new PlaceholderInfo("dup", 123)));
|
||||
Assert.IsTrue(set.Contains(new PlaceholderInfo("UNIQUE", 999)));
|
||||
Assert.IsFalse(set.Contains(new PlaceholderInfo("missing", 0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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 Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class PlaceholderParserTests
|
||||
{
|
||||
private IPlaceholderParser _parser;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_parser = new PlaceholderParser();
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> ValidPlaceholderTestData =>
|
||||
[
|
||||
[
|
||||
"Hello {name}!",
|
||||
true,
|
||||
"Hello ",
|
||||
new[] { "name" },
|
||||
new[] { 6 }
|
||||
],
|
||||
[
|
||||
"User {user_name} has {count} items",
|
||||
true,
|
||||
"User ",
|
||||
new[] { "user_name", "count" },
|
||||
new[] { 5, 21 }
|
||||
],
|
||||
[
|
||||
"Order {order-id} for {name} by {name}",
|
||||
true,
|
||||
"Order ",
|
||||
new[] { "order-id", "name", "name" },
|
||||
new[] { 6, 21, 31 }
|
||||
],
|
||||
[
|
||||
"{start} and {end}",
|
||||
true,
|
||||
string.Empty,
|
||||
new[] { "start", "end" },
|
||||
new[] { 0, 12 }
|
||||
],
|
||||
[
|
||||
"Number {123} and text {abc}",
|
||||
true,
|
||||
"Number ",
|
||||
new[] { "123", "abc" },
|
||||
new[] { 7, 22 }
|
||||
]
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> InvalidPlaceholderTestData =>
|
||||
[
|
||||
[string.Empty, false, string.Empty, Array.Empty<string>()],
|
||||
["No placeholders here", false, "No placeholders here", Array.Empty<string>()],
|
||||
["GUID: {550e8400-e29b-41d4-a716-446655440000}", false, "GUID: {550e8400-e29b-41d4-a716-446655440000}", Array.Empty<string>()],
|
||||
["Invalid {user.name} placeholder", false, "Invalid {user.name} placeholder", Array.Empty<string>()],
|
||||
["Empty {} placeholder", false, "Empty {} placeholder", Array.Empty<string>()],
|
||||
["Unclosed {placeholder", false, "Unclosed {placeholder", Array.Empty<string>()],
|
||||
["No opening brace placeholder}", false, "No opening brace placeholder}", Array.Empty<string>()],
|
||||
["Invalid chars {user@domain}", false, "Invalid chars {user@domain}", Array.Empty<string>()],
|
||||
["Spaces { name }", false, "Spaces { name }", Array.Empty<string>()]
|
||||
];
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(ValidPlaceholderTestData))]
|
||||
public void ParsePlaceholders_ValidInput_ReturnsExpectedResults(
|
||||
string input,
|
||||
bool expectedResult,
|
||||
string expectedHead,
|
||||
string[] expectedPlaceholderNames,
|
||||
int[] expectedIndexes)
|
||||
{
|
||||
// Act
|
||||
var result = _parser.ParsePlaceholders(input, out var head, out var placeholders);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expectedResult, result);
|
||||
Assert.AreEqual(expectedHead, head);
|
||||
Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count);
|
||||
|
||||
var actualNames = placeholders.Select(p => p.Name).ToArray();
|
||||
var actualIndexes = placeholders.Select(p => p.Index).ToArray();
|
||||
|
||||
// Validate names and indexes (allow duplicates, ignore order)
|
||||
CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames);
|
||||
CollectionAssert.AreEquivalent(expectedIndexes, actualIndexes);
|
||||
|
||||
// Validate name-index pairing exists for each expected placeholder occurrence
|
||||
for (var i = 0; i < expectedPlaceholderNames.Length; i++)
|
||||
{
|
||||
var expectedName = expectedPlaceholderNames[i];
|
||||
var expectedIndex = expectedIndexes[i];
|
||||
Assert.IsTrue(
|
||||
placeholders.Any(p => p.Name == expectedName && p.Index == expectedIndex),
|
||||
$"Expected placeholder '{{{expectedName}}}' at index {expectedIndex} was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(InvalidPlaceholderTestData))]
|
||||
public void ParsePlaceholders_InvalidInput_ReturnsExpectedResults(
|
||||
string input,
|
||||
bool expectedResult,
|
||||
string expectedHead,
|
||||
string[] expectedPlaceholderNames)
|
||||
{
|
||||
// Act
|
||||
var result = _parser.ParsePlaceholders(input, out var head, out var placeholders);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(expectedResult, result);
|
||||
Assert.AreEqual(expectedHead, head);
|
||||
Assert.AreEqual(expectedPlaceholderNames.Length, placeholders.Count);
|
||||
|
||||
var actualNames = placeholders.Select(p => p.Name).ToArray();
|
||||
CollectionAssert.AreEquivalent(expectedPlaceholderNames, actualNames);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ParsePlaceholders_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
Assert.ThrowsException<ArgumentNullException>(() => _parser.ParsePlaceholders(null!, out _, out _));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_Equality_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var placeholder1 = new PlaceholderInfo("name", 0);
|
||||
var placeholder2 = new PlaceholderInfo("name", 0);
|
||||
var placeholder3 = new PlaceholderInfo("other", 0);
|
||||
var placeholder4 = new PlaceholderInfo("name", 1);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(placeholder1, placeholder2);
|
||||
Assert.AreNotEqual(placeholder1, placeholder3);
|
||||
Assert.AreEqual(placeholder1.GetHashCode(), placeholder2.GetHashCode());
|
||||
Assert.AreNotEqual(placeholder1, placeholder4);
|
||||
Assert.AreNotEqual(placeholder1.GetHashCode(), placeholder4.GetHashCode());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_ToString_ReturnsName()
|
||||
{
|
||||
// Arrange
|
||||
var placeholder = new PlaceholderInfo("userName", 0);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("userName", placeholder.ToString());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_Constructor_ThrowsOnNull()
|
||||
{
|
||||
// Assert
|
||||
Assert.ThrowsException<ArgumentNullException>(() => new PlaceholderInfo(null!, 0));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Placeholder_Constructor_ThrowsArgumentOutOfRange()
|
||||
{
|
||||
// Assert
|
||||
Assert.ThrowsException<ArgumentOutOfRangeException>(() => new PlaceholderInfo("Name", -1));
|
||||
}
|
||||
}
|
||||
@@ -40,16 +40,4 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
Assert.IsNotNull(githubBookmark);
|
||||
Assert.AreEqual("https://github.com", githubBookmark.Bookmark);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidateWebUrlDetection()
|
||||
{
|
||||
// Setup
|
||||
var bookmarks = Settings.CreateDefaultBookmarks();
|
||||
var microsoftBookmark = bookmarks.Data.FirstOrDefault(b => b.Name == "Microsoft");
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(microsoftBookmark);
|
||||
Assert.IsTrue(microsoftBookmark.IsWebUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
public static class Settings
|
||||
{
|
||||
public static Bookmarks CreateDefaultBookmarks()
|
||||
public static BookmarksData CreateDefaultBookmarks()
|
||||
{
|
||||
var bookmarks = new Bookmarks();
|
||||
var bookmarks = new BookmarksData();
|
||||
|
||||
// Add some test bookmarks
|
||||
bookmarks.Data.Add(new BookmarkData
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class UriHelperTests
|
||||
{
|
||||
private static bool TryGetScheme(ReadOnlySpan<char> input, out string scheme, out string remainder)
|
||||
{
|
||||
return UriHelper.TryGetScheme(input, out scheme, out remainder);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("http://example.com", "http", "//example.com")]
|
||||
[DataRow("ftp:", "ftp", "")]
|
||||
[DataRow("my-app:payload", "my-app", "payload")]
|
||||
[DataRow("x-cmdpal://settings/", "x-cmdpal", "//settings/")]
|
||||
[DataRow("custom+ext.-scheme:xyz", "custom+ext.-scheme", "xyz")]
|
||||
[DataRow("MAILTO:foo@bar", "MAILTO", "foo@bar")]
|
||||
[DataRow("a:b", "a", "b")]
|
||||
public void TryGetScheme_ValidSchemes_ReturnsTrueAndSplits(string input, string expectedScheme, string expectedRemainder)
|
||||
{
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok, "Expected valid scheme.");
|
||||
Assert.AreEqual(expectedScheme, scheme);
|
||||
Assert.AreEqual(expectedRemainder, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_OnlySchemeAndColon_ReturnsEmptyRemainder()
|
||||
{
|
||||
var ok = TryGetScheme("http:".AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("http", scheme);
|
||||
Assert.AreEqual(string.Empty, remainder);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("123:http")] // starts with digit
|
||||
[DataRow(":nope")] // colon at start
|
||||
[DataRow("noColon")] // no colon at all
|
||||
[DataRow("bad_scheme:")] // underscore not allowed
|
||||
[DataRow("bad*scheme:")] // asterisk not allowed
|
||||
[DataRow(":")] // syntactically invalid literal just for completeness; won't compile, example only
|
||||
public void TryGetScheme_InvalidInputs_ReturnsFalse(string input)
|
||||
{
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsFalse(ok);
|
||||
Assert.AreEqual(string.Empty, scheme);
|
||||
Assert.AreEqual(string.Empty, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_MultipleColons_SplitsOnFirst()
|
||||
{
|
||||
const string input = "shell:::{645FF040-5081-101B-9F08-00AA002F954E}";
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("shell", scheme);
|
||||
Assert.AreEqual("::{645FF040-5081-101B-9F08-00AA002F954E}", remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_MinimumLength_OneLetterAndColon()
|
||||
{
|
||||
var ok = TryGetScheme("a:".AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("a", scheme);
|
||||
Assert.AreEqual(string.Empty, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_TooShort_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(TryGetScheme("a".AsSpan(), out _, out _), "No colon.");
|
||||
Assert.IsFalse(TryGetScheme(":".AsSpan(), out _, out _), "Colon at start; no scheme.");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("HTTP://x", "HTTP", "//x")]
|
||||
[DataRow("hTtP:rest", "hTtP", "rest")]
|
||||
public void TryGetScheme_CaseIsPreserved(string input, string expectedScheme, string expectedRemainder)
|
||||
{
|
||||
var ok = TryGetScheme(input.AsSpan(), out var scheme, out var remainder);
|
||||
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual(expectedScheme, scheme);
|
||||
Assert.AreEqual(expectedRemainder, remainder);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_WhitespaceInsideScheme_Fails()
|
||||
{
|
||||
Assert.IsFalse(TryGetScheme("ht tp:rest".AsSpan(), out _, out _));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryGetScheme_PlusMinusDot_AllowedInMiddleOnly()
|
||||
{
|
||||
Assert.IsTrue(TryGetScheme("a+b.c-d:rest".AsSpan(), out var s1, out var r1));
|
||||
Assert.AreEqual("a+b.c-d", s1);
|
||||
Assert.AreEqual("rest", r1);
|
||||
|
||||
// The first character must be a letter; plus is not allowed as first char
|
||||
Assert.IsFalse(TryGetScheme("+abc:rest".AsSpan(), out _, out _));
|
||||
Assert.IsFalse(TryGetScheme(".abc:rest".AsSpan(), out _, out _));
|
||||
Assert.IsFalse(TryGetScheme("-abc:rest".AsSpan(), out _, out _));
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Core.Common.Services;
|
||||
using Microsoft.CmdPal.Ext.Shell.Pages;
|
||||
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
|
||||
@@ -83,7 +84,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 +116,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 +142,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, () =>
|
||||
{
|
||||
@@ -154,4 +155,131 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
// Should find at least the ping command from history
|
||||
Assert.IsTrue(commandList.Length > 1);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestCacheBackToSameDirectory()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Load up everything in c:\, for the sake of comparing:
|
||||
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
|
||||
// Should find only items for what's in c:\
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Win"; });
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||
|
||||
commandList = page.GetItems();
|
||||
|
||||
// Should still find everything
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
|
||||
await TypeStringIntoPage(page, "c:\\Windows\\Pro");
|
||||
await BackspaceSearchText(page, "c:\\Windows\\Pro", 3); // 3 characters for c:\
|
||||
|
||||
commandList = page.GetItems();
|
||||
|
||||
// Should still find everything
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
}
|
||||
|
||||
private async Task TypeStringIntoPage(IDynamicListPage page, string searchText)
|
||||
{
|
||||
// type the string one character at a time
|
||||
for (var i = 0; i < searchText.Length; i++)
|
||||
{
|
||||
var substr = searchText[..i];
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BackspaceSearchText(IDynamicListPage page, string originalSearchText, int finalStringLength)
|
||||
{
|
||||
var originalLength = originalSearchText.Length;
|
||||
for (var i = originalLength; i >= finalStringLength; i--)
|
||||
{
|
||||
var substr = originalSearchText[..i];
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = substr; });
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestCacheSameDirectorySlashy()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Load up everything in c:\, for the sake of comparing:
|
||||
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||
var filesInWindows = Directory.EnumerateFileSystemEntries("C:\\Windows");
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
Assert.IsTrue(commandList.Length == filesInC.Count());
|
||||
|
||||
// First navigate to c:\Windows. This should match everything that matches "windows" inside of C:\
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||
var cWindowsCommandsPre = page.GetItems();
|
||||
|
||||
// Then go into c:\windows\. This will only have the results in c:\windows\
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows\\"; });
|
||||
var windowsCommands = page.GetItems();
|
||||
Assert.IsTrue(windowsCommands.Length != cWindowsCommandsPre.Length);
|
||||
|
||||
// now go back to c:\windows. This should match the results from the last time we entered this string
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Windows"; });
|
||||
var cWindowsCommandsPost = page.GetItems();
|
||||
Assert.IsTrue(cWindowsCommandsPre.Length == cWindowsCommandsPost.Length);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestPathWithSpaces()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
// Load up everything in c:\, for the sake of comparing:
|
||||
var filesInC = Directory.EnumerateFileSystemEntries("C:\\");
|
||||
var filesInProgramFiles = Directory.EnumerateFileSystemEntries("C:\\Program Files");
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
Assert.IsTrue(commandList.Length == filesInProgramFiles.Count());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task TestNoWrapSuggestionsWithSpaces()
|
||||
{
|
||||
// Setup
|
||||
var settings = Settings.CreateDefaultSettings();
|
||||
var mockHistoryService = CreateMockHistoryService();
|
||||
|
||||
var page = new ShellListPage(settings, mockHistoryService.Object, telemetryService: null);
|
||||
|
||||
await UpdatePageAndWaitForItems(page, () => { page.SearchText = "c:\\Program Files\\"; });
|
||||
|
||||
var commandList = page.GetItems();
|
||||
|
||||
foreach (var item in commandList)
|
||||
{
|
||||
Assert.IsTrue(!string.IsNullOrEmpty(item.TextToSuggest));
|
||||
Assert.IsFalse(item.TextToSuggest.StartsWith('"'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.UnitTestBase;
|
||||
|
||||
@@ -32,9 +33,14 @@ public class CommandPaletteUnitTestBase
|
||||
// and wait for the event to be raised.
|
||||
var tcs = new TaskCompletionSource<object>();
|
||||
|
||||
page.ItemsChanged += (sender, args) => tcs.SetResult(null);
|
||||
TypedEventHandler<object, IItemsChangedEventArgs> handleItemsChanged = (object s, IItemsChangedEventArgs e) =>
|
||||
{
|
||||
tcs.TrySetResult(e);
|
||||
};
|
||||
|
||||
page.ItemsChanged += handleItemsChanged;
|
||||
modification();
|
||||
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,24 +16,19 @@ using WyHash;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps;
|
||||
|
||||
public sealed partial class AppCommand : InvokableCommand
|
||||
internal sealed partial class AppCommand : InvokableCommand
|
||||
{
|
||||
private readonly AppItem _app;
|
||||
|
||||
public AppCommand(AppItem app)
|
||||
{
|
||||
_app = app;
|
||||
|
||||
Name = Resources.run_command_action;
|
||||
Name = Resources.run_command_action!;
|
||||
Id = GenerateId();
|
||||
|
||||
if (!string.IsNullOrEmpty(app.IcoPath))
|
||||
{
|
||||
Icon = new(app.IcoPath);
|
||||
}
|
||||
Icon = Icons.GenericAppIcon;
|
||||
}
|
||||
|
||||
internal static async Task StartApp(string aumid)
|
||||
private static async Task StartApp(string aumid)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
@@ -58,7 +53,7 @@ public sealed partial class AppCommand : InvokableCommand
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal static async Task StartExe(string path)
|
||||
private static async Task StartExe(string path)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
@@ -73,7 +68,7 @@ public sealed partial class AppCommand : InvokableCommand
|
||||
});
|
||||
}
|
||||
|
||||
internal async Task Launch()
|
||||
private async Task Launch()
|
||||
{
|
||||
if (_app.IsPackaged)
|
||||
{
|
||||
|
||||
@@ -5,34 +5,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Apps.Commands;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
|
||||
internal sealed partial class AppListItem : ListItem
|
||||
{
|
||||
private readonly AppItem _app;
|
||||
private static readonly Tag _appTag = new("App");
|
||||
|
||||
private readonly AppCommand _appCommand;
|
||||
private readonly AppItem _app;
|
||||
private readonly Lazy<Details> _details;
|
||||
private readonly Lazy<IconInfo> _icon;
|
||||
private readonly Lazy<Task<IconInfo?>> _iconLoadTask;
|
||||
|
||||
private InterlockedBoolean _isLoadingIcon;
|
||||
|
||||
public override IDetails? Details { get => _details.Value; set => base.Details = value; }
|
||||
|
||||
public override IIconInfo? Icon { get => _icon.Value; set => base.Icon = value; }
|
||||
public override IIconInfo? Icon
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_isLoadingIcon.Set())
|
||||
{
|
||||
_ = LoadIconAsync();
|
||||
}
|
||||
|
||||
return base.Icon;
|
||||
}
|
||||
set => base.Icon = value;
|
||||
}
|
||||
|
||||
public string AppIdentifier => _app.AppIdentifier;
|
||||
|
||||
public AppListItem(AppItem app, bool useThumbnails, bool isPinned)
|
||||
: base(new AppCommand(app))
|
||||
{
|
||||
Command = _appCommand = new AppCommand(app);
|
||||
_app = app;
|
||||
Title = app.Name;
|
||||
Subtitle = app.Subtitle;
|
||||
Tags = [_appTag];
|
||||
Icon = Icons.GenericAppIcon;
|
||||
|
||||
MoreCommands = AddPinCommands(_app.Commands!, isPinned);
|
||||
|
||||
@@ -43,12 +60,19 @@ internal sealed partial class AppListItem : ListItem
|
||||
return t.Result;
|
||||
});
|
||||
|
||||
_icon = new Lazy<IconInfo>(() =>
|
||||
_iconLoadTask = new Lazy<Task<IconInfo?>>(async () => await FetchIcon(useThumbnails));
|
||||
}
|
||||
|
||||
private async Task LoadIconAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = FetchIcon(useThumbnails);
|
||||
t.Wait();
|
||||
return t.Result;
|
||||
});
|
||||
Icon = _appCommand.Icon = await _iconLoadTask.Value ?? Icons.GenericAppIcon;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning($"Failed to load icon for {AppIdentifier}\n{ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Details> BuildDetails()
|
||||
@@ -87,12 +111,12 @@ internal sealed partial class AppListItem : ListItem
|
||||
return new Details()
|
||||
{
|
||||
Title = this.Title,
|
||||
HeroImage = heroImage ?? this.Icon ?? new IconInfo(string.Empty),
|
||||
HeroImage = heroImage ?? this.Icon ?? Icons.GenericAppIcon,
|
||||
Metadata = metadata.ToArray(),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IconInfo> FetchIcon(bool useThumbnails)
|
||||
private async Task<IconInfo> FetchIcon(bool useThumbnails)
|
||||
{
|
||||
IconInfo? icon = null;
|
||||
if (_app.IsPackaged)
|
||||
@@ -108,12 +132,12 @@ internal sealed partial class AppListItem : ListItem
|
||||
var stream = await ThumbnailHelper.GetThumbnail(_app.ExePath);
|
||||
if (stream is not null)
|
||||
{
|
||||
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
|
||||
icon = new IconInfo(data, data);
|
||||
icon = IconInfo.FromStream(stream);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug($"Failed to load icon for {AppIdentifier}:\n{ex}");
|
||||
}
|
||||
|
||||
icon = icon ?? new IconInfo(_app.IcoPath);
|
||||
|
||||
@@ -6,21 +6,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Apps;
|
||||
|
||||
internal sealed class Icons
|
||||
internal static class Icons
|
||||
{
|
||||
internal static IconInfo AllAppsIcon => IconHelpers.FromRelativePath("Assets\\AllApps.svg");
|
||||
internal static IconInfo AllAppsIcon { get; } = IconHelpers.FromRelativePath("Assets\\AllApps.svg");
|
||||
|
||||
internal static IconInfo RunAsUserIcon => new("\uE7EE"); // OtherUser icon
|
||||
internal static IconInfo RunAsUserIcon { get; } = new("\uE7EE"); // OtherUser icon
|
||||
|
||||
internal static IconInfo RunAsAdminIcon => new("\uE7EF"); // Admin icon
|
||||
internal static IconInfo RunAsAdminIcon { get; } = new("\uE7EF"); // Admin icon
|
||||
|
||||
internal static IconInfo OpenPathIcon => new("\ue838"); // Folder Open icon
|
||||
internal static IconInfo OpenPathIcon { get; } = new("\ue838"); // Folder Open icon
|
||||
|
||||
internal static IconInfo CopyIcon => new("\ue8c8"); // Copy icon
|
||||
internal static IconInfo CopyIcon { get; } = new("\ue8c8"); // Copy icon
|
||||
|
||||
public static IconInfo UnpinIcon { get; } = new("\uE77A"); // Unpin icon
|
||||
|
||||
public static IconInfo PinIcon { get; } = new("\uE840"); // Pin icon
|
||||
|
||||
public static IconInfo UninstallApplicationIcon { get; } = new("\uE74D"); // Uninstall icon
|
||||
|
||||
public static IconInfo GenericAppIcon { get; } = new("\uE737"); // Favicon
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
public class BookmarkData
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Bookmark { get; set; } = string.Empty;
|
||||
|
||||
// public string Type { get; set; } = string.Empty;
|
||||
[JsonIgnore]
|
||||
public bool IsPlaceholder => Bookmark.Contains('{') && Bookmark.Contains('}');
|
||||
|
||||
internal void GetExeAndArgs(out string exe, out string args)
|
||||
{
|
||||
ShellHelpers.ParseExecutableAndArgs(Bookmark, out exe, out args);
|
||||
}
|
||||
|
||||
internal bool IsWebUrl()
|
||||
{
|
||||
GetExeAndArgs(out var exe, out var args);
|
||||
if (string.IsNullOrEmpty(exe))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(exe, UriKind.Absolute, out var uri))
|
||||
{
|
||||
if (uri.Scheme == Uri.UriSchemeFile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// return true if the scheme is http or https, or if there's no scheme (e.g., "www.example.com") but there is a dot in the host
|
||||
return
|
||||
uri.Scheme == Uri.UriSchemeHttp ||
|
||||
uri.Scheme == Uri.UriSchemeHttps ||
|
||||
(string.IsNullOrEmpty(uri.Scheme) && uri.Host.Contains('.'));
|
||||
}
|
||||
|
||||
// If we can't parse it as a URI, we assume it's not a web URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class BookmarkPlaceholderForm : FormContent
|
||||
{
|
||||
private static readonly CompositeFormat ErrorMessage = System.Text.CompositeFormat.Parse(Resources.bookmarks_required_placeholder);
|
||||
|
||||
private readonly List<string> _placeholderNames;
|
||||
|
||||
private readonly string _bookmark = string.Empty;
|
||||
|
||||
// TODO pass in an array of placeholders
|
||||
public BookmarkPlaceholderForm(string name, string url)
|
||||
{
|
||||
_bookmark = url;
|
||||
var r = new Regex(Regex.Escape("{") + "(.*?)" + Regex.Escape("}"));
|
||||
var matches = r.Matches(url);
|
||||
_placeholderNames = matches.Select(m => m.Groups[1].Value).ToList();
|
||||
var inputs = _placeholderNames.Select(p =>
|
||||
{
|
||||
var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, p);
|
||||
return $$"""
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
"id": "{{p}}",
|
||||
"label": "{{p}}",
|
||||
"isRequired": true,
|
||||
"errorMessage": "{{errorMessage}}"
|
||||
}
|
||||
""";
|
||||
}).ToList();
|
||||
|
||||
var allInputs = string.Join(",", inputs);
|
||||
|
||||
TemplateJson = $$"""
|
||||
{
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.5",
|
||||
"body": [
|
||||
""" + allInputs + $$"""
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Action.Submit",
|
||||
"title": "{{Resources.bookmarks_form_open}}",
|
||||
"data": {
|
||||
"placeholder": "placeholder"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
public override CommandResult SubmitForm(string payload)
|
||||
{
|
||||
var target = _bookmark;
|
||||
|
||||
// parse the submitted JSON and then open the link
|
||||
var formInput = JsonNode.Parse(payload);
|
||||
var formObject = formInput?.AsObject();
|
||||
if (formObject is null)
|
||||
{
|
||||
return CommandResult.GoHome();
|
||||
}
|
||||
|
||||
foreach (var (key, value) in formObject)
|
||||
{
|
||||
var placeholderString = $"{{{key}}}";
|
||||
var placeholderData = value?.ToString();
|
||||
target = target.Replace(placeholderString, placeholderData);
|
||||
}
|
||||
|
||||
var success = UrlCommand.LaunchCommand(target);
|
||||
|
||||
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class BookmarkPlaceholderPage : ContentPage
|
||||
{
|
||||
private readonly Lazy<IconInfo> _icon;
|
||||
private readonly FormContent _bookmarkPlaceholder;
|
||||
|
||||
public override IContent[] GetContent() => [_bookmarkPlaceholder];
|
||||
|
||||
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
|
||||
|
||||
public BookmarkPlaceholderPage(BookmarkData data)
|
||||
: this(data.Name, data.Bookmark)
|
||||
{
|
||||
}
|
||||
|
||||
public BookmarkPlaceholderPage(string name, string url)
|
||||
{
|
||||
Name = Properties.Resources.bookmarks_command_name_open;
|
||||
|
||||
_bookmarkPlaceholder = new BookmarkPlaceholderForm(name, url);
|
||||
|
||||
_icon = new Lazy<IconInfo>(() =>
|
||||
{
|
||||
ShellHelpers.ParseExecutableAndArgs(url, out var exe, out var args);
|
||||
var t = UrlCommand.GetIconForPath(exe);
|
||||
t.Wait();
|
||||
return t.Result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,186 +2,129 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Contracts;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CmdPal.Ext.Indexer;
|
||||
using System.Threading;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
public partial class BookmarksCommandProvider : CommandProvider
|
||||
public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
{
|
||||
private readonly List<CommandItem> _commands = [];
|
||||
private const int LoadStateNotLoaded = 0;
|
||||
private const int LoadStateLoading = 1;
|
||||
private const int LoadStateLoaded = 2;
|
||||
|
||||
private readonly AddBookmarkPage _addNewCommand = new(null);
|
||||
private readonly IPlaceholderParser _placeholderParser = new PlaceholderParser();
|
||||
private readonly IBookmarksManager _bookmarksManager;
|
||||
private readonly IBookmarkResolver _commandResolver;
|
||||
private readonly IBookmarkIconLocator _iconLocator = new IconLocator();
|
||||
|
||||
private readonly IBookmarkDataSource _dataSource;
|
||||
private readonly BookmarkJsonParser _parser;
|
||||
private Bookmarks? _bookmarks;
|
||||
private readonly ListItem _addNewItem;
|
||||
private readonly Lock _bookmarksLock = new();
|
||||
|
||||
public BookmarksCommandProvider()
|
||||
: this(new FileBookmarkDataSource(StateJsonPath()))
|
||||
private ICommandItem[] _commands = [];
|
||||
private List<BookmarkListItem> _bookmarks = [];
|
||||
private int _loadState;
|
||||
|
||||
private static string StateJsonPath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
Directory.CreateDirectory(directory);
|
||||
return Path.Combine(directory, "bookmarks.json");
|
||||
}
|
||||
|
||||
internal BookmarksCommandProvider(IBookmarkDataSource dataSource)
|
||||
public static BookmarksCommandProvider CreateWithDefaultStore()
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
_parser = new BookmarkJsonParser();
|
||||
return new BookmarksCommandProvider(new BookmarksManager(new FileBookmarkDataSource(StateJsonPath())));
|
||||
}
|
||||
|
||||
internal BookmarksCommandProvider(IBookmarksManager bookmarksManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
_bookmarksManager = bookmarksManager;
|
||||
_bookmarksManager.BookmarkAdded += OnBookmarkAdded;
|
||||
_bookmarksManager.BookmarkRemoved += OnBookmarkRemoved;
|
||||
|
||||
_commandResolver = new BookmarkResolver(_placeholderParser);
|
||||
|
||||
Id = "Bookmarks";
|
||||
DisplayName = Resources.bookmarks_display_name;
|
||||
Icon = Icons.PinIcon;
|
||||
|
||||
_addNewCommand.AddedCommand += AddNewCommand_AddedCommand;
|
||||
var addBookmarkPage = new AddBookmarkPage(null);
|
||||
addBookmarkPage.AddedCommand += (_, e) => _bookmarksManager.Add(e.Name, e.Bookmark);
|
||||
_addNewItem = new ListItem(addBookmarkPage);
|
||||
}
|
||||
|
||||
private void AddNewCommand_AddedCommand(object sender, BookmarkData args)
|
||||
private void OnBookmarkAdded(BookmarkData bookmarkData)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Adding bookmark ({args.Name},{args.Bookmark})");
|
||||
_bookmarks?.Data.Add(args);
|
||||
var newItem = new BookmarkListItem(bookmarkData, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser);
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_bookmarks.Add(newItem);
|
||||
}
|
||||
|
||||
SaveAndUpdateCommands();
|
||||
NotifyChange();
|
||||
}
|
||||
|
||||
// In the edit path, `args` was already in _bookmarks, we just updated it
|
||||
private void Edit_AddedCommand(object sender, BookmarkData args)
|
||||
private void OnBookmarkRemoved(BookmarkData bookmarkData)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Edited bookmark ({args.Name},{args.Bookmark})");
|
||||
|
||||
SaveAndUpdateCommands();
|
||||
}
|
||||
|
||||
private void SaveAndUpdateCommands()
|
||||
{
|
||||
try
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
var jsonData = _parser.SerializeBookmarks(_bookmarks);
|
||||
_dataSource.SaveBookmarkData(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to save bookmarks: {ex.Message}");
|
||||
_bookmarks.RemoveAll(t => t.BookmarkId == bookmarkData.Id);
|
||||
}
|
||||
|
||||
LoadCommands();
|
||||
RaiseItemsChanged(0);
|
||||
}
|
||||
|
||||
private void LoadCommands()
|
||||
{
|
||||
List<CommandItem> collected = [];
|
||||
collected.Add(new CommandItem(_addNewCommand));
|
||||
|
||||
if (_bookmarks is null)
|
||||
{
|
||||
LoadBookmarksFromFile();
|
||||
}
|
||||
|
||||
if (_bookmarks is not null)
|
||||
{
|
||||
collected.AddRange(_bookmarks.Data.Select(BookmarkToCommandItem));
|
||||
}
|
||||
|
||||
_commands.Clear();
|
||||
_commands.AddRange(collected);
|
||||
}
|
||||
|
||||
private void LoadBookmarksFromFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonData = _dataSource.GetBookmarkData();
|
||||
_bookmarks = _parser.ParseBookmarks(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex.Message);
|
||||
}
|
||||
|
||||
if (_bookmarks is null)
|
||||
{
|
||||
_bookmarks = new();
|
||||
}
|
||||
}
|
||||
|
||||
private CommandItem BookmarkToCommandItem(BookmarkData bookmark)
|
||||
{
|
||||
ICommand command = bookmark.IsPlaceholder ?
|
||||
new BookmarkPlaceholderPage(bookmark) :
|
||||
new UrlCommand(bookmark);
|
||||
|
||||
var listItem = new CommandItem(command) { Icon = command.Icon };
|
||||
|
||||
List<CommandContextItem> contextMenu = [];
|
||||
|
||||
// Add commands for folder types
|
||||
if (command is UrlCommand urlCommand)
|
||||
{
|
||||
if (!bookmark.IsWebUrl())
|
||||
{
|
||||
contextMenu.Add(
|
||||
new CommandContextItem(new DirectoryPage(urlCommand.Url)));
|
||||
|
||||
contextMenu.Add(
|
||||
new CommandContextItem(new OpenInTerminalCommand(urlCommand.Url)));
|
||||
}
|
||||
}
|
||||
|
||||
listItem.Title = bookmark.Name;
|
||||
listItem.Subtitle = bookmark.Bookmark;
|
||||
|
||||
var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon };
|
||||
edit.AddedCommand += Edit_AddedCommand;
|
||||
contextMenu.Add(new CommandContextItem(edit));
|
||||
|
||||
var delete = new CommandContextItem(
|
||||
title: Resources.bookmarks_delete_title,
|
||||
name: Resources.bookmarks_delete_name,
|
||||
action: () =>
|
||||
{
|
||||
if (_bookmarks is not null)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Deleting bookmark ({bookmark.Name},{bookmark.Bookmark})");
|
||||
|
||||
_bookmarks.Data.Remove(bookmark);
|
||||
|
||||
SaveAndUpdateCommands();
|
||||
}
|
||||
},
|
||||
result: CommandResult.KeepOpen())
|
||||
{
|
||||
IsCritical = true,
|
||||
Icon = Icons.DeleteIcon,
|
||||
};
|
||||
contextMenu.Add(delete);
|
||||
|
||||
listItem.MoreCommands = contextMenu.ToArray();
|
||||
|
||||
return listItem;
|
||||
NotifyChange();
|
||||
}
|
||||
|
||||
public override ICommandItem[] TopLevelCommands()
|
||||
{
|
||||
if (_commands.Count == 0)
|
||||
if (Volatile.Read(ref _loadState) != LoadStateLoaded)
|
||||
{
|
||||
LoadCommands();
|
||||
if (Interlocked.CompareExchange(ref _loadState, LoadStateLoading, LoadStateNotLoaded) == LoadStateNotLoaded)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_bookmarks = [.. _bookmarksManager.Bookmarks.Select(bookmark => new BookmarkListItem(bookmark, _bookmarksManager, _commandResolver, _iconLocator, _placeholderParser))];
|
||||
_commands = BuildTopLevelCommandsUnsafe();
|
||||
}
|
||||
|
||||
Volatile.Write(ref _loadState, LoadStateLoaded);
|
||||
RaiseItemsChanged();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Volatile.Write(ref _loadState, LoadStateNotLoaded);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _commands.ToArray();
|
||||
return _commands;
|
||||
}
|
||||
|
||||
internal static string StateJsonPath()
|
||||
private void NotifyChange()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
Directory.CreateDirectory(directory);
|
||||
if (Volatile.Read(ref _loadState) != LoadStateLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// now, the state is just next to the exe
|
||||
return System.IO.Path.Combine(directory, "bookmarks.json");
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
_commands = BuildTopLevelCommandsUnsafe();
|
||||
}
|
||||
|
||||
RaiseItemsChanged();
|
||||
}
|
||||
|
||||
[Pure]
|
||||
private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager
|
||||
{
|
||||
private readonly IBookmarkDataSource _dataSource;
|
||||
private readonly BookmarkJsonParser _parser = new();
|
||||
private readonly SupersedingAsyncGate _savingGate;
|
||||
private readonly Lock _lock = new();
|
||||
private BookmarksData _bookmarksData = new();
|
||||
|
||||
public event Action<BookmarkData>? BookmarkAdded;
|
||||
|
||||
public event Action<BookmarkData, BookmarkData>? BookmarkUpdated; // old, new
|
||||
|
||||
public event Action<BookmarkData>? BookmarkRemoved;
|
||||
|
||||
public IReadOnlyCollection<BookmarkData> Bookmarks
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _bookmarksData.Data.ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BookmarksManager(IBookmarkDataSource dataSource)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dataSource);
|
||||
_dataSource = dataSource;
|
||||
_savingGate = new SupersedingAsyncGate(WriteData);
|
||||
LoadBookmarksFromFile();
|
||||
}
|
||||
|
||||
public BookmarkData Add(string name, string bookmark)
|
||||
{
|
||||
var newBookmark = new BookmarkData(name, bookmark);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_bookmarksData.Data.Add(newBookmark);
|
||||
_ = SaveChangesAsync();
|
||||
BookmarkAdded?.Invoke(newBookmark);
|
||||
return newBookmark;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Remove(Guid id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var bookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id);
|
||||
if (bookmark != null && _bookmarksData.Data.Remove(bookmark))
|
||||
{
|
||||
_ = SaveChangesAsync();
|
||||
BookmarkRemoved?.Invoke(bookmark);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public BookmarkData? Update(Guid id, string name, string bookmark)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var existingBookmark = _bookmarksData.Data.FirstOrDefault(b => b.Id == id);
|
||||
if (existingBookmark != null)
|
||||
{
|
||||
var updatedBookmark = existingBookmark with
|
||||
{
|
||||
Name = name,
|
||||
Bookmark = bookmark,
|
||||
};
|
||||
|
||||
var index = _bookmarksData.Data.IndexOf(existingBookmark);
|
||||
_bookmarksData.Data[index] = updatedBookmark;
|
||||
|
||||
_ = SaveChangesAsync();
|
||||
BookmarkUpdated?.Invoke(existingBookmark, updatedBookmark);
|
||||
return updatedBookmark;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadBookmarksFromFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonData = _dataSource.GetBookmarkData();
|
||||
_bookmarksData = _parser.ParseBookmarks(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteData(CancellationToken arg)
|
||||
{
|
||||
List<BookmarkData> dataToSave;
|
||||
lock (_lock)
|
||||
{
|
||||
dataToSave = _bookmarksData.Data.ToList();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var jsonData = _parser.SerializeBookmarks(new BookmarksData { Data = dataToSave });
|
||||
_dataSource.SaveBookmarkData(jsonData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to save bookmarks: {ex.Message}");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveChangesAsync()
|
||||
{
|
||||
await _savingGate.ExecuteAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public void Dispose() => _savingGate.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Commands;
|
||||
|
||||
internal sealed partial class DeleteBookmarkCommand : InvokableCommand
|
||||
{
|
||||
private readonly BookmarkData _bookmark;
|
||||
private readonly IBookmarksManager _bookmarksManager;
|
||||
|
||||
public DeleteBookmarkCommand(BookmarkData bookmark, IBookmarksManager bookmarksManager)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmark);
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
|
||||
_bookmark = bookmark;
|
||||
_bookmarksManager = bookmarksManager;
|
||||
Name = Resources.bookmarks_delete_name;
|
||||
Icon = Icons.DeleteIcon;
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
_bookmarksManager.Remove(_bookmark.Id);
|
||||
return CommandResult.GoHome();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Commands;
|
||||
|
||||
internal sealed partial class LaunchBookmarkCommand : BaseObservable, IInvokableCommand, IDisposable
|
||||
{
|
||||
private static readonly CompositeFormat FailedToOpenMessageFormat = CompositeFormat.Parse(Resources.bookmark_toast_failed_open_text!);
|
||||
|
||||
private readonly BookmarkData _bookmarkData;
|
||||
private readonly Dictionary<string, string>? _placeholders;
|
||||
private readonly IBookmarkResolver _bookmarkResolver;
|
||||
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
|
||||
private readonly Classification _classification;
|
||||
|
||||
private IIconInfo? _icon;
|
||||
|
||||
public IIconInfo Icon => _icon ?? Icons.Reloading;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public LaunchBookmarkCommand(BookmarkData bookmarkData, Classification classification, IBookmarkIconLocator iconLocator, IBookmarkResolver bookmarkResolver, Dictionary<string, string>? placeholders = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmarkData);
|
||||
ArgumentNullException.ThrowIfNull(classification);
|
||||
|
||||
_bookmarkData = bookmarkData;
|
||||
_classification = classification;
|
||||
_placeholders = placeholders;
|
||||
_bookmarkResolver = bookmarkResolver;
|
||||
|
||||
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
|
||||
Name = Resources.bookmarks_command_name_open;
|
||||
|
||||
_iconReloadGate = new(
|
||||
async ct => await iconLocator.GetIconForPath(_classification, ct),
|
||||
icon =>
|
||||
{
|
||||
_icon = icon;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
});
|
||||
|
||||
RequestIconReloadAsync();
|
||||
}
|
||||
|
||||
private void RequestIconReloadAsync()
|
||||
{
|
||||
_icon = null;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
_ = _iconReloadGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
public ICommandResult Invoke(object sender)
|
||||
{
|
||||
var bookmarkAddress = ReplacePlaceholders(_bookmarkData.Bookmark);
|
||||
var classification = _bookmarkResolver.ClassifyOrUnknown(bookmarkAddress);
|
||||
|
||||
var success = CommandLauncher.Launch(classification);
|
||||
|
||||
return success
|
||||
? CommandResult.Dismiss()
|
||||
: CommandResult.ShowToast(new ToastArgs
|
||||
{
|
||||
Message = !string.IsNullOrWhiteSpace(_bookmarkData.Name)
|
||||
? string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, _bookmarkData.Name + ": " + bookmarkAddress)
|
||||
: string.Format(CultureInfo.CurrentCulture, FailedToOpenMessageFormat, bookmarkAddress),
|
||||
Result = CommandResult.KeepOpen(),
|
||||
});
|
||||
}
|
||||
|
||||
private string ReplacePlaceholders(string input)
|
||||
{
|
||||
var result = input;
|
||||
if (_placeholders?.Count > 0)
|
||||
{
|
||||
foreach (var (key, value) in _placeholders)
|
||||
{
|
||||
var placeholderString = $"{{{key}}}";
|
||||
|
||||
var encodedValue = value;
|
||||
if (_classification.Kind is CommandKind.Protocol or CommandKind.WebUrl)
|
||||
{
|
||||
encodedValue = Uri.EscapeDataString(value);
|
||||
}
|
||||
|
||||
result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_iconReloadGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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.
|
||||
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
global using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
public sealed record Classification(
|
||||
CommandKind Kind,
|
||||
string Input,
|
||||
string Target,
|
||||
string Arguments,
|
||||
LaunchMethod Launch,
|
||||
string? WorkingDirectory,
|
||||
bool IsPlaceholder,
|
||||
string? FileSystemTarget = null,
|
||||
string? DisplayName = null)
|
||||
{
|
||||
public static Classification Unknown(string rawInput) =>
|
||||
new(CommandKind.Unknown, rawInput, rawInput, string.Empty, LaunchMethod.ShellExecute, string.Empty, false, null, null);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static class CommandIds
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns id of a command associated with a bookmark item. This id is for a command that launches the bookmark - regardless of whether
|
||||
/// the bookmark type of if it is a placeholder bookmark or not.
|
||||
/// </summary>
|
||||
/// <param name="id">Bookmark ID</param>
|
||||
public static string GetLaunchBookmarkItemId(Guid id) => "Bookmarks.Launch." + id;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a command or bookmark target type.
|
||||
/// </summary>
|
||||
public enum CommandKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown or unsupported target.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// HTTP/HTTPS URL.
|
||||
/// </summary>
|
||||
WebUrl,
|
||||
|
||||
/// <summary>
|
||||
/// Any non-file URI scheme (e.g., mailto:, ms-settings:, wt:, myapp:).
|
||||
/// </summary>
|
||||
Protocol,
|
||||
|
||||
/// <summary>
|
||||
/// Application User Model ID (e.g., shell:AppsFolder\AUMID or pkgfamily!app).
|
||||
/// </summary>
|
||||
Aumid,
|
||||
|
||||
/// <summary>
|
||||
/// Existing folder path.
|
||||
/// </summary>
|
||||
Directory,
|
||||
|
||||
/// <summary>
|
||||
/// Existing executable file (e.g., .exe, .bat, .cmd).
|
||||
/// </summary>
|
||||
FileExecutable,
|
||||
|
||||
/// <summary>
|
||||
/// Existing document file.
|
||||
/// </summary>
|
||||
FileDocument,
|
||||
|
||||
/// <summary>
|
||||
/// Windows shortcut file (*.lnk).
|
||||
/// </summary>
|
||||
Shortcut,
|
||||
|
||||
/// <summary>
|
||||
/// Internet shortcut file (*.url).
|
||||
/// </summary>
|
||||
InternetShortcut,
|
||||
|
||||
/// <summary>
|
||||
/// Bare command resolved via PATH/PATHEXT (e.g., "wt", "git").
|
||||
/// </summary>
|
||||
PathCommand,
|
||||
|
||||
/// <summary>
|
||||
/// Shell item not matching other types (e.g., Control Panel item, purely virtual directory).
|
||||
/// </summary>
|
||||
VirtualShellItem,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using ManagedCommon;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static class CommandLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Launches the classified item.
|
||||
/// </summary>
|
||||
/// <param name="classification">Classification produced by CommandClassifier.</param>
|
||||
/// <param name="runAsAdmin">Optional: force elevation if possible.</param>
|
||||
public static bool Launch(Classification classification, bool runAsAdmin = false)
|
||||
{
|
||||
switch (classification.Launch)
|
||||
{
|
||||
case LaunchMethod.ExplorerOpen:
|
||||
// Folders and shell: URIs are best handled by explorer.exe
|
||||
// You can notice the difference with Recycle Bin for example:
|
||||
// - "explorer ::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
// - "::{645FF040-5081-101B-9F08-00AA002F954E}"
|
||||
return ShellHelpers.OpenInShell("explorer.exe", classification.Target);
|
||||
|
||||
case LaunchMethod.ActivateAppId:
|
||||
return ActivateAppId(classification.Target, classification.Arguments);
|
||||
|
||||
case LaunchMethod.ShellExecute:
|
||||
default:
|
||||
return ShellHelpers.OpenInShell(classification.Target, classification.Arguments, classification.WorkingDirectory, runAsAdmin ? ShellHelpers.ShellRunAsType.Administrator : ShellHelpers.ShellRunAsType.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ActivateAppId(string aumidOrAppsFolder, string? arguments)
|
||||
{
|
||||
const string shellAppsFolder = "shell:AppsFolder\\";
|
||||
try
|
||||
{
|
||||
if (aumidOrAppsFolder.StartsWith(shellAppsFolder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
aumidOrAppsFolder = aumidOrAppsFolder[shellAppsFolder.Length..];
|
||||
}
|
||||
|
||||
ApplicationActivationManager.ActivateApplication(aumidOrAppsFolder, arguments, 0, out _);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Can't activate AUMID using app store '{aumidOrAppsFolder}'", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ShellHelpers.OpenInShell(shellAppsFolder + aumidOrAppsFolder, arguments);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Can't activate AUMID using shell '{aumidOrAppsFolder}'", ex);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static class ApplicationActivationManager
|
||||
{
|
||||
public static void ActivateApplication(string aumid, string? args, int options, out uint pid)
|
||||
{
|
||||
var mgr = (IApplicationActivationManager)new _ApplicationActivationManager();
|
||||
var hr = mgr.ActivateApplication(aumid, args ?? string.Empty, options, out pid);
|
||||
if (hr < 0)
|
||||
{
|
||||
throw new Win32Exception(hr);
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1300:Element should begin with upper-case letter", Justification = "Private class")]
|
||||
private class _ApplicationActivationManager;
|
||||
|
||||
[ComImport]
|
||||
[Guid("2E941141-7F97-4756-BA1D-9DECDE894A3D")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IApplicationActivationManager
|
||||
{
|
||||
int ActivateApplication(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string arguments,
|
||||
int options,
|
||||
out uint processId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for parsing command lines and expanding paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Warning: This code handles parsing specifically for Bookmarks, and is NOT a general-purpose command line parser.
|
||||
/// In some cases it mimics system rules (e.g. CreateProcess, CommandLineToArgvW) but in other cases it uses, but it can also
|
||||
/// bend the rules to be more forgiving.
|
||||
/// </remarks>
|
||||
internal static partial class CommandLineHelper
|
||||
{
|
||||
private static readonly char[] PathSeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];
|
||||
|
||||
public static string[] SplitCommandLine(string commandLine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLine);
|
||||
|
||||
var argv = NativeMethods.CommandLineToArgvW(commandLine, out var argc);
|
||||
if (argv == IntPtr.Zero)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = new string[argc];
|
||||
for (var i = 0; i < argc; i++)
|
||||
{
|
||||
var p = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
|
||||
result[i] = Marshal.PtrToStringUni(p)!;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.LocalFree(argv);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits the raw command line into the first argument (Head) and the remainder (Tail). This method follows the rules
|
||||
/// of CommandLineToArgvW.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a mental support for SplitLongestHeadBeforeQuotedArg.
|
||||
///
|
||||
/// Rules:
|
||||
/// - If the input starts with any whitespace, Head is an empty string (per CommandLineToArgvW behavior for first segment, handles by CreateProcess rules).
|
||||
/// - Otherwise, Head uses the CreateProcess "program name" rule:
|
||||
/// - If the first char is a quote, Head is everything up to the next quote (backslashes do NOT escape it).
|
||||
/// - Else, Head is the run up to the first whitespace.
|
||||
/// - Tail starts at the first non-whitespace character after Head (or is empty if nothing remains).
|
||||
/// No normalization is performed; returned slices preserve the original text (no un/escaping).
|
||||
/// </remarks>
|
||||
public static (string Head, string Tail) SplitHeadAndArgs(string input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (input.Length == 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
var s = input.AsSpan();
|
||||
var n = s.Length;
|
||||
var i = 0;
|
||||
|
||||
// Leading whitespace -> empty argv[0]
|
||||
if (char.IsWhiteSpace(s[0]))
|
||||
{
|
||||
while (i < n && char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var tailAfterWs = i < n ? input[i..] : string.Empty;
|
||||
return (string.Empty, tailAfterWs);
|
||||
}
|
||||
|
||||
string head;
|
||||
if (s[i] == '"')
|
||||
{
|
||||
// Quoted program name: everything up to the next unescaped quote (CreateProcess rule: slashes don't escape here)
|
||||
i++;
|
||||
var start = i;
|
||||
while (i < n && s[i] != '"')
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
head = input.Substring(start, i - start);
|
||||
if (i < n && s[i] == '"')
|
||||
{
|
||||
i++; // consume closing quote
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unquoted program name: read to next whitespace
|
||||
var start = i;
|
||||
while (i < n && !char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
head = input.Substring(start, i - start);
|
||||
}
|
||||
|
||||
// Skip inter-argument whitespace; tail begins at the next non-ws char (or is empty)
|
||||
while (i < n && char.IsWhiteSpace(s[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
var tail = i < n ? input[i..] : string.Empty;
|
||||
|
||||
return (head, tail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the longest possible head (may include spaces) and the tail that starts at the
|
||||
/// first *quoted argument*.
|
||||
///
|
||||
/// Definition of "quoted argument start":
|
||||
/// - A token boundary (start-of-line or preceded by whitespace),
|
||||
/// - followed by zero or more backslashes,
|
||||
/// - followed by a double-quote ("),
|
||||
/// - where the number of immediately preceding backslashes is EVEN (so the quote toggles quoting).
|
||||
///
|
||||
/// Notes:
|
||||
/// - Quotes appearing mid-token (e.g., C:\Some\"Path\file.txt) do NOT stop the head.
|
||||
/// - Trailing spaces before the quoted arg are not included in Head; Tail begins at that quote.
|
||||
/// - Leading whitespace before the first token is ignored (Head starts from first non-ws).
|
||||
/// Examples:
|
||||
/// C:\app exe -p "1" -q -> Head: "C:\app exe -p", Tail: "\"1\" -q"
|
||||
/// "\\server\share\" with args -> Head: "", Tail: "\"\\\\server\\share\\\" with args"
|
||||
/// C:\Some\"Path\file.txt -> Head: "C:\\Some\\\"Path\\file.txt", Tail: ""
|
||||
/// </summary>
|
||||
public static (string Head, string Tail) SplitLongestHeadBeforeQuotedArg(string input)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
if (input.Length == 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
var s = input.AsSpan();
|
||||
var n = s.Length;
|
||||
|
||||
// Start at first non-whitespace (we don't treat leading ws as part of Head here)
|
||||
var start = 0;
|
||||
while (start < n && char.IsWhiteSpace(s[start]))
|
||||
{
|
||||
start++;
|
||||
}
|
||||
|
||||
if (start >= n)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
// Scan for a quote that OPENS a quoted argument at a token boundary.
|
||||
for (var i = start; i < n; i++)
|
||||
{
|
||||
if (s[i] != '"')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count immediate backslashes before this quote
|
||||
int j = i - 1, backslashes = 0;
|
||||
while (j >= start && s[j] == '\\')
|
||||
{
|
||||
backslashes++;
|
||||
j--;
|
||||
}
|
||||
|
||||
// The quote is at a token boundary if the char before the backslashes is start-of-line or whitespace.
|
||||
var atTokenBoundary = j < start || char.IsWhiteSpace(s[j]);
|
||||
|
||||
// Even number of backslashes -> this quote toggles quoting (opens if at boundary).
|
||||
if (atTokenBoundary && (backslashes % 2 == 0))
|
||||
{
|
||||
// Trim trailing spaces off Head so Tail starts exactly at the opening quote
|
||||
var headEnd = i;
|
||||
while (headEnd > start && char.IsWhiteSpace(s[headEnd - 1]))
|
||||
{
|
||||
headEnd--;
|
||||
}
|
||||
|
||||
var head = input[start..headEnd];
|
||||
var tail = input[headEnd..]; // starts at the opening quote
|
||||
return (head, tail.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
// No quoted-arg start found: entire remainder (trimmed right) is the Head
|
||||
var wholeHead = input[start..].TrimEnd();
|
||||
return (wholeHead, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to expand the path to full physical path, expanding environment variables and shell: monikers.
|
||||
/// </summary>
|
||||
internal static bool ExpandPathToPhysicalFile(string input, bool expandShell, out string full)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
full = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input);
|
||||
|
||||
var firstSegment = GetFirstPathSegment(expanded);
|
||||
if (expandShell && HasShellPrefix(firstSegment) && TryExpandShellMoniker(expanded, out var shellExpanded))
|
||||
{
|
||||
expanded = shellExpanded;
|
||||
}
|
||||
else if (firstSegment is "~" or "." or "..")
|
||||
{
|
||||
expanded = ExpandUserRelative(firstSegment, expanded);
|
||||
}
|
||||
|
||||
if (Path.Exists(expanded))
|
||||
{
|
||||
full = Path.GetFullPath(expanded);
|
||||
return true;
|
||||
}
|
||||
|
||||
full = expanded; // return the attempted expansion even if it doesn't exist
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExpandShellMoniker(string input, out string expanded)
|
||||
{
|
||||
var separatorIndex = input.IndexOfAny(PathSeparators);
|
||||
var shellFolder = separatorIndex > 0 ? input[..separatorIndex] : input;
|
||||
var relativePath = separatorIndex > 0 ? input[(separatorIndex + 1)..] : string.Empty;
|
||||
|
||||
if (ShellNames.TryGetFileSystemPath(shellFolder, out var fsPath))
|
||||
{
|
||||
expanded = Path.GetFullPath(Path.Combine(fsPath, relativePath));
|
||||
return true;
|
||||
}
|
||||
|
||||
expanded = input;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ExpandUserRelative(string firstSegment, string input)
|
||||
{
|
||||
// Treat relative paths as relative to the user home directory.
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
if (firstSegment == "~")
|
||||
{
|
||||
// Remove "~" (+ optional following separator) before combining.
|
||||
var skip = 1;
|
||||
if (input.Length > 1 && IsSeparator(input[1]))
|
||||
{
|
||||
skip++;
|
||||
}
|
||||
|
||||
input = input[skip..];
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(homeDirectory, input));
|
||||
}
|
||||
|
||||
private static bool IsSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
|
||||
|
||||
private static string GetFirstPathSegment(string input)
|
||||
{
|
||||
var separatorIndex = input.IndexOfAny(PathSeparators);
|
||||
return separatorIndex > 0 ? input[..separatorIndex] : input;
|
||||
}
|
||||
|
||||
internal static bool HasShellPrefix(string input)
|
||||
{
|
||||
return input.StartsWith("shell:", StringComparison.OrdinalIgnoreCase) || input.StartsWith("::", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
public enum LaunchMethod
|
||||
{
|
||||
ShellExecute, // UseShellExecute = true (Explorer/associations/protocols)
|
||||
ExplorerOpen, // explorer.exe <folder/shell:uri>
|
||||
ActivateAppId, // IApplicationActivationManager (AUMID / pkgfamily!app)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static partial class NativeMethods
|
||||
{
|
||||
[LibraryImport("shell32.dll", EntryPoint = "SHParseDisplayName", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int SHParseDisplayName(
|
||||
string pszName,
|
||||
nint pbc,
|
||||
out nint ppidl,
|
||||
uint sfgaoIn,
|
||||
nint psfgaoOut);
|
||||
|
||||
[LibraryImport("shell32.dll", EntryPoint = "SHGetNameFromIDList", StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial int SHGetNameFromIDList(
|
||||
nint pidl,
|
||||
SIGDN sigdnName,
|
||||
out nint ppszName);
|
||||
|
||||
[LibraryImport("ole32.dll")]
|
||||
internal static partial void CoTaskMemFree(nint pv);
|
||||
|
||||
[LibraryImport("shell32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial IntPtr CommandLineToArgvW(string lpCmdLine, out int pNumArgs);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
internal static partial IntPtr LocalFree(IntPtr hMem);
|
||||
|
||||
internal enum SIGDN : uint
|
||||
{
|
||||
NORMALDISPLAY = 0x00000000,
|
||||
DESKTOPABSOLUTEPARSING = 0x80028000,
|
||||
DESKTOPABSOLUTEEDITING = 0x8004C000,
|
||||
FILESYSPATH = 0x80058000,
|
||||
URL = 0x80068000,
|
||||
PARENTRELATIVE = 0x80080001,
|
||||
PARENTRELATIVEFORADDRESSBAR = 0x8007C001,
|
||||
PARENTRELATIVEPARSING = 0x80018001,
|
||||
PARENTRELATIVEEDITING = 0x80031001,
|
||||
PARENTRELATIVEFORUI = 0x80094001,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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.Runtime.InteropServices;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for getting user-friendly shell names and paths.
|
||||
/// </summary>
|
||||
internal static class ShellNames
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to get a localized friendly name (e.g. "This PC", "Downloads") for a shell path like:
|
||||
/// - "shell:Downloads"
|
||||
/// - "shell:::{20D04FE0-3AEA-1069-A2D8-08002B30309D}"
|
||||
/// - "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}"
|
||||
/// </summary>
|
||||
public static bool TryGetFriendlyName(string shellPath, [NotNullWhen(true)] out string? displayName)
|
||||
{
|
||||
displayName = null;
|
||||
|
||||
// Normalize a bare GUID to the "::" moniker if someone passes only "{GUID}"
|
||||
if (shellPath.Length > 0 && shellPath[0] == '{' && shellPath[^1] == '}')
|
||||
{
|
||||
shellPath = "::" + shellPath;
|
||||
}
|
||||
|
||||
nint pidl = 0;
|
||||
try
|
||||
{
|
||||
var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0);
|
||||
if (hr != 0 || pidl == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ask for the human-friendly localized name
|
||||
nint psz;
|
||||
hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.NORMALDISPLAY, out psz);
|
||||
if (hr != 0 || psz == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
displayName = Marshal.PtrToStringUni(psz);
|
||||
return !string.IsNullOrWhiteSpace(displayName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(psz);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pidl != 0)
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(pidl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optionally, also try to obtain a filesystem path (if the item represents one).
|
||||
/// Returns false for purely virtual items like "This PC".
|
||||
/// </summary>
|
||||
public static bool TryGetFileSystemPath(string shellPath, [NotNullWhen(true)] out string? fileSystemPath)
|
||||
{
|
||||
fileSystemPath = null;
|
||||
|
||||
nint pidl = 0;
|
||||
try
|
||||
{
|
||||
var hr = NativeMethods.SHParseDisplayName(shellPath, 0, out pidl, 0, 0);
|
||||
if (hr != 0 || pidl == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
nint psz;
|
||||
hr = NativeMethods.SHGetNameFromIDList(pidl, NativeMethods.SIGDN.FILESYSPATH, out psz);
|
||||
if (hr != 0 || psz == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
fileSystemPath = Marshal.PtrToStringUni(psz);
|
||||
return !string.IsNullOrWhiteSpace(fileSystemPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(psz);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pidl != 0)
|
||||
{
|
||||
NativeMethods.CoTaskMemFree(pidl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// 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.Bookmarks.Helpers;
|
||||
|
||||
internal static class UriHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to split a URI string into scheme and remainder.
|
||||
/// Scheme must be valid per RFC 3986 and followed by ':'.
|
||||
/// </summary>
|
||||
public static bool TryGetScheme(ReadOnlySpan<char> input, out string scheme, out string remainder)
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc3986#page-17
|
||||
scheme = string.Empty;
|
||||
remainder = string.Empty;
|
||||
|
||||
if (input.Length < 2)
|
||||
{
|
||||
return false; // must have at least "a:"
|
||||
}
|
||||
|
||||
// Must contain ':' delimiter
|
||||
var colonIndex = input.IndexOf(':');
|
||||
if (colonIndex <= 0)
|
||||
{
|
||||
return false; // no colon or colon at start
|
||||
}
|
||||
|
||||
// First char must be a letter
|
||||
var first = input[0];
|
||||
if (!char.IsLetter(first))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate scheme part
|
||||
for (var i = 1; i < colonIndex; i++)
|
||||
{
|
||||
var c = input[i];
|
||||
if (!(char.IsLetterOrDigit(c) || c == '+' || c == '-' || c == '.'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract scheme and remainder
|
||||
scheme = input[..colonIndex].ToString();
|
||||
remainder = colonIndex + 1 < input.Length ? input[(colonIndex + 1)..].ToString() : string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal interface IBookmarksManager
|
||||
{
|
||||
event Action<BookmarkData>? BookmarkAdded;
|
||||
|
||||
event Action<BookmarkData, BookmarkData>? BookmarkUpdated;
|
||||
|
||||
event Action<BookmarkData>? BookmarkRemoved;
|
||||
|
||||
IReadOnlyCollection<BookmarkData> Bookmarks { get; }
|
||||
|
||||
BookmarkData Add(string name, string bookmark);
|
||||
|
||||
bool Remove(Guid id);
|
||||
|
||||
BookmarkData? Update(Guid id, string name, string bookmark);
|
||||
}
|
||||
@@ -2,17 +2,41 @@
|
||||
// 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.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed class Icons
|
||||
internal static class Icons
|
||||
{
|
||||
internal static IconInfo BookmarkIcon => IconHelpers.FromRelativePath("Assets\\Bookmark.svg");
|
||||
internal static IconInfo BookmarkIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmark.svg");
|
||||
|
||||
internal static IconInfo DeleteIcon { get; private set; } = new("\uE74D"); // Delete
|
||||
internal static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete
|
||||
|
||||
internal static IconInfo EditIcon { get; private set; } = new("\uE70F"); // Edit
|
||||
internal static IconInfo EditIcon { get; } = new("\uE70F"); // Edit
|
||||
|
||||
internal static IconInfo PinIcon { get; private set; } = new IconInfo("\uE718"); // Pin
|
||||
internal static IconInfo PinIcon { get; } = new IconInfo("\uE718"); // Pin
|
||||
|
||||
internal static IconInfo Reloading { get; } = new IconInfo("\uF16A"); // ProgressRing
|
||||
|
||||
internal static IconInfo CopyPath { get; } = new IconInfo("\uE8C8"); // Copy
|
||||
|
||||
internal static class BookmarkTypes
|
||||
{
|
||||
internal static IconInfo WebUrl { get; } = new("\uE774"); // Globe
|
||||
|
||||
internal static IconInfo FilePath { get; } = new("\uE8A5"); // OpenFile
|
||||
|
||||
internal static IconInfo FolderPath { get; } = new("\uE8B7"); // OpenFolder
|
||||
|
||||
internal static IconInfo Application { get; } = new("\uE737"); // Favicon (~looks like empty window)
|
||||
|
||||
internal static IconInfo Command { get; } = new("\uE756"); // CommandPrompt
|
||||
|
||||
internal static IconInfo Unknown { get; } = new("\uE71B"); // Link
|
||||
|
||||
internal static IconInfo Game { get; } = new("\uE7FC"); // Game controller
|
||||
}
|
||||
|
||||
private static IconInfo DualColorFromRelativePath(string name)
|
||||
{
|
||||
return IconHelpers.FromRelativePaths($"Assets\\Icons\\{name}.light.svg", $"Assets\\Icons\\{name}.dark.svg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Windows.System;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal static class KeyChords
|
||||
{
|
||||
internal static KeyChord CopyPath => WellKnownKeyChords.CopyFilePath;
|
||||
|
||||
internal static KeyChord OpenFileLocation => WellKnownKeyChords.OpenFileLocation;
|
||||
|
||||
internal static KeyChord OpenInConsole => WellKnownKeyChords.OpenInConsole;
|
||||
|
||||
internal static KeyChord DeleteBookmark => KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete);
|
||||
}
|
||||
@@ -10,13 +10,15 @@
|
||||
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
|
||||
<ProjectPriFileName>Microsoft.CmdPal.Ext.Bookmarks.pri</ProjectPriFileName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="Assets\Bookmark.svg" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
|
||||
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Assets\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
@@ -26,14 +28,6 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="Assets\Bookmark.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\Bookmark.svg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
@@ -41,4 +35,7 @@
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Bookmarks.UnitTests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,43 +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 ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal sealed partial class OpenInTerminalCommand : InvokableCommand
|
||||
{
|
||||
private readonly string _folder;
|
||||
|
||||
public OpenInTerminalCommand(string folder)
|
||||
{
|
||||
Name = Resources.bookmarks_open_in_terminal_name;
|
||||
_folder = folder;
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Start Windows Terminal with the specified folder
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "wt.exe",
|
||||
Arguments = $"-d \"{_folder}\"",
|
||||
UseShellExecute = true,
|
||||
};
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex.Message);
|
||||
}
|
||||
|
||||
return CommandResult.Dismiss();
|
||||
}
|
||||
}
|
||||
@@ -4,38 +4,28 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
|
||||
internal sealed partial class AddBookmarkForm : FormContent
|
||||
{
|
||||
internal event TypedEventHandler<object, BookmarkData>? AddedCommand;
|
||||
|
||||
private readonly BookmarkData? _bookmark;
|
||||
|
||||
internal event TypedEventHandler<object, BookmarkData>? AddedCommand;
|
||||
|
||||
public AddBookmarkForm(BookmarkData? bookmark)
|
||||
{
|
||||
_bookmark = bookmark;
|
||||
var name = _bookmark?.Name ?? string.Empty;
|
||||
var url = _bookmark?.Bookmark ?? string.Empty;
|
||||
var name = bookmark?.Name ?? string.Empty;
|
||||
var url = bookmark?.Bookmark ?? string.Empty;
|
||||
TemplateJson = $$"""
|
||||
{
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.5",
|
||||
"body": [
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
"id": "name",
|
||||
"label": "{{Resources.bookmarks_form_name_label}}",
|
||||
"value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}},
|
||||
"isRequired": true,
|
||||
"errorMessage": "{{Resources.bookmarks_form_name_required}}"
|
||||
},
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
@@ -44,6 +34,15 @@ internal sealed partial class AddBookmarkForm : FormContent
|
||||
"label": "{{Resources.bookmarks_form_bookmark_label}}",
|
||||
"isRequired": true,
|
||||
"errorMessage": "{{Resources.bookmarks_form_bookmark_required}}"
|
||||
},
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
"id": "name",
|
||||
"label": "{{Resources.bookmarks_form_name_label}}",
|
||||
"value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}},
|
||||
"isRequired": false,
|
||||
"errorMessage": "{{Resources.bookmarks_form_name_required}}"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
@@ -71,13 +70,7 @@ internal sealed partial class AddBookmarkForm : FormContent
|
||||
// get the name and url out of the values
|
||||
var formName = formInput["name"] ?? string.Empty;
|
||||
var formBookmark = formInput["bookmark"] ?? string.Empty;
|
||||
var hasPlaceholder = formBookmark.ToString().Contains('{') && formBookmark.ToString().Contains('}');
|
||||
|
||||
var updated = _bookmark ?? new BookmarkData();
|
||||
updated.Name = formName.ToString();
|
||||
updated.Bookmark = formBookmark.ToString();
|
||||
|
||||
AddedCommand?.Invoke(this, updated);
|
||||
AddedCommand?.Invoke(this, new BookmarkData(formName.ToString(), formBookmark.ToString()) { Id = _bookmark?.Id ?? Guid.Empty });
|
||||
return CommandResult.GoHome();
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,33 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Properties;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
|
||||
internal sealed partial class AddBookmarkPage : ContentPage
|
||||
{
|
||||
private readonly AddBookmarkForm _addBookmark;
|
||||
|
||||
internal event TypedEventHandler<object, BookmarkData>? AddedCommand
|
||||
{
|
||||
add => _addBookmark.AddedCommand += value;
|
||||
remove => _addBookmark.AddedCommand -= value;
|
||||
add => _addBookmarkForm.AddedCommand += value;
|
||||
remove => _addBookmarkForm.AddedCommand -= value;
|
||||
}
|
||||
|
||||
public override IContent[] GetContent() => [_addBookmark];
|
||||
private readonly AddBookmarkForm _addBookmarkForm;
|
||||
|
||||
public AddBookmarkPage(BookmarkData? bookmark)
|
||||
{
|
||||
var name = bookmark?.Name ?? string.Empty;
|
||||
var url = bookmark?.Bookmark ?? string.Empty;
|
||||
|
||||
Icon = Icons.BookmarkIcon;
|
||||
var isAdd = string.IsNullOrEmpty(name) && string.IsNullOrEmpty(url);
|
||||
Title = isAdd ? Resources.bookmarks_add_title : Resources.bookmarks_edit_name;
|
||||
Name = isAdd ? Resources.bookmarks_add_name : Resources.bookmarks_edit_name;
|
||||
_addBookmark = new(bookmark);
|
||||
_addBookmarkForm = new AddBookmarkForm(bookmark);
|
||||
}
|
||||
|
||||
public override IContent[] GetContent() => [_addBookmarkForm];
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
// 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.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Common.Commands;
|
||||
using Microsoft.CmdPal.Core.Common.Commands;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Commands;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.CmdPal.Ext.Indexer;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
|
||||
internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
{
|
||||
private readonly IBookmarksManager _bookmarksManager;
|
||||
private readonly IBookmarkResolver _commandResolver;
|
||||
private readonly IBookmarkIconLocator _iconLocator;
|
||||
private readonly IPlaceholderParser _placeholderParser;
|
||||
private readonly SupersedingAsyncValueGate<BookmarkListItemReclassifyResult> _classificationGate;
|
||||
private readonly TaskCompletionSource _initializationTcs = new();
|
||||
|
||||
private BookmarkData _bookmark;
|
||||
|
||||
public Task IsInitialized => _initializationTcs.Task;
|
||||
|
||||
public string BookmarkAddress => _bookmark.Bookmark;
|
||||
|
||||
public string BookmarkTitle => _bookmark.Name;
|
||||
|
||||
public Guid BookmarkId => _bookmark.Id;
|
||||
|
||||
public BookmarkListItem(BookmarkData bookmark, IBookmarksManager bookmarksManager, IBookmarkResolver commandResolver, IBookmarkIconLocator iconLocator, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmark);
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
ArgumentNullException.ThrowIfNull(commandResolver);
|
||||
|
||||
_bookmark = bookmark;
|
||||
_bookmarksManager = bookmarksManager;
|
||||
_bookmarksManager.BookmarkUpdated += BookmarksManagerOnBookmarkUpdated;
|
||||
_commandResolver = commandResolver;
|
||||
_iconLocator = iconLocator;
|
||||
_placeholderParser = placeholderParser;
|
||||
_classificationGate = new SupersedingAsyncValueGate<BookmarkListItemReclassifyResult>(ClassifyAsync, ApplyClassificationResult);
|
||||
_ = _classificationGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
private void BookmarksManagerOnBookmarkUpdated(BookmarkData original, BookmarkData @new)
|
||||
{
|
||||
if (original.Id == _bookmark.Id)
|
||||
{
|
||||
Update(@new);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_classificationGate.Dispose();
|
||||
var existing = Command;
|
||||
if (existing != null)
|
||||
{
|
||||
existing.PropChanged -= CommandPropertyChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update(BookmarkData data)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
|
||||
try
|
||||
{
|
||||
_bookmark = data;
|
||||
OnPropertyChanged(nameof(BookmarkTitle));
|
||||
OnPropertyChanged(nameof(BookmarkAddress));
|
||||
|
||||
Subtitle = Resources.bookmarks_item_refreshing;
|
||||
_ = _classificationGate.ExecuteAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to update bookmark", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BookmarkListItemReclassifyResult> ClassifyAsync(CancellationToken ct)
|
||||
{
|
||||
TypedEventHandler<object, BookmarkData> bookmarkSavedHandler = BookmarkSaved;
|
||||
List<IContextItem> contextMenu = [];
|
||||
|
||||
var classification = (await _commandResolver.TryClassifyAsync(_bookmark.Bookmark, ct)).Result;
|
||||
|
||||
var title = BuildTitle(_bookmark, classification);
|
||||
var subtitle = BuildSubtitle(_bookmark, classification);
|
||||
|
||||
ICommand command = classification.IsPlaceholder
|
||||
? new BookmarkPlaceholderPage(_bookmark, _iconLocator, _commandResolver, _placeholderParser)
|
||||
: new LaunchBookmarkCommand(_bookmark, classification, _iconLocator, _commandResolver);
|
||||
|
||||
BuildSpecificContextMenuItems(classification, contextMenu);
|
||||
AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu);
|
||||
|
||||
return new BookmarkListItemReclassifyResult(
|
||||
command,
|
||||
title,
|
||||
subtitle,
|
||||
contextMenu.ToArray());
|
||||
}
|
||||
|
||||
private void ApplyClassificationResult(BookmarkListItemReclassifyResult classificationResult)
|
||||
{
|
||||
var existing = Command;
|
||||
if (existing != null)
|
||||
{
|
||||
existing.PropChanged -= CommandPropertyChanged;
|
||||
}
|
||||
|
||||
classificationResult.Command.PropChanged += CommandPropertyChanged;
|
||||
Command = classificationResult.Command;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
Title = classificationResult.Title;
|
||||
Subtitle = classificationResult.Subtitle;
|
||||
MoreCommands = classificationResult.MoreCommands;
|
||||
|
||||
_initializationTcs.TrySetResult();
|
||||
}
|
||||
|
||||
private void CommandPropertyChanged(object sender, IPropChangedEventArgs args) =>
|
||||
OnPropertyChanged(args.PropertyName);
|
||||
|
||||
private static void BuildSpecificContextMenuItems(Classification classification, List<IContextItem> contextMenu)
|
||||
{
|
||||
// TODO: unify across all built-in extensions
|
||||
var bookmarkTargetType = classification.Kind;
|
||||
|
||||
// TODO: add "Run as administrator" for executables/shortcuts
|
||||
if (!classification.IsPlaceholder)
|
||||
{
|
||||
if (bookmarkTargetType == CommandKind.FileDocument && File.Exists(classification.Target))
|
||||
{
|
||||
contextMenu.Add(new CommandContextItem(new OpenWithCommand(classification.Input)));
|
||||
}
|
||||
}
|
||||
|
||||
string? directoryPath = null;
|
||||
var targetPath = classification.Target;
|
||||
switch (bookmarkTargetType)
|
||||
{
|
||||
case CommandKind.Directory:
|
||||
directoryPath = targetPath;
|
||||
contextMenu.Add(new CommandContextItem(new DirectoryPage(directoryPath))); // Browse
|
||||
break;
|
||||
case CommandKind.FileExecutable:
|
||||
case CommandKind.FileDocument:
|
||||
case CommandKind.Shortcut:
|
||||
case CommandKind.InternetShortcut:
|
||||
try
|
||||
{
|
||||
directoryPath = Path.GetDirectoryName(targetPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore any path parsing errors
|
||||
}
|
||||
|
||||
break;
|
||||
case CommandKind.WebUrl:
|
||||
case CommandKind.Protocol:
|
||||
case CommandKind.Aumid:
|
||||
case CommandKind.PathCommand:
|
||||
case CommandKind.Unknown:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Add "Copy Path" or "Copy Address" command
|
||||
if (!string.IsNullOrWhiteSpace(classification.Input))
|
||||
{
|
||||
var copyCommand = new CopyPathCommand(targetPath)
|
||||
{
|
||||
Name = bookmarkTargetType is CommandKind.WebUrl or CommandKind.Protocol
|
||||
? Resources.bookmarks_copy_address_name
|
||||
: Resources.bookmarks_copy_path_name,
|
||||
Icon = Icons.CopyPath,
|
||||
};
|
||||
|
||||
contextMenu.Add(new CommandContextItem(copyCommand) { RequestedShortcut = KeyChords.CopyPath });
|
||||
}
|
||||
|
||||
// Add "Open in Console" and "Show in Folder" commands if we have a valid directory path
|
||||
if (!string.IsNullOrWhiteSpace(directoryPath) && Directory.Exists(directoryPath))
|
||||
{
|
||||
contextMenu.Add(new CommandContextItem(new ShowFileInFolderCommand(targetPath)) { RequestedShortcut = KeyChords.OpenFileLocation });
|
||||
contextMenu.Add(new CommandContextItem(OpenInConsoleCommand.FromDirectory(directoryPath)) { RequestedShortcut = KeyChords.OpenInConsole });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(targetPath) && (File.Exists(targetPath) || Directory.Exists(targetPath)))
|
||||
{
|
||||
contextMenu.Add(new CommandContextItem(new OpenPropertiesCommand(targetPath)));
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSubtitle(BookmarkData bookmark, Classification classification)
|
||||
{
|
||||
var subtitle = BuildSubtitleCore(bookmark, classification);
|
||||
#if DEBUG
|
||||
subtitle = $" ({classification.Kind}) • " + subtitle;
|
||||
#endif
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
private static string BuildSubtitleCore(BookmarkData bookmark, Classification classification)
|
||||
{
|
||||
if (classification.Kind == CommandKind.Unknown)
|
||||
{
|
||||
return bookmark.Bookmark;
|
||||
}
|
||||
|
||||
if (classification.Kind is CommandKind.VirtualShellItem &&
|
||||
ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName))
|
||||
{
|
||||
return friendlyName;
|
||||
}
|
||||
|
||||
if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) &&
|
||||
!string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return bookmark.Bookmark;
|
||||
}
|
||||
|
||||
private static string BuildTitle(BookmarkData bookmark, Classification classification)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(bookmark.Name))
|
||||
{
|
||||
return bookmark.Name;
|
||||
}
|
||||
|
||||
if (classification.Kind is CommandKind.Unknown or CommandKind.WebUrl or CommandKind.Protocol)
|
||||
{
|
||||
return bookmark.Bookmark;
|
||||
}
|
||||
|
||||
if (ShellNames.TryGetFriendlyName(classification.Target, out var friendlyName))
|
||||
{
|
||||
return friendlyName;
|
||||
}
|
||||
|
||||
if (ShellNames.TryGetFileSystemPath(bookmark.Bookmark, out var displayName) &&
|
||||
!string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return bookmark.Bookmark;
|
||||
}
|
||||
|
||||
private static void AddCommonContextMenuItems(
|
||||
BookmarkData bookmark,
|
||||
IBookmarksManager bookmarksManager,
|
||||
TypedEventHandler<object, BookmarkData> bookmarkSavedHandler,
|
||||
List<IContextItem> contextMenu)
|
||||
{
|
||||
contextMenu.Add(new Separator());
|
||||
|
||||
var edit = new AddBookmarkPage(bookmark) { Icon = Icons.EditIcon };
|
||||
edit.AddedCommand += bookmarkSavedHandler;
|
||||
contextMenu.Add(new CommandContextItem(edit));
|
||||
|
||||
var confirmableCommand = new ConfirmableCommand
|
||||
{
|
||||
Command = new DeleteBookmarkCommand(bookmark, bookmarksManager),
|
||||
ConfirmationTitle = Resources.bookmarks_delete_prompt_title!,
|
||||
ConfirmationMessage = Resources.bookmarks_delete_prompt_message!,
|
||||
Name = Resources.bookmarks_delete_name,
|
||||
Icon = Icons.DeleteIcon,
|
||||
};
|
||||
var delete = new CommandContextItem(confirmableCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteBookmark };
|
||||
contextMenu.Add(delete);
|
||||
}
|
||||
|
||||
private void BookmarkSaved(object sender, BookmarkData args)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Saving bookmark ({args.Name},{args.Bookmark})");
|
||||
_bookmarksManager.Update(args.Id, args.Name, args.Bookmark);
|
||||
}
|
||||
|
||||
private readonly record struct BookmarkListItemReclassifyResult(
|
||||
ICommand Command,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
IContextItem[] MoreCommands
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
|
||||
internal sealed partial class BookmarkPlaceholderForm : FormContent
|
||||
{
|
||||
private static readonly CompositeFormat ErrorMessage = CompositeFormat.Parse(Resources.bookmarks_required_placeholder);
|
||||
|
||||
private readonly BookmarkData _bookmarkData;
|
||||
private readonly IBookmarkResolver _commandResolver;
|
||||
|
||||
public BookmarkPlaceholderForm(BookmarkData data, IBookmarkResolver commandResolver, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(data);
|
||||
ArgumentNullException.ThrowIfNull(commandResolver);
|
||||
|
||||
_bookmarkData = data;
|
||||
_commandResolver = commandResolver;
|
||||
placeholderParser.ParsePlaceholders(data.Bookmark, out _, out var placeholders);
|
||||
var inputs = placeholders.Distinct(PlaceholderInfoNameEqualityComparer.Instance).Select(placeholder =>
|
||||
{
|
||||
var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, placeholder.Name);
|
||||
return $$"""
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"style": "text",
|
||||
"id": "{{placeholder.Name}}",
|
||||
"label": "{{placeholder.Name}}",
|
||||
"isRequired": true,
|
||||
"errorMessage": "{{errorMessage}}"
|
||||
}
|
||||
""";
|
||||
}).ToList();
|
||||
|
||||
var allInputs = string.Join(",", inputs);
|
||||
|
||||
TemplateJson = $$"""
|
||||
{
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.5",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"size": "Medium",
|
||||
"weight": "Bolder",
|
||||
"text": "{{_bookmarkData.Name}}"
|
||||
},
|
||||
{{allInputs}}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Action.Submit",
|
||||
"title": "{{Resources.bookmarks_form_open}}",
|
||||
"data": {
|
||||
"placeholder": "placeholder"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
public override CommandResult SubmitForm(string payload)
|
||||
{
|
||||
// parse the submitted JSON and then open the link
|
||||
var formInput = JsonNode.Parse(payload);
|
||||
var formObject = formInput?.AsObject();
|
||||
if (formObject is null)
|
||||
{
|
||||
return CommandResult.GoHome();
|
||||
}
|
||||
|
||||
// we need to classify this twice:
|
||||
// first we need to know if the original bookmark is a URL or protocol, because that determines how we encode the placeholders
|
||||
// then we need to classify the final target to be sure the classification didn't change by adding the placeholders
|
||||
var placeholderClassification = _commandResolver.ClassifyOrUnknown(_bookmarkData.Bookmark);
|
||||
|
||||
var placeholders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in formObject)
|
||||
{
|
||||
var placeholderData = value?.ToString();
|
||||
placeholders[key] = placeholderData ?? string.Empty;
|
||||
}
|
||||
|
||||
var target = ReplacePlaceholders(_bookmarkData.Bookmark, placeholders, placeholderClassification);
|
||||
var classification = _commandResolver.ClassifyOrUnknown(target);
|
||||
var success = CommandLauncher.Launch(classification);
|
||||
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
|
||||
}
|
||||
|
||||
private static string ReplacePlaceholders(string input, Dictionary<string, string> placeholders, Classification classification)
|
||||
{
|
||||
var result = input;
|
||||
foreach (var (key, value) in placeholders)
|
||||
{
|
||||
var placeholderString = $"{{{key}}}";
|
||||
var encodedValue = value;
|
||||
if (classification.Kind is CommandKind.Protocol or CommandKind.WebUrl)
|
||||
{
|
||||
encodedValue = Uri.EscapeDataString(value);
|
||||
}
|
||||
|
||||
result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
|
||||
internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable
|
||||
{
|
||||
private readonly FormContent _bookmarkPlaceholder;
|
||||
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
|
||||
|
||||
public BookmarkPlaceholderPage(BookmarkData bookmarkData, IBookmarkIconLocator iconLocator, IBookmarkResolver resolver, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
Name = Resources.bookmarks_command_name_open;
|
||||
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
|
||||
|
||||
_bookmarkPlaceholder = new BookmarkPlaceholderForm(bookmarkData, resolver, placeholderParser);
|
||||
|
||||
_iconReloadGate = new(
|
||||
async ct =>
|
||||
{
|
||||
var c = resolver.ClassifyOrUnknown(bookmarkData.Bookmark);
|
||||
return await iconLocator.GetIconForPath(c, ct);
|
||||
},
|
||||
icon =>
|
||||
{
|
||||
Icon = icon as IconInfo ?? Icons.PinIcon;
|
||||
});
|
||||
RequestIconReloadAsync();
|
||||
}
|
||||
|
||||
public override IContent[] GetContent() => [_bookmarkPlaceholder];
|
||||
|
||||
private void RequestIconReloadAsync()
|
||||
{
|
||||
Icon = Icons.Reloading;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
_ = _iconReloadGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
public void Dispose() => _iconReloadGate.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
public sealed record BookmarkData
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required string Bookmark { get; init; }
|
||||
|
||||
[JsonConstructor]
|
||||
[SetsRequiredMembers]
|
||||
public BookmarkData(Guid id, string? name, string? bookmark)
|
||||
{
|
||||
Id = id == Guid.Empty ? Guid.NewGuid() : id;
|
||||
Name = name ?? string.Empty;
|
||||
Bookmark = bookmark ?? string.Empty;
|
||||
}
|
||||
|
||||
[SetsRequiredMembers]
|
||||
public BookmarkData(string? name, string? bookmark)
|
||||
: this(Guid.NewGuid(), name, bookmark)
|
||||
{
|
||||
}
|
||||
|
||||
[SetsRequiredMembers]
|
||||
public BookmarkData()
|
||||
: this(Guid.NewGuid(), string.Empty, string.Empty)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -2,11 +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.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
public class BookmarkJsonParser
|
||||
{
|
||||
@@ -14,32 +12,32 @@ public class BookmarkJsonParser
|
||||
{
|
||||
}
|
||||
|
||||
public Bookmarks ParseBookmarks(string json)
|
||||
public BookmarksData ParseBookmarks(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return new Bookmarks();
|
||||
return new BookmarksData();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bookmarks = JsonSerializer.Deserialize<Bookmarks>(json, BookmarkSerializationContext.Default.Bookmarks);
|
||||
return bookmarks ?? new Bookmarks();
|
||||
var bookmarks = JsonSerializer.Deserialize<BookmarksData>(json, BookmarkSerializationContext.Default.BookmarksData);
|
||||
return bookmarks ?? new BookmarksData();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
ExtensionHost.LogMessage($"parse bookmark data failed. ex: {ex.Message}");
|
||||
return new Bookmarks();
|
||||
return new BookmarksData();
|
||||
}
|
||||
}
|
||||
|
||||
public string SerializeBookmarks(Bookmarks? bookmarks)
|
||||
public string SerializeBookmarks(BookmarksData? bookmarks)
|
||||
{
|
||||
if (bookmarks == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.Bookmarks);
|
||||
return JsonSerializer.Serialize(bookmarks, BookmarkSerializationContext.Default.BookmarksData);
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,16 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
[JsonSerializable(typeof(float))]
|
||||
[JsonSerializable(typeof(int))]
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(bool))]
|
||||
[JsonSerializable(typeof(BookmarkData))]
|
||||
[JsonSerializable(typeof(Bookmarks))]
|
||||
[JsonSerializable(typeof(BookmarksData))]
|
||||
[JsonSerializable(typeof(List<BookmarkData>), TypeInfoPropertyName = "BookmarkList")]
|
||||
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
|
||||
internal sealed partial class BookmarkSerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
internal sealed partial class BookmarkSerializationContext : JsonSerializerContext;
|
||||
@@ -2,13 +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.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
public sealed class Bookmarks
|
||||
public sealed class BookmarksData
|
||||
{
|
||||
public List<BookmarkData> Data { get; set; } = [];
|
||||
}
|
||||
@@ -2,13 +2,11 @@
|
||||
// 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.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
public class FileBookmarkDataSource : IBookmarkDataSource
|
||||
public sealed partial class FileBookmarkDataSource : IBookmarkDataSource
|
||||
{
|
||||
private readonly string _filePath;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// 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.Bookmarks;
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
public interface IBookmarkDataSource
|
||||
internal interface IBookmarkDataSource
|
||||
{
|
||||
string GetBookmarkData();
|
||||
|
||||
@@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Failed to open {0}.
|
||||
/// </summary>
|
||||
public static string bookmark_toast_failed_open_text {
|
||||
get {
|
||||
return ResourceManager.GetString("bookmark_toast_failed_open_text", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Add bookmark.
|
||||
/// </summary>
|
||||
@@ -87,6 +96,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Copy address.
|
||||
/// </summary>
|
||||
public static string bookmarks_copy_address_name {
|
||||
get {
|
||||
return ResourceManager.GetString("bookmarks_copy_address_name", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Copy path.
|
||||
/// </summary>
|
||||
public static string bookmarks_copy_path_name {
|
||||
get {
|
||||
return ResourceManager.GetString("bookmarks_copy_path_name", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Delete.
|
||||
/// </summary>
|
||||
@@ -96,6 +123,24 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Are you sure you want to delete this bookmark?.
|
||||
/// </summary>
|
||||
public static string bookmarks_delete_prompt_message {
|
||||
get {
|
||||
return ResourceManager.GetString("bookmarks_delete_prompt_message", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Delete bookmark?.
|
||||
/// </summary>
|
||||
public static string bookmarks_delete_prompt_title {
|
||||
get {
|
||||
return ResourceManager.GetString("bookmarks_delete_prompt_title", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Delete bookmark.
|
||||
/// </summary>
|
||||
@@ -177,6 +222,15 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to (Refreshing bookmark...).
|
||||
/// </summary>
|
||||
public static string bookmarks_item_refreshing {
|
||||
get {
|
||||
return ResourceManager.GetString("bookmarks_item_refreshing", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Open in Terminal.
|
||||
/// </summary>
|
||||
@@ -194,5 +248,14 @@ namespace Microsoft.CmdPal.Ext.Bookmarks.Properties {
|
||||
return ResourceManager.GetString("bookmarks_required_placeholder", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Unpin.
|
||||
/// </summary>
|
||||
public static string bookmarks_unpin_name {
|
||||
get {
|
||||
return ResourceManager.GetString("bookmarks_unpin_name", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,4 +164,25 @@
|
||||
<value>{0} is required</value>
|
||||
<comment>{0} will be replaced by a parameter name provided by the user</comment>
|
||||
</data>
|
||||
<data name="bookmarks_item_refreshing" xml:space="preserve">
|
||||
<value>(Refreshing bookmark...)</value>
|
||||
</data>
|
||||
<data name="bookmarks_delete_prompt_title" xml:space="preserve">
|
||||
<value>Delete bookmark?</value>
|
||||
</data>
|
||||
<data name="bookmarks_delete_prompt_message" xml:space="preserve">
|
||||
<value>Are you sure you want to delete this bookmark?</value>
|
||||
</data>
|
||||
<data name="bookmarks_copy_path_name" xml:space="preserve">
|
||||
<value>Copy path</value>
|
||||
</data>
|
||||
<data name="bookmarks_copy_address_name" xml:space="preserve">
|
||||
<value>Copy address</value>
|
||||
</data>
|
||||
<data name="bookmarks_unpin_name" xml:space="preserve">
|
||||
<value>Unpin</value>
|
||||
</data>
|
||||
<data name="bookmark_toast_failed_open_text" xml:space="preserve">
|
||||
<value>Failed to open {0}</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,547 @@
|
||||
// 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.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
internal sealed partial class BookmarkResolver : IBookmarkResolver
|
||||
{
|
||||
private readonly IPlaceholderParser _placeholderParser;
|
||||
|
||||
private const string UriSchemeShell = "shell";
|
||||
|
||||
public BookmarkResolver(IPlaceholderParser placeholderParser)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(placeholderParser);
|
||||
_placeholderParser = placeholderParser;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, Classification Result)> TryClassifyAsync(
|
||||
string? input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await Task.Run(
|
||||
() => TryClassify(input, out var classification)
|
||||
? classification
|
||||
: Classification.Unknown(input ?? string.Empty),
|
||||
cancellationToken);
|
||||
return (true, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to classify", ex);
|
||||
var result = Classification.Unknown(input ?? string.Empty);
|
||||
return (false, result);
|
||||
}
|
||||
}
|
||||
|
||||
public Classification ClassifyOrUnknown(string input)
|
||||
{
|
||||
return TryClassify(input, out var c) ? c : Classification.Unknown(input);
|
||||
}
|
||||
|
||||
private bool TryClassify(string? input, out Classification result)
|
||||
{
|
||||
try
|
||||
{
|
||||
bool success;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
result = Classification.Unknown(input ?? string.Empty);
|
||||
success = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
input = input.Trim();
|
||||
|
||||
// is placeholder?
|
||||
var isPlaceholder = _placeholderParser.ParsePlaceholders(input, out var inputUntilFirstPlaceholder, out _);
|
||||
success = ClassifyCore(input, out result, isPlaceholder, inputUntilFirstPlaceholder, _placeholderParser);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to classify bookmark \"{input}\"", ex);
|
||||
result = Classification.Unknown(input ?? string.Empty);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ClassifyCore(string input, out Classification result, bool isPlaceholder, string inputUntilFirstPlaceholder, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
// 1) Try URI parsing first (accepts custom schemes, e.g., shell:, ms-settings:)
|
||||
// File URIs must start with "file:" to avoid confusion with local paths - which are handled below, in more sophisticated ways -
|
||||
// as TryCreate would automatically add "file://" to bare paths like "C:\path\to\file.txt" which we don't want.
|
||||
if (Uri.TryCreate(input, UriKind.Absolute, out var uri)
|
||||
&& !string.IsNullOrWhiteSpace(uri.Scheme)
|
||||
&& (uri.Scheme != Uri.UriSchemeFile || input.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
&& uri.Scheme != UriSchemeShell)
|
||||
{
|
||||
// http/https → Url; any other scheme → Protocol (mailto:, ms-settings:, slack://, etc.)
|
||||
var isWeb = uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
|
||||
|
||||
result = new Classification(
|
||||
isWeb ? CommandKind.WebUrl : CommandKind.Protocol,
|
||||
input,
|
||||
input,
|
||||
string.Empty,
|
||||
LaunchMethod.ShellExecute, // Shell picks the right handler
|
||||
null,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 1a) We're a placeholder and start look like a protocol scheme (e.g. "myapp:{{placeholder}}")
|
||||
if (isPlaceholder && UriHelper.TryGetScheme(inputUntilFirstPlaceholder, out var scheme, out _))
|
||||
{
|
||||
// single letter schemes are probably drive letters, ignore, file and shell protocols are handled elsewhere
|
||||
if (scheme.Length > 1 && scheme != Uri.UriSchemeFile && scheme != UriSchemeShell)
|
||||
{
|
||||
var isWeb = scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
result = new Classification(
|
||||
isWeb ? CommandKind.WebUrl : CommandKind.Protocol,
|
||||
input,
|
||||
input,
|
||||
string.Empty,
|
||||
LaunchMethod.ShellExecute, // Shell picks the right handler
|
||||
null,
|
||||
isPlaceholder);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Existing file/dir or "longest plausible prefix"
|
||||
// Try to grow head (only for unquoted original) to include spaces until a path exists.
|
||||
|
||||
// Find longest unquoted argument string
|
||||
var (longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitLongestHeadBeforeQuotedArg(input);
|
||||
if (longestUnquotedHead == string.Empty)
|
||||
{
|
||||
(longestUnquotedHead, tailAfterLongestUnquotedHead) = CommandLineHelper.SplitHeadAndArgs(input);
|
||||
}
|
||||
|
||||
var (headPath, tailArgs) = ExpandToBestExistingPath(longestUnquotedHead, tailAfterLongestUnquotedHead, isPlaceholder, placeholderParser);
|
||||
if (headPath is not null)
|
||||
{
|
||||
var args = tailArgs ?? string.Empty;
|
||||
|
||||
if (Directory.Exists(headPath))
|
||||
{
|
||||
result = new Classification(
|
||||
CommandKind.Directory,
|
||||
input,
|
||||
headPath,
|
||||
string.Empty,
|
||||
LaunchMethod.ExplorerOpen,
|
||||
headPath,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(headPath);
|
||||
if (ShellHelpers.IsExecutableExtension(ext))
|
||||
{
|
||||
result = new Classification(
|
||||
CommandKind.FileExecutable,
|
||||
input,
|
||||
headPath,
|
||||
args,
|
||||
LaunchMethod.ShellExecute, // direct exec; or ShellExecute if you want verb support
|
||||
Path.GetDirectoryName(headPath),
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var isShellLink = ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase);
|
||||
var isUrlLink = ext.Equals(".url", StringComparison.OrdinalIgnoreCase);
|
||||
if (isShellLink || isUrlLink)
|
||||
{
|
||||
// In the future we can fetch data out of the link
|
||||
result = new Classification(
|
||||
isUrlLink ? CommandKind.InternetShortcut : CommandKind.Shortcut,
|
||||
input,
|
||||
headPath,
|
||||
string.Empty,
|
||||
LaunchMethod.ShellExecute,
|
||||
Path.GetDirectoryName(headPath),
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
result = new Classification(
|
||||
CommandKind.FileDocument,
|
||||
input,
|
||||
headPath,
|
||||
args,
|
||||
LaunchMethod.ShellExecute,
|
||||
Path.GetDirectoryName(headPath),
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryGetAumid(longestUnquotedHead, out var aumid))
|
||||
{
|
||||
result = new Classification(
|
||||
CommandKind.Aumid,
|
||||
longestUnquotedHead,
|
||||
aumid,
|
||||
tailAfterLongestUnquotedHead,
|
||||
LaunchMethod.ActivateAppId,
|
||||
null,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3) Bare command resolution via PATH + executable ext
|
||||
// At this point 'head' is our best intended command token.
|
||||
var (firstHead, tail) = SplitHeadAndArgs(input);
|
||||
CommandLineHelper.ExpandPathToPhysicalFile(firstHead, true, out var head);
|
||||
|
||||
// 3.1) UWP/AppX via AppsFolder/AUMID or pkgfamily!app
|
||||
// Since the AUMID can be actually anything, we either take a full shell:AppsFolder\AUMID
|
||||
// as entered and we try to detect packaged app ids (pkgfamily!app).
|
||||
if (TryGetAumid(head, out var aumid2))
|
||||
{
|
||||
result = new Classification(
|
||||
CommandKind.Aumid,
|
||||
head,
|
||||
aumid2,
|
||||
tail,
|
||||
LaunchMethod.ActivateAppId,
|
||||
null,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3.2) It's a virtual shell item (e.g. Control Panel, Recycle Bin, This PC)
|
||||
// Shell items that are backed by filesystem paths (e.g. Downloads) should be already handled above.
|
||||
if (CommandLineHelper.HasShellPrefix(head))
|
||||
{
|
||||
ShellNames.TryGetFriendlyName(input, out var displayName);
|
||||
ShellNames.TryGetFileSystemPath(input, out var fsPath);
|
||||
result = new Classification(
|
||||
CommandKind.VirtualShellItem,
|
||||
input,
|
||||
input,
|
||||
string.Empty,
|
||||
LaunchMethod.ShellExecute,
|
||||
fsPath is not null && Directory.Exists(fsPath) ? fsPath : null,
|
||||
isPlaceholder,
|
||||
fsPath,
|
||||
displayName);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3.3) Search paths for the file name (with or without ext)
|
||||
// If head is a file name with extension, we look only for that. If there's no extension
|
||||
// we go and follow Windows Shell resolution rules.
|
||||
if (TryResolveViaPath(head, out var resolvedFilePath))
|
||||
{
|
||||
result = new Classification(
|
||||
CommandKind.PathCommand,
|
||||
input,
|
||||
resolvedFilePath,
|
||||
tail,
|
||||
LaunchMethod.ShellExecute,
|
||||
null,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3.4) If it looks like a path with ext but missing file, treat as document (Shell will handle assoc / error)
|
||||
if (LooksPathy(head) && Path.HasExtension(head))
|
||||
{
|
||||
var extension = Path.GetExtension(head);
|
||||
|
||||
// if the path extension contains placeholders, we can't assume what it is so, skip it and treat it as unknown
|
||||
var hasSpecificExtension = !isPlaceholder || !extension.Contains('{');
|
||||
if (hasSpecificExtension)
|
||||
{
|
||||
result = new Classification(
|
||||
ShellHelpers.IsExecutableExtension(extension) ? CommandKind.FileExecutable : CommandKind.FileDocument,
|
||||
input,
|
||||
head,
|
||||
tail,
|
||||
LaunchMethod.ShellExecute,
|
||||
HasDir(head) ? Path.GetDirectoryName(head) : null,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) looks like a web URL without scheme, but not like a file with extension
|
||||
if (head.Contains('.', StringComparison.OrdinalIgnoreCase) && head.StartsWith("www", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// treat as URL, add https://
|
||||
var url = "https://" + input;
|
||||
result = new Classification(
|
||||
CommandKind.WebUrl,
|
||||
input,
|
||||
url,
|
||||
string.Empty,
|
||||
LaunchMethod.ShellExecute,
|
||||
null,
|
||||
isPlaceholder);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 5) Fallback: let ShellExecute try the whole input
|
||||
result = new Classification(
|
||||
CommandKind.Unknown,
|
||||
input,
|
||||
head,
|
||||
tail,
|
||||
LaunchMethod.ShellExecute,
|
||||
null,
|
||||
isPlaceholder);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static (string Head, string Tail) SplitHeadAndArgs(string input) => CommandLineHelper.SplitHeadAndArgs(input);
|
||||
|
||||
// Finds the best existing path prefix in an *unquoted* input by scanning
|
||||
// whitespace boundaries. Prefers files to directories; for same kind,
|
||||
// prefers the longer path.
|
||||
// Returns (head, tail) or (null, null) if nothing found.
|
||||
private static (string? Head, string? Tail) ExpandToBestExistingPath(string head, string tail, bool containsPlaceholders, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
try
|
||||
{
|
||||
// This goes greedy from the longest head down to shortest; exactly opposite of what
|
||||
// CreateProcess rules are for the first token. But here we operate with a slightly different goal.
|
||||
var (greedyHead, greedyTail) = GreedyFind(head, containsPlaceholders, placeholderParser);
|
||||
|
||||
// put tails back together:
|
||||
return (Head: greedyHead, string.Join(" ", greedyTail, tail).Trim());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to find best path", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? Head, string? Tail) GreedyFind(string input, bool containsPlaceholders, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
// Be greedy: try to find the longest existing path prefix
|
||||
for (var i = input.Length; i >= 0; i--)
|
||||
{
|
||||
if (i < input.Length && !char.IsWhiteSpace(input[i]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = input.AsSpan(0, i).TrimEnd().ToString();
|
||||
if (candidate.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we have placeholders, check if this candidate would contain a non-path placeholder
|
||||
if (containsPlaceholders && ContainsNonPathPlaceholder(candidate, placeholderParser))
|
||||
{
|
||||
continue; // Skip this candidate, try a shorter one
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (CommandLineHelper.ExpandPathToPhysicalFile(candidate, true, out var full))
|
||||
{
|
||||
var tail = i < input.Length ? input[i..].TrimStart() : string.Empty;
|
||||
return (full, tail);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore malformed paths; keep scanning
|
||||
}
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// Attempts to guess if any placeholders in the candidate string are likely not part of a filesystem path.
|
||||
private static bool ContainsNonPathPlaceholder(string candidate, IPlaceholderParser placeholderParser)
|
||||
{
|
||||
placeholderParser.ParsePlaceholders(candidate, out _, out var placeholders);
|
||||
foreach (var match in placeholders)
|
||||
{
|
||||
var placeholderContext = GuessPlaceholderContextInFileSystemPath(candidate, match.Index);
|
||||
|
||||
// If placeholder appears after what looks like a command-line flag/option
|
||||
if (placeholderContext.IsAfterFlag)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// If placeholder doesn't look like a typical path component
|
||||
if (!placeholderContext.LooksLikePathComponent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heuristically determines the context of a placeholder inside a filesystem-like input string.
|
||||
// Sets:
|
||||
// - IsAfterFlag: true if immediately preceded by a token that looks like a command-line flag prefix (" -", " /", " --").
|
||||
// - LooksLikePathComponent: true if (a) not after a flag or (b) nearby text shows path separators.
|
||||
private static PlaceholderContext GuessPlaceholderContextInFileSystemPath(string input, int placeholderIndex)
|
||||
{
|
||||
var beforePlaceholder = input[..placeholderIndex].TrimEnd();
|
||||
|
||||
var isAfterFlag = beforePlaceholder.EndsWith(" -", StringComparison.OrdinalIgnoreCase) ||
|
||||
beforePlaceholder.EndsWith(" /", StringComparison.OrdinalIgnoreCase) ||
|
||||
beforePlaceholder.EndsWith(" --", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var looksLikePathComponent = !isAfterFlag;
|
||||
|
||||
var nearbyText = input.Substring(Math.Max(0, placeholderIndex - 20), Math.Min(40, input.Length - Math.Max(0, placeholderIndex - 20)));
|
||||
var hasPathSeparators = nearbyText.Contains('\\') || nearbyText.Contains('/');
|
||||
|
||||
if (!hasPathSeparators && isAfterFlag)
|
||||
{
|
||||
looksLikePathComponent = false;
|
||||
}
|
||||
|
||||
return new PlaceholderContext(isAfterFlag, looksLikePathComponent);
|
||||
}
|
||||
|
||||
private static bool TryGetAumid(string input, out string aumid)
|
||||
{
|
||||
// App ids are a lot of fun, since they can look like anything.
|
||||
// And yes, they can contain spaces too, like Zoom:
|
||||
// shell:AppsFolder\zoom.us.Zoom Video Meetings
|
||||
// so unless that thing is quoted, we can't just assume the first token is the AUMID.
|
||||
const string appsFolder = "shell:AppsFolder\\";
|
||||
|
||||
// Guard against null or empty input
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
aumid = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Already a fully qualified AUMID path
|
||||
if (input.StartsWith(appsFolder, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
aumid = input;
|
||||
return true;
|
||||
}
|
||||
|
||||
aumid = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksPathy(string input)
|
||||
{
|
||||
// Basic: drive:\, UNC, relative with . or .., or has dir separator
|
||||
if (input.Contains('\\') || input.Contains('/'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (input is [_, ':', ..])
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (input.StartsWith(@"\\", StringComparison.InvariantCulture) || input.StartsWith("./", StringComparison.InvariantCulture) || input.StartsWith(".\\", StringComparison.InvariantCulture) || input.StartsWith("..\\", StringComparison.InvariantCulture))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasDir(string path) => !string.IsNullOrEmpty(Path.GetDirectoryName(path));
|
||||
|
||||
private static bool TryResolveViaPath(string head, out string resolvedFile)
|
||||
{
|
||||
resolvedFile = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(head))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Path.HasExtension(head) && ShellHelpers.FileExistInPath(head, out resolvedFile))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// If head has dir, treat as path probe
|
||||
if (HasDir(head))
|
||||
{
|
||||
if (Path.HasExtension(head))
|
||||
{
|
||||
var p = TryProbe(Environment.CurrentDirectory, head);
|
||||
if (p is not null)
|
||||
{
|
||||
resolvedFile = p;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var ext in ShellHelpers.ExecutableExtensions)
|
||||
{
|
||||
var p = TryProbe(null, head + ext);
|
||||
if (p is not null)
|
||||
{
|
||||
resolvedFile = p;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return ShellHelpers.TryResolveExecutableAsShell(head, out resolvedFile);
|
||||
}
|
||||
|
||||
private static string? TryProbe(string? dir, string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = dir is null ? name : Path.Combine(dir, name);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private record PlaceholderContext(bool IsAfterFlag, bool LooksLikePathComponent);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
public sealed partial class FaviconLoader : IFaviconLoader, IDisposable
|
||||
{
|
||||
private readonly HttpClient _http = CreateClient();
|
||||
private bool _disposed;
|
||||
|
||||
private static HttpClient CreateClient()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 10,
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
|
||||
};
|
||||
|
||||
var client = new HttpClient(handler, disposeHandler: true);
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) WindowsCommandPalette/1.0");
|
||||
client.DefaultRequestHeaders.Accept.ParseAdd("image/*");
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
public async Task<IRandomAccessStream?> TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default)
|
||||
{
|
||||
if (siteUri.Scheme != Uri.UriSchemeHttp && siteUri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1) First attempt: favicon on the original authority (preserves port).
|
||||
var first = BuildFaviconUri(siteUri);
|
||||
|
||||
// Try download; if this fails (non-image or path lost), retry on final host.
|
||||
var stream = await TryDownloadImageAsync(first, ct).ConfigureAwait(false);
|
||||
if (stream is not null)
|
||||
{
|
||||
return stream;
|
||||
}
|
||||
|
||||
// 2) If the server redirected and "lost" the path, try /favicon.ico on the *final* host.
|
||||
// We discover the final host by doing a HEAD/GET to the original URL and inspecting the final RequestUri.
|
||||
var finalAuthority = await ResolveFinalAuthorityAsync(first, ct).ConfigureAwait(false);
|
||||
if (finalAuthority is null || UriEqualsAuthority(first, finalAuthority))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var second = BuildFaviconUri(finalAuthority);
|
||||
if (second == first)
|
||||
{
|
||||
return null; // nothing new to try
|
||||
}
|
||||
|
||||
return await TryDownloadImageAsync(second, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static Uri BuildFaviconUri(Uri anyUriOnSite)
|
||||
{
|
||||
var b = new UriBuilder(anyUriOnSite.Scheme, anyUriOnSite.Host)
|
||||
{
|
||||
Port = anyUriOnSite.IsDefaultPort ? -1 : anyUriOnSite.Port,
|
||||
Path = "/favicon.ico",
|
||||
};
|
||||
return b.Uri;
|
||||
}
|
||||
|
||||
private async Task<Uri?> ResolveFinalAuthorityAsync(Uri url, CancellationToken ct)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// We only need headers to learn the final RequestUri after redirects
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var final = resp.RequestMessage?.RequestUri;
|
||||
return final is null ? null : new UriBuilder(final.Scheme, final.Host)
|
||||
{
|
||||
Port = final.IsDefaultPort ? -1 : final.Port,
|
||||
Path = "/",
|
||||
}.Uri;
|
||||
}
|
||||
|
||||
private async Task<IRandomAccessStream?> TryDownloadImageAsync(Uri url, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the redirect chain dumped us on an HTML page (common for root), bail.
|
||||
var mediaType = resp.Content.Headers.ContentType?.MediaType;
|
||||
if (mediaType is not null &&
|
||||
!mediaType.StartsWith("image", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bytes = await resp.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
var stream = new InMemoryRandomAccessStream();
|
||||
|
||||
using (var output = stream.GetOutputStreamAt(0))
|
||||
using (var writer = new DataWriter(output))
|
||||
{
|
||||
writer.WriteBytes(bytes);
|
||||
await writer.StoreAsync().AsTask(ct);
|
||||
await writer.FlushAsync().AsTask(ct);
|
||||
}
|
||||
|
||||
stream.Seek(0);
|
||||
return stream;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool UriEqualsAuthority(Uri a, Uri b)
|
||||
=> a.Scheme.Equals(b.Scheme, StringComparison.OrdinalIgnoreCase)
|
||||
&& a.Host.Equals(b.Host, StringComparison.OrdinalIgnoreCase)
|
||||
&& (a.IsDefaultPort ? -1 : a.Port) == (b.IsDefaultPort ? -1 : b.Port);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_http.Dispose();
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
public interface IBookmarkIconLocator
|
||||
{
|
||||
Task<IIconInfo> GetIconForPath(Classification classification, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
internal interface IBookmarkResolver
|
||||
{
|
||||
Task<(bool Success, Classification Result)> TryClassifyAsync(string input, CancellationToken cancellationToken = default);
|
||||
|
||||
Classification ClassifyOrUnknown(string input);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service to load favicons for websites.
|
||||
/// </summary>
|
||||
public interface IFaviconLoader
|
||||
{
|
||||
Task<IRandomAccessStream?> TryGetFaviconAsync(Uri siteUri, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 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.Bookmarks.Services;
|
||||
|
||||
public interface IPlaceholderParser
|
||||
{
|
||||
bool ParsePlaceholders(string input, out string head, out List<PlaceholderInfo> placeholders);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
internal class IconLocator : IBookmarkIconLocator
|
||||
{
|
||||
private readonly IFaviconLoader _faviconLoader;
|
||||
|
||||
public IconLocator()
|
||||
: this(new FaviconLoader())
|
||||
{
|
||||
}
|
||||
|
||||
private IconLocator(IFaviconLoader faviconLoader)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(faviconLoader);
|
||||
_faviconLoader = faviconLoader;
|
||||
}
|
||||
|
||||
public async Task<IIconInfo> GetIconForPath(
|
||||
Classification classification,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(classification);
|
||||
|
||||
var icon = classification.Kind switch
|
||||
{
|
||||
CommandKind.WebUrl => await TryGetWebIcon(classification.Target),
|
||||
CommandKind.Protocol => await TryGetProtocolIcon(classification.Target),
|
||||
CommandKind.FileExecutable => await TryGetExecutableIcon(classification.Target),
|
||||
CommandKind.Unknown => FallbackIcon(classification),
|
||||
_ => await MaybeGetIconForPath(classification.Target),
|
||||
};
|
||||
|
||||
return icon ?? FallbackIcon(classification);
|
||||
}
|
||||
|
||||
private async Task<IIconInfo?> TryGetWebIcon(string target)
|
||||
{
|
||||
// Get the base url up to the first placeholder
|
||||
var placeholderIndex = target.IndexOf('{');
|
||||
var baseString = placeholderIndex > 0 ? target[..placeholderIndex] : target;
|
||||
try
|
||||
{
|
||||
var uri = new Uri(baseString);
|
||||
var iconStream = await _faviconLoader.TryGetFaviconAsync(uri, CancellationToken.None);
|
||||
if (iconStream != null)
|
||||
{
|
||||
return IconInfo.FromStream(iconStream);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to get web bookmark favicon for " + baseString, ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<IIconInfo?> TryGetExecutableIcon(string target)
|
||||
{
|
||||
IIconInfo? icon = null;
|
||||
var exeExists = false;
|
||||
var fullExePath = string.Empty;
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
// Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation
|
||||
var pathResolutionTask = Task.Run(
|
||||
() =>
|
||||
{
|
||||
// Don't check cancellation token here - let the Task timeout handle it
|
||||
exeExists = ShellHelpers.FileExistInPath(target, out fullExePath);
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Wait for either completion or timeout
|
||||
pathResolutionTask.Wait(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Debug.WriteLine("Operation was canceled.");
|
||||
}
|
||||
|
||||
if (exeExists)
|
||||
{
|
||||
// If the executable exists, try to get the icon from the file
|
||||
icon = await MaybeGetIconForPath(fullExePath);
|
||||
if (icon is not null)
|
||||
{
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
private static async Task<IconInfo?> TryGetProtocolIcon(string target)
|
||||
{
|
||||
// Special case for steam: protocol - use game icon
|
||||
// Steam protocol have only a file name (steam.exe) associated with it, but is not
|
||||
// in PATH or AppPaths. So we can't resolve it to an executable. But at the same time,
|
||||
// this is a very common protocol, so we special-case it here.
|
||||
if (target.StartsWith("steam:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Icons.BookmarkTypes.Game;
|
||||
}
|
||||
|
||||
// extract protocol from classification.Target (until the first ':'):
|
||||
IconInfo? icon = null;
|
||||
var colonIndex = target.IndexOf(':');
|
||||
string protocol;
|
||||
if (colonIndex > 0)
|
||||
{
|
||||
protocol = target[..colonIndex];
|
||||
}
|
||||
else
|
||||
{
|
||||
return icon;
|
||||
}
|
||||
|
||||
icon = await ThumbnailHelper.GetProtocolIconStream(protocol, true) is { } stream
|
||||
? IconInfo.FromStream(stream)
|
||||
: null;
|
||||
|
||||
if (icon is null)
|
||||
{
|
||||
var protocolIconPath = ProtocolIconResolver.GetIconString(protocol);
|
||||
if (protocolIconPath is not null)
|
||||
{
|
||||
icon = new IconInfo(protocolIconPath);
|
||||
}
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
private static IconInfo FallbackIcon(Classification classification)
|
||||
{
|
||||
return classification.Kind switch
|
||||
{
|
||||
CommandKind.FileExecutable => Icons.BookmarkTypes.Application,
|
||||
CommandKind.FileDocument => Icons.BookmarkTypes.FilePath,
|
||||
CommandKind.Directory => Icons.BookmarkTypes.FolderPath,
|
||||
CommandKind.PathCommand => Icons.BookmarkTypes.Command,
|
||||
CommandKind.Aumid => Icons.BookmarkTypes.Application,
|
||||
CommandKind.Shortcut => Icons.BookmarkTypes.Application,
|
||||
CommandKind.InternetShortcut => Icons.BookmarkTypes.WebUrl,
|
||||
CommandKind.WebUrl => Icons.BookmarkTypes.WebUrl,
|
||||
CommandKind.Protocol => Icons.BookmarkTypes.Application,
|
||||
_ => Icons.BookmarkTypes.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IconInfo?> MaybeGetIconForPath(string target)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = await ThumbnailHelper.GetThumbnail(target);
|
||||
if (stream is not null)
|
||||
{
|
||||
return IconInfo.FromStream(stream);
|
||||
}
|
||||
|
||||
if (ShellNames.TryGetFileSystemPath(target, out var fileSystemPath))
|
||||
{
|
||||
stream = await ThumbnailHelper.GetThumbnail(fileSystemPath);
|
||||
if (stream is not null)
|
||||
{
|
||||
return IconInfo.FromStream(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogDebug($"Failed to load icon for {target}\n" + ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static class ProtocolIconResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the icon resource string for a given URI protocol (e.g. "steam" or "mailto").
|
||||
/// Returns something like "C:\Path\app.exe,0" or null if not found.
|
||||
/// </summary>
|
||||
public static string? GetIconString(string protocol)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(protocol))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protocol = protocol.TrimEnd(':').ToLowerInvariant();
|
||||
|
||||
// Try HKCR\<protocol>\DefaultIcon
|
||||
using (var di = Registry.ClassesRoot.OpenSubKey(protocol + "\\DefaultIcon"))
|
||||
{
|
||||
var value = di?.GetValue(null) as string;
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: HKCR\<protocol>\shell\open\command
|
||||
using (var cmd = Registry.ClassesRoot.OpenSubKey(protocol + "\\shell\\open\\command"))
|
||||
{
|
||||
var command = cmd?.GetValue(null) as string;
|
||||
if (!string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
var exe = ExtractExecutable(command);
|
||||
if (!string.IsNullOrWhiteSpace(exe))
|
||||
{
|
||||
return exe; // default index 0 implied
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to get protocol information from registry; will return nothing instead", ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractExecutable(string command)
|
||||
{
|
||||
command = command.Trim();
|
||||
|
||||
if (command.StartsWith('\"'))
|
||||
{
|
||||
var end = command.IndexOf('"', 1);
|
||||
if (end > 1)
|
||||
{
|
||||
return command[1..end];
|
||||
}
|
||||
}
|
||||
|
||||
var space = command.IndexOf(' ');
|
||||
return space > 0 ? command[..space] : command;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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.Bookmarks.Services;
|
||||
|
||||
public sealed class PlaceholderInfo
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
public int Index { get; }
|
||||
|
||||
public PlaceholderInfo(string name, int index)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(index, 0);
|
||||
|
||||
Name = name;
|
||||
Index = index;
|
||||
}
|
||||
|
||||
private bool Equals(PlaceholderInfo other) => Name == other.Name && Index == other.Index;
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(this, obj))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (obj.GetType() != GetType())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Equals((PlaceholderInfo)obj);
|
||||
}
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Name, Index);
|
||||
|
||||
public static bool operator ==(PlaceholderInfo? left, PlaceholderInfo? right)
|
||||
{
|
||||
return Equals(left, right);
|
||||
}
|
||||
|
||||
public static bool operator !=(PlaceholderInfo? left, PlaceholderInfo? right)
|
||||
{
|
||||
return !Equals(left, right);
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
|
||||
public class PlaceholderInfoNameEqualityComparer : IEqualityComparer<PlaceholderInfo>
|
||||
{
|
||||
public static PlaceholderInfoNameEqualityComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(PlaceholderInfo? x, PlaceholderInfo? y)
|
||||
{
|
||||
if (x is null && y is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public int GetHashCode(PlaceholderInfo obj)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(obj);
|
||||
return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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.Bookmarks.Services;
|
||||
|
||||
public class PlaceholderParser : IPlaceholderParser
|
||||
{
|
||||
public bool ParsePlaceholders(string input, out string head, out List<PlaceholderInfo> placeholders)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
head = string.Empty;
|
||||
placeholders = [];
|
||||
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
head = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
var foundPlaceholders = new List<PlaceholderInfo>();
|
||||
var searchStart = 0;
|
||||
var firstPlaceholderStart = -1;
|
||||
var hasValidPlaceholder = false;
|
||||
|
||||
while (searchStart < input.Length)
|
||||
{
|
||||
var openBrace = input.IndexOf('{', searchStart);
|
||||
if (openBrace == -1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var closeBrace = input.IndexOf('}', openBrace + 1);
|
||||
if (closeBrace == -1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract potential placeholder name
|
||||
var placeholderContent = input.Substring(openBrace + 1, closeBrace - openBrace - 1);
|
||||
|
||||
// Check if it's a valid placeholder
|
||||
if (!string.IsNullOrEmpty(placeholderContent) &&
|
||||
!IsGuidFormat(placeholderContent) &&
|
||||
IsValidPlaceholderName(placeholderContent))
|
||||
{
|
||||
// Valid placeholder found
|
||||
foundPlaceholders.Add(new PlaceholderInfo(placeholderContent, openBrace));
|
||||
hasValidPlaceholder = true;
|
||||
|
||||
// Remember the first valid placeholder position
|
||||
if (firstPlaceholderStart == -1)
|
||||
{
|
||||
firstPlaceholderStart = openBrace;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue searching after this brace pair
|
||||
searchStart = closeBrace + 1;
|
||||
}
|
||||
|
||||
// Convert to Placeholder objects
|
||||
placeholders = foundPlaceholders;
|
||||
|
||||
if (hasValidPlaceholder)
|
||||
{
|
||||
head = input[..firstPlaceholderStart];
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
head = input;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidPlaceholderName(string name)
|
||||
{
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
var c = name[i];
|
||||
if (!(char.IsLetterOrDigit(c) || c == '_' || c == '-'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsGuidFormat(string content) => Guid.TryParse(content, out _);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Storage.Streams;
|
||||
using Windows.System;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
public partial class UrlCommand : InvokableCommand
|
||||
{
|
||||
private readonly Lazy<IconInfo> _icon;
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public override IconInfo Icon { get => _icon.Value; set => base.Icon = value; }
|
||||
|
||||
public UrlCommand(BookmarkData data)
|
||||
: this(data.Name, data.Bookmark)
|
||||
{
|
||||
}
|
||||
|
||||
public UrlCommand(string name, string url)
|
||||
{
|
||||
Name = Properties.Resources.bookmarks_command_name_open;
|
||||
|
||||
Url = url;
|
||||
|
||||
_icon = new Lazy<IconInfo>(() =>
|
||||
{
|
||||
ShellHelpers.ParseExecutableAndArgs(Url, out var exe, out var args);
|
||||
var t = GetIconForPath(exe);
|
||||
t.Wait();
|
||||
return t.Result;
|
||||
});
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
var success = LaunchCommand(Url);
|
||||
|
||||
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
|
||||
}
|
||||
|
||||
internal static bool LaunchCommand(string target)
|
||||
{
|
||||
ShellHelpers.ParseExecutableAndArgs(target, out var exe, out var args);
|
||||
return LaunchCommand(exe, args);
|
||||
}
|
||||
|
||||
internal static bool LaunchCommand(string exe, string args)
|
||||
{
|
||||
if (string.IsNullOrEmpty(exe))
|
||||
{
|
||||
var message = "No executable found in the command.";
|
||||
Logger.LogError(message);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ShellHelpers.OpenInShell(exe, args))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we reach here, it means the command could not be executed
|
||||
// If there aren't args, then try again as a https: uri
|
||||
if (string.IsNullOrEmpty(args))
|
||||
{
|
||||
var uri = GetUri(exe);
|
||||
if (uri is not null)
|
||||
{
|
||||
_ = Launcher.LaunchUriAsync(uri);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogError("The provided URL is not valid.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static Uri? GetUri(string url)
|
||||
{
|
||||
Uri? uri;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out uri))
|
||||
{
|
||||
if (!Uri.TryCreate("https://" + url, UriKind.Absolute, out uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
public static async Task<IconInfo> GetIconForPath(string target)
|
||||
{
|
||||
IconInfo? icon = null;
|
||||
|
||||
// First, try to get the icon from the thumbnail helper
|
||||
// This works for local files and folders
|
||||
icon = await MaybeGetIconForPath(target);
|
||||
if (icon is not null)
|
||||
{
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Okay, that failed. Try to resolve the full path of the executable
|
||||
var exeExists = false;
|
||||
var fullExePath = string.Empty;
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
// Use Task.Run with timeout - this will actually timeout even if the sync operations don't respond to cancellation
|
||||
var pathResolutionTask = Task.Run(
|
||||
() =>
|
||||
{
|
||||
// Don't check cancellation token here - let the Task timeout handle it
|
||||
exeExists = ShellHelpers.FileExistInPath(target, out fullExePath);
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
// Wait for either completion or timeout
|
||||
pathResolutionTask.Wait(cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Debug.WriteLine("Operation was canceled.");
|
||||
}
|
||||
|
||||
if (exeExists)
|
||||
{
|
||||
// If the executable exists, try to get the icon from the file
|
||||
icon = await MaybeGetIconForPath(fullExePath);
|
||||
if (icon is not null)
|
||||
{
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the base url up to the first placeholder
|
||||
var placeholderIndex = target.IndexOf('{');
|
||||
var baseString = placeholderIndex > 0 ? target.Substring(0, placeholderIndex) : target;
|
||||
try
|
||||
{
|
||||
var uri = GetUri(baseString);
|
||||
if (uri is not null)
|
||||
{
|
||||
var hostname = uri.Host;
|
||||
var faviconUrl = $"{uri.Scheme}://{hostname}/favicon.ico";
|
||||
icon = new IconInfo(faviconUrl);
|
||||
}
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
}
|
||||
|
||||
// If we still don't have an icon, use the target as the icon
|
||||
icon = icon ?? new IconInfo(target);
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
private static async Task<IconInfo?> MaybeGetIconForPath(string target)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = await ThumbnailHelper.GetThumbnail(target);
|
||||
if (stream is not null)
|
||||
{
|
||||
var data = new IconData(RandomAccessStreamReference.CreateFromStream(stream));
|
||||
return new IconInfo(data, data);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,15 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Open URL.
|
||||
/// </summary>
|
||||
public static string open_url_command_name {
|
||||
get {
|
||||
return ResourceManager.GetString("open_url_command_name", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Paste.
|
||||
/// </summary>
|
||||
|
||||
@@ -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.
|
||||
@@ -78,7 +51,7 @@ public static class CommandLineNormalizer
|
||||
///
|
||||
/// The resulting strings are used for comparisons in profile matching.
|
||||
/// </remarks>
|
||||
public static string NormalizeCommandLine(string commandLine)
|
||||
public static string NormalizeCommandLine(string commandLine, bool allowDirectory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(commandLine))
|
||||
{
|
||||
@@ -106,7 +79,7 @@ public static class CommandLineNormalizer
|
||||
// The given commandLine should start with an executable name or path.
|
||||
// This loop tries to resolve relative paths, as well as executable names in %PATH%
|
||||
// into absolute paths and normalizes them.
|
||||
var executablePath = ResolveExecutablePath(argv, ref startOfArguments);
|
||||
var executablePath = ResolveExecutablePath(argv, allowDirectory, ref startOfArguments);
|
||||
|
||||
// We've (hopefully) finished resolving the path to the executable.
|
||||
// We're now going to append all remaining arguments to the resulting string.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +163,7 @@ public static class CommandLineNormalizer
|
||||
/// Resolves the executable path from the command line arguments.
|
||||
/// Handles cases where the path contains spaces and was split during parsing.
|
||||
/// </summary>
|
||||
private static string ResolveExecutablePath(string[] argv, ref int startOfArguments)
|
||||
private static string ResolveExecutablePath(string[] argv, bool allowDirectory, ref int startOfArguments)
|
||||
{
|
||||
if (argv.Length == 0)
|
||||
{
|
||||
@@ -208,7 +183,7 @@ public static class CommandLineNormalizer
|
||||
}
|
||||
|
||||
var candidatePath = pathBuilder.ToString();
|
||||
var resolvedPath = TryResolveExecutable(candidatePath);
|
||||
var resolvedPath = TryResolveExecutable(candidatePath, allowDirectory);
|
||||
|
||||
if (!string.IsNullOrEmpty(resolvedPath))
|
||||
{
|
||||
@@ -225,41 +200,52 @@ public static class CommandLineNormalizer
|
||||
/// <summary>
|
||||
/// Attempts to resolve an executable path using SearchPathW.
|
||||
/// </summary>
|
||||
private static string TryResolveExecutable(string executableName)
|
||||
private static string TryResolveExecutable(string executableName, bool allowDirectory)
|
||||
{
|
||||
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...
|
||||
var attributes = PInvoke.GetFileAttributes(resolvedPath);
|
||||
|
||||
// ... and if we don't want to allow directories, reject paths that are dirs
|
||||
var rejectDirectory = !allowDirectory &&
|
||||
(attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0;
|
||||
|
||||
return attributes == INVALID_FILE_ATTRIBUTES ||
|
||||
rejectDirectory ?
|
||||
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,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.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 +11,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 _);
|
||||
@@ -54,11 +18,10 @@ public class ShellListPageHelpers
|
||||
|
||||
internal static bool FileExistInPath(string filename, out string fullPath, CancellationToken? token = null)
|
||||
{
|
||||
// TODO! remove this method and just use ShellHelpers.FileExistInPath directly
|
||||
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
|
||||
@@ -145,7 +108,7 @@ public class ShellListPageHelpers
|
||||
/// </summary>
|
||||
public static void NormalizeCommandLineAndArgs(string input, out string executable, out string arguments)
|
||||
{
|
||||
var normalized = CommandLineNormalizer.NormalizeCommandLine(input);
|
||||
var normalized = CommandLineNormalizer.NormalizeCommandLine(input, allowDirectory: true);
|
||||
var segments = normalized.Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
||||
executable = string.Empty;
|
||||
arguments = string.Empty;
|
||||
|
||||
@@ -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,40 +45,35 @@ 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
|
||||
|
||||
// NOTE ME:
|
||||
// If there are spaces on originalDir, trim them off, BEFORE combining originalDir and fileName.
|
||||
// THEN add quotes at the end
|
||||
|
||||
// Trim off leading & trailing quote, if there is one
|
||||
var trimmed = originalDir.Trim('"');
|
||||
var originalPath = Path.Combine(trimmed, fileName);
|
||||
var suggestion = originalPath;
|
||||
var hasSpace = originalPath.Contains(' ');
|
||||
if (hasSpace)
|
||||
{
|
||||
// wrap it in quotes
|
||||
suggestion = string.Concat("\"", suggestion, "\"");
|
||||
}
|
||||
|
||||
TextToSuggest = suggestion;
|
||||
TextToSuggest = path;
|
||||
|
||||
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,72 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
||||
if (dirExists)
|
||||
{
|
||||
// Check for cancellation before file system enumeration
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// 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();
|
||||
|
||||
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.Length));
|
||||
var originalBeginning = originalPath.Remove(originalPath.Length - searchPathTrailer.Length);
|
||||
if (isDriveRoot)
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
originalBeginning = string.Concat(originalBeginning, '\\');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a list of commands for each file
|
||||
var commands = files.Select(f => PathToListItem(f, originalBeginning)).ToList();
|
||||
// If the directory we're in changed, then first rebuild the cache
|
||||
// of all the items in the directory, _then_ filter them below.
|
||||
if (directoryPath != _currentSubdir)
|
||||
{
|
||||
// Get all the files in the directory.
|
||||
// Run this on a background thread to avoid blocking
|
||||
var files = await Task.Run(() => Directory.GetFileSystemEntries(directoryPath), cancellationToken);
|
||||
|
||||
// Final cancellation check before updating results
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Check for cancellation after file enumeration
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the commands to the list
|
||||
_pathItems = commands;
|
||||
var searchPathTrailer = trimmed.Remove(0, Math.Min(directoryPath.Length, trimmed.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 newPathItems = files
|
||||
.Select(f => PathToListItem(f, originalBeginning))
|
||||
.ToDictionary(item => item.Title, item => item);
|
||||
|
||||
// Final cancellation check before updating results
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the commands to the list
|
||||
_pathItems.Clear();
|
||||
_currentSubdir = directoryPath;
|
||||
_currentPathItems.Clear();
|
||||
foreach ((var k, IListItem v) in newPathItems)
|
||||
{
|
||||
_currentPathItems[k] = (ListItem)v;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter the items from this directory
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -458,7 +521,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.");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user