Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa

public CommandItemViewModel? SecondaryCommand => _secondaryMoreCommand;

public bool CanOpenContextMenu => AllCommands.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);
public bool CanOpenContextMenu =>

// BEAR LOADING: A visible synthetic primary command makes the item
// context-openable immediately, even if out-of-proc MoreCommands are still
// hydrating. Without this fast path, the first open request can race slow
// menu initialization and get dropped.
_defaultCommandContextItemViewModel?.ShouldBeVisible == true ||
_moreCommandsSnapshot.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);

public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);

Expand Down Expand Up @@ -132,13 +139,15 @@ public void FastInitializeProperties()
return;
}

Command = new(model.Command, PageContext);
var command = model.Command;
Command = new(command, PageContext);
Command.FastInitializeProperties();

_itemTitle = model.Title;
Subtitle = model.Subtitle;
_titleCache.Invalidate();
_subtitleCache.Invalidate();
TryCreateDefaultCommandContextItem(command);

Initialized |= InitializedState.FastInitialized;
}
Expand Down Expand Up @@ -215,7 +224,7 @@ public virtual void SlowInitializeProperties()

BuildAndInitMoreCommands();

TryCreateDefaultCommandContextItem(model);
TryCreateDefaultCommandContextItem(model.Command);

lock (_moreCommandsLock)
{
Expand Down Expand Up @@ -316,7 +325,8 @@ protected virtual void FetchProperty(string propertyName)
{
case nameof(Command):
Command.PropertyChanged -= Command_PropertyChanged;
Command = new(model.Command, PageContext);
var command = model.Command;
Command = new(command, PageContext);
Command.InitializeProperties();
Command.PropertyChanged += Command_PropertyChanged;

Expand All @@ -332,7 +342,7 @@ protected virtual void FetchProperty(string propertyName)
}
else
{
TryCreateDefaultCommandContextItem(model);
TryCreateDefaultCommandContextItem(command);
}

UpdateProperty(nameof(Name));
Expand Down Expand Up @@ -407,7 +417,7 @@ private void Command_PropertyChanged(object? sender, System.ComponentModel.Prope
}
else
{
TryCreateDefaultCommandContextItem(model);
TryCreateDefaultCommandContextItem(model.Command);
}

break;
Expand All @@ -427,19 +437,22 @@ private void Command_PropertyChanged(object? sender, System.ComponentModel.Prope
/// When a new instance is created, the snapshot is refreshed and
/// <see cref="AllCommands"/> is notified.
/// </summary>
private void TryCreateDefaultCommandContextItem(ICommandItem model)
private void TryCreateDefaultCommandContextItem(ICommand? commandModel)
{
if (_defaultCommandContextItemViewModel is not null)
{
return;
}

if (string.IsNullOrEmpty(model.Command?.Name))
// We only synthesize the primary entry when the command is already
// usable; a null/empty primary must still fall back to late
// MoreCommands-based opening.
if (string.IsNullOrEmpty(Command.Name) || commandModel is null)
{
return;
}

_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext)
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(commandModel), PageContext)
{
Comment on lines +447 to 456
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating _defaultCommandContextItemViewModel here changes the computed CanOpenContextMenu value, but this method doesn’t raise a CanOpenContextMenu notification when the synthetic primary gets created. In the fast-init path this can leave listeners that react specifically to CanOpenContextMenu (e.g., CommandBarViewModel.SelectedItemPropertyChanged) out of date. Consider raising CanOpenContextMenu alongside AllCommands when the synthetic primary is first created.

Copilot uses AI. Check for mistakes.
_itemTitle = Name,
Subtitle = Subtitle,
Expand Down
110 changes: 89 additions & 21 deletions src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public sealed partial class ListPage : Page,

private ListItemViewModel? _stickySelectedItem;
private ListItemViewModel? _lastPushedToVm;
private long _pendingContextMenuOpenRequestId;
private Action? _cancelPendingContextMenuOpen;

// A single search-text change can produce multiple ItemsUpdated calls
// dispatched as separate UI-thread callbacks. A later "soft" update
Expand Down Expand Up @@ -124,6 +126,8 @@ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
base.OnNavigatingFrom(e);

CancelPendingContextMenuOpen();

WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this);
Expand Down Expand Up @@ -283,17 +287,7 @@ private void Items_RightTapped(object sender, RightTappedRoutedEventArgs e)
ViewModel?.UpdateSelectedItemCommand.Execute(item);

var pos = e.GetPosition(element);

_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
RequestContextMenuOpen(item, element, pos);
}
}

Expand Down Expand Up @@ -1014,21 +1008,14 @@ private void Items_OnContextRequested(UIElement sender, ContextRequestedEventArg
pos = new(0, element.ActualHeight);
}

_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
ViewModel?.UpdateSelectedItemCommand.Execute(item);
RequestContextMenuOpen(item, element, pos);
e.Handled = true;
Comment on lines +1011 to 1013
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both Items_RightTapped and Items_OnContextRequested are wired up in XAML and each calls UpdateSelectedItemCommand.Execute(item) and RequestContextMenuOpen(...). If these two events can fire for the same mouse right-click, this will run selection updates twice and can cancel/restart the background slow-init work in ListViewModel.SetSelectedItem, potentially delaying menu hydration. Consider consolidating to a single code path or adding a guard to ensure the selection/open logic runs only once per user action.

Copilot uses AI. Check for mistakes.
}

private void Items_OnContextCanceled(UIElement sender, RoutedEventArgs e)
{
CancelPendingContextMenuOpen();
_ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>());
}

Expand Down Expand Up @@ -1210,6 +1197,87 @@ private void ResetScrollToTop()
scroll.ChangeView(horizontalOffset: null, verticalOffset: 0, zoomFactor: null, disableAnimation: true);
}

private void RequestContextMenuOpen(ListItemViewModel item, FrameworkElement element, Point pos)
{
// BEAR LOADING: Right-click can arrive before the selected item's slow
// context-menu hydration completes, especially for out-of-proc
// providers. Keep this exact open request alive until the same
// selected item becomes context-openable instead of dropping the first
// click.
CancelPendingContextMenuOpen();
var requestId = Interlocked.Increment(ref _pendingContextMenuOpenRequestId);

System.ComponentModel.PropertyChangedEventHandler? onItemChanged = null;
Action? detach = null;
detach = () =>
{
if (onItemChanged is not null)
{
item.PropertyChanged -= onItemChanged;
}

if (ReferenceEquals(_cancelPendingContextMenuOpen, detach))
{
_cancelPendingContextMenuOpen = null;
}
};

onItemChanged = (_, args) =>
{
if (args.PropertyName is nameof(ListItemViewModel.CanOpenContextMenu) or nameof(ListItemViewModel.AllCommands) &&
TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
};

item.PropertyChanged += onItemChanged;
_cancelPendingContextMenuOpen = detach;

if (TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
}

private bool TryOpenContextMenuIfReady(ListItemViewModel item, FrameworkElement element, Point pos, long requestId)
{
// Ignore stale requests so rapid selection changes or cancelled opens
// can't resurrect an old context menu on the wrong item.
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item) ||
!item.CanOpenContextMenu)
{
return false;
}

_ = DispatcherQueue.TryEnqueue(
() =>
{
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item))
{
return;
}

WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});

return true;
}

private void CancelPendingContextMenuOpen()
{
Interlocked.Increment(ref _pendingContextMenuOpenRequestId);
_cancelPendingContextMenuOpen?.Invoke();
_cancelPendingContextMenuOpen = null;
}
Comment on lines +1274 to +1279
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CancelPendingContextMenuOpen is only invoked on navigation, explicit context-cancel, or a new context-menu request. If the user changes selection while an open request is pending (e.g., slow provider never hydrates, or selection changes due to list updates), the PropertyChanged handler remains attached to the old item indefinitely. Consider canceling pending requests on selection changes (and/or when the selected item no longer matches) so the handler is detached promptly and doesn’t keep doing work for stale requests.

Copilot uses AI. Check for mistakes.

private IDisposable SuppressSelectionChangedScope()
{
_suppressSelectionChanged = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ public void SecondaryCommand_IgnoresLeadingSeparators()
Assert.AreEqual("Secondary", viewModel.SecondaryCommand.Name);
}

[TestMethod]
public void FastInitializeProperties_CreatesPrimaryContextItem()
{
// Context menus are opened from fast-initialized list items before slow init completes.
// The synthetic primary command must already exist so the first right-click can open the menu.
var pageContext = new TestPageContext();
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
{
Title = "Primary",
};

var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.FastInitializeProperties();

Assert.AreEqual(1, viewModel.AllCommands.Count);
Assert.IsTrue(viewModel.CanOpenContextMenu);
Assert.AreEqual("Primary", ((CommandContextItemViewModel)viewModel.AllCommands[0]).Name);
}

[TestMethod]
public void LatePrimaryCommandCreation_AddsPrimaryToAllCommands()
{
Expand Down
Loading