CmdPal: Add option to choose default order for Terminal profiles (#41945)

## Summary of the Pull Request

This PR adds a new drop-down to the Windows Terminal extension settings
page that allows the user to choose the default sort order of profiles.
The available options are:
- Default (currently alphabetical, but may change in the future)
- Alphabetical
- Most recently used

It also extends the app data to store AppSettings, including a list of
up to 64 recently used profiles, stored as pairs of profile name and
terminal app ID.

Pictures? Pictures!

<img width="1821" height="1097" alt="image"
src="https://github.com/user-attachments/assets/c751dcbf-e638-4207-a3e4-6dd283c5239c"
/>

<img width="1694" height="521" alt="image"
src="https://github.com/user-attachments/assets/914c0498-98fa-4ed7-997d-f988253c923c"
/>

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #41430 
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2025-09-23 23:23:58 +02:00
committed by GitHub
parent 0edf06bb5f
commit 830a32fc1f
12 changed files with 190 additions and 13 deletions

View File

@@ -20,13 +20,15 @@ internal sealed partial class LaunchProfileAsAdminCommand : InvokableCommand
private readonly string _profile;
private readonly bool _openNewTab;
private readonly bool _openQuake;
private readonly AppSettingsManager _appSettingsManager;
internal LaunchProfileAsAdminCommand(string id, string profile, bool openNewTab, bool openQuake)
internal LaunchProfileAsAdminCommand(string id, string profile, bool openNewTab, bool openQuake, AppSettingsManager appSettingsManager)
{
this._id = id;
this._profile = profile;
this._openNewTab = openNewTab;
this._openQuake = openQuake;
this._appSettingsManager = appSettingsManager;
this.Name = Resources.launch_profile_as_admin;
this.Icon = Icons.AdminIcon;
@@ -59,6 +61,17 @@ internal sealed partial class LaunchProfileAsAdminCommand : InvokableCommand
//_context.API.ShowMsg(name, message, string.Empty);
Logger.LogError($"Failed to open Windows Terminal: {ex.Message}");
}
try
{
_appSettingsManager.Current.AddRecentlyUsedProfile(id, profile);
_appSettingsManager.Save();
}
catch (Exception ex)
{
// We don't want to fail the whole operation if we can't save the recently used profile
Logger.LogError($"Failed to save recently used profile: {ex.Message}");
}
}
#pragma warning restore IDE0059, CS0168, SA1005

View File

@@ -20,13 +20,15 @@ internal sealed partial class LaunchProfileCommand : InvokableCommand
private readonly string _profile;
private readonly bool _openNewTab;
private readonly bool _openQuake;
private readonly AppSettingsManager _appSettingsManager;
internal LaunchProfileCommand(string id, string profile, string iconPath, bool openNewTab, bool openQuake)
internal LaunchProfileCommand(string id, string profile, string iconPath, bool openNewTab, bool openQuake, AppSettingsManager appSettingsManager)
{
this._id = id;
this._profile = profile;
this._openNewTab = openNewTab;
this._openQuake = openQuake;
this._appSettingsManager = appSettingsManager;
this.Name = Resources.launch_profile;
this.Icon = new IconInfo(iconPath);
@@ -62,6 +64,17 @@ internal sealed partial class LaunchProfileCommand : InvokableCommand
// _context.API.ShowMsg(name, message, string.Empty);
Logger.LogError($"Failed to open Windows Terminal: {ex.Message}");
}
try
{
_appSettingsManager.Current.AddRecentlyUsedProfile(id, profile);
_appSettingsManager.Save();
}
catch (Exception ex)
{
// We don't want to fail the whole operation if we can't save the recently used profile
Logger.LogError($"Failed to save recently used profile: {ex.Message}");
}
}
#pragma warning restore IDE0059, CS0168

View File

@@ -4,7 +4,9 @@
#nullable enable
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers;
@@ -15,10 +17,30 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers;
/// </summary>
public sealed class AppSettings
{
private const int MaxRecentProfilesCount = 64;
/// <summary>
/// Gets or sets the last selected channel identifier for the Windows Terminal extension.
/// Empty string when no channel has been selected yet.
/// </summary>
[JsonPropertyName("lastSelectedChannel")]
public string LastSelectedChannel { get; set; } = string.Empty;
/// <summary>
/// Gets the list of recently used profile identifiers.
/// </summary>
[JsonPropertyName("recentlyUsedProfiles")]
public List<TerminalProfileKey> RecentlyUsedProfiles { get; init; } = [];
public void AddRecentlyUsedProfile(string appId, string profileName)
{
var key = new TerminalProfileKey(appId, profileName);
RecentlyUsedProfiles.Remove(key);
RecentlyUsedProfiles.Insert(0, key);
if (RecentlyUsedProfiles.Count > MaxRecentProfilesCount)
{
RecentlyUsedProfiles.RemoveRange(MaxRecentProfilesCount, RecentlyUsedProfiles.Count - MaxRecentProfilesCount);
}
}
}

View File

@@ -10,6 +10,4 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers;
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(AppSettings))]
internal sealed partial class AppSettingsJsonContext : JsonSerializerContext
{
}
internal sealed partial class AppSettingsJsonContext : JsonSerializerContext;

View File

@@ -12,8 +12,6 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers;
#nullable enable
public sealed class AppSettingsManager
{
private const string FileName = "appsettings.json";
@@ -42,7 +40,7 @@ public sealed class AppSettingsManager
if (File.Exists(_filePath))
{
var json = File.ReadAllText(_filePath);
var loaded = JsonSerializer.Deserialize(json, AppSettingsJsonContext.Default.AppSettings);
var loaded = JsonSerializer.Deserialize(json, AppSettingsJsonContext.Default.AppSettings!);
if (loaded is not null)
{
Current = loaded;
@@ -60,7 +58,7 @@ public sealed class AppSettingsManager
{
try
{
var json = JsonSerializer.Serialize(Current, AppSettingsJsonContext.Default.AppSettings);
var json = JsonSerializer.Serialize(Current, AppSettingsJsonContext.Default.AppSettings!);
File.WriteAllText(_filePath, json);
}
catch (Exception ex)

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers;
public enum ProfileSortOrder
{
Default = 0,
Alphabetical = 1,
MostRecentlyUsed = 2,
}

View File

@@ -40,6 +40,16 @@ public class SettingsManager : JsonSettingsManager
Resources.save_last_selected_channel_description!,
false);
private readonly ChoiceSetSetting _profileSortOrder = new(
Namespaced(nameof(ProfileSortOrder)),
Resources.profile_sort_order!,
Resources.profile_sort_order_description!,
[
new ChoiceSetSetting.Choice(Resources.profile_sort_order_item_default!, ProfileSortOrder.Default.ToString("G")),
new ChoiceSetSetting.Choice(Resources.profile_sort_order_item_alphabetical!, ProfileSortOrder.Alphabetical.ToString("G")),
new ChoiceSetSetting.Choice(Resources.profile_sort_order_item_mru!, ProfileSortOrder.MostRecentlyUsed.ToString("G")),
]);
public bool ShowHiddenProfiles => _showHiddenProfiles.Value;
public bool OpenNewTab => _openNewTab.Value;
@@ -48,6 +58,8 @@ public class SettingsManager : JsonSettingsManager
public bool SaveLastSelectedChannel => _saveLastSelectedChannel.Value;
public ProfileSortOrder ProfileSortOrder => System.Enum.TryParse<ProfileSortOrder>(_profileSortOrder.Value, out var result) ? result : ProfileSortOrder.Default;
private static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
@@ -65,6 +77,7 @@ public class SettingsManager : JsonSettingsManager
Settings.Add(_openNewTab);
Settings.Add(_openQuake);
Settings.Add(_saveLastSelectedChannel);
Settings.Add(_profileSortOrder);
// Load settings from file upon initialization
LoadSettings();

View File

@@ -0,0 +1,8 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
namespace Microsoft.CmdPal.Ext.WindowsTerminal.Helpers;
public sealed record TerminalProfileKey(string AppId, string ProfileName);

View File

@@ -58,7 +58,7 @@ public class TerminalQuery : ITerminalQuery
profiles.AddRange(TerminalHelper.ParseSettings(terminal, settingsJson));
}
return profiles.OrderBy(p => p.Name);
return profiles;
}
public IEnumerable<TerminalPackage> GetTerminals()

View File

@@ -51,9 +51,16 @@ internal sealed partial class ProfilesListPage : ListPage, INotifyItemsChanged
Icon = Icons.TerminalIcon;
Name = Resources.profiles_list_page_name;
_terminalSettings = terminalSettings;
_terminalSettings.Settings.SettingsChanged += Settings_SettingsChanged;
_appSettingsManager = appSettingsManager;
}
private void Settings_SettingsChanged(object sender, Settings args)
{
EnsureInitialized();
RaiseItemsChanged();
}
private List<ListItem> Query()
{
EnsureInitialized();
@@ -62,7 +69,27 @@ internal sealed partial class ProfilesListPage : ListPage, INotifyItemsChanged
openNewTab = _terminalSettings.OpenNewTab;
openQuake = _terminalSettings.OpenQuake;
var profiles = _terminalQuery.GetProfiles();
var profiles = _terminalQuery.GetProfiles()!;
switch (_terminalSettings.ProfileSortOrder)
{
case ProfileSortOrder.MostRecentlyUsed:
var mru = _appSettingsManager.Current.RecentlyUsedProfiles ?? [];
profiles = profiles.OrderBy(p =>
{
var key = new TerminalProfileKey(p.Terminal?.AppUserModelId ?? string.Empty, p.Name ?? string.Empty);
var index = mru.IndexOf(key);
return index == -1 ? int.MaxValue : index;
})
.ThenBy(static p => p.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
break;
case ProfileSortOrder.Default:
case ProfileSortOrder.Alphabetical:
default:
profiles = profiles.OrderBy(static p => p.Name, StringComparer.CurrentCultureIgnoreCase);
break;
}
if (terminalFilters?.IsAllSelected == false)
{
@@ -78,12 +105,12 @@ internal sealed partial class ProfilesListPage : ListPage, INotifyItemsChanged
continue;
}
result.Add(new ListItem(new LaunchProfileCommand(profile.Terminal.AppUserModelId, profile.Name, profile.Terminal.LogoPath, openNewTab, openQuake))
result.Add(new ListItem(new LaunchProfileCommand(profile.Terminal.AppUserModelId, profile.Name, profile.Terminal.LogoPath, openNewTab, openQuake, _appSettingsManager))
{
Title = profile.Name,
Subtitle = profile.Terminal.DisplayName,
MoreCommands = [
new CommandContextItem(new LaunchProfileAsAdminCommand(profile.Terminal.AppUserModelId, profile.Name, openNewTab, openQuake)),
new CommandContextItem(new LaunchProfileAsAdminCommand(profile.Terminal.AppUserModelId, profile.Name, openNewTab, openQuake, _appSettingsManager)),
],
});
}

View File

@@ -159,6 +159,60 @@ namespace Microsoft.CmdPal.Ext.WindowsTerminal.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Profiles order.
/// </summary>
internal static string profile_sort_order {
get {
return ResourceManager.GetString("profile_sort_order", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Profiles order.
/// </summary>
internal static string profile_sort_order_description {
get {
return ResourceManager.GetString("profile_sort_order_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Alphabetical.
/// </summary>
internal static string profile_sort_order_item_alphabetical {
get {
return ResourceManager.GetString("profile_sort_order_item_alphabetical", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to As defined in Terminal.
/// </summary>
internal static string profile_sort_order_item_as_in_terminal {
get {
return ResourceManager.GetString("profile_sort_order_item_as_in_terminal", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Default (alphabetical).
/// </summary>
internal static string profile_sort_order_item_default {
get {
return ResourceManager.GetString("profile_sort_order_item_default", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Most recently used.
/// </summary>
internal static string profile_sort_order_item_mru {
get {
return ResourceManager.GetString("profile_sort_order_item_mru", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Terminal Profiles.
/// </summary>

View File

@@ -173,4 +173,22 @@
<data name="save_last_selected_channel_description" xml:space="preserve">
<value>Remember the last selected channel instead of resetting to All Channels.</value>
</data>
<data name="profile_sort_order" xml:space="preserve">
<value>Profiles order</value>
</data>
<data name="profile_sort_order_description" xml:space="preserve">
<value>Profiles order</value>
</data>
<data name="profile_sort_order_item_default" xml:space="preserve">
<value>Default (alphabetical)</value>
</data>
<data name="profile_sort_order_item_mru" xml:space="preserve">
<value>Most recently used</value>
</data>
<data name="profile_sort_order_item_as_in_terminal" xml:space="preserve">
<value>As defined in Terminal</value>
</data>
<data name="profile_sort_order_item_alphabetical" xml:space="preserve">
<value>Alphabetical</value>
</data>
</root>