Compare commits

...

21 Commits

Author SHA1 Message Date
Mike Griese
953258a6d1 yeeps 2026-05-11 06:45:22 -05:00
Michael Jolley
175ed9e9b0 Weird Dock reference 2026-05-07 12:40:44 -05:00
Michael Jolley
ab4a495aca Fix spelling: expand 'STJ' to 'System.Text.Json' in test comment
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 12:02:44 -05:00
Michael Jolley
587f73e8ae Fix dock orientation not updating for per-monitor side override
DockControl.UpdateSettings used the global settings.Side for orientation,
DockSide, and DockSize — ignoring per-monitor side overrides. This caused
left/right docks to render with horizontal item layout instead of vertical.

Pass the effective side (respecting per-monitor overrides) from DockWindow
into DockControl.UpdateSettings so visual state triggers, ItemsOrientation,
and compact mode all use the correct side.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 11:55:17 -05:00
Michael Jolley
8575ca3557 Fix fresh install crash: null-coalesce MonitorConfigs init setter
When STJ deserializes settings without a MonitorConfigs key (fresh install
or upgrade), it passes null to the init setter. The backing field stored
null, and C# record 'with' expressions copy raw fields (not getter results),
propagating the null to clones and crashing PopulateMonitorConfigs.

Fix: coalesce null to Empty in the init setter so the backing field is
never null regardless of deserialization or cloning path.

Add regression test verifying MonitorConfigs survives deserialization of
empty JSON and multiple record 'with' expressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 10:04:51 -05:00
Michael Jolley
e63773ce30 Merging main 2026-05-04 16:18:33 -05:00
Niels Laute
655cd49cad Merge branch 'main' into dev/mjolley/multi-mon 2026-04-21 18:11:40 +02:00
Michael Jolley
f9b4b70294 Fix Reconciler_EmptyConfigs test: secondary monitors are IsCustomized=true
The reconciler intentionally creates secondary monitor configs with
IsCustomized=true and empty band lists so users choose what to pin
per-monitor. The test incorrectly asserted IsCustomized=false for all
monitors. Updated to assert IsCustomized=false only for primary and
IsCustomized=true for secondary monitors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 16:26:58 -05:00
copilot-swe-agent[bot]
e4165e5a73 Merge remote-tracking branch 'origin/main' into dev/mjolley/multi-mon
# Conflicts:
#	src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs

Co-authored-by: michaeljolley <1228996+michaeljolley@users.noreply.github.com>
2026-04-20 19:47:46 +00:00
Michael Jolley
f3ea6e6a08 Feedback 2026-04-20 14:36:29 -05:00
Michael Jolley
9bb1e6c531 Address PR review feedback: cache safety and reconciler resilience
1. MonitorService.GetMonitors() now returns AsReadOnly() wrapper to
   prevent callers from downcasting List<T> and mutating the cache.

2. MonitorConfigReconciler.Reconcile() returns existingConfigs (not
   empty list) when currentMonitors is empty. Prevents data loss if
   monitor enumeration temporarily fails during startup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 19:33:23 -05:00
Michael Jolley
08ae18a896 Copilot feedback 2026-04-14 19:28:28 -05:00
Michael Jolley
5a28299f01 Fix per-monitor pin not appearing and label settings not persisting
Three root cause fixes:

1. AllPinnedCommands only returned global bands — commands pinned to
   specific monitors were never loaded as TopLevelViewModels, so they
   could never appear in the dock UI. Now includes bands from customized
   per-monitor configs.

2. PinDockBand duplicate check only checked global bands — when pinning
   to a specific monitor, the check would silently reject if the command
   existed globally (even though the target monitor might not have it).
   Now checks the target monitor's resolved bands instead.

3. ReplaceBandInSettings (label save) only updated global bands — label
   changes for bands on customized monitors were silently lost since the
   band wasn't found in the global lists. Now also searches and updates
   per-monitor bands in all customized MonitorConfigs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 14:39:47 -05:00
Michael Jolley
9b70e61b61 Fix dock label clobber and stale settings after pin
Two fixes for multi-monitor dock persistence:

1. DockBandViewModel.SaveShowLabels() now only writes to settings when
   labels actually changed from the snapshot. Previously, when multiple
   non-customized monitors shared global bands, the last monitor to save
   would overwrite label changes made by other monitors (last-save-wins).

2. DockViewModel.DockBands_CollectionChanged refreshes _settings from
   the settings service before calling SetupBands(). Pin operations use
   hotReload: false so SettingsChanged never fires, leaving _settings
   stale. The refresh ensures the UI reflects the latest persisted state
   including new pins and label changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 13:19:48 -05:00
Michael Jolley
1e5d64391c Fix dock edit save overwriting other monitors' changes
SaveBandOrder() was replacing the entire DockSettings with the
local _settings snapshot, so when multiple DockViewModels saved
sequentially (via ExitDockEditModeMessage broadcast), the last
save would overwrite all earlier monitors' changes.

Fix: SaveBandOrder() now merges only this monitor's bands into
the CURRENT persisted DockSettings via the UpdateSettings lambda,
reading s.DockSettings instead of replacing with _settings. Each
monitor's config is updated independently, preserving other
monitors' changes.

Also:
- UpdateSettings() now skips when _isEditing is true to prevent
  external settings changes from clobbering in-progress edits
- Local _settings is refreshed from persisted state after save
- Added test verifying per-monitor save isolation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 12:53:41 -05:00
Michael Jolley
65a623f66d Fix pin-to-dock not appearing on the dock
Two bugs prevented pinned commands from showing on the dock:

1. DockViewModel._settings was captured once at construction and never
   refreshed. After a pin updated settings, the ViewModel still read
   from the stale snapshot so the new band never rendered. Fixed by
   having DockWindowManager call UpdateSettings on all existing
   ViewModels during SyncDocksToSettings.

2. With a single monitor, SelectedMonitorDeviceId returned the monitor
   device ID, routing the pin through PinDockBandToMonitor. This created
   a per-monitor config unnecessarily. Changed to return null for single-
   monitor so pins land in global bands, matching pre-multi-monitor
   behavior and ensuring visibility on all future monitors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 12:34:16 -05:00
Michael Jolley
7a96d8bb80 Broadcast dock edit mode exit/discard to all monitors
When entering edit mode, EnterDockEditModeMessage already broadcasts to
all DockControl instances. The Done and Discard buttons, however, only
acted on the local DockControl. This caused multi-monitor docks to stay
in edit mode when clicking Discard or Done on one monitor.

Add ExitDockEditModeMessage(bool Discard) and have both button handlers
send it via WeakReferenceMessenger. Each DockControl receives the
message and calls its local ExitEditMode() or DiscardEditMode().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 12:17:02 -05:00
Michael Jolley
55d8baa8fc Removing squad agent artifacts 2026-04-13 16:26:32 -05:00
Michael Jolley
491492f7cb Apply XamlStyler formatting to PinToDockDialogContent.xaml
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 14:27:23 -05:00
Michael Jolley
5981ad3a41 Address PR #46915 review feedback for multi-monitor dock
- Fix check-spelling forbidden patterns: ', otherwise' -> '; otherwise'
- MonitorService: return cached monitors, only re-enumerate after invalidation
- MonitorService: handle GetDpiForMonitor HRESULT failure, fall back to 96 DPI
- MonitorService: add NotifyMonitorsChanged to IMonitorService interface
- DockWindow: notify MonitorService on WM_DISPLAYCHANGE
- DockWindow: use OrdinalIgnoreCase for device ID comparison
- DockWindowManager: ShowDocks now calls SyncDocksToSettings for reconciliation
- DockWindowManager: Dispose calls HideDocks to close windows before disposing
- MonitorConfigReconciler: restrict Phase 2 fuzzy matching to primary monitor only
- Wire MonitorDeviceId through pin flow (PinToDockMessage -> PinDockBand)
- Remove dead _dockWindow field from ShellPage
- Add test: fuzzy match does not match non-primary monitors

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:50:34 -05:00
Michael Jolley
3435e492ad Multi-mon support part 1 2026-04-11 22:37:40 -05:00
31 changed files with 3320 additions and 178 deletions

View File

@@ -496,16 +496,46 @@ 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)
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings;
var dockSettings = settings.DockSettings;
// Prevent duplicate pins — check all sections
if (dockSettings.StartBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.CenterBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.EndBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId))
// Prevent duplicate pins — check the target destination's bands.
// When pinning to a specific monitor, check that monitor's resolved bands
// (which include forked-from-global bands). Otherwise, check global bands.
bool alreadyPinned;
if (monitorDeviceId is not null)
{
var configs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
DockMonitorConfig? targetConfig = null;
foreach (var cfg in configs)
{
if (string.Equals(cfg.MonitorDeviceId, monitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
targetConfig = cfg;
break;
}
}
// Resolve the bands for the target monitor (per-monitor if customized, else global)
var resolvedStart = targetConfig?.ResolveStartBands(dockSettings.StartBands) ?? dockSettings.StartBands;
var resolvedCenter = targetConfig?.ResolveCenterBands(dockSettings.CenterBands) ?? dockSettings.CenterBands;
var resolvedEnd = targetConfig?.ResolveEndBands(dockSettings.EndBands) ?? dockSettings.EndBands;
alreadyPinned = resolvedStart.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
resolvedCenter.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
resolvedEnd.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId);
}
else
{
alreadyPinned = dockSettings.StartBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.CenterBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.EndBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId);
}
if (alreadyPinned)
{
Logger.LogDebug($"Dock band '{commandId}' from provider '{this.ProviderId}' is already pinned; skipping.");
return;
@@ -519,6 +549,21 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
ShowSubtitles = showSubtitles,
};
if (monitorDeviceId is not null)
{
PinDockBandToMonitor(settingsService, bandSettings, side, monitorDeviceId);
}
else
{
PinDockBandGlobal(settingsService, bandSettings, side);
}
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
private static void PinDockBandGlobal(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side)
{
settingsService.UpdateSettings(
s =>
{
@@ -534,9 +579,59 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
};
},
hotReload: false);
}
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
private static void PinDockBandToMonitor(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side, string monitorDeviceId)
{
settingsService.UpdateSettings(
s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
// Find or create the monitor config
DockMonitorConfig? target = null;
var targetIndex = -1;
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, monitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
target = configs[i];
targetIndex = i;
break;
}
}
if (target is null)
{
// Monitor not yet in config; create and fork from global
target = new DockMonitorConfig { MonitorDeviceId = monitorDeviceId, Enabled = true };
target = target.ForkFromGlobal(dockSettings);
configs = configs.Add(target);
targetIndex = configs.Count - 1;
}
else if (!target.IsCustomized)
{
// Fork from global on first per-monitor customization
target = target.ForkFromGlobal(dockSettings);
}
// Add band to the appropriate section
target = side switch
{
Dock.DockPinSide.Center => target with { CenterBands = (target.CenterBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
Dock.DockPinSide.End => target with { EndBands = (target.EndBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
_ => target with { StartBands = (target.StartBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
};
configs = configs.SetItem(targetIndex, target);
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs },
};
},
hotReload: false);
}
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)

View File

@@ -105,7 +105,18 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
/// </summary>
internal void SaveShowLabels()
{
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
// Only write to settings if the label values actually changed from
// the snapshot. When multiple non-customized monitors share global
// bands, an unconditional save would overwrite changes made by
// another monitor's ViewModel (last-save-wins clobber).
var changed = _showTitlesSnapshot is null
|| _showTitles != _showTitlesSnapshot
|| _showSubtitles != _showSubtitlesSnapshot;
if (changed)
{
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
}
_showTitlesSnapshot = null;
_showSubtitlesSnapshot = null;
}
@@ -135,15 +146,52 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
s =>
{
var dockSettings = s.DockSettings;
return s with
// Update in global bands
var updatedDock = dockSettings with
{
DockSettings = dockSettings with
{
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
},
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
};
// Also update in per-monitor bands for customized monitors
var configs = updatedDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
var configsChanged = false;
for (var i = 0; i < configs.Count; i++)
{
var config = configs[i];
if (!config.IsCustomized)
{
continue;
}
var start = config.StartBands ?? ImmutableList<DockBandSettings>.Empty;
var center = config.CenterBands ?? ImmutableList<DockBandSettings>.Empty;
var end = config.EndBands ?? ImmutableList<DockBandSettings>.Empty;
var newStart = ReplaceBandInList(start, commandId, newSettings);
var newCenter = ReplaceBandInList(center, commandId, newSettings);
var newEnd = ReplaceBandInList(end, commandId, newSettings);
if (newStart != start || newCenter != center || newEnd != end)
{
configs = configs.SetItem(i, config with
{
StartBands = newStart,
CenterBands = newCenter,
EndBands = newEnd,
});
configsChanged = true;
}
}
if (configsChanged)
{
updatedDock = updatedDock with { MonitorConfigs = configs };
}
return s with { DockSettings = updatedDock };
},
false);
_bandSettings = newSettings;

View File

@@ -0,0 +1,197 @@
// 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.Immutable;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
/// <summary>
/// ViewModel wrapping a <see cref="DockMonitorConfig"/> paired with its
/// <see cref="MonitorInfo"/>. Exposes bindable properties for the monitor
/// config UI and persists changes through <see cref="ISettingsService"/>.
/// </summary>
public partial class DockMonitorConfigViewModel : ObservableObject
{
private static readonly CompositeFormat ResolutionFormat = CompositeFormat.Parse("{0} \u00D7 {1}");
private readonly MonitorInfo _monitorInfo;
private readonly ISettingsService _settingsService;
private readonly string _monitorDeviceId;
public DockMonitorConfigViewModel(
DockMonitorConfig config,
MonitorInfo monitorInfo,
ISettingsService settingsService)
{
_monitorInfo = monitorInfo;
_settingsService = settingsService;
_monitorDeviceId = config.MonitorDeviceId;
}
/// <summary>Gets the human-readable display name from the monitor hardware.</summary>
public string DisplayName => _monitorInfo.DisplayName;
/// <summary>Gets the stable device identifier for this monitor.</summary>
public string DeviceId => _monitorInfo.DeviceId;
/// <summary>Gets a value indicating whether this is the primary monitor.</summary>
public bool IsPrimary => _monitorInfo.IsPrimary;
/// <summary>Gets the monitor resolution formatted as "W × H".</summary>
public string Resolution => string.Format(
CultureInfo.CurrentCulture,
ResolutionFormat,
_monitorInfo.Bounds.Width,
_monitorInfo.Bounds.Height);
/// <summary>
/// Gets or sets a value indicating whether the dock is enabled on this monitor.
/// </summary>
public bool IsEnabled
{
get => GetConfig()?.Enabled ?? true;
set
{
UpdateConfig(c => c with { Enabled = value });
OnPropertyChanged();
}
}
/// <summary>
/// Gets or sets the side-override index for ComboBox binding.
/// 0 = "Use default" (inherit), 1 = Left, 2 = Top, 3 = Right, 4 = Bottom.
/// </summary>
public int SideOverrideIndex
{
get => GetConfig()?.Side switch
{
null => 0,
DockSide.Left => 1,
DockSide.Top => 2,
DockSide.Right => 3,
DockSide.Bottom => 4,
_ => 0,
};
set
{
var newSide = value switch
{
1 => (DockSide?)DockSide.Left,
2 => (DockSide?)DockSide.Top,
3 => (DockSide?)DockSide.Right,
4 => (DockSide?)DockSide.Bottom,
_ => null,
};
UpdateConfig(c => c with { Side = newSide });
OnPropertyChanged();
OnPropertyChanged(nameof(HasSideOverride));
}
}
/// <summary>Gets a value indicating whether this monitor has a per-monitor side override.</summary>
public bool HasSideOverride => GetConfig()?.Side is not null;
/// <summary>
/// Gets or sets a value indicating whether this monitor uses custom band pinning.
/// When toggled ON, forks band lists from global settings.
/// When toggled OFF, clears per-monitor bands.
/// </summary>
public bool IsCustomized
{
get => GetConfig()?.IsCustomized ?? false;
set
{
_settingsService.UpdateSettings(s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs;
var index = FindConfigIndex(configs);
if (index < 0)
{
return s;
}
var config = configs[index];
DockMonitorConfig updated;
if (value)
{
updated = config.ForkFromGlobal(dockSettings);
}
else
{
updated = config with
{
IsCustomized = false,
StartBands = ImmutableList<DockBandSettings>.Empty,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
}
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
};
});
OnPropertyChanged();
}
}
private DockMonitorConfig? GetConfig()
{
var configs = _settingsService.Settings.DockSettings.MonitorConfigs;
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return configs[i];
}
}
return null;
}
private void UpdateConfig(Func<DockMonitorConfig, DockMonitorConfig> transform)
{
_settingsService.UpdateSettings(s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs;
var index = FindConfigIndex(configs);
if (index < 0)
{
return s;
}
var updated = transform(configs[index]);
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
};
});
}
private int FindConfigIndex(ImmutableList<DockMonitorConfig> configs)
{
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return -1;
}
}

View File

@@ -14,15 +14,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public sealed partial class DockViewModel
public sealed partial class DockViewModel : IDisposable
{
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly ISettingsService _settingsService;
private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves
private readonly IContextMenuFactory _contextMenuFactory;
private readonly string? _monitorDeviceId;
private DockSettings _settings;
private bool _isEditing;
private bool _disposed;
/// <summary>
/// Gets the monitor device identifier this dock is associated with, or <c>null</c>
/// for the default (single-monitor) dock.
/// </summary>
public string? MonitorDeviceId => _monitorDeviceId;
public TaskScheduler Scheduler { get; }
@@ -38,12 +46,14 @@ public sealed partial class DockViewModel
TopLevelCommandManager tlcManager,
IContextMenuFactory contextMenuFactory,
TaskScheduler scheduler,
ISettingsService settingsService)
ISettingsService settingsService,
string? monitorDeviceId = null)
{
_topLevelCommandManager = tlcManager;
_contextMenuFactory = contextMenuFactory;
_settingsService = settingsService;
_settings = _settingsService.Settings.DockSettings;
_monitorDeviceId = monitorDeviceId;
Scheduler = scheduler;
_pageContext = new(this);
@@ -72,17 +82,168 @@ public sealed partial class DockViewModel
public void UpdateSettings(DockSettings settings)
{
if (_isEditing)
{
Logger.LogDebug("DockViewModel.UpdateSettings skipped (edit in progress)");
return;
}
Logger.LogDebug($"DockViewModel.UpdateSettings");
_settings = settings;
SetupBands();
}
/// <summary>
/// Initializes bands from current settings. Call after the UI scheduler is ready
/// (i.e., after the DockWindow is shown) to ensure proper dispatcher access.
/// </summary>
public void InitializeBands() => SetupBands();
/// <summary>
/// Gets the active band lists for this dock instance. Returns per-monitor bands
/// when the associated monitor is customized; otherwise falls back to global bands.
/// </summary>
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBands()
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null)
{
return (
config.ResolveStartBands(_settings.StartBands),
config.ResolveCenterBands(_settings.CenterBands),
config.ResolveEndBands(_settings.EndBands));
}
}
return (_settings.StartBands, _settings.CenterBands, _settings.EndBands);
}
/// <summary>
/// Returns an updated <see cref="DockSettings"/> with the given bands placed in the
/// correct location — per-monitor config when customized, or global otherwise.
/// </summary>
private DockSettings WithActiveBands(
ImmutableList<DockBandSettings> start,
ImmutableList<DockBandSettings> center,
ImmutableList<DockBandSettings> end)
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null && config.IsCustomized)
{
var updatedConfig = config with
{
StartBands = start,
CenterBands = center,
EndBands = end,
};
return _settings with
{
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, updatedConfig),
};
}
}
return _settings with
{
StartBands = start,
CenterBands = center,
EndBands = end,
};
}
/// <summary>
/// Ensures the monitor associated with this dock has its own independent band lists.
/// If the monitor is not yet customized, forks bands from global settings.
/// Returns <c>true</c> if the fork was performed, <c>false</c> if already customized or no monitor.
/// </summary>
public bool EnsureMonitorForked()
{
if (_monitorDeviceId is null)
{
return false;
}
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is null || config.IsCustomized)
{
return false;
}
var forked = config.ForkFromGlobal(_settings);
_settings = _settings with
{
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, forked),
};
SaveSettings();
return true;
}
/// <summary>
/// Gets the effective dock side for this instance, considering per-monitor overrides.
/// </summary>
public DockSide GetEffectiveSide()
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null)
{
return config.ResolveSide(_settings.Side);
}
}
return _settings.Side;
}
private static DockMonitorConfig? FindMonitorConfig(DockSettings settings, string deviceId)
{
var configs = settings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
foreach (var config in configs)
{
if (string.Equals(config.MonitorDeviceId, deviceId, System.StringComparison.OrdinalIgnoreCase))
{
return config;
}
}
return null;
}
private static ImmutableList<DockMonitorConfig> ReplaceMonitorConfig(
ImmutableList<DockMonitorConfig> configs,
DockMonitorConfig updated)
{
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, updated.MonitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
return configs.SetItem(i, updated);
}
}
return configs.Add(updated);
}
public void Dispose()
{
if (!_disposed)
{
_topLevelCommandManager.DockBands.CollectionChanged -= DockBands_CollectionChanged;
_disposed = true;
}
}
private void SetupBands()
{
Logger.LogDebug($"Setting up dock bands");
SetupBands(_settings.StartBands, StartItems);
SetupBands(_settings.CenterBands, CenterItems);
SetupBands(_settings.EndBands, EndItems);
var (start, center, end) = GetActiveBands();
SetupBands(start, StartItems);
SetupBands(center, CenterItems);
SetupBands(end, EndItems);
}
private void SetupBands(
@@ -207,42 +368,46 @@ public sealed partial class DockViewModel
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeSt, activeCt, activeEnd) = GetActiveBands();
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
var bandSettings = activeSt.FirstOrDefault(b => b.CommandId == bandId)
?? activeCt.FirstOrDefault(b => b.CommandId == bandId)
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
return;
}
// Remove from all settings lists
var newDock = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
// Remove from all active band lists
var newStart = activeSt.RemoveAll(b => b.CommandId == bandId);
var newCenter = activeCt.RemoveAll(b => b.CommandId == bandId);
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
// Add to target settings list at the correct index
// Add to target list at the correct index
var targetList = targetSide switch
{
DockPinSide.Start => newDock.StartBands,
DockPinSide.Center => newDock.CenterBands,
DockPinSide.End => newDock.EndBands,
_ => newDock.StartBands,
DockPinSide.Start => newStart,
DockPinSide.Center => newCenter,
DockPinSide.End => newEnd,
_ => newStart,
};
var insertIndex = Math.Min(targetIndex, targetList.Count);
newDock = targetSide switch
switch (targetSide)
{
DockPinSide.Start => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.Center => newDock with { CenterBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.End => newDock with { EndBands = targetList.Insert(insertIndex, bandSettings) },
_ => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
};
_settings = newDock;
case DockPinSide.Start:
newStart = newStart.Insert(insertIndex, bandSettings);
break;
case DockPinSide.Center:
newCenter = newCenter.Insert(insertIndex, bandSettings);
break;
case DockPinSide.End:
default:
newEnd = newEnd.Insert(insertIndex, bandSettings);
break;
}
_settings = WithActiveBands(newStart, newCenter, newEnd);
}
/// <summary>
@@ -252,11 +417,11 @@ public sealed partial class DockViewModel
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeSt, activeCt, activeEnd) = GetActiveBands();
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
var bandSettings = activeSt.FirstOrDefault(b => b.CommandId == bandId)
?? activeCt.FirstOrDefault(b => b.CommandId == bandId)
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
@@ -265,12 +430,9 @@ public sealed partial class DockViewModel
}
// Remove from all sides (settings)
var newDock = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
var newStart = activeSt.RemoveAll(b => b.CommandId == bandId);
var newCenter = activeCt.RemoveAll(b => b.CommandId == bandId);
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
// Remove from UI collections
StartItems.Remove(band);
@@ -282,8 +444,8 @@ public sealed partial class DockViewModel
{
case DockPinSide.Start:
{
var settingsIndex = Math.Min(targetIndex, newDock.StartBands.Count);
newDock = newDock with { StartBands = newDock.StartBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newStart.Count);
newStart = newStart.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIndex, band);
@@ -292,8 +454,8 @@ public sealed partial class DockViewModel
case DockPinSide.Center:
{
var settingsIndex = Math.Min(targetIndex, newDock.CenterBands.Count);
newDock = newDock with { CenterBands = newDock.CenterBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newCenter.Count);
newCenter = newCenter.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIndex, band);
@@ -302,8 +464,8 @@ public sealed partial class DockViewModel
case DockPinSide.End:
{
var settingsIndex = Math.Min(targetIndex, newDock.EndBands.Count);
newDock = newDock with { EndBands = newDock.EndBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newEnd.Count);
newEnd = newEnd.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIndex, band);
@@ -311,7 +473,7 @@ public sealed partial class DockViewModel
}
}
_settings = newDock;
_settings = WithActiveBands(newStart, newCenter, newEnd);
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
}
@@ -331,29 +493,95 @@ public sealed partial class DockViewModel
// Preserve any per-band label edits made while in edit mode. Those edits are
// saved independently of reorder, so merge the latest band settings back into
// the local reordered snapshot before we persist dock settings.
var latestBandSettings = BuildBandSettingsLookup(_settingsService.Settings.DockSettings);
_settings = _settings with
{
StartBands = MergeBandSettings(_settings.StartBands, latestBandSettings),
CenterBands = MergeBandSettings(_settings.CenterBands, latestBandSettings),
EndBands = MergeBandSettings(_settings.EndBands, latestBandSettings),
};
var (latestStart, latestCenter, latestEnd) = GetActiveBandsFromSettings(_settingsService.Settings.DockSettings);
var latestBandSettings = BuildBandSettingsLookup(latestStart, latestCenter, latestEnd);
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
_settings = WithActiveBands(
MergeBandSettings(activeStart, latestBandSettings),
MergeBandSettings(activeCenter, latestBandSettings),
MergeBandSettings(activeEnd, latestBandSettings));
_snapshotDockSettings = null;
_snapshotBandViewModels = null;
// Save without hotReload to avoid triggering SettingsChanged → SetupBands,
// which could race with stale DockBands_CollectionChanged work items and
// re-add bands that were just unpinned.
_settingsService.UpdateSettings(s => s with { DockSettings = _settings }, false);
// Extract the final merged bands for this monitor
var (myStart, myCenter, myEnd) = GetActiveBands();
// Save only this monitor's bands into the CURRENT persisted settings,
// preserving other monitors' changes. Without this, each DockViewModel's
// save would overwrite the entire DockSettings, causing the last save to
// clobber changes from monitors that saved earlier.
_settingsService.UpdateSettings(
s =>
{
var currentDock = s.DockSettings;
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(currentDock, _monitorDeviceId);
if (config is not null && config.IsCustomized)
{
var updatedConfig = config with
{
StartBands = myStart,
CenterBands = myCenter,
EndBands = myEnd,
};
var configs = currentDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
return s with
{
DockSettings = currentDock with
{
MonitorConfigs = ReplaceMonitorConfig(configs, updatedConfig),
},
};
}
}
return s with
{
DockSettings = currentDock with
{
StartBands = myStart,
CenterBands = myCenter,
EndBands = myEnd,
},
};
},
false);
// Refresh local settings from persisted state so all monitors' changes are visible
_settings = _settingsService.Settings.DockSettings;
_isEditing = false;
Logger.LogDebug("Saved band order to settings");
}
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(DockSettings dockSettings)
/// <summary>
/// Gets active bands from a given DockSettings, considering this dock's monitor.
/// </summary>
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBandsFromSettings(DockSettings dockSettings)
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(dockSettings, _monitorDeviceId);
if (config is not null)
{
return (
config.ResolveStartBands(dockSettings.StartBands),
config.ResolveCenterBands(dockSettings.CenterBands),
config.ResolveEndBands(dockSettings.EndBands));
}
}
return (dockSettings.StartBands, dockSettings.CenterBands, dockSettings.EndBands);
}
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(
ImmutableList<DockBandSettings> start,
ImmutableList<DockBandSettings> center,
ImmutableList<DockBandSettings> end)
{
var lookup = new Dictionary<string, DockBandSettings>(StringComparer.Ordinal);
foreach (var band in dockSettings.StartBands.Concat(dockSettings.CenterBands).Concat(dockSettings.EndBands))
foreach (var band in start.Concat(center).Concat(end))
{
lookup[band.CommandId] = band;
}
@@ -450,13 +678,13 @@ public sealed partial class DockViewModel
return;
}
var dockSettings = _settings;
var (activeSt, activeCt, activeEnd) = GetActiveBands();
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
foreach (var bandSettings in activeSt)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -464,7 +692,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.CenterBands)
foreach (var bandSettings in activeCt)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -472,7 +700,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.EndBands)
foreach (var bandSettings in activeEnd)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -483,7 +711,7 @@ public sealed partial class DockViewModel
private void RebuildUICollections()
{
var dockSettings = _settings;
var (activeSt, activeCt, activeEnd) = GetActiveBands();
// Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
@@ -492,7 +720,7 @@ public sealed partial class DockViewModel
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
foreach (var bandSettings in activeSt)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -500,7 +728,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.CenterBands)
foreach (var bandSettings in activeCt)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -508,7 +736,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.EndBands)
foreach (var bandSettings in activeEnd)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -561,6 +789,7 @@ public sealed partial class DockViewModel
// Create settings for the new band
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId };
var dockSettings = _settings;
var (activeSt, activeCt, activeEnd) = GetActiveBands();
// Create the band view model
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
@@ -569,15 +798,15 @@ public sealed partial class DockViewModel
switch (targetSide)
{
case DockPinSide.Start:
_settings = dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) };
_settings = WithActiveBands(activeSt.Add(bandSettings), activeCt, activeEnd);
StartItems.Add(bandVm);
break;
case DockPinSide.Center:
_settings = dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) };
_settings = WithActiveBands(activeSt, activeCt.Add(bandSettings), activeEnd);
CenterItems.Add(bandVm);
break;
case DockPinSide.End:
_settings = dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) };
_settings = WithActiveBands(activeSt, activeCt, activeEnd.Add(bandSettings));
EndItems.Add(bandVm);
break;
}
@@ -600,15 +829,13 @@ public sealed partial class DockViewModel
public void UnpinBand(DockBandViewModel band)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeSt, activeCt, activeEnd) = GetActiveBands();
// Remove from settings
_settings = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
_settings = WithActiveBands(
activeSt.RemoveAll(b => b.CommandId == bandId),
activeCt.RemoveAll(b => b.CommandId == bandId),
activeEnd.RemoveAll(b => b.CommandId == bandId));
// Remove from UI collections
StartItems.Remove(band);
@@ -670,14 +897,16 @@ public sealed partial class DockViewModel
private void EmitDockConfiguration()
{
var isDockEnabled = _settingsService.Settings.EnableDock;
var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none";
var dockSide = isDockEnabled ? GetEffectiveSide().ToString().ToLowerInvariant() : "none";
var (activeSt, activeCt, activeEnd) = GetActiveBands();
static string FormatBands(ImmutableList<DockBandSettings> bands) =>
string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}"));
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(_settings.CenterBands) : string.Empty;
var endBands = isDockEnabled ? FormatBands(_settings.EndBands) : string.Empty;
var startBands = isDockEnabled ? FormatBands(activeSt) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(activeCt) : string.Empty;
var endBands = isDockEnabled ? FormatBands(activeEnd) : string.Empty;
WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage(
isDockEnabled, dockSide, startBands, centerBands, endBands));

View File

@@ -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.UI.ViewModels.Messages;
/// <summary>
/// Broadcast when the user exits dock edit mode on any monitor.
/// All DockControls should respond by saving or discarding their changes.
/// </summary>
/// <param name="Discard">True to discard changes; false to save them.</param>
public record ExitDockEditModeMessage(bool Discard);

View File

@@ -12,4 +12,5 @@ public record PinToDockMessage(
bool Pin,
DockPinSide Side = DockPinSide.Start,
bool? ShowTitles = null,
bool? ShowSubtitles = null);
bool? ShowSubtitles = null,
string? MonitorDeviceId = null);

View File

@@ -2,6 +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.Collections.Generic;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
@@ -12,4 +13,5 @@ public record ShowPinToDockDialogMessage(
string Title,
string Subtitle,
IconInfoViewModel? Icon,
DockSide DockSide);
DockSide DockSide,
IReadOnlyList<Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo>? AvailableMonitors = null);

View File

@@ -0,0 +1,39 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Service for enumerating and tracking connected display monitors.
/// </summary>
public interface IMonitorService
{
/// <summary>
/// Gets all currently connected monitors.
/// </summary>
IReadOnlyList<MonitorInfo> GetMonitors();
/// <summary>
/// Gets a specific monitor by its device identifier.
/// </summary>
MonitorInfo? GetMonitorByDeviceId(string deviceId);
/// <summary>
/// Gets the primary monitor.
/// </summary>
MonitorInfo? GetPrimaryMonitor();
/// <summary>
/// Invalidates the cached monitor list and raises <see cref="MonitorsChanged"/>.
/// Call this when a display settings change is detected (e.g. WM_DISPLAYCHANGE).
/// </summary>
void NotifyMonitorsChanged();
/// <summary>
/// Raised when the set of connected monitors changes (connect, disconnect, or resolution change).
/// </summary>
event System.EventHandler? MonitorsChanged;
}

View File

@@ -0,0 +1,49 @@
// 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.Text.Json.Serialization;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Represents a physical display monitor connected to the system.
/// </summary>
public sealed record MonitorInfo
{
/// <summary>
/// Gets the device identifier (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
public required string DeviceId { get; init; }
/// <summary>
/// Gets the human-readable display name (e.g. <c>DELL U2723QE</c>).
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Gets the full monitor rectangle in virtual-screen coordinates.
/// </summary>
public required ScreenRect Bounds { get; init; }
/// <summary>
/// Gets the work area (excludes the taskbar) in virtual-screen coordinates.
/// </summary>
public required ScreenRect WorkArea { get; init; }
/// <summary>
/// Gets the DPI value for this monitor (e.g. 96, 120, 144, 192).
/// </summary>
public required uint Dpi { get; init; }
/// <summary>
/// Gets a value indicating whether this is the primary monitor.
/// </summary>
public required bool IsPrimary { get; init; }
/// <summary>
/// Gets the scale factor for this monitor (e.g. 1.0 = 100%, 1.5 = 150%).
/// </summary>
[JsonIgnore]
public double ScaleFactor => Dpi / 96.0;
}

View File

@@ -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.UI.ViewModels.Models;
/// <summary>
/// Represents the bounds of a monitor in virtual-screen coordinates.
/// </summary>
public readonly record struct ScreenRect(int Left, int Top, int Right, int Bottom)
{
public int Width => Right - Left;
public int Height => Bottom - Top;
}

View File

@@ -2,12 +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.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
@@ -87,6 +89,8 @@ public sealed class SettingsService : ISettingsService
DeprecatedHotkeyGoesHomeKey,
(ref SettingsModel model, bool goesHome) => model = model with { AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan },
JsonSerializationContext.Default.Boolean);
migratedAny |= TryMigrateBandShowLabels(root, ref _settings);
}
}
catch (Exception ex)
@@ -132,4 +136,106 @@ public sealed class SettingsService : ISettingsService
return false;
}
/// <summary>
/// Migrates per-band <c>ShowLabels</c> to <c>ShowTitles</c> and <c>ShowSubtitles</c>.
/// The old <c>ShowLabels</c> property on <see cref="DockBandSettings"/> was renamed to
/// <c>ShowTitles</c> (with <c>ShowSubtitles</c> added). Because the legacy property is
/// <c>[JsonIgnore]</c>, old JSON values are lost during deserialization. This migration
/// reads the raw JSON to recover them.
/// </summary>
private static bool TryMigrateBandShowLabels(JsonObject root, ref SettingsModel model)
{
try
{
if (root[nameof(SettingsModel.DockSettings)] is not JsonObject dockSettingsNode)
{
return false;
}
var migrated = false;
var ds = model.DockSettings;
var newStart = MigrateBandList(dockSettingsNode, nameof(DockSettings.StartBands), ds.StartBands, ref migrated);
var newCenter = MigrateBandList(dockSettingsNode, nameof(DockSettings.CenterBands), ds.CenterBands, ref migrated);
var newEnd = MigrateBandList(dockSettingsNode, nameof(DockSettings.EndBands), ds.EndBands, ref migrated);
if (migrated)
{
model = model with
{
DockSettings = ds with
{
StartBands = newStart,
CenterBands = newCenter,
EndBands = newEnd,
},
};
}
return migrated;
}
catch (Exception ex)
{
Logger.LogError("Error during band ShowLabels migration.", ex);
return false;
}
}
/// <summary>
/// Scans a single band array in the raw JSON for <c>ShowLabels</c> entries that
/// need migrating to <c>ShowTitles</c> / <c>ShowSubtitles</c>.
/// </summary>
private static ImmutableList<DockBandSettings> MigrateBandList(
JsonObject dockSettingsNode,
string bandKey,
ImmutableList<DockBandSettings> bands,
ref bool anyMigrated)
{
if (dockSettingsNode[bandKey] is not JsonArray jsonBands)
{
return bands;
}
var builder = bands.ToBuilder();
var listChanged = false;
for (var i = 0; i < builder.Count && i < jsonBands.Count; i++)
{
if (jsonBands[i] is not JsonObject jsonBand)
{
continue;
}
// Only migrate if old key exists and new key does not
if (!jsonBand.ContainsKey("ShowLabels") || jsonBand.ContainsKey("ShowTitles"))
{
continue;
}
var showLabelsNode = jsonBand["ShowLabels"];
if (showLabelsNode is null)
{
continue;
}
var showLabels = showLabelsNode.GetValue<bool>();
var band = builder[i];
band = band with
{
ShowTitles = band.ShowTitles ?? showLabels,
ShowSubtitles = band.ShowSubtitles ?? showLabels,
};
builder[i] = band;
listChanged = true;
}
if (listChanged)
{
anyMigrated = true;
return builder.ToImmutable();
}
return bands;
}
}

View File

@@ -13,7 +13,8 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>.
/// settings are in <see cref="DockBandSettings"/>. Per-monitor overrides are
/// stored in <see cref="MonitorConfigs"/>.
/// </summary>
public record DockSettings
{
@@ -92,11 +93,147 @@ public record DockSettings
public bool ShowLabels { get; init; } = true;
/// <summary>
/// Gets the per-monitor dock configurations. Each entry overrides global
/// settings for a specific display. Empty by default (all monitors use global).
/// </summary>
private ImmutableList<DockMonitorConfig>? _monitorConfigs = ImmutableList<DockMonitorConfig>.Empty;
public ImmutableList<DockMonitorConfig> MonitorConfigs
{
get => _monitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
init => _monitorConfigs = value ?? ImmutableList<DockMonitorConfig>.Empty;
}
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands
{
get
{
// Start with global bands
var result = StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
// Include per-monitor bands so that commands pinned to specific
// monitors are loaded as TopLevelViewModels and appear in the dock.
var configs = MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
foreach (var config in configs)
{
if (config.IsCustomized)
{
var start = config.StartBands ?? ImmutableList<DockBandSettings>.Empty;
var center = config.CenterBands ?? ImmutableList<DockBandSettings>.Empty;
var end = config.EndBands ?? ImmutableList<DockBandSettings>.Empty;
result = result
.Concat(start.Select(b => (b.ProviderId, b.CommandId)))
.Concat(center.Select(b => (b.ProviderId, b.CommandId)))
.Concat(end.Select(b => (b.ProviderId, b.CommandId)));
}
}
return result;
}
}
}
/// <summary>
/// Per-monitor configuration for the dock. Each monitor can override the global
/// dock side, enable/disable its dock, and optionally maintain independent band lists.
/// Uses a nullable-override pattern: <c>null</c> values inherit from global <see cref="DockSettings"/>.
/// </summary>
public sealed record DockMonitorConfig
{
/// <summary>
/// Gets the monitor device identifier (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
public required string MonitorDeviceId { get; init; }
/// <summary>
/// Gets a value indicating whether the dock is enabled on this monitor. Defaults to <c>true</c>.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gets the dock side override for this monitor. When <c>null</c>, inherits the global
/// <see cref="DockSettings.Side"/> value.
/// </summary>
public DockSide? Side { get; init; }
/// <summary>
/// Gets a value indicating whether this monitor is the primary display.
/// Used as a stable key for reconciliation when device IDs change across reboots.
/// </summary>
public bool IsPrimary { get; init; }
/// <summary>
/// Gets a value indicating whether this monitor has its own independent band lists.
/// When <c>false</c>, the monitor inherits bands from the global <see cref="DockSettings"/>.
/// </summary>
public bool IsCustomized { get; init; }
/// <summary>
/// Gets the per-monitor start bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? StartBands { get; init; }
/// <summary>
/// Gets the per-monitor center bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? CenterBands { get; init; }
/// <summary>
/// Gets the per-monitor end bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? EndBands { get; init; }
/// <summary>
/// Gets the UTC timestamp when this monitor was last seen connected. Used for
/// staleness pruning: configs not seen for 6+ months are automatically removed
/// during reconciliation.
/// </summary>
public DateTime? LastSeen { get; init; }
/// <summary>
/// Resolves the effective dock side for this monitor.
/// </summary>
public DockSide ResolveSide(DockSide defaultSide) => Side ?? defaultSide;
/// <summary>
/// Resolves the effective start bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveStartBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && StartBands is not null ? StartBands : globalBands;
/// <summary>
/// Resolves the effective center bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveCenterBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && CenterBands is not null ? CenterBands : globalBands;
/// <summary>
/// Resolves the effective end bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveEndBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && EndBands is not null ? EndBands : globalBands;
/// <summary>
/// Creates a new <see cref="DockMonitorConfig"/> that is a customized fork of the
/// given global dock settings. Copies global bands into per-monitor band lists so
/// they can be independently modified.
/// </summary>
public DockMonitorConfig ForkFromGlobal(DockSettings globalSettings) => this with
{
IsCustomized = true,
// Create independent copies by rebuilding the immutable lists
StartBands = ImmutableList.CreateRange(globalSettings.StartBands ?? ImmutableList<DockBandSettings>.Empty),
CenterBands = ImmutableList.CreateRange(globalSettings.CenterBands ?? ImmutableList<DockBandSettings>.Empty),
EndBands = ImmutableList.CreateRange(globalSettings.EndBands ?? ImmutableList<DockBandSettings>.Empty),
};
}
/// <summary>

View File

@@ -0,0 +1,206 @@
// 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.Collections.Immutable;
using Microsoft.CmdPal.UI.ViewModels.Models;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Reconciles persisted <see cref="DockMonitorConfig"/> entries against the
/// set of currently connected monitors. Handles stale device IDs that may change
/// across reboots by using the <see cref="DockMonitorConfig.IsPrimary"/> flag as
/// a secondary matching key.
/// </summary>
/// <remarks>
/// All operations are pure — they return new immutable lists rather than
/// mutating input collections.
/// </remarks>
public static class MonitorConfigReconciler
{
/// <summary>
/// Configs whose <see cref="DockMonitorConfig.LastSeen"/> is older than this
/// duration are pruned during reconciliation.
/// </summary>
internal static readonly TimeSpan StaleThreshold = TimeSpan.FromDays(180);
/// <summary>
/// Reconciles persisted monitor configs against the current set of connected monitors.
/// <para>
/// <b>Phase 1</b>: Exact DeviceId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 2</b>: Fuzzy matching — reassociate unmatched configs by IsPrimary flag.<br/>
/// <b>Phase 3</b>: Create default configs for monitors that have no matching config.<br/>
/// <b>Phase 4</b>: Retain disconnected monitor configs for future reconnection; prune entries not seen for 6+ months.
/// </para>
/// </summary>
public static ImmutableList<DockMonitorConfig> Reconcile(
ImmutableList<DockMonitorConfig>? existingConfigs,
IReadOnlyList<MonitorInfo> currentMonitors)
{
// Use Date (day granularity) so the value stabilizes across multiple reconciliations
// within the same day. This prevents infinite loops: SettingsChanged → SyncDocks →
// Reconcile → SettingsChanged when LastSeen changes by milliseconds each call.
return Reconcile(existingConfigs, currentMonitors, DateTime.UtcNow.Date);
}
/// <summary>
/// Overload accepting an explicit <paramref name="utcNow"/> for testability.
/// </summary>
internal static ImmutableList<DockMonitorConfig> Reconcile(
ImmutableList<DockMonitorConfig>? existingConfigs,
IReadOnlyList<MonitorInfo> currentMonitors,
DateTime utcNow)
{
existingConfigs ??= ImmutableList<DockMonitorConfig>.Empty;
if (currentMonitors.Count == 0)
{
return existingConfigs;
}
// Build sets for tracking
var matchedMonitorDeviceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedConfigIndices = new HashSet<int>();
var result = new List<DockMonitorConfig>(currentMonitors.Count);
// Convert to mutable working list for easier manipulation
var configs = new List<DockMonitorConfig>(existingConfigs);
// Phase 1: Exact DeviceId match
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
for (var ci = 0; ci < configs.Count; ci++)
{
if (matchedConfigIndices.Contains(ci))
{
continue;
}
if (string.Equals(configs[ci].MonitorDeviceId, monitor.DeviceId, StringComparison.OrdinalIgnoreCase))
{
// Update IsPrimary and LastSeen to current state
result.Add(configs[ci] with { IsPrimary = monitor.IsPrimary, LastSeen = utcNow });
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedConfigIndices.Add(ci);
break;
}
}
}
// Phase 2: Fuzzy match by IsPrimary for unmatched configs (primary only).
// Non-primary monitors are not interchangeable, so we only fuzzy-match the primary.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (!monitor.IsPrimary || matchedMonitorDeviceIds.Contains(monitor.DeviceId))
{
continue;
}
for (var ci = 0; ci < configs.Count; ci++)
{
if (matchedConfigIndices.Contains(ci))
{
continue;
}
if (configs[ci].IsPrimary)
{
// Reassociate: update DeviceId, IsPrimary, and LastSeen
result.Add(configs[ci] with
{
MonitorDeviceId = monitor.DeviceId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedConfigIndices.Add(ci);
break;
}
}
}
// Phase 3: Create defaults for new monitors with no matching config.
// Primary monitors inherit global bands (IsCustomized = false) for a seamless
// upgrade path. Secondary monitors start with empty band lists so users don't
// have to manually unpin bands from every new display.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorDeviceIds.Contains(monitor.DeviceId))
{
continue;
}
if (monitor.IsPrimary)
{
// Primary: inherit global bands (IsCustomized = false)
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.DeviceId,
Enabled = true,
IsPrimary = true,
LastSeen = utcNow,
});
}
else
{
// Secondary: start with empty bands so users choose what to pin per-monitor
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.DeviceId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
StartBands = ImmutableList<DockBandSettings>.Empty,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
LastSeen = utcNow,
});
}
}
// Phase 4: Retain disconnected monitor configs so settings survive reconnection.
// Prune entries not seen for longer than StaleThreshold (6 months).
for (var ci = 0; ci < configs.Count; ci++)
{
if (matchedConfigIndices.Contains(ci))
{
continue;
}
var config = configs[ci];
var lastSeen = config.LastSeen ?? utcNow; // Treat legacy entries (no LastSeen) as fresh
if ((utcNow - lastSeen) < StaleThreshold)
{
result.Add(config);
}
}
// Return the original reference when nothing actually changed so callers
// can use reference equality to skip no-op settings writes.
if (result.Count == existingConfigs.Count)
{
var changed = false;
for (var i = 0; i < result.Count; i++)
{
if (!result[i].Equals(existingConfigs[i]))
{
changed = true;
break;
}
}
if (!changed)
{
return existingConfigs;
}
}
return ImmutableList.CreateRange(result);
}
}

View File

@@ -196,6 +196,8 @@ public record SettingsModel
[JsonSerializable(typeof(ImmutableDictionary<string, FallbackSettings>), TypeInfoPropertyName = "ImmutableFallbackDictionary")]
[JsonSerializable(typeof(ImmutableList<string>), TypeInfoPropertyName = "ImmutableStringList")]
[JsonSerializable(typeof(ImmutableList<DockBandSettings>), TypeInfoPropertyName = "ImmutableDockBandSettingsList")]
[JsonSerializable(typeof(DockMonitorConfig))]
[JsonSerializable(typeof(ImmutableList<DockMonitorConfig>), TypeInfoPropertyName = "ImmutableDockMonitorConfigList")]
[JsonSerializable(typeof(ImmutableDictionary<string, ProviderSettings>), TypeInfoPropertyName = "ImmutableProviderSettingsDictionary")]
[JsonSerializable(typeof(ImmutableDictionary<string, CommandAlias>), TypeInfoPropertyName = "ImmutableAliasDictionary")]
[JsonSerializable(typeof(ImmutableList<TopLevelHotkey>), TypeInfoPropertyName = "ImmutableTopLevelHotkeyList")]

View File

@@ -5,7 +5,9 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -29,6 +31,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly IMonitorService? _monitorService;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -250,20 +253,26 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();
public ObservableCollection<DockMonitorConfigViewModel> MonitorConfigs { get; } = new();
public SettingsExtensionsViewModel Extensions { get; }
public SettingsViewModel(
TopLevelCommandManager topLevelCommandManager,
TaskScheduler scheduler,
IThemeService themeService,
ISettingsService settingsService)
ISettingsService settingsService,
IMonitorService? monitorService = null)
{
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
_monitorService = monitorService;
Appearance = new AppearanceSettingsViewModel(themeService, settingsService);
DockAppearance = new DockAppearanceSettingsViewModel(themeService, settingsService);
PopulateMonitorConfigs();
var activeProviders = GetCommandProviders();
var allProviderSettings = _settingsService.Settings.ProviderSettings;
@@ -332,4 +341,43 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_settingsService.UpdateSettings(s => s with { FallbackRanks = FallbackRankings.Select(s2 => s2.Id).ToArray() });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings)));
}
/// <summary>
/// Builds or refreshes the <see cref="MonitorConfigs"/> collection by reconciling
/// connected monitors with persisted per-monitor settings.
/// </summary>
public void PopulateMonitorConfigs()
{
if (_monitorService is null)
{
return;
}
var monitors = _monitorService.GetMonitors();
var currentSettings = _settingsService.Settings.DockSettings;
var reconciled = MonitorConfigReconciler.Reconcile(currentSettings.MonitorConfigs, monitors);
if (!reconciled.SequenceEqual(currentSettings.MonitorConfigs))
{
_settingsService.UpdateSettings(s => s with
{
DockSettings = s.DockSettings with { MonitorConfigs = reconciled },
});
}
MonitorConfigs.Clear();
foreach (var monitor in monitors)
{
var config = reconciled.FirstOrDefault(c =>
string.Equals(c.MonitorDeviceId, monitor.DeviceId, StringComparison.OrdinalIgnoreCase));
if (config is not null)
{
MonitorConfigs.Add(new DockMonitorConfigViewModel(config, monitor, _settingsService));
}
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MonitorConfigs)));
}
}

View File

@@ -707,7 +707,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
if (message.Pin)
{
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles);
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles, message.MonitorDeviceId);
}
else
{

View File

@@ -257,6 +257,14 @@ public partial class App : Application, IDisposable
services.AddSingleton<DockViewModel>();
services.AddSingleton<IContextMenuFactory, CommandPaletteContextMenuFactory>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
// Multi-monitor dock support
services.AddSingleton<IMonitorService, MonitorService>();
services.AddSingleton<Dock.DockWindowManager>(sp =>
new Dock.DockWindowManager(
sp.GetRequiredService<IMonitorService>(),
sp.GetRequiredService<ISettingsService>(),
Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()));
}
public void Dispose()

View File

@@ -2,12 +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 System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
@@ -20,11 +23,13 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
{
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly IMonitorService? _monitorService;
public CommandPaletteContextMenuFactory(ISettingsService settingsService, TopLevelCommandManager topLevelCommandManager)
public CommandPaletteContextMenuFactory(ISettingsService settingsService, TopLevelCommandManager topLevelCommandManager, IMonitorService? monitorService = null)
{
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
_monitorService = monitorService;
}
/// <summary>
@@ -212,7 +217,8 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
PinLocation.Dock,
_settingsService,
_topLevelCommandManager,
commandItemViewModel: commandItem);
commandItemViewModel: commandItem,
monitorService: _monitorService);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
@@ -261,6 +267,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
private readonly string _providerId;
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly IMonitorService? _monitorService;
private readonly bool _pin;
private readonly PinLocation _pinLocation;
private readonly CommandItemViewModel? _commandItemViewModel;
@@ -282,7 +289,8 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
PinLocation pinLocation,
ISettingsService settingsService,
TopLevelCommandManager topLevelCommandManager,
CommandItemViewModel? commandItemViewModel = null)
CommandItemViewModel? commandItemViewModel = null,
IMonitorService? monitorService = null)
{
_commandId = commandId;
_providerId = providerId;
@@ -291,6 +299,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
_topLevelCommandManager = topLevelCommandManager;
_pin = pin;
_commandItemViewModel = commandItemViewModel;
_monitorService = monitorService;
}
public override CommandResult Invoke()
@@ -346,7 +355,8 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var subtitle = _commandItemViewModel?.Subtitle ?? string.Empty;
var icon = _commandItemViewModel?.Icon;
var dockSide = _settingsService.Settings.DockSettings.Side;
ShowPinToDockDialogMessage message = new(_providerId, _commandId, title, subtitle, icon, dockSide);
IReadOnlyList<MonitorInfo>? monitors = _monitorService?.GetMonitors();
ShowPinToDockDialogMessage message = new(_providerId, _commandId, title, subtitle, icon, dockSide, monitors);
WeakReferenceMessenger.Default.Send(message);
}

View File

@@ -23,12 +23,18 @@ using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Dock;
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>
{
private DockViewModel _viewModel;
internal DockViewModel ViewModel => _viewModel;
/// <summary>
/// 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; }
public static readonly DependencyProperty ItemsOrientationProperty =
DependencyProperty.Register(nameof(ItemsOrientation), typeof(Orientation), typeof(DockControl), new PropertyMetadata(Orientation.Horizontal));
@@ -89,6 +95,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
WeakReferenceMessenger.Default.UnregisterAll(this);
WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<EnterDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<ExitDockEditModeMessage>(this);
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
@@ -142,6 +149,21 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
});
}
public void Receive(ExitDockEditModeMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
if (message.Discard)
{
DiscardEditMode();
}
else
{
ExitEditMode();
}
});
}
private void UpdateEditMode(bool isEditMode)
{
// Update center visibility based on edit mode and center items
@@ -231,20 +253,21 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void DoneEditingButton_Click(object sender, RoutedEventArgs e)
{
ExitEditMode();
WeakReferenceMessenger.Default.Send(new ExitDockEditModeMessage(Discard: false));
}
private void DiscardEditingButton_Click(object sender, RoutedEventArgs e)
{
DiscardEditMode();
WeakReferenceMessenger.Default.Send(new ExitDockEditModeMessage(Discard: true));
}
internal void UpdateSettings(DockSettings settings)
internal void UpdateSettings(DockSettings settings, DockSide? effectiveSide = null)
{
DockSide = settings.Side;
var side = effectiveSide ?? settings.Side;
DockSide = side;
// Compact mode is only supported for Top/Bottom positions
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
var isHorizontal = side == DockSide.Top || side == DockSide.Bottom;
var effectiveSize = isHorizontal ? settings.DockSize : DockSize.Default;
DockSize = effectiveSize;
@@ -378,7 +401,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
var isPage = command.Model.Unsafe is not IInvokableCommand invokable;
if (isPage)
{
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos));
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, OwnerHwnd));
}
}
catch (COMException e)

View File

@@ -9,6 +9,7 @@ using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
@@ -33,8 +34,8 @@ namespace Microsoft.CmdPal.UI.Dock;
#pragma warning disable SA1402 // File may only contain a single type
public sealed partial class DockWindow : WindowEx,
IRecipient<BringToTopMessage>,
IRecipient<RequestShowPaletteAtMessage>,
IRecipient<BringToTopMessage>,
IRecipient<RequestShowPaletteAtMessage>,
IRecipient<QuitMessage>,
IDisposable
{
@@ -46,6 +47,7 @@ public sealed partial class DockWindow : WindowEx,
private readonly IThemeService _themeService;
private readonly ISettingsService _settingsService;
private readonly IMonitorService _monitorService;
private readonly DockWindowViewModel _windowViewModel;
private readonly HiddenOwnerWindowBehavior _hiddenOwnerWindowBehavior = new();
@@ -65,21 +67,50 @@ public sealed partial class DockWindow : WindowEx,
private DockSize _lastSize;
private bool _isDisposed;
/// <summary>
/// The monitor this dock window is displayed on. Null means primary monitor (legacy behavior).
/// </summary>
private ViewModels.Models.MonitorInfo? _targetMonitor;
/// <summary>
/// Per-monitor dock side override. Null means use the global setting.
/// </summary>
private DockSide? _sideOverride;
/// <summary>
/// Gets the effective dock side for this window, respecting per-monitor overrides.
/// </summary>
private DockSide EffectiveSide => _sideOverride ?? _settings.Side;
// Store the original WndProc
private WNDPROC? _originalWndProc;
private WNDPROC? _customWndProc;
// internal Settings CurrentSettings => _settings;
public DockWindow()
: this(App.Current.Services.GetService<DockViewModel>()!)
{
}
public DockWindow(DockViewModel dockViewModel)
: this(dockViewModel, null, null)
{
}
public DockWindow(DockViewModel dockViewModel, ViewModels.Models.MonitorInfo? targetMonitor, DockSide? sideOverride)
{
_targetMonitor = targetMonitor;
_sideOverride = sideOverride;
var serviceProvider = App.Current.Services;
var mainSettings = serviceProvider.GetRequiredService<ISettingsService>().Settings;
_settingsService = serviceProvider.GetRequiredService<ISettingsService>();
_settingsService.SettingsChanged += SettingsChangedHandler;
_monitorService = serviceProvider.GetRequiredService<IMonitorService>();
_settings = mainSettings.DockSettings;
_lastSize = EffectiveDockSize(_settings);
viewModel = serviceProvider.GetService<DockViewModel>()!;
viewModel = dockViewModel;
_themeService = serviceProvider.GetRequiredService<IThemeService>();
_themeService.ThemeChanged += ThemeService_ThemeChanged;
InitializeBackdropSupport();
@@ -108,6 +139,7 @@ public sealed partial class DockWindow : WindowEx,
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
_hwnd = GetWindowHandle(this);
_dock.OwnerHwnd = (nint)_hwnd;
// Subclass the window to intercept messages
//
@@ -143,7 +175,13 @@ public sealed partial class DockWindow : WindowEx,
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args)
{
if (_isDisposed)
{
return;
}
_settings = args.DockSettings;
RefreshSideOverride();
DispatcherQueue.TryEnqueue(UpdateSettingsOnUiThread);
}
@@ -172,12 +210,17 @@ public sealed partial class DockWindow : WindowEx,
private void UpdateSettingsOnUiThread()
{
if (_isDisposed)
{
return;
}
this.viewModel.UpdateSettings(_settings);
UpdateBackdrop();
_dock.UpdateSettings(_settings);
_dock.UpdateSettings(_settings, EffectiveSide);
var side = DockSettingsToViews.GetAppBarEdge(_settings.Side);
var side = DockSettingsToViews.GetAppBarEdge(EffectiveSide);
if (_appBarData.hWnd != IntPtr.Zero)
{
@@ -394,7 +437,7 @@ public sealed partial class DockWindow : WindowEx,
var scaleFactor = dpi / 96.0;
var effectiveSize = EffectiveDockSize(_settings);
UpdateAppBarDataForEdge(_settings.Side, effectiveSize, scaleFactor);
UpdateAppBarDataForEdge(EffectiveSide, effectiveSize, scaleFactor);
// Query and set position
PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
@@ -405,7 +448,7 @@ public sealed partial class DockWindow : WindowEx,
// bar keeps its correct size. Without this, a second bar docked to
// the same side would get a zero-height/width rect and fail to
// reserve work-area space.
switch (_settings.Side)
switch (EffectiveSide)
{
case DockSide.Top:
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
@@ -442,6 +485,48 @@ public sealed partial class DockWindow : WindowEx,
true);
}
/// <summary>
/// Re-resolves <see cref="_targetMonitor"/> against the current monitor list.
/// <see cref="MonitorInfo"/> is an immutable record, so the instance captured
/// at construction time becomes stale whenever the display topography changes.
/// If the monitor is no longer connected we keep the stale reference; the
/// <see cref="DockWindowManager"/> will close this window shortly.
/// </summary>
private void RefreshTargetMonitor()
{
if (_targetMonitor is null)
{
return;
}
var refreshed = _monitorService.GetMonitorByDeviceId(_targetMonitor.DeviceId);
if (refreshed is not null)
{
_targetMonitor = refreshed;
}
}
private void RefreshSideOverride()
{
if (_targetMonitor is null)
{
_sideOverride = null;
return;
}
_sideOverride = null;
var monitorConfigs = _settings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
for (var i = 0; i < monitorConfigs.Count; i++)
{
var cfg = monitorConfigs[i];
if (string.Equals(cfg.MonitorDeviceId, _targetMonitor.DeviceId, System.StringComparison.OrdinalIgnoreCase))
{
_sideOverride = cfg.Side;
break;
}
}
}
/// <summary>
/// Compact mode is only supported for Top/Bottom dock positions.
/// For Left/Right, always use Default size.
@@ -457,46 +542,61 @@ public sealed partial class DockWindow : WindowEx,
Logger.LogDebug("UpdateAppBarDataForEdge");
var horizontalHeightDips = DockSettingsToViews.HeightForSize(size);
var verticalWidthDips = DockSettingsToViews.WidthForSize(size);
var screenHeight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
var screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
// Use monitor-specific bounds when available; fall back to primary screen metrics
int monLeft, monTop, monRight, monBottom;
if (_targetMonitor is not null)
{
monLeft = _targetMonitor.Bounds.Left;
monTop = _targetMonitor.Bounds.Top;
monRight = _targetMonitor.Bounds.Right;
monBottom = _targetMonitor.Bounds.Bottom;
}
else
{
monLeft = 0;
monTop = 0;
monRight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
monBottom = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
}
if (side == DockSide.Top)
{
_appBarData.uEdge = PInvoke.ABE_TOP;
_appBarData.rc.left = 0;
_appBarData.rc.top = 0;
_appBarData.rc.right = screenWidth;
_appBarData.rc.bottom = (int)(horizontalHeightDips * scaleFactor);
_appBarData.rc.left = monLeft;
_appBarData.rc.top = monTop;
_appBarData.rc.right = monRight;
_appBarData.rc.bottom = monTop + (int)(horizontalHeightDips * scaleFactor);
}
else if (side == DockSide.Bottom)
{
var heightPixels = (int)(horizontalHeightDips * scaleFactor);
_appBarData.uEdge = PInvoke.ABE_BOTTOM;
_appBarData.rc.left = 0;
_appBarData.rc.top = screenHeight - heightPixels;
_appBarData.rc.right = screenWidth;
_appBarData.rc.bottom = screenHeight;
_appBarData.rc.left = monLeft;
_appBarData.rc.top = monBottom - heightPixels;
_appBarData.rc.right = monRight;
_appBarData.rc.bottom = monBottom;
}
else if (side == DockSide.Left)
{
var widthPixels = (int)(verticalWidthDips * scaleFactor);
_appBarData.uEdge = PInvoke.ABE_LEFT;
_appBarData.rc.left = 0;
_appBarData.rc.top = 0;
_appBarData.rc.right = widthPixels;
_appBarData.rc.bottom = screenHeight;
_appBarData.rc.left = monLeft;
_appBarData.rc.top = monTop;
_appBarData.rc.right = monLeft + widthPixels;
_appBarData.rc.bottom = monBottom;
}
else if (side == DockSide.Right)
{
var widthPixels = (int)(verticalWidthDips * scaleFactor);
_appBarData.uEdge = PInvoke.ABE_RIGHT;
_appBarData.rc.left = screenWidth - widthPixels;
_appBarData.rc.top = 0;
_appBarData.rc.right = screenWidth;
_appBarData.rc.bottom = screenHeight;
_appBarData.rc.left = monRight - widthPixels;
_appBarData.rc.top = monTop;
_appBarData.rc.right = monRight;
_appBarData.rc.bottom = monBottom;
}
else
{
@@ -521,8 +621,27 @@ public sealed partial class DockWindow : WindowEx,
{
Logger.LogDebug("WM_DISPLAYCHANGE");
// Use dispatcher to ensure we're on the UI thread
DispatcherQueue.TryEnqueue(() => UpdateWindowPosition());
// Invalidate the monitor cache so DockWindowManager can reconcile
_monitorService.NotifyMonitorsChanged();
// Use dispatcher to ensure we're on the UI thread.
// Refresh _targetMonitor before re-positioning: the MonitorInfo
// captured at construction is an immutable record, so its Bounds
// are stale after a topology change (e.g. an external display was
// disconnected, shifting our monitor's virtual-screen origin).
// Without this, UpdateAppBarDataForEdge would compute the AppBar
// rect against the old coordinates and produce a wildly incorrect
// size/position.
DispatcherQueue.TryEnqueue(() =>
{
if (_isDisposed)
{
return;
}
RefreshTargetMonitor();
UpdateWindowPosition();
});
}
// Intercept WM_SYSCOMMAND to prevent minimize and maximize
@@ -659,6 +778,11 @@ public sealed partial class DockWindow : WindowEx,
void IRecipient<RequestShowPaletteAtMessage>.Receive(RequestShowPaletteAtMessage message)
{
if (_isDisposed || message.OwnerHwnd != (nint)_hwnd)
{
return;
}
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => RequestShowPaletteOnUiThread(message.PosDips));
}
@@ -672,8 +796,23 @@ public sealed partial class DockWindow : WindowEx,
var scaleFactor = dpi / 96.0;
var screenPosPixels = new Point(screenPosDips.X * scaleFactor, screenPosDips.Y * scaleFactor);
var screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
var screenHeight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
// Use monitor-specific bounds when available
int screenWidth, screenHeight;
if (_targetMonitor is not null)
{
screenWidth = _targetMonitor.Bounds.Width;
screenHeight = _targetMonitor.Bounds.Height;
// Adjust to monitor-local coordinates for quadrant calculation
screenPosPixels = new Point(
screenPosPixels.X - _targetMonitor.Bounds.Left,
screenPosPixels.Y - _targetMonitor.Bounds.Top);
}
else
{
screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
screenHeight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
}
// Now we're going to find the best position for the palette.
@@ -695,7 +834,7 @@ public sealed partial class DockWindow : WindowEx,
var onRightHalf = !onLeftHalf;
var onBottomHalf = !onTopHalf;
var anchorPoint = _settings.Side switch
var anchorPoint = EffectiveSide switch
{
DockSide.Top => onLeftHalf ? AnchorPoint.TopLeft : AnchorPoint.TopRight,
DockSide.Bottom => onLeftHalf ? AnchorPoint.BottomLeft : AnchorPoint.BottomRight,
@@ -710,7 +849,7 @@ public sealed partial class DockWindow : WindowEx,
PInvoke.GetWindowRect(_hwnd, out var ourRect);
// Depending on the side we're on, we need to offset differently
switch (_settings.Side)
switch (EffectiveSide)
{
case DockSide.Top:
screenPosPixels.Y = ourRect.bottom + paddingPixels;
@@ -733,6 +872,8 @@ public sealed partial class DockWindow : WindowEx,
public DockWindowViewModel WindowViewModel => _windowViewModel;
public string? MonitorDeviceId => viewModel.MonitorDeviceId;
public void Dispose()
{
Cleanup();
@@ -850,7 +991,7 @@ internal static class ShowDesktop
internal sealed record BringToTopMessage(bool BringToFront);
internal sealed record RequestShowPaletteAtMessage(Point PosDips);
internal sealed record RequestShowPaletteAtMessage(Point PosDips, IntPtr OwnerHwnd);
internal sealed record ShowPaletteAtMessage(Point PosPixels, AnchorPoint Anchor);

View File

@@ -0,0 +1,283 @@
// 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.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using WinUIEx;
namespace Microsoft.CmdPal.UI.Dock;
/// <summary>
/// Manages multiple <see cref="DockWindow"/> instances, one per enabled monitor.
/// Replaces the single <c>_dockWindow</c> field previously held by ShellPage.
/// </summary>
public sealed partial class DockWindowManager : IDisposable
{
private readonly IMonitorService _monitorService;
private readonly ISettingsService _settingsService;
private readonly DispatcherQueue _dispatcherQueue;
private readonly Dictionary<string, DockWindow> _dockWindows = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DockViewModel> _dockViewModels = new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
private bool _syncing;
public DockWindowManager(
IMonitorService monitorService,
ISettingsService settingsService,
DispatcherQueue dispatcherQueue)
{
_monitorService = monitorService;
_settingsService = settingsService;
_dispatcherQueue = dispatcherQueue;
_monitorService.MonitorsChanged += OnMonitorsChanged;
_settingsService.SettingsChanged += OnSettingsChanged;
}
/// <summary>
/// Creates dock windows for all enabled monitors according to current settings.
/// Runs reconciliation to ensure configs match currently connected monitors.
/// </summary>
public void ShowDocks()
{
var settings = _settingsService.Settings;
if (!settings.EnableDock)
{
return;
}
SyncDocksToSettings();
}
/// <summary>
/// Destroys all dock windows.
/// </summary>
public void HideDocks()
{
foreach (var kvp in _dockWindows)
{
kvp.Value.Close();
}
_dockWindows.Clear();
foreach (var kvp in _dockViewModels)
{
kvp.Value.Dispose();
}
_dockViewModels.Clear();
}
/// <summary>
/// Synchronizes running dock windows to match the current settings and connected monitors.
/// </summary>
public void SyncDocksToSettings()
{
if (_syncing)
{
return;
}
_syncing = true;
try
{
SyncDocksToSettingsCore();
}
finally
{
_syncing = false;
}
}
private void SyncDocksToSettingsCore()
{
var settings = _settingsService.Settings;
if (!settings.EnableDock)
{
HideDocks();
return;
}
var dockSettings = settings.DockSettings;
// Reconcile stale monitor device IDs with currently connected monitors
var monitors = _monitorService.GetMonitors();
var currentConfigs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
var reconciled = MonitorConfigReconciler.Reconcile(currentConfigs, monitors);
if (reconciled != currentConfigs)
{
_settingsService.UpdateSettings(s => s with
{
DockSettings = s.DockSettings with { MonitorConfigs = reconciled },
});
// Re-read settings after update
dockSettings = _settingsService.Settings.DockSettings;
}
var configs = GetEffectiveConfigs(dockSettings);
var desiredMonitorIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Refresh settings on existing ViewModels so they pick up new pins/changes
foreach (var kvp in _dockViewModels)
{
kvp.Value.UpdateSettings(dockSettings);
}
for (var i = 0; i < configs.Count; i++)
{
var config = configs[i];
if (!config.Enabled)
{
continue;
}
var monitor = _monitorService.GetMonitorByDeviceId(config.MonitorDeviceId);
if (monitor is null)
{
continue;
}
desiredMonitorIds.Add(config.MonitorDeviceId);
if (!_dockWindows.ContainsKey(config.MonitorDeviceId))
{
CreateDockForMonitor(config.MonitorDeviceId, dockSettings);
}
}
// Remove dock windows for monitors that are no longer desired
var toRemove = new List<string>();
foreach (var id in _dockWindows.Keys)
{
if (!desiredMonitorIds.Contains(id))
{
toRemove.Add(id);
}
}
for (var i = 0; i < toRemove.Count; i++)
{
var id = toRemove[i];
if (_dockWindows.Remove(id, out var window))
{
window.Close();
}
if (_dockViewModels.Remove(id, out var vm))
{
vm.Dispose();
}
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_monitorService.MonitorsChanged -= OnMonitorsChanged;
_settingsService.SettingsChanged -= OnSettingsChanged;
HideDocks();
}
private void CreateDockForMonitor(string monitorDeviceId, DockSettings dockSettings)
{
var viewModel = CreateDockViewModel(monitorDeviceId);
_dockViewModels[monitorDeviceId] = viewModel;
var monitor = _monitorService.GetMonitorByDeviceId(monitorDeviceId);
DockSide? sideOverride = null;
var monitorConfigs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
for (var i = 0; i < monitorConfigs.Count; i++)
{
var cfg = monitorConfigs[i];
if (string.Equals(cfg.MonitorDeviceId, monitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
sideOverride = cfg.Side;
break;
}
}
var window = new DockWindow(viewModel, monitor, sideOverride);
_dockWindows[monitorDeviceId] = window;
window.Show();
viewModel.InitializeBands();
}
private DockViewModel CreateDockViewModel(string monitorDeviceId)
{
var serviceProvider = App.Current.Services;
var tlcManager = serviceProvider.GetRequiredService<TopLevelCommandManager>();
var contextMenuFactory = serviceProvider.GetRequiredService<IContextMenuFactory>();
var scheduler = serviceProvider.GetRequiredService<TaskScheduler>();
return new DockViewModel(tlcManager, contextMenuFactory, scheduler, _settingsService, monitorDeviceId);
}
private void OnMonitorsChanged(object? sender, EventArgs e)
{
_dispatcherQueue.TryEnqueue(() =>
{
if (!_disposed)
{
SyncDocksToSettings();
}
});
}
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
{
_dispatcherQueue.TryEnqueue(() =>
{
if (!_disposed)
{
SyncDocksToSettings();
}
});
}
/// <summary>
/// Returns the effective list of monitor configs. If settings have no explicit
/// configs (legacy / first-run), synthesizes one for the primary monitor.
/// </summary>
private IReadOnlyList<DockMonitorConfig> GetEffectiveConfigs(DockSettings dockSettings)
{
var configs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
if (configs.Count > 0)
{
return configs;
}
// Legacy / migration: no per-monitor configs saved yet.
// Synthesize a config for the primary monitor inheriting global Side.
var primary = _monitorService.GetPrimaryMonitor();
if (primary is null)
{
return Array.Empty<DockMonitorConfig>();
}
return new[]
{
new DockMonitorConfig
{
MonitorDeviceId = primary.DeviceId,
Enabled = true,
Side = null,
IsPrimary = true,
},
};
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Dock.PinToDockDialogContent"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
@@ -107,6 +107,18 @@
</tkcontrols:Segmented>
</StackPanel>
<!-- Monitor Selector (hidden when only one monitor) -->
<StackPanel
x:Name="MonitorSelectorPanel"
Spacing="8"
Visibility="Collapsed">
<TextBlock
x:Uid="PinToDock_MonitorHeader"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<ComboBox x:Name="MonitorComboBox" HorizontalAlignment="Stretch" />
</StackPanel>
<!-- Label Options -->
<StackPanel Spacing="4">
<CheckBox

View File

@@ -2,8 +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.Collections.Generic;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -17,6 +19,7 @@ public sealed partial class PinToDockDialogContent : UserControl
{
private string _title = string.Empty;
private string _subtitle = string.Empty;
private IReadOnlyList<MonitorInfo>? _monitors;
public DockPinSide SelectedSide => SectionSegmented.SelectedIndex switch
{
@@ -30,12 +33,28 @@ public sealed partial class PinToDockDialogContent : UserControl
public bool? ShowSubtitles => ShowSubtitleCheckBox.IsChecked;
public string? SelectedMonitorDeviceId
{
get
{
// When only one monitor exists, return null so the pin lands in the
// global bands (visible on all monitors by default). The per-monitor
// path is only used when the user explicitly chooses from 2+ monitors.
if (_monitors is null or { Count: <= 1 } || MonitorComboBox.SelectedIndex < 0 || MonitorComboBox.SelectedIndex >= _monitors.Count)
{
return null;
}
return _monitors[MonitorComboBox.SelectedIndex].DeviceId;
}
}
public PinToDockDialogContent()
{
InitializeComponent();
}
public void Configure(string title, string subtitle, IconInfoViewModel? icon, DockSide dockSide)
public void Configure(string title, string subtitle, IconInfoViewModel? icon, DockSide dockSide, IReadOnlyList<MonitorInfo>? monitors = null)
{
_title = title;
_subtitle = subtitle;
@@ -63,6 +82,7 @@ public sealed partial class PinToDockDialogContent : UserControl
}
ApplyDockOrientation(dockSide);
ConfigureMonitorSelector(monitors);
}
public static async System.Threading.Tasks.Task<(ContentDialogResult Result, PinToDockDialogContent Content)> ShowAsync(
@@ -70,10 +90,11 @@ public sealed partial class PinToDockDialogContent : UserControl
string title,
string subtitle,
IconInfoViewModel? icon,
DockSide dockSide)
DockSide dockSide,
IReadOnlyList<MonitorInfo>? monitors = null)
{
var content = new PinToDockDialogContent();
content.Configure(title, subtitle, icon, dockSide);
content.Configure(title, subtitle, icon, dockSide, monitors);
var dialog = new ContentDialog
{
@@ -168,4 +189,31 @@ public sealed partial class PinToDockDialogContent : UserControl
PreviewTextPanel.Visibility = (showTitle || showSubtitle) ? Visibility.Visible : Visibility.Collapsed;
}
private void ConfigureMonitorSelector(IReadOnlyList<MonitorInfo>? monitors)
{
_monitors = monitors;
if (monitors is null || monitors.Count <= 1)
{
MonitorSelectorPanel.Visibility = Visibility.Collapsed;
return;
}
MonitorSelectorPanel.Visibility = Visibility.Visible;
var displayNames = new List<string>(monitors.Count);
var primaryIndex = 0;
for (var i = 0; i < monitors.Count; i++)
{
displayNames.Add(monitors[i].DisplayName);
if (monitors[i].IsPrimary)
{
primaryIndex = i;
}
}
MonitorComboBox.ItemsSource = displayNames;
MonitorComboBox.SelectedIndex = primaryIndex;
}
}

View File

@@ -117,4 +117,15 @@ WM_DPICHANGED
QUERY_USER_NOTIFICATION_STATE
EnumWindows
EnumDisplayMonitors
MONITORINFOEXW
IsWindowVisible
GetDisplayConfigBufferSizes
QueryDisplayConfig
DisplayConfigGetDeviceInfo
DISPLAYCONFIG_TARGET_DEVICE_NAME
DISPLAYCONFIG_SOURCE_DEVICE_NAME
DISPLAYCONFIG_PATH_INFO
DISPLAYCONFIG_MODE_INFO
DISPLAYCONFIG_DEVICE_INFO_TYPE
QUERY_DISPLAY_CONFIG_FLAGS

View File

@@ -26,7 +26,6 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Animation;
using WinUIEx;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using VirtualKey = Windows.System.VirtualKey;
@@ -68,7 +67,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private readonly CompositeFormat _pageNavigatedAnnouncement;
private SettingsWindow? _settingsWindow;
private DockWindow? _dockWindow;
private DockWindowManager? _dockWindowManager;
private CancellationTokenSource? _focusAfterLoadedCts;
private WeakReference<Page>? _lastNavigatedPageRef;
@@ -116,8 +115,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
if (App.Current.Services.GetRequiredService<ISettingsService>().Settings.EnableDock)
{
_dockWindow = new DockWindow();
_dockWindow.Show();
_dockWindowManager = App.Current.Services.GetService<Dock.DockWindowManager>();
_dockWindowManager?.ShowDocks();
}
}
@@ -234,7 +233,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
message.Title,
message.Subtitle,
message.Icon,
message.DockSide);
message.DockSide,
message.AvailableMonitors);
if (result == ContentDialogResult.Primary)
{
@@ -244,7 +244,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
Pin: true,
Side: content.SelectedSide,
ShowTitles: content.ShowTitles,
ShowSubtitles: content.ShowSubtitles);
ShowSubtitles: content.ShowSubtitles,
MonitorDeviceId: content.SelectedMonitorDeviceId);
WeakReferenceMessenger.Default.Send(pinMessage);
}
}
@@ -543,17 +544,16 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
if (message.ShowDock)
{
if (_dockWindow is null)
if (_dockWindowManager is null)
{
_dockWindow = new DockWindow();
_dockWindowManager = App.Current.Services.GetService<Dock.DockWindowManager>();
}
_dockWindow.Show();
_dockWindowManager?.ShowDocks();
}
else if (_dockWindow is not null)
else
{
_dockWindow.Close();
_dockWindow = null;
_dockWindowManager?.HideDocks();
}
});
}
@@ -854,20 +854,9 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_focusAfterLoadedCts?.Dispose();
_focusAfterLoadedCts = null;
var dockWindow = _dockWindow;
_dockWindow = null;
if (dockWindow is not null)
{
if (DispatcherQueue.HasThreadAccess)
{
dockWindow.Close();
}
else
{
DispatcherQueue.TryEnqueue(dockWindow.Close);
}
}
var dockWindowManager = _dockWindowManager;
_dockWindowManager = null;
dockWindowManager?.Dispose();
GC.SuppressFinalize(this);
}

View File

@@ -0,0 +1,263 @@
// 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 ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Windows.Win32;
using Windows.Win32.Devices.Display;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.HiDpi;
namespace Microsoft.CmdPal.UI.Services;
/// <summary>
/// Implementation of <see cref="IMonitorService"/> using Win32 monitor enumeration APIs.
/// </summary>
public sealed class MonitorService : IMonitorService
{
private const uint PrimaryFlag = 0x00000001;
private readonly object _lock = new();
private List<MonitorInfo>? _cachedMonitors;
private IReadOnlyList<MonitorInfo>? _cachedSnapshot;
/// <inheritdoc/>
public event EventHandler? MonitorsChanged;
/// <inheritdoc/>
public IReadOnlyList<MonitorInfo> GetMonitors()
{
lock (_lock)
{
if (_cachedSnapshot is not null)
{
return _cachedSnapshot;
}
_cachedMonitors = EnumerateMonitors();
_cachedSnapshot = _cachedMonitors.AsReadOnly();
return _cachedSnapshot;
}
}
/// <inheritdoc/>
public MonitorInfo? GetMonitorByDeviceId(string deviceId)
{
var monitors = GetMonitors();
foreach (var monitor in monitors)
{
if (string.Equals(monitor.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase))
{
return monitor;
}
}
return null;
}
/// <inheritdoc/>
public MonitorInfo? GetPrimaryMonitor()
{
var monitors = GetMonitors();
foreach (var monitor in monitors)
{
if (monitor.IsPrimary)
{
return monitor;
}
}
return null;
}
/// <summary>
/// Call this when a display settings change message is received (e.g. WM_DISPLAYCHANGE)
/// to invalidate the cached monitor list and raise <see cref="MonitorsChanged"/>.
/// </summary>
public void NotifyMonitorsChanged()
{
lock (_lock)
{
_cachedMonitors = null;
_cachedSnapshot = null;
}
MonitorsChanged?.Invoke(this, EventArgs.Empty);
}
private static unsafe List<MonitorInfo> EnumerateMonitors()
{
var monitors = new List<MonitorInfo>();
var friendlyNames = BuildFriendlyNameMap();
PInvoke.EnumDisplayMonitors(
HDC.Null,
(RECT*)null,
(HMONITOR hMonitor, HDC hdcMonitor, RECT* lprcMonitor, LPARAM dwData) =>
{
var infoEx = default(MONITORINFOEXW);
infoEx.monitorInfo.cbSize = (uint)sizeof(MONITORINFOEXW);
if (PInvoke.GetMonitorInfo(hMonitor, (MONITORINFO*)&infoEx))
{
var hr = PInvoke.GetDpiForMonitor(
hMonitor,
MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI,
out var dpiX,
out _);
if (hr.Failed || dpiX == 0)
{
dpiX = 96;
}
var isPrimary = (infoEx.monitorInfo.dwFlags & PrimaryFlag) != 0;
var deviceName = new string(infoEx.szDevice.AsSpan()).TrimEnd('\0');
var displayName = FormatDisplayName(deviceName, isPrimary, friendlyNames);
var rcMonitor = infoEx.monitorInfo.rcMonitor;
var rcWork = infoEx.monitorInfo.rcWork;
monitors.Add(new MonitorInfo
{
DeviceId = deviceName,
DisplayName = displayName,
Bounds = new ScreenRect(
rcMonitor.left,
rcMonitor.top,
rcMonitor.right,
rcMonitor.bottom),
WorkArea = new ScreenRect(
rcWork.left,
rcWork.top,
rcWork.right,
rcWork.bottom),
Dpi = dpiX,
IsPrimary = isPrimary,
});
}
return true;
},
0);
return monitors;
}
/// <summary>
/// Builds a map from GDI device name (e.g. <c>\\.\DISPLAY1</c>) to the hardware
/// friendly name (e.g. <c>DELL U2723QE</c>) using the Display Configuration APIs.
/// Returns an empty dictionary on failure so callers can fall back gracefully.
/// </summary>
private static unsafe Dictionary<string, string> BuildFriendlyNameMap()
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var result = PInvoke.GetDisplayConfigBufferSizes(
QUERY_DISPLAY_CONFIG_FLAGS.QDC_ONLY_ACTIVE_PATHS,
out var pathCount,
out var modeCount);
if (result != WIN32_ERROR.NO_ERROR)
{
return map;
}
var paths = new DISPLAYCONFIG_PATH_INFO[pathCount];
var modes = new DISPLAYCONFIG_MODE_INFO[modeCount];
fixed (DISPLAYCONFIG_PATH_INFO* pathsPtr = paths)
{
fixed (DISPLAYCONFIG_MODE_INFO* modesPtr = modes)
{
result = PInvoke.QueryDisplayConfig(
QUERY_DISPLAY_CONFIG_FLAGS.QDC_ONLY_ACTIVE_PATHS,
ref pathCount,
pathsPtr,
ref modeCount,
modesPtr,
null);
if (result != WIN32_ERROR.NO_ERROR)
{
return map;
}
}
}
for (var i = 0; i < pathCount; i++)
{
var path = paths[i];
// Get the GDI device name from the source info
var sourceName = default(DISPLAYCONFIG_SOURCE_DEVICE_NAME);
sourceName.header.type = DISPLAYCONFIG_DEVICE_INFO_TYPE.DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
sourceName.header.size = (uint)sizeof(DISPLAYCONFIG_SOURCE_DEVICE_NAME);
sourceName.header.adapterId = path.sourceInfo.adapterId;
sourceName.header.id = path.sourceInfo.id;
if (PInvoke.DisplayConfigGetDeviceInfo(ref sourceName.header) != 0)
{
continue;
}
var gdiName = new string(sourceName.viewGdiDeviceName.AsSpan()).TrimEnd('\0');
if (string.IsNullOrEmpty(gdiName))
{
continue;
}
// Get the friendly name from the target info
var targetName = default(DISPLAYCONFIG_TARGET_DEVICE_NAME);
targetName.header.type = DISPLAYCONFIG_DEVICE_INFO_TYPE.DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
targetName.header.size = (uint)sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME);
targetName.header.adapterId = path.targetInfo.adapterId;
targetName.header.id = path.targetInfo.id;
if (PInvoke.DisplayConfigGetDeviceInfo(ref targetName.header) != 0)
{
continue;
}
var friendly = new string(targetName.monitorFriendlyDeviceName.AsSpan()).TrimEnd('\0');
if (!string.IsNullOrEmpty(friendly))
{
map.TryAdd(gdiName, friendly);
}
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
Logger.LogError($"BuildFriendlyNameMap failed: {ex.Message}");
}
return map;
}
private static string FormatDisplayName(string deviceName, bool isPrimary, Dictionary<string, string> friendlyNames)
{
string name;
if (friendlyNames.TryGetValue(deviceName, out var friendly))
{
name = friendly;
}
else if (deviceName.StartsWith(@"\\.\DISPLAY", StringComparison.OrdinalIgnoreCase))
{
// Fallback: convert "\\.\DISPLAY1" → "Display 1"
var number = deviceName.Substring(@"\\.\DISPLAY".Length);
name = $"Display {number}";
}
else
{
name = deviceName;
}
if (isPrimary)
{
name += " (Primary)";
}
return name;
}
}

View File

@@ -233,6 +233,40 @@
<ToggleSwitch IsOn="{x:Bind ViewModel.Dock_AlwaysOnTop, Mode=TwoWay}" />
</controls:SettingsCard>
<!-- Monitors Section -->
<TextBlock x:Uid="DockMonitors_Header" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
<ItemsRepeater ItemsSource="{x:Bind ViewModel.MonitorConfigs, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Spacing="{StaticResource SettingsCardSpacing}" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="dockVm:DockMonitorConfigViewModel">
<controls:SettingsExpander
Description="{x:Bind Resolution, Mode=OneWay}"
Header="{x:Bind DisplayName, Mode=OneWay}"
HeaderIcon="{ui:FontIcon Glyph=&#xE7F4;}"
IsExpanded="False">
<ToggleSwitch IsOn="{x:Bind IsEnabled, Mode=TwoWay}" />
<controls:SettingsExpander.Items>
<controls:SettingsCard x:Uid="DockMonitor_Position_SettingsCard">
<ComboBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}"
SelectedIndex="{x:Bind SideOverrideIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="DockMonitor_Position_UseDefault" />
<ComboBoxItem x:Uid="DockMonitor_Position_Left" />
<ComboBoxItem x:Uid="DockMonitor_Position_Top" />
<ComboBoxItem x:Uid="DockMonitor_Position_Right" />
<ComboBoxItem x:Uid="DockMonitor_Position_Bottom" />
</ComboBox>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
<!-- Bands Section -->
<!-- <TextBlock x:Uid="DockBandsSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />

View File

@@ -6,6 +6,7 @@ using System.Diagnostics;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
@@ -31,8 +32,9 @@ public sealed partial class DockSettingsPage : Page
var themeService = App.Current.Services.GetService<IThemeService>()!;
var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!;
var settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
var monitorService = App.Current.Services.GetService<IMonitorService>();
ViewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService);
ViewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService, monitorService);
// Initialize UI state
InitializeSettings();

View File

@@ -1118,6 +1118,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Choose where to pin this command</value>
<comment>Header for the dock section selector in the pin to dock dialog</comment>
</data>
<data name="PinToDock_MonitorHeader.Text" xml:space="preserve">
<value>Choose which monitor</value>
<comment>Header for the monitor selector in the pin to dock dialog</comment>
</data>
<data name="PinToDock_Start.Content" xml:space="preserve">
<value>Start</value>
<comment>Start section option in pin to dock dialog</comment>
@@ -1170,4 +1174,36 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Right</value>
<comment>Right section label in pin to dock dialog (code access, horizontal end)</comment>
</data>
<data name="DockMonitors_Header.Text" xml:space="preserve">
<value>Monitors</value>
<comment>Section header for per-monitor dock settings</comment>
</data>
<data name="DockMonitor_Position_UseDefault.Content" xml:space="preserve">
<value>Use default</value>
<comment>Option to inherit the global dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Left.Content" xml:space="preserve">
<value>Left</value>
<comment>Left dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Top.Content" xml:space="preserve">
<value>Top</value>
<comment>Top dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Right.Content" xml:space="preserve">
<value>Right</value>
<comment>Right dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Bottom.Content" xml:space="preserve">
<value>Bottom</value>
<comment>Bottom dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Primary" xml:space="preserve">
<value>Primary</value>
<comment>Label indicating a monitor is the primary display</comment>
</data>
<data name="DockMonitor_Position_SettingsCard.Header" xml:space="preserve">
<value>Dock position</value>
<comment>Per-monitor dock position settings card header</comment>
</data>
</root>

View File

@@ -0,0 +1,752 @@
// 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.Collections.Immutable;
using System.Text.Json;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class DockMultiMonitorTests
{
private static readonly MonitorInfo PrimaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY1",
DisplayName = "Display 1 (Primary)",
Bounds = new ScreenRect(0, 0, 1920, 1080),
WorkArea = new ScreenRect(0, 0, 1920, 1040),
Dpi = 96,
IsPrimary = true,
};
private static readonly MonitorInfo SecondaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY2",
DisplayName = "Display 2",
Bounds = new ScreenRect(1920, 0, 3840, 1080),
WorkArea = new ScreenRect(1920, 0, 3840, 1040),
Dpi = 144,
IsPrimary = false,
};
// --- ScreenRect tests ---
[TestMethod]
public void ScreenRect_WidthAndHeight_ComputedCorrectly()
{
var rect = new ScreenRect(100, 200, 500, 600);
Assert.AreEqual(400, rect.Width);
Assert.AreEqual(400, rect.Height);
}
// --- MonitorInfo tests ---
[TestMethod]
public void MonitorInfo_ScaleFactor_ComputedFromDpi()
{
Assert.AreEqual(1.0, PrimaryMonitor.ScaleFactor, 0.001);
Assert.AreEqual(1.5, SecondaryMonitor.ScaleFactor, 0.001);
}
// --- DockMonitorConfig tests ---
[TestMethod]
public void DockMonitorConfig_ResolveSide_ReturnsOverrideWhenSet()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
Side = DockSide.Left,
};
Assert.AreEqual(DockSide.Left, config.ResolveSide(DockSide.Bottom));
}
[TestMethod]
public void DockMonitorConfig_ResolveSide_ReturnsGlobalWhenNull()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
Side = null,
};
Assert.AreEqual(DockSide.Bottom, config.ResolveSide(DockSide.Bottom));
}
[TestMethod]
public void DockMonitorConfig_ResolveBands_ReturnsOwnBandsWhenCustomized()
{
var customBand = new DockBandSettings { ProviderId = "test", CommandId = "cmd1" };
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = true,
StartBands = ImmutableList.Create(customBand),
};
var globalBands = ImmutableList.Create(new DockBandSettings { ProviderId = "global", CommandId = "g1" });
var resolved = config.ResolveStartBands(globalBands);
Assert.AreEqual(1, resolved.Count);
Assert.AreEqual("cmd1", resolved[0].CommandId);
}
[TestMethod]
public void DockMonitorConfig_ResolveBands_ReturnsGlobalWhenNotCustomized()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = false,
};
var globalBands = ImmutableList.Create(new DockBandSettings { ProviderId = "global", CommandId = "g1" });
var resolved = config.ResolveStartBands(globalBands);
Assert.AreEqual(1, resolved.Count);
Assert.AreEqual("g1", resolved[0].CommandId);
}
[TestMethod]
public void DockMonitorConfig_ForkFromGlobal_CopiesBandsAndSetsCustomized()
{
var globalSettings = CreateMinimalDockSettings() with
{
StartBands = ImmutableList.Create(new DockBandSettings { ProviderId = "p1", CommandId = "c1" }),
CenterBands = ImmutableList.Create(new DockBandSettings { ProviderId = "p2", CommandId = "c2" }),
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = false,
};
var forked = config.ForkFromGlobal(globalSettings);
Assert.IsTrue(forked.IsCustomized);
Assert.IsNotNull(forked.StartBands);
Assert.AreEqual(1, forked.StartBands!.Count);
Assert.AreEqual("c1", forked.StartBands![0].CommandId);
Assert.IsNotNull(forked.CenterBands);
Assert.AreEqual(1, forked.CenterBands!.Count);
Assert.AreEqual("c2", forked.CenterBands![0].CommandId);
Assert.IsNotNull(forked.EndBands);
Assert.AreEqual(0, forked.EndBands!.Count);
}
[TestMethod]
public void DockMonitorConfig_ForkFromGlobal_ProducesIndependentCopy()
{
var band = new DockBandSettings { ProviderId = "p1", CommandId = "c1" };
var globalSettings = CreateMinimalDockSettings() with
{
StartBands = ImmutableList.Create(band),
};
var config = new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1" };
var forked = config.ForkFromGlobal(globalSettings);
// Modify forked bands — global should be unaffected
var newForked = forked with { StartBands = forked.StartBands!.Add(new DockBandSettings { ProviderId = "p2", CommandId = "c2" }) };
Assert.AreEqual(1, globalSettings.StartBands.Count);
Assert.AreEqual(2, newForked.StartBands.Count);
}
// --- MonitorConfigReconciler tests ---
[TestMethod]
public void Reconciler_ExactMatch_PreservesExistingConfigs()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(2, result.Count);
}
[TestMethod]
public void Reconciler_NewMonitor_CreatesDefaultConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(2, result.Count);
var newConfig = result[1];
Assert.AreEqual(@"\\.\DISPLAY2", newConfig.MonitorDeviceId);
Assert.IsTrue(newConfig.Enabled);
}
[TestMethod]
public void Reconciler_DisconnectedMonitor_PreservesConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, LastSeen = DateTime.UtcNow },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY99", Enabled = true, IsCustomized = true, LastSeen = DateTime.UtcNow });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Connected monitor is first, disconnected monitor is retained at end
Assert.AreEqual(2, result.Count);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY99", result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "Disconnected monitor should preserve its customizations.");
}
[TestMethod]
public void Reconciler_EmptyConfigs_CreatesForAllMonitors()
{
var configs = ImmutableList<DockMonitorConfig>.Empty;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(2, result.Count);
}
[TestMethod]
public void Reconciler_NewPrimaryMonitor_InheritsGlobalBands()
{
// On first run / upgrade, the primary monitor should inherit global bands
var configs = ImmutableList<DockMonitorConfig>.Empty;
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(1, result.Count);
Assert.IsFalse(result[0].IsCustomized, "Primary monitor should inherit global bands (IsCustomized = false).");
}
[TestMethod]
public void Reconciler_NewSecondaryMonitor_StartsWithEmptyBands()
{
// On first run / upgrade with multi-monitor, secondary monitors should start
// with empty bands so users are not forced to manually unpin from every display.
var configs = ImmutableList<DockMonitorConfig>.Empty;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
var secondary = result.Find(c => !c.IsPrimary);
Assert.IsNotNull(secondary, "A secondary monitor config should have been created.");
Assert.IsTrue(secondary!.IsCustomized, "Secondary monitor should be customized (IsCustomized = true).");
Assert.AreEqual(0, secondary.StartBands?.Count ?? 0, "Secondary monitor should start with empty StartBands.");
Assert.AreEqual(0, secondary.CenterBands?.Count ?? 0, "Secondary monitor should start with empty CenterBands.");
Assert.AreEqual(0, secondary.EndBands?.Count ?? 0, "Secondary monitor should start with empty EndBands.");
}
[TestMethod]
public void Reconciler_FuzzyMatch_UpdatesPrimaryFlag()
{
// Config has old device ID but marked as primary
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_OLD", Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.IsTrue(result[0].IsPrimary);
}
[TestMethod]
public void Reconciler_FuzzyMatch_DoesNotMatchNonPrimaryMonitors()
{
// Config has stale device ID for a non-primary monitor
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_STALE", Enabled = true, IsPrimary = false, IsCustomized = true, LastSeen = DateTime.UtcNow });
// Current monitors have primary + a different secondary
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Primary keeps its config, new secondary gets a fresh customized config,
// stale secondary is retained at end for future reconnection
Assert.AreEqual(3, result.Count);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY2", result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "New secondary should get an empty-bands customized config.");
Assert.AreEqual(0, result[1].StartBands?.Count ?? 0, "New secondary should start with empty bands.");
Assert.AreEqual(@"\\.\DISPLAY_STALE", result[2].MonitorDeviceId, "Stale config should be preserved.");
Assert.IsTrue(result[2].IsCustomized, "Stale config should retain its customizations.");
}
// --- JSON serialization round-trip ---
[TestMethod]
public void DockMonitorConfig_JsonRoundTrip_PreservesAllFields()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
Enabled = true,
Side = DockSide.Left,
IsPrimary = true,
IsCustomized = true,
StartBands = ImmutableList.Create(new DockBandSettings { ProviderId = "p1", CommandId = "c1" }),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var json = JsonSerializer.Serialize(config, JsonSerializationContext.Default.DockMonitorConfig);
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockMonitorConfig);
Assert.IsNotNull(deserialized);
Assert.AreEqual(config.MonitorDeviceId, deserialized!.MonitorDeviceId);
Assert.AreEqual(config.Enabled, deserialized.Enabled);
Assert.AreEqual(config.Side, deserialized.Side);
Assert.AreEqual(config.IsPrimary, deserialized.IsPrimary);
Assert.AreEqual(config.IsCustomized, deserialized.IsCustomized);
Assert.IsNotNull(deserialized.StartBands);
Assert.AreEqual(1, deserialized.StartBands!.Count);
Assert.AreEqual("c1", deserialized.StartBands![0].CommandId);
}
[TestMethod]
public void DockSettings_MonitorConfigs_JsonRoundTrip()
{
var settings = CreateMinimalDockSettings() with
{
MonitorConfigs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = false }),
};
var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.DockSettings);
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockSettings);
Assert.IsNotNull(deserialized);
Assert.AreEqual(2, deserialized.MonitorConfigs.Count);
Assert.AreEqual(@"\\.\DISPLAY1", deserialized.MonitorConfigs[0].MonitorDeviceId);
Assert.IsTrue(deserialized.MonitorConfigs[0].Enabled);
Assert.AreEqual(@"\\.\DISPLAY2", deserialized.MonitorConfigs[1].MonitorDeviceId);
Assert.IsFalse(deserialized.MonitorConfigs[1].Enabled);
}
private static DockSettings CreateMinimalDockSettings()
{
// Deserialize from minimal JSON to avoid WinUI3 dependencies
var json = "{}";
return JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockSettings)
?? new DockSettings();
}
[TestMethod]
public void Reconciler_ReturnsOriginalReference_WhenNothingChanged()
{
var now = DateTime.UtcNow;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.DeviceId, Enabled = true, IsPrimary = false, LastSeen = now });
var reconciled = MonitorConfigReconciler.Reconcile(configs, monitors, now);
Assert.AreSame(configs, reconciled, "Reconciler should return the same reference when nothing changed");
}
// --- Per-monitor save isolation test ---
[TestMethod]
public void WithActiveBands_PerMonitor_DoesNotClobberOtherMonitorConfig()
{
var bandA = new DockBandSettings { ProviderId = "provA", CommandId = "cmdA" };
var bandB = new DockBandSettings { ProviderId = "provB", CommandId = "cmdB" };
var monitorOneConfig = new DockMonitorConfig
{
MonitorDeviceId = PrimaryMonitor.DeviceId,
Enabled = true,
IsPrimary = true,
IsCustomized = true,
StartBands = ImmutableList.Create(bandA),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var monitorTwoConfig = new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.DeviceId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
StartBands = ImmutableList.Create(bandB),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var settings = new DockSettings
{
MonitorConfigs = ImmutableList.Create(monitorOneConfig, monitorTwoConfig),
};
// Simulate Monitor A saving new bands — only A's config should change
var newBandA = new DockBandSettings { ProviderId = "provA2", CommandId = "cmdA2" };
var configA = settings.MonitorConfigs[0];
var updatedConfigA = configA with { StartBands = ImmutableList.Create(newBandA) };
var afterSaveA = settings with
{
MonitorConfigs = ImmutableList.Create(updatedConfigA, monitorTwoConfig),
};
// Verify Monitor A's config was updated
Assert.AreEqual("provA2", afterSaveA.MonitorConfigs![0].StartBands![0].ProviderId);
// Verify Monitor B's config was NOT changed
Assert.AreEqual("provB", afterSaveA.MonitorConfigs![1].StartBands![0].ProviderId);
Assert.AreEqual("cmdB", afterSaveA.MonitorConfigs![1].StartBands![0].CommandId);
}
[TestMethod]
public void AllPinnedCommands_IncludesPerMonitorBands()
{
// Set up global bands
var globalBand = new DockBandSettings { ProviderId = "prov1", CommandId = "globalCmd" };
// Set up a customized per-monitor config with a unique band
var perMonitorBand = new DockBandSettings { ProviderId = "prov1", CommandId = "monitorOnlyCmd" };
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY2",
IsCustomized = true,
StartBands = ImmutableList.Create(globalBand, perMonitorBand),
};
var settings = new DockSettings
{
StartBands = ImmutableList.Create(globalBand),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
MonitorConfigs = ImmutableList.Create(config),
};
var allPinned = new List<(string ProviderId, string CommandId)>(settings.AllPinnedCommands);
// Should include the global band AND the per-monitor band
Assert.IsTrue(allPinned.Exists(p => p.CommandId == "globalCmd"), "Global band should be included");
Assert.IsTrue(allPinned.Exists(p => p.CommandId == "monitorOnlyCmd"), "Per-monitor band should be included");
}
[TestMethod]
public void AllPinnedCommands_ExcludesNonCustomizedMonitorBands()
{
var globalBand = new DockBandSettings { ProviderId = "prov1", CommandId = "globalCmd" };
// Non-customized config should NOT contribute its own bands
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY2",
IsCustomized = false,
StartBands = ImmutableList.Create(new DockBandSettings { ProviderId = "prov1", CommandId = "shouldNotAppear" }),
};
var settings = new DockSettings
{
StartBands = ImmutableList.Create(globalBand),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
MonitorConfigs = ImmutableList.Create(config),
};
var allPinned = new List<(string ProviderId, string CommandId)>(settings.AllPinnedCommands);
Assert.IsTrue(allPinned.Exists(p => p.CommandId == "globalCmd"), "Global band should be included");
Assert.IsFalse(allPinned.Exists(p => p.CommandId == "shouldNotAppear"), "Non-customized per-monitor band should NOT be included");
}
// --- DockMonitorConfigViewModel tests ---
[TestMethod]
public void DockMonitorConfigViewModel_IsEnabled_ReadsFromConfig()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = false, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.IsFalse(vm.IsEnabled);
}
[TestMethod]
public void DockMonitorConfigViewModel_IsEnabled_PersistsChange()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
vm.IsEnabled = false;
mockSettings.Verify(s => s.UpdateSettings(It.IsAny<Func<SettingsModel, SettingsModel>>(), It.IsAny<bool>()), Times.Once);
}
[TestMethod]
public void DockMonitorConfigViewModel_SideOverrideIndex_ReturnsZeroWhenNull()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Side = null });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.AreEqual(0, vm.SideOverrideIndex);
Assert.IsFalse(vm.HasSideOverride);
}
[TestMethod]
public void DockMonitorConfigViewModel_SideOverrideIndex_MapsCorrectly()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Side = DockSide.Right });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.AreEqual(3, vm.SideOverrideIndex);
Assert.IsTrue(vm.HasSideOverride);
}
[TestMethod]
public void DockMonitorConfigViewModel_DisplayInfo_ExposesMonitorProperties()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.AreEqual("Display 1 (Primary)", vm.DisplayName);
Assert.AreEqual(PrimaryMonitor.DeviceId, vm.DeviceId);
Assert.IsTrue(vm.IsPrimary);
Assert.AreEqual("1920 \u00D7 1080", vm.Resolution);
}
[TestMethod]
public void Reconciler_EmptyConfigs_CreatesDefaultsForAllMonitors()
{
// Simulate upgrade: no per-monitor configs, 3 monitors connected
var tertiary = new MonitorInfo
{
DeviceId = @"\\.\DISPLAY3",
DisplayName = "Display 3",
Bounds = new ScreenRect(3840, 0, 5760, 1080),
WorkArea = new ScreenRect(3840, 0, 5760, 1040),
Dpi = 96,
IsPrimary = false,
};
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor, tertiary };
var emptyConfigs = ImmutableList<DockMonitorConfig>.Empty;
var reconciled = MonitorConfigReconciler.Reconcile(emptyConfigs, monitors);
Assert.AreEqual(3, reconciled.Count, "Should create configs for all 3 monitors");
// All monitors should be enabled by default
foreach (var config in reconciled)
{
Assert.IsTrue(config.Enabled, $"Monitor {config.MonitorDeviceId} should be enabled");
Assert.IsNull(config.Side, $"Monitor {config.MonitorDeviceId} should inherit global side");
}
// Primary inherits global bands (IsCustomized=false); secondary starts with
// empty bands (IsCustomized=true) so users choose what to pin per-monitor.
var primaryCfg = reconciled.Find(c => c.IsPrimary);
Assert.IsFalse(primaryCfg!.IsCustomized, "Primary should inherit global bands");
foreach (var config in reconciled)
{
if (!config.IsPrimary)
{
Assert.IsTrue(config.IsCustomized, $"Monitor {config.MonitorDeviceId} (secondary) should be customized with empty bands");
}
}
// Primary should be flagged correctly
var primaryConfig = reconciled.Find(c => c.MonitorDeviceId == PrimaryMonitor.DeviceId);
Assert.IsNotNull(primaryConfig, "Primary monitor config should exist");
Assert.IsTrue(primaryConfig.IsPrimary, "Primary config should be marked as primary");
var secondaryConfig = reconciled.Find(c => c.MonitorDeviceId == SecondaryMonitor.DeviceId);
Assert.IsNotNull(secondaryConfig, "Secondary monitor config should exist");
Assert.IsFalse(secondaryConfig.IsPrimary, "Secondary config should not be marked as primary");
}
[TestMethod]
public void Reconcile_NullExistingConfigs_CreatesDefaultsForAllMonitors()
{
// Simulate upgrade from a version that didn't have multi-monitor settings —
// MonitorConfigs is null because it didn't exist in the older settings.json.
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var reconciled = MonitorConfigReconciler.Reconcile(null, monitors);
Assert.AreEqual(2, reconciled.Count, "Should create configs for all monitors even when existing configs is null");
var primaryConfig = reconciled.Find(c => c.IsPrimary);
Assert.IsNotNull(primaryConfig, "Primary config should be created");
Assert.IsTrue(primaryConfig.Enabled, "Primary should be enabled by default");
Assert.IsFalse(primaryConfig.IsCustomized, "Primary should inherit global bands");
var secondaryConfig = reconciled.Find(c => !c.IsPrimary);
Assert.IsNotNull(secondaryConfig, "Secondary config should be created");
Assert.IsTrue(secondaryConfig.Enabled, "Secondary should be enabled by default");
Assert.IsTrue(secondaryConfig.IsCustomized, "Secondary should start with custom (empty) bands");
}
[TestMethod]
public void Reconcile_DisconnectThenReconnect_PreservesCustomizations()
{
// Step 1: Both monitors connected with customized secondary
var customBands = ImmutableList.Create(new DockBandSettings { ProviderId = "custom", CommandId = "cmd1" });
var now = DateTime.UtcNow;
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.DeviceId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
StartBands = customBands,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
Side = DockSide.Left,
LastSeen = now,
});
// Step 2: Disconnect secondary monitor
var onlyPrimary = new List<MonitorInfo> { PrimaryMonitor };
var afterDisconnect = MonitorConfigReconciler.Reconcile(configs, onlyPrimary, now);
Assert.AreEqual(2, afterDisconnect.Count, "Disconnected monitor config should be retained");
// Step 3: Reconnect secondary monitor
var bothMonitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var afterReconnect = MonitorConfigReconciler.Reconcile(afterDisconnect, bothMonitors, now);
// Verify customizations survived the round-trip
var secondaryConfig = afterReconnect.Find(c =>
string.Equals(c.MonitorDeviceId, SecondaryMonitor.DeviceId, StringComparison.OrdinalIgnoreCase));
Assert.IsNotNull(secondaryConfig, "Secondary config should be found after reconnection");
Assert.IsTrue(secondaryConfig.IsCustomized, "Customization flag should survive");
Assert.AreEqual(DockSide.Left, secondaryConfig.Side, "Side override should survive");
Assert.AreEqual(1, secondaryConfig.StartBands?.Count ?? 0, "Custom start bands should survive");
Assert.AreEqual("custom", secondaryConfig.StartBands![0].ProviderId);
}
[TestMethod]
public void Reconcile_StaleConfig_PrunedAfterSixMonths()
{
var now = DateTime.UtcNow;
var sevenMonthsAgo = now - TimeSpan.FromDays(210);
var fiveMonthsAgo = now - TimeSpan.FromDays(150);
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_STALE", Enabled = true, IsPrimary = false, LastSeen = sevenMonthsAgo },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_RECENT", Enabled = true, IsPrimary = false, LastSeen = fiveMonthsAgo });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors, now);
// Primary is matched, RECENT is retained (< 6 months), STALE is pruned (> 6 months)
Assert.AreEqual(2, result.Count, "Should have matched primary + recently-seen disconnected config");
Assert.AreEqual(PrimaryMonitor.DeviceId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY_RECENT", result[1].MonitorDeviceId, "Recently-seen config should be retained");
}
[TestMethod]
public void Reconcile_LegacyConfigWithoutLastSeen_TreatedAsFresh()
{
// Configs from before LastSeen was added (LastSeen is null)
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_LEGACY", Enabled = true, IsPrimary = false, IsCustomized = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Legacy config (null LastSeen) should be treated as fresh and retained
Assert.AreEqual(2, result.Count, "Legacy config without LastSeen should be retained");
Assert.AreEqual(@"\\.\DISPLAY_LEGACY", result[1].MonitorDeviceId);
}
private static SettingsModel CreateSettingsModelWithConfigs(params DockMonitorConfig[] configs)
{
var dockSettings = CreateMinimalDockSettings() with
{
MonitorConfigs = ImmutableList.Create(configs),
};
var minimalJson = "{}";
var settingsModel = JsonSerializer.Deserialize(
minimalJson,
JsonSerializationContext.Default.SettingsModel) ?? new SettingsModel();
return settingsModel with { DockSettings = dockSettings };
}
[TestMethod]
public void FreshInstall_MonitorConfigs_SurvivesRecordWithExpression()
{
// Simulate fresh install: JSON has no MonitorConfigs key at all.
// System.Text.Json passes null to the init setter. Verify the backing field is
// properly coalesced so that record `with` clones remain non-null.
var json = "{}";
var deserialized = JsonSerializer.Deserialize(
json,
JsonSerializationContext.Default.DockSettings) ?? new DockSettings();
// Direct read through getter should be non-null
Assert.IsNotNull(deserialized.MonitorConfigs, "MonitorConfigs should never be null after deserialization");
Assert.AreEqual(0, deserialized.MonitorConfigs.Count);
// Crucially: a `with` clone must also have non-null MonitorConfigs
var clone = deserialized with { ShowLabels = false };
Assert.IsNotNull(clone.MonitorConfigs, "MonitorConfigs should survive record 'with' expression");
Assert.AreEqual(0, clone.MonitorConfigs.Count);
// Double-clone to be thorough
var clone2 = clone with { Side = DockSide.Left };
Assert.IsNotNull(clone2.MonitorConfigs, "MonitorConfigs should survive multiple 'with' expressions");
}
private static Mock<ISettingsService> CreateMockSettingsService(SettingsModel settings)
{
var mock = new Mock<ISettingsService>();
mock.Setup(s => s.Settings).Returns(settings);
mock.Setup(s => s.UpdateSettings(It.IsAny<Func<SettingsModel, SettingsModel>>(), It.IsAny<bool>()))
.Callback<Func<SettingsModel, SettingsModel>, bool>((transform, _) =>
{
var updated = transform(settings);
mock.Setup(s => s.Settings).Returns(updated);
});
return mock;
}
}

View File

@@ -0,0 +1,344 @@
// 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.Immutable;
using System.IO;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Tests for dock settings migration from single-monitor (ShowLabels) to
/// multi-monitor (ShowTitles/ShowSubtitles) format.
/// </summary>
[TestClass]
public class DockSettingsMigrationTests
{
private string _testDirectory = null!;
private string _settingsFilePath = null!;
private Mock<IPersistenceService> _mockPersistence = null!;
private Mock<IApplicationInfoService> _mockAppInfo = null!;
[TestInitialize]
public void Setup()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"CmdPalMigrationTest_{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
_settingsFilePath = Path.Combine(_testDirectory, "settings.json");
_mockPersistence = new Mock<IPersistenceService>();
_mockAppInfo = new Mock<IApplicationInfoService>();
_mockAppInfo.Setup(a => a.ConfigDirectory).Returns(_testDirectory);
}
[TestCleanup]
public void Cleanup()
{
try
{
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
catch
{
// Best-effort cleanup
}
}
[TestMethod]
public void Migration_ShowLabels_MigratedToShowTitlesAndShowSubtitles()
{
// Arrange: old-format JSON with ShowLabels on bands
var oldJson = """
{
"DockSettings": {
"StartBands": [
{ "ProviderId": "p1", "CommandId": "c1", "ShowLabels": false },
{ "ProviderId": "p2", "CommandId": "c2", "ShowLabels": true }
],
"CenterBands": [],
"EndBands": [
{ "ProviderId": "p3", "CommandId": "c3", "ShowLabels": false }
]
}
}
""";
File.WriteAllText(_settingsFilePath, oldJson);
// Model as deserialized (ShowLabels is [JsonIgnore], so ShowTitles/ShowSubtitles are null)
var model = CreateModelWithBands(
startBands: ImmutableList.Create(
new DockBandSettings { ProviderId = "p1", CommandId = "c1" },
new DockBandSettings { ProviderId = "p2", CommandId = "c2" }),
endBands: ImmutableList.Create(
new DockBandSettings { ProviderId = "p3", CommandId = "c3" }));
SetupMockLoad(model);
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
// Assert: ShowTitles and ShowSubtitles should have the old ShowLabels values
var start = service.Settings.DockSettings.StartBands;
Assert.AreEqual(false, start[0].ShowTitles, "Band 0 ShowTitles should be false");
Assert.AreEqual(false, start[0].ShowSubtitles, "Band 0 ShowSubtitles should be false");
Assert.AreEqual(true, start[1].ShowTitles, "Band 1 ShowTitles should be true");
Assert.AreEqual(true, start[1].ShowSubtitles, "Band 1 ShowSubtitles should be true");
var end = service.Settings.DockSettings.EndBands;
Assert.AreEqual(false, end[0].ShowTitles, "End band ShowTitles should be false");
Assert.AreEqual(false, end[0].ShowSubtitles, "End band ShowSubtitles should be false");
// Save should have been called (migration triggers Save)
_mockPersistence.Verify(
p => p.Save(
It.IsAny<SettingsModel>(),
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<SettingsModel>>()),
Times.AtLeastOnce);
}
[TestMethod]
public void Migration_AlreadyMigrated_ShowTitlesPresent_NoOp()
{
// Arrange: new-format JSON already has ShowTitles
var newJson = """
{
"DockSettings": {
"StartBands": [
{ "ProviderId": "p1", "CommandId": "c1", "ShowTitles": false, "ShowSubtitles": true }
],
"CenterBands": [],
"EndBands": []
}
}
""";
File.WriteAllText(_settingsFilePath, newJson);
var model = CreateModelWithBands(
startBands: ImmutableList.Create(
new DockBandSettings { ProviderId = "p1", CommandId = "c1", ShowTitles = false, ShowSubtitles = true }));
SetupMockLoad(model);
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
// Assert: values unchanged
Assert.AreEqual(false, service.Settings.DockSettings.StartBands[0].ShowTitles);
Assert.AreEqual(true, service.Settings.DockSettings.StartBands[0].ShowSubtitles);
}
[TestMethod]
public void Migration_MixedBands_OnlyUnmigratedBandsTouched()
{
// Arrange: one band has old ShowLabels, another already has ShowTitles
var mixedJson = """
{
"DockSettings": {
"StartBands": [
{ "ProviderId": "p1", "CommandId": "c1", "ShowLabels": false },
{ "ProviderId": "p2", "CommandId": "c2", "ShowTitles": true, "ShowSubtitles": false }
],
"CenterBands": [],
"EndBands": []
}
}
""";
File.WriteAllText(_settingsFilePath, mixedJson);
var model = CreateModelWithBands(
startBands: ImmutableList.Create(
new DockBandSettings { ProviderId = "p1", CommandId = "c1" },
new DockBandSettings { ProviderId = "p2", CommandId = "c2", ShowTitles = true, ShowSubtitles = false }));
SetupMockLoad(model);
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
var start = service.Settings.DockSettings.StartBands;
// Band 0: migrated from ShowLabels
Assert.AreEqual(false, start[0].ShowTitles, "Unmigrated band should get ShowTitles from ShowLabels");
Assert.AreEqual(false, start[0].ShowSubtitles, "Unmigrated band should get ShowSubtitles from ShowLabels");
// Band 1: already migrated, untouched
Assert.AreEqual(true, start[1].ShowTitles, "Already-migrated band ShowTitles should be unchanged");
Assert.AreEqual(false, start[1].ShowSubtitles, "Already-migrated band ShowSubtitles should be unchanged");
}
[TestMethod]
public void Migration_NoDockSettings_NoCrash()
{
// Arrange: JSON without DockSettings at all
var json = """
{
"ShowAppDetails": true
}
""";
File.WriteAllText(_settingsFilePath, json);
var model = CreateMinimalModel();
SetupMockLoad(model);
// Act: should not throw
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
// Assert: model is usable
Assert.IsNotNull(service.Settings);
}
[TestMethod]
public void Migration_EmptyBands_NoCrash()
{
// Arrange: DockSettings with empty band arrays
var json = """
{
"DockSettings": {
"StartBands": [],
"CenterBands": [],
"EndBands": []
}
}
""";
File.WriteAllText(_settingsFilePath, json);
var model = CreateModelWithBands(
startBands: ImmutableList<DockBandSettings>.Empty,
centerBands: ImmutableList<DockBandSettings>.Empty,
endBands: ImmutableList<DockBandSettings>.Empty);
SetupMockLoad(model);
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
Assert.AreEqual(0, service.Settings.DockSettings.StartBands.Count);
}
[TestMethod]
public void Migration_GlobalShowLabels_PreservedAsIs()
{
// Arrange: DockSettings-level ShowLabels (NOT per-band) — should not be modified
var json = """
{
"DockSettings": {
"ShowLabels": false,
"StartBands": [],
"CenterBands": [],
"EndBands": []
}
}
""";
File.WriteAllText(_settingsFilePath, json);
var model = CreateModelWithBands(
startBands: ImmutableList<DockBandSettings>.Empty);
model = model with
{
DockSettings = model.DockSettings with { ShowLabels = false },
};
SetupMockLoad(model);
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
// Global ShowLabels is preserved (it's NOT [JsonIgnore] at DockSettings level)
Assert.AreEqual(false, service.Settings.DockSettings.ShowLabels);
}
[TestMethod]
public void Migration_BandContent_PreservedThroughUpgrade()
{
// Arrange: bands with various properties
var json = """
{
"DockSettings": {
"StartBands": [
{ "ProviderId": "com.microsoft.cmdpal.builtin.core", "CommandId": "com.microsoft.cmdpal.home", "ShowLabels": true },
{ "ProviderId": "WinGet", "CommandId": "com.microsoft.cmdpal.winget", "ShowLabels": false }
],
"CenterBands": [],
"EndBands": [
{ "ProviderId": "PerformanceMonitor", "CommandId": "com.microsoft.cmdpal.performanceWidget" }
]
}
}
""";
File.WriteAllText(_settingsFilePath, json);
var model = CreateModelWithBands(
startBands: ImmutableList.Create(
new DockBandSettings { ProviderId = "com.microsoft.cmdpal.builtin.core", CommandId = "com.microsoft.cmdpal.home" },
new DockBandSettings { ProviderId = "WinGet", CommandId = "com.microsoft.cmdpal.winget" }),
endBands: ImmutableList.Create(
new DockBandSettings { ProviderId = "PerformanceMonitor", CommandId = "com.microsoft.cmdpal.performanceWidget" }));
SetupMockLoad(model);
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
var start = service.Settings.DockSettings.StartBands;
Assert.AreEqual(2, start.Count);
Assert.AreEqual("com.microsoft.cmdpal.builtin.core", start[0].ProviderId);
Assert.AreEqual("com.microsoft.cmdpal.home", start[0].CommandId);
Assert.AreEqual("WinGet", start[1].ProviderId);
Assert.AreEqual("com.microsoft.cmdpal.winget", start[1].CommandId);
var end = service.Settings.DockSettings.EndBands;
Assert.AreEqual(1, end.Count);
Assert.AreEqual("PerformanceMonitor", end[0].ProviderId);
}
[TestMethod]
public void Migration_NoSettingsFile_NoCrash()
{
// Arrange: no settings.json on disk
var model = CreateMinimalModel();
SetupMockLoad(model);
// Act: should not throw
var service = new SettingsService(_mockPersistence.Object, _mockAppInfo.Object);
Assert.IsNotNull(service.Settings);
}
// --- Helpers ---
private void SetupMockLoad(SettingsModel model)
{
_mockPersistence
.Setup(p => p.Load(
It.IsAny<string>(),
It.IsAny<System.Text.Json.Serialization.Metadata.JsonTypeInfo<SettingsModel>>()))
.Returns(model);
}
private static SettingsModel CreateMinimalModel()
{
var json = "{}";
return System.Text.Json.JsonSerializer.Deserialize(
json,
JsonSerializationContext.Default.SettingsModel) ?? new SettingsModel();
}
private static SettingsModel CreateModelWithBands(
ImmutableList<DockBandSettings>? startBands = null,
ImmutableList<DockBandSettings>? centerBands = null,
ImmutableList<DockBandSettings>? endBands = null)
{
// Build DockSettings independently to avoid NRE if deserialization yields null DockSettings
var dockJson = "{}";
var ds = System.Text.Json.JsonSerializer.Deserialize(
dockJson,
JsonSerializationContext.Default.DockSettings) ?? new DockSettings();
ds = ds with
{
StartBands = startBands ?? ImmutableList<DockBandSettings>.Empty,
CenterBands = centerBands ?? ImmutableList<DockBandSettings>.Empty,
EndBands = endBands ?? ImmutableList<DockBandSettings>.Empty,
};
var model = CreateMinimalModel();
return model with { DockSettings = ds };
}
}