Compare commits

...

21 Commits

Author SHA1 Message Date
Mike Griese
d0946ca3ac PRE-Merge branch 'dev/migrie/b/context-menu-page-on-dock' into dev/migrie/selfhost-11-002 2026-05-19 13:31:09 -05:00
Mike Griese
df2c146b30 PRE-Merge branch 'dev/migrie/f/Bookmark-On-Doch' into dev/migrie/selfhost-11-002 2026-05-19 13:30:44 -05:00
Mike Griese
8026df4d16 context menus and confirmations from the dock 2026-05-19 11:42:11 -05:00
Mike Griese
eb4792f942 fix opening a page from a context menu on a dock item 2026-05-19 11:42:11 -05:00
Mike Griese
8352afbb65 less logging 2026-05-19 10:55:20 -05:00
Mike Griese
242ec2020c more surgical 2026-05-19 10:45:42 -05:00
Mike Griese
2927ffa8b7 debounce updates to dock 2026-05-19 10:06:08 -05:00
Mike Griese
f492cda5d5 REVERTME: far too much logging 2026-05-19 09:19:07 -05:00
Mike Griese
e2248a6e1e Merge remote-tracking branch 'origin/main' into dev/migrie/f/Bookmark-On-Doch 2026-05-19 06:57:05 -05:00
Mike Griese
20306ed599 Revert "ABSOLUTELY CONTRIVED, DO NOT USE"
This reverts commit a2be31e5f0.
2026-05-19 06:13:54 -05:00
Mike Griese
a2be31e5f0 ABSOLUTELY CONTRIVED, DO NOT USE 2026-04-20 09:59:40 -05:00
Mike Griese
ca627134b3 Merge remote-tracking branch 'origin/main' into dev/migrie/f/Bookmark-On-Doch 2026-04-20 09:59:19 -05:00
Mike Griese
4b8cbde9e6 more cleanup 2026-04-01 11:22:01 -05:00
Mike Griese
0728481923 Yep I think this is ready to push 2026-03-31 16:38:24 -05:00
Mike Griese
158e2f8d8a Wait am I the dummy 2026-03-27 13:45:51 -05:00
Mike Griese
380489122a you needed a lot of help today dummy 2026-03-27 13:39:09 -05:00
Mike Griese
869a1f0560 i'm still smarter than you, dumb clanker 2026-03-27 12:41:50 -05:00
Mike Griese
3185267804 Revert "lost the track"
This reverts commit d2ceeccc6e.
2026-03-27 12:28:17 -05:00
Mike Griese
d2ceeccc6e lost the track 2026-03-27 12:28:13 -05:00
Mike Griese
ee014d06b8 This is pretty close but there's a fuckin storm of events raised when this happens 2026-03-27 10:46:19 -05:00
Mike Griese
2a2a6cc9f5 Adds support for drag-droping files to bookmark to the dock, but we need more 2026-03-27 08:49:51 -05:00
18 changed files with 462 additions and 47 deletions

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -481,8 +481,9 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
public void PinDockBand(string commandId, IServiceProvider serviceProvider, bool withReload, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
{
Logger.LogDebug($"CommandProviderWrapper.PinDockBand(commandId): provider='{ProviderId}', commandId='{commandId}', withReload={withReload}, side={side}, monitor='{monitorDeviceId ?? "<global>"}'");
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings;
var dockSettings = settings.DockSettings;
@@ -535,7 +536,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
}
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
if (withReload)
{
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
}
private static void PinDockBandGlobal(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side)
@@ -610,7 +614,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
hotReload: false);
}
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider, bool withReload)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
settingsService.UpdateSettings(
@@ -630,7 +634,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
if (withReload)
{
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
}
public ICommandProviderContext GetProviderContext() => this;
@@ -639,8 +646,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
public override int GetHashCode() => _commandProvider.GetHashCode();
private void CommandProvider_ItemsChanged(object sender, IItemsChangedEventArgs args) =>
private void CommandProvider_ItemsChanged(object sender, IItemsChangedEventArgs args)
{
// We don't want to handle this ourselves - we want the
// TopLevelCommandManager to know about this, so they can remove
// our old commands from their own list.
@@ -648,6 +655,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
// In handling this, a call will be made to `LoadTopLevelCommands` to
// retrieve the new items.
this.CommandsChanged?.Invoke(this, args);
}
internal void PinDockBand(TopLevelViewModel bandVm)
{

View File

@@ -228,6 +228,20 @@ public partial class ContextMenuViewModel : ObservableObject,
}
}
/// <summary>
/// Raised after a command is actually invoked (i.e. sent as a <see cref="PerformCommandMessage"/>)
/// from this context menu. Not raised when the user navigates into a submenu.
/// </summary>
public event EventHandler<CommandItemViewModel>? CommandInvoked;
/// <summary>
/// Raised immediately before the <see cref="PerformCommandMessage"/> is sent.
/// Subscribers can decorate the message (for example, to attach an
/// <see cref="PerformCommandMessage.OnBeforeShowConfirmation"/> callback).
/// Not raised when the user navigates into a submenu.
/// </summary>
public event EventHandler<PerformCommandMessage>? CommandInvoking;
public ContextKeybindingResult InvokeCommand(CommandItemViewModel? command)
{
if (command is null)
@@ -245,8 +259,11 @@ public partial class ContextMenuViewModel : ObservableObject,
}
else
{
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(command.Command.Model, command.Model));
var message = new PerformCommandMessage(command.Command.Model, command.Model);
CommandInvoking?.Invoke(this, message);
WeakReferenceMessenger.Default.Send(message);
UpdateContextItems();
CommandInvoked?.Invoke(this, command);
return ContextKeybindingResult.Hide;
}
}

View File

@@ -66,29 +66,23 @@ public sealed partial class DockViewModel : IDisposable
{
if (_isEditing)
{
Logger.LogDebug("Skipping DockBands_CollectionChanged during edit mode");
return;
}
Logger.LogDebug("Starting DockBands_CollectionChanged");
// Refresh settings so newly pinned/unpinned bands are visible.
// Pin/unpin operations save with hotReload:false (to avoid
// double-updates), so _settings can be stale here.
_settings = _settingsService.Settings.DockSettings;
SetupBands();
Logger.LogDebug("Ended DockBands_CollectionChanged");
}
public void UpdateSettings(DockSettings settings)
{
if (_isEditing)
{
Logger.LogDebug("DockViewModel.UpdateSettings skipped (edit in progress)");
return;
}
Logger.LogDebug($"DockViewModel.UpdateSettings");
_settings = settings;
SetupBands();
}
@@ -239,7 +233,6 @@ public sealed partial class DockViewModel : IDisposable
private void SetupBands()
{
Logger.LogDebug($"Setting up dock bands");
var (start, center, end) = GetActiveBands();
SetupBands(start, StartItems);
SetupBands(center, CenterItems);
@@ -258,7 +251,7 @@ public sealed partial class DockViewModel : IDisposable
if (topLevelCommand is null)
{
Logger.LogWarning($"Failed to find band {commandId}");
Logger.LogWarning($"[DockDrop] DockViewModel.SetupBands: failed to find band command '{commandId}' (provider='{band.ProviderId}')");
}
if (topLevelCommand is not null)

View File

@@ -0,0 +1,82 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// An <see cref="ObservableCollection{T}"/> of <see cref="TopLevelViewModel"/>
/// that supports replacing the entire contents atomically with a single
/// <see cref="NotifyCollectionChangedAction.Reset"/> notification.
///
/// <para>
/// Using <see cref="ObservableCollection{T}"/>'s built-in Add/Remove/Insert
/// mutations (or helpers like <c>ListHelpers.InPlaceUpdateList</c>) fires one
/// <see cref="INotifyCollectionChanged.CollectionChanged"/> event per item
/// mutation. The dock subscribes to that event and does a full rebuild for
/// each, so a single provider reload (which can churn dozens of band entries)
/// turns into dozens of full dock rebuilds.
/// </para>
///
/// <para>
/// <see cref="ReplaceWith"/> bypasses the per-item notifications by mutating
/// the protected <see cref="Collection{T}.Items"/> list directly and then
/// raising one <c>Reset</c> at the end.
/// </para>
/// </summary>
public sealed partial class DockBandsCollection : ObservableCollection<TopLevelViewModel>
{
/// <summary>
/// Replaces the contents of this collection with <paramref name="newItems"/>
/// and raises exactly one <see cref="NotifyCollectionChangedAction.Reset"/>
/// event (plus the standard <c>Count</c> / <c>Item[]</c> property change
/// notifications). If the new contents are reference-equal to the current
/// contents, no notification is raised.
/// </summary>
public void ReplaceWith(IEnumerable<TopLevelViewModel> newItems)
{
ArgumentNullException.ThrowIfNull(newItems);
// Materialize once so we can compare and iterate without re-enumerating.
var snapshot = newItems as IList<TopLevelViewModel> ?? [.. newItems];
// Cheap short-circuit: same length and same instances in the same
// order means there is nothing to broadcast.
if (SequenceReferenceEquals(Items, snapshot))
{
return;
}
Items.Clear();
for (var i = 0; i < snapshot.Count; i++)
{
Items.Add(snapshot[i]);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
private static bool SequenceReferenceEquals(IList<TopLevelViewModel> a, IList<TopLevelViewModel> b)
{
if (a.Count != b.Count)
{
return false;
}
for (var i = 0; i < a.Count; i++)
{
if (!ReferenceEquals(a[i], b[i]))
{
return false;
}
}
return true;
}
}

View File

@@ -20,6 +20,15 @@ public record PerformCommandMessage
public bool TransientPage { get; set; }
/// <summary>
/// Optional callback raised by <see cref="ShellViewModel"/> just before a
/// <see cref="ShowConfirmationMessage"/> is dispatched for this command's
/// result. Lets the sender prepare UI (for example, the dock uses this to
/// open the cmdpal window anchored at the invoking dock item so that the
/// confirmation dialog appears in the right place).
/// </summary>
public Action? OnBeforeShowConfirmation { get; set; }
public PerformCommandMessage(ExtensionObject<ICommand> command)
{
Command = command;

View File

@@ -10,6 +10,7 @@ public record PinToDockMessage(
string ProviderId,
string CommandId,
bool Pin,
bool WithReload = true,
DockPinSide Side = DockPinSide.Start,
bool? ShowTitles = null,
bool? ShowSubtitles = null,

View File

@@ -386,7 +386,7 @@ public partial class ShellViewModel : ObservableObject,
var result = invokable.Invoke(message.Context);
// But if it did succeed, we need to handle the result.
UnsafeHandleCommandResult(result);
UnsafeHandleCommandResult(result, message.OnBeforeShowConfirmation);
success = true;
_handleInvokeTask = null;
@@ -412,7 +412,7 @@ public partial class ShellViewModel : ObservableObject,
}
}
private void UnsafeHandleCommandResult(ICommandResult? result)
private void UnsafeHandleCommandResult(ICommandResult? result, Action? onBeforeShowConfirmation = null)
{
if (result is null)
{
@@ -464,6 +464,17 @@ public partial class ShellViewModel : ObservableObject,
{
if (result.Args is IConfirmationArgs a)
{
// Give the original sender (e.g. the dock) a chance to
// prepare UI before the confirmation dialog surfaces.
try
{
onBeforeShowConfirmation?.Invoke();
}
catch (Exception ex)
{
CoreLogger.LogError(ex.ToString());
}
WeakReferenceMessenger.Default.Send<ShowConfirmationMessage>(new(a));
}
@@ -475,7 +486,7 @@ public partial class ShellViewModel : ObservableObject,
if (result.Args is IToastArgs a)
{
WeakReferenceMessenger.Default.Send<ShowToastMessage>(new(a.Message));
UnsafeHandleCommandResult(a.Result);
UnsafeHandleCommandResult(a.Result, onBeforeShowConfirmation);
}
break;

View File

@@ -68,7 +68,12 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelViewModel> DockBands { get; set; } = [];
// DockBands uses a custom collection so that bulk rewrites (see
// UpdateCommandsForProvider) raise a single Reset notification instead of
// one event per inserted/removed/moved item. The dock subscribes to this
// collection and does a full rebuild per event, so collapsing the burst
// here avoids dozens of redundant rebuilds for one provider reload.
public DockBandsCollection DockBands { get; } = new();
[ObservableProperty]
public partial bool IsLoading { get; private set; } = true;
@@ -194,8 +199,10 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
// By all accounts, we're already on a background thread (the COM call
// to handle the event shouldn't be on the main thread.). But just to
// be sure we don't block the caller, hop off this thread
private void CommandProvider_CommandsChanged(CommandProviderWrapper sender, IItemsChangedEventArgs args) =>
private void CommandProvider_CommandsChanged(CommandProviderWrapper sender, IItemsChangedEventArgs args)
{
_ = Task.Run(async () => await UpdateCommandsForProvider(sender, args));
}
/// <summary>
/// Called when a command provider raises its ItemsChanged event. We'll
@@ -240,12 +247,14 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
lock (_dockBandsLock)
{
// same idea for DockBands
// Same idea as TopLevelCommands above, but we deliberately use
// ReplaceWith so the dock only sees one CollectionChanged event
// for the whole rewrite instead of one per item.
List<TopLevelViewModel> dockClone = [.. DockBands];
var dockStartIndex = FindIndexForFirstProviderItem(dockClone, sender.ProviderId);
dockClone.RemoveAll(item => item.CommandProviderId == sender.ProviderId);
dockClone.InsertRange(dockStartIndex, newBands);
ListHelpers.InPlaceUpdateList(DockBands, dockClone);
DockBands.ReplaceWith(dockClone);
}
return;
@@ -726,13 +735,17 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
if (message.Pin)
{
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles, message.MonitorDeviceId);
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.WithReload, message.Side, message.ShowTitles, message.ShowSubtitles, message.MonitorDeviceId);
}
else
{
wrapper?.UnpinDockBand(message.CommandId, _serviceProvider);
wrapper?.UnpinDockBand(message.CommandId, _serviceProvider, message.WithReload);
}
}
else
{
Logger.LogWarning($"[DockDrop] PinToDockMessage: no provider found for '{message.ProviderId}'");
}
}
public CommandProviderWrapper? LookupProvider(string providerId)

View File

@@ -162,7 +162,10 @@ public partial class App : Application, IDisposable
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
services.AddSingleton<ICommandProvider>(files);
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>(_ => BookmarksCommandProvider.CreateWithDefaultStore());
var bookmarks = BookmarksCommandProvider.CreateWithDefaultStore();
services.AddSingleton<ICommandProvider>(bookmarks);
services.AddSingleton<IBookmarksManager>(bookmarks.BookmarksManager);
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>();

View File

@@ -206,9 +206,12 @@
<Grid
x:Name="RootGrid"
AllowDrop="True"
Background="Transparent"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
DragOver="RootGrid_DragOver"
Drop="RootGrid_Drop"
RightTapped="RootGrid_RightTapped">
<!-- Dock content with Start / Center / End sections -->
<local:DockContentControl

View File

@@ -7,12 +7,14 @@ using System.Collections.Specialized;
using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Bookmarks;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -21,6 +23,8 @@ using Microsoft.UI.Xaml.Media;
using Windows.ApplicationModel.DataTransfer;
using Windows.Foundation;
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
namespace Microsoft.CmdPal.UI.Dock;
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>, IRecipient<CrossMonitorBandDropMessage>
@@ -30,7 +34,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
internal DockViewModel ViewModel => _viewModel;
/// <summary>
/// The HWND of the parent DockWindow that owns this control.
/// Gets or sets the HWND of the parent DockWindow that owns this control.
/// Used to target palette-show messages to the correct DockWindow in multi-monitor setups.
/// </summary>
internal IntPtr OwnerHwnd { get; set; }
@@ -98,6 +102,11 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
WeakReferenceMessenger.Default.Register<ExitDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<CrossMonitorBandDropMessage>(this);
ContextControl.ViewModel.CommandInvoked -= ContextMenu_CommandInvoked;
ContextControl.ViewModel.CommandInvoked += ContextMenu_CommandInvoked;
ContextControl.ViewModel.CommandInvoking -= ContextMenu_CommandInvoking;
ContextControl.ViewModel.CommandInvoking += ContextMenu_CommandInvoking;
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
@@ -108,6 +117,9 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
WeakReferenceMessenger.Default.UnregisterAll(this);
ContextControl.ViewModel.CommandInvoked -= ContextMenu_CommandInvoked;
ContextControl.ViewModel.CommandInvoking -= ContextMenu_CommandInvoking;
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
if (EditButtonsTeachingTip.IsOpen)
@@ -291,10 +303,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
if (sender is DockItemControl dockItem && dockItem.DataContext is DockBandViewModel band && dockItem.Tag is DockItemViewModel item)
{
// Use the center of the border as the point to open at
var borderPos = dockItem.TransformToVisual(null).TransformPoint(new Point(0, 0));
var borderCenter = new Point(
borderPos.X + (dockItem.ActualWidth / 2),
borderPos.Y + (dockItem.ActualHeight / 2));
var borderCenter = GetDockItemCenter(dockItem);
InvokeItem(item, borderCenter);
e.Handled = true;
@@ -311,6 +320,11 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
// Stores the band that was right-clicked for edit mode context menu
private DockBandViewModel? _editModeContextBand;
// Position (in window coords) of the dock item whose context menu is currently
// open, used to anchor the cmdpal palette when a Page command is invoked from
// the context menu. Null when the open context menu is not anchored to a band.
private Point? _bandContextMenuPalettePos;
private void BandItem_RightTapped(object sender, Microsoft.UI.Xaml.Input.RightTappedRoutedEventArgs e)
{
if (sender is DockItemControl dockItem && dockItem.DataContext is DockBandViewModel band && dockItem.Tag is DockItemViewModel item)
@@ -348,6 +362,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
// Normal mode - show the command context menu
if (item.CanOpenContextMenu)
{
// Remember where to anchor the palette if the user picks a Page
// command from the context menu.
_bandContextMenuPalettePos = GetDockItemCenter(dockItem);
ContextControl.ViewModel.SelectedItem = item;
ContextControl.ShowFilterBox = true;
ContextControl.PrepareForOpen(GetDockContextMenuFilterLocation());
@@ -392,17 +410,25 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void InvokeItem(DockItemViewModel item, Point pos)
{
var command = item.Command;
var hwnd = OwnerHwnd;
try
{
PerformCommandMessage m = new(command.Model);
m.WithAnimation = false;
m.TransientPage = true;
PerformCommandMessage m = new(command.Model)
{
WithAnimation = false,
TransientPage = true,
// If the command is invokable and its result asks for a
// confirmation dialog, surface the cmdpal window anchored at
// this dock item before the dialog appears.
OnBeforeShowConfirmation = () =>
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, hwnd)),
};
WeakReferenceMessenger.Default.Send(m);
var isPage = command.Model.Unsafe is not IInvokableCommand invokable;
if (isPage)
if (IsPageCommand(command.Model.Unsafe))
{
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, OwnerHwnd));
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, hwnd));
}
}
catch (COMException e)
@@ -411,6 +437,59 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
}
}
private static bool IsPageCommand(ICommand? command)
{
// A Page command is one that's not directly invokable - selecting it
// navigates into a page rather than performing an action in place.
return command is not null and not IInvokableCommand;
}
private static Point GetDockItemCenter(FrameworkElement dockItem)
{
var borderPos = dockItem.TransformToVisual(null).TransformPoint(new Point(0, 0));
return new Point(
borderPos.X + (dockItem.ActualWidth / 2),
borderPos.Y + (dockItem.ActualHeight / 2));
}
private void ContextMenu_CommandInvoked(object? sender, CommandItemViewModel command)
{
// The context menu just invoked a command. If it came from a dock band
// (i.e. _bandContextMenuPalettePos is set) and the command is a Page,
// open the cmdpal palette anchored at the dock item — mirroring what
// a direct click on the band does.
var pos = _bandContextMenuPalettePos;
_bandContextMenuPalettePos = null;
if (pos is null)
{
return;
}
if (IsPageCommand(command.Command.Model.Unsafe))
{
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos.Value, OwnerHwnd));
}
}
private void ContextMenu_CommandInvoking(object? sender, PerformCommandMessage message)
{
// The context menu is about to dispatch a command. If it was opened
// from a dock band, attach a callback so that an invokable command
// whose result is a Confirm surfaces the cmdpal window anchored at the
// dock item before the confirmation dialog appears.
var pos = _bandContextMenuPalettePos;
if (pos is null)
{
return;
}
var hwnd = OwnerHwnd;
var capturedPos = pos.Value;
message.OnBeforeShowConfirmation = () =>
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(capturedPos, hwnd));
}
private void ContextMenuFlyout_Opened(object sender, object e)
{
// We need to wait until our flyout is opened to try and toss focus
@@ -434,6 +513,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
return;
}
// This context menu is for the dock itself (not a band), so the palette
// should not be opened on invocation.
_bandContextMenuPalettePos = null;
var pos = e.GetPosition(null);
var item = this.ViewModel.GetContextMenuForDock();
if (item.HasMoreCommands)
@@ -725,6 +808,102 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
}
}
private void RootGrid_DragOver(object sender, DragEventArgs e)
{
// Don't intercept internal band drag-drop during edit mode
if (_draggedBand != null)
{
return;
}
if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
e.DataView.Contains(StandardDataFormats.Uri))
{
e.AcceptedOperation = DataPackageOperation.Link;
e.DragUIOverride.Caption = RS_.GetString("Dock_DropFile_Caption");
e.DragUIOverride.IsGlyphVisible = true;
e.DragUIOverride.IsCaptionVisible = true;
// DON'T mark the event as handled - if you do, we won't get the Drop event.
}
}
private async void RootGrid_Drop(object sender, DragEventArgs e)
{
// Don't intercept internal band drag-drop during edit mode
if (_draggedBand != null)
{
Logger.LogDebug("[DockDrop] RootGrid_Drop: ignoring (internal band drag in progress)");
return;
}
var hasStorageItems = e.DataView.Contains(StandardDataFormats.StorageItems);
var hasUri = e.DataView.Contains(StandardDataFormats.Uri);
if (!hasStorageItems && !hasUri)
{
return;
}
e.Handled = true;
try
{
var bookmarksManager = App.Current.Services.GetService<IBookmarksManager>();
if (bookmarksManager == null)
{
Logger.LogWarning("[DockDrop] IBookmarksManager service is not registered; cannot pin dropped item");
return;
}
var foundItem = false;
if (hasStorageItems)
{
var items = await e.DataView.GetStorageItemsAsync();
foreach (var item in items)
{
var path = item.Path;
if (string.IsNullOrEmpty(path))
{
continue;
}
var name = Path.GetFileNameWithoutExtension(path);
AddBookmarkAndPinToDock(bookmarksManager, name, path);
foundItem = true;
}
}
if (foundItem)
{
return;
}
if (hasUri)
{
var uri = await e.DataView.GetUriAsync();
var url = uri.AbsoluteUri;
var name = uri.Host;
AddBookmarkAndPinToDock(bookmarksManager, name, url);
}
}
catch (Exception ex)
{
Logger.LogError("[DockDrop] Error handling file drop on dock", ex);
}
}
private static void AddBookmarkAndPinToDock(IBookmarksManager bookmarksManager, string name, string bookmarkValue)
{
var bookmark = bookmarksManager.Add(name, bookmarkValue);
// Make the command ID exactly the same as the ID it would have in the
// top-level list, so that pinning to the dock from the top-level is seamless.
var commandId = Ext.Bookmarks.Helpers.CommandIds.GetLaunchBookmarkItemId(bookmark.Id);
Logger.LogDebug($"[DockDrop] Pinning dropped item '{name}' as bookmark id={bookmark.Id} (commandId='{commandId}')");
WeakReferenceMessenger.Default.Send(new PinToDockMessage("Bookmarks", commandId, true, WithReload: false));
}
public void Receive(CrossMonitorBandDropMessage message)
{
// Only match if this dock has a real monitor ID that matches the source.

View File

@@ -1018,6 +1018,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Dock_EditMode_Unpin.Text" xml:space="preserve">
<value>Unpin</value>
</data>
<data name="Dock_DropFile_Caption" xml:space="preserve">
<value>Pin to Dock</value>
<comment>Drag-over caption shown when dragging a file or shortcut onto the dock to bookmark and pin it</comment>
</data>
<data name="Dock_AddBand_NoCommandsAvailable.Text" xml:space="preserve">
<value>All available bands are already pinned.</value>
</data>

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -6,6 +6,7 @@ using System.Diagnostics.Contracts;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Pages;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
@@ -24,6 +25,8 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
private readonly IBookmarkResolver _commandResolver;
private readonly IBookmarkIconLocator _iconLocator = new IconLocator();
public IBookmarksManager BookmarksManager => _bookmarksManager;
private readonly ListItem _addNewItem;
private readonly Lock _bookmarksLock = new();
@@ -127,4 +130,42 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
[Pure]
private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks];
public override ICommandItem[]? GetDockBands()
{
BookmarkListItem[] bookmarks;
lock (_bookmarksLock)
{
// Here we're creating an entirely different set of items to return
// as bands.
//
// These items will have the same ID, but bookmarks to directories
// will have their default command be the "DirectoryPage", so that
// clicking it will automatically open the palette with the files in
// that dir.
bookmarks = [.. _bookmarksManager.Bookmarks
.Select(bookmark => new BookmarkListItem(
bookmark,
_bookmarksManager,
_commandResolver,
_iconLocator,
_placeholderParser,
asBand: true))];
}
var bands = new List<ICommandItem>();
// Now take all those commands, and stick them into individual bands. We
// don't want one band with all bookmarks, we want one band per
// bookmark.
foreach (var b in bookmarks)
{
var id = CommandIds.GetLaunchBookmarkItemId(b.BookmarkId);
var wrapped = new WrappedDockItem(items: [b], id: id, displayTitle: b.Title);
bands.Add(wrapped);
}
return bands.Count > 0 ? bands.ToArray() : null;
}
}

View File

@@ -47,6 +47,7 @@ internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager
public BookmarkData Add(string name, string bookmark)
{
var newBookmark = new BookmarkData(name, bookmark);
Logger.LogDebug($"BookmarksManager.Add: created bookmark id={newBookmark.Id} name='{name}' value='{bookmark}'");
lock (_lock)
{

View File

@@ -1,10 +1,10 @@
// Copyright (c) Microsoft Corporation
// 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
public 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

View File

@@ -6,7 +6,7 @@ using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
namespace Microsoft.CmdPal.Ext.Bookmarks;
internal interface IBookmarksManager
public interface IBookmarksManager
{
event Action<BookmarkData>? BookmarkAdded;

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -26,6 +26,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
private readonly IPlaceholderParser _placeholderParser;
private readonly SupersedingAsyncValueGate<BookmarkListItemReclassifyResult> _classificationGate;
private readonly TaskCompletionSource _initializationTcs = new();
private readonly bool _isBandItem;
private BookmarkData _bookmark;
@@ -37,12 +38,18 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
public Guid BookmarkId => _bookmark.Id;
public BookmarkListItem(BookmarkData bookmark, IBookmarksManager bookmarksManager, IBookmarkResolver commandResolver, IBookmarkIconLocator iconLocator, IPlaceholderParser placeholderParser)
public BookmarkListItem(
BookmarkData bookmark,
IBookmarksManager bookmarksManager,
IBookmarkResolver commandResolver,
IBookmarkIconLocator iconLocator,
IPlaceholderParser placeholderParser,
bool asBand = false)
{
ArgumentNullException.ThrowIfNull(bookmark);
ArgumentNullException.ThrowIfNull(bookmarksManager);
ArgumentNullException.ThrowIfNull(commandResolver);
_isBandItem = asBand;
_bookmark = bookmark;
_bookmarksManager = bookmarksManager;
_bookmarksManager.BookmarkUpdated += BookmarksManagerOnBookmarkUpdated;
@@ -107,6 +114,23 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
BuildSpecificContextMenuItems(classification, contextMenu);
AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu);
// If we're a band AND the classification kind was directory , then flip
// the command and the first contextMenu item
if (_isBandItem && classification.Kind == CommandKind.Directory && contextMenu.Count > 0)
{
var firstContextCommand = contextMenu[0] as CommandContextItem;
if (firstContextCommand != null)
{
var browseCommand = firstContextCommand.Command;
if (browseCommand != null)
{
contextMenu.RemoveAt(0);
contextMenu.Insert(0, new CommandContextItem(command));
command = browseCommand;
}
}
}
return new BookmarkListItemReclassifyResult(
command,
title,
@@ -155,7 +179,8 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
{
case CommandKind.Directory:
directoryPath = targetPath;
contextMenu.Add(new CommandContextItem(new DirectoryPage(directoryPath))); // Browse
var c = new DirectoryPage(directoryPath);
contextMenu.Add(new CommandContextItem(c)); // Browse
break;
case CommandKind.FileExecutable:
case CommandKind.FileDocument:

View File

@@ -1,11 +1,13 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -15,9 +17,10 @@ using Windows.Storage.Streams;
#nullable enable
namespace Microsoft.CmdPal.Ext.Indexer;
public sealed partial class DirectoryPage : ListPage
public sealed partial class DirectoryPage : ListPage, IDisposable
{
private readonly string _path;
private readonly SupersedingAsyncValueGate<IconInfo?> _iconReloadGate;
private List<IndexerListItem>? _directoryContents;
@@ -27,6 +30,28 @@ public sealed partial class DirectoryPage : ListPage
Icon = Icons.FileExplorerIcon;
Name = Resources.Indexer_Command_Browse;
Title = path;
_iconReloadGate = new(
async ct =>
{
var stream = await ThumbnailHelper.GetThumbnail(path);
return stream is not null ? IconInfo.FromStream(stream) : null;
},
icon =>
{
if (icon is not null)
{
Icon = icon;
}
});
_ = _iconReloadGate.ExecuteAsync();
}
public void Dispose()
{
_iconReloadGate.Dispose();
GC.SuppressFinalize(this);
}
public override IListItem[] GetItems()