Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/spelling/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ LEBOM
lhs
LIBYAML
liv
listsort
liwpx
localizationpriority
localsource
Expand Down
4 changes: 4 additions & 0 deletions doc/ReleaseNotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ Added a user setting (`logging.fileNameStrategy`) for controlling the default na
| guid | The log name is a GUID |
| shortguid | The log name is the first 8 characters of a GUID |

### Sortable `list` output

`winget list` now supports sorting results via `--sort <field>` (repeatable for multi-field sorting), `--ascending`/`--descending` direction flags, and a persistent `output.sortOrder` setting. Available sort fields: `name`, `id`, `version`, `source`, `available`, `relevance`. By default, results are sorted alphabetically by name when no query is present; use `--sort relevance` to preserve the previous source-determined ordering.

## Bug Fixes

* `winget export` now works when the destination path is a hidden file
Expand Down
39 changes: 39 additions & 0 deletions doc/windows/package-manager/winget/list.md
Comment thread
Madhusudhan-MSFT marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ The options allow you to customize the list experience to meet your needs.
| **--upgrade-available** | Lists only packages which have an upgrade available. |
| **-u,--unknown,--include-unknown** | List packages even if their current version cannot be determined. Can only be used with the --upgrade-available argument. |
| **--pinned,--include-pinned** | List packages even if they have a pin that prevents upgrade. Can only be used with the --upgrade-available argument. |
| **--sort** | Sort results by a property. Can be repeated for multi-field sorting (e.g., `--sort source --sort name`). Valid values: `name`, `id`, `version`, `source`, `available`, `relevance`. |
| **--ascending,--asc** | Sort results in ascending order (default). |
| **--descending,--desc** | Sort results in descending order. |
| **-?,--help** | Get additional help on this command. |
| **--wait** | Prompts the user to press any key before exiting. |
| **--logs,--open-logs** | Open the default logs location. |
Expand All @@ -70,6 +73,42 @@ The following example limits the output of list to 9 apps.

![list count command](images/list-count.png)

## Sorting output

By default, results are sorted by name in ascending order. When a query argument is used (for example, `winget list foo`), results preserve relevance ordering from the package source. You can override either default through command-line arguments or user settings.

### Sort via command-line arguments

Use `--sort` to sort by one or more fields. When multiple `--sort` options are specified, results are sorted by the first field, then ties are broken by the second field, and so on.

```cmd
winget list --sort name
winget list --sort source --sort name
winget list --sort name --descending
```

### Sort via user settings

You can set a default sort order in your [settings](https://aka.ms/winget-settings) under `output.sortOrder`:

```json
{
"output": {
"sortOrder": ["source", "name"]
}
}
```

An empty array (`[]`) results in default sorting (sorted by name when listing, relevance preserved when querying).

### Resolution order

When both settings and command-line arguments are present, the following priority applies:

1. **`--sort` command-line argument** — takes highest priority, overrides settings.
2. **`output.sortOrder` in settings** — used when no `--sort` argument is provided. If the user has configured a sort order in settings, it is applied even when a query is present.
3. **Default** — sorted by name in ascending order. When a query is used, relevance ordering is preserved instead.

## List with Update

As stated above, the **list** command allows you to see what apps you have installed that have updates available.
Expand Down
2 changes: 2 additions & 0 deletions src/AppInstallerCLICore/AppInstallerCLICore.vcxproj
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@
<ClInclude Include="Workflows\SourceFlow.h" />
<ClInclude Include="Workflows\UninstallFlow.h" />
<ClInclude Include="Workflows\UpdateFlow.h" />
<ClInclude Include="Workflows\PackageTableSortHelper.h" />
<ClInclude Include="Workflows\WorkflowBase.h" />
</ItemGroup>
<ItemGroup>
Expand Down Expand Up @@ -459,6 +460,7 @@
<ClCompile Include="Workflows\UninstallFlow.cpp" />
<ClCompile Include="Workflows\UpdateFlow.cpp" />
<ClCompile Include="Workflows\WorkflowBase.cpp" />
<ClCompile Include="Workflows\PackageTableSortHelper.cpp" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
Expand Down
6 changes: 6 additions & 0 deletions src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@
<ClInclude Include="Workflows\ShellExecuteInstallerHandler.h">
<Filter>Workflows</Filter>
</ClInclude>
<ClInclude Include="Workflows\PackageTableSortHelper.h">
<Filter>Workflows</Filter>
</ClInclude>
<ClInclude Include="Workflows\WorkflowBase.h">
<Filter>Workflows</Filter>
</ClInclude>
Expand Down Expand Up @@ -322,6 +325,9 @@
<ClCompile Include="Workflows\WorkflowBase.cpp">
<Filter>Workflows</Filter>
</ClCompile>
<ClCompile Include="Workflows\PackageTableSortHelper.cpp">
<Filter>Workflows</Filter>
</ClCompile>
<ClCompile Include="Commands\ShowCommand.cpp">
<Filter>Commands</Filter>
</ClCompile>
Expand Down
13 changes: 13 additions & 0 deletions src/AppInstallerCLICore/Command.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,19 @@ namespace AppInstaller::CLI
}
}

if (execArgs.Contains(Execution::Args::Type::Sort))
{
for (const auto& arg : *execArgs.GetArgs(Execution::Args::Type::Sort))
{
if (!Settings::ConvertToSortField(arg))
{
auto validOptions = Utility::Join(", "_liv, std::vector<Utility::LocIndString>{
"name"_lis, "id"_lis, "version"_lis, "source"_lis, "available"_lis, "relevance"_lis });
throw CommandException(Resource::String::InvalidArgumentValueError(ArgumentCommon::ForType(Execution::Args::Type::Sort).Name, validOptions));
}
}
}

Argument::ValidateExclusiveArguments(execArgs);

ValidateArgumentsInternal(execArgs);
Expand Down
2 changes: 1 addition & 1 deletion src/AppInstallerCLICore/Commands/ListCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ namespace AppInstaller::CLI
Argument{ Execution::Args::Type::IncludeUnknown, Resource::String::IncludeUnknownInListArgumentDescription, ArgumentType::Flag },
Argument{ Execution::Args::Type::IncludePinned, Resource::String::IncludePinnedInListArgumentDescription, ArgumentType::Flag},
Argument::ForType(Execution::Args::Type::ListDetails),
Argument{ Execution::Args::Type::Sort, Resource::String::SortArgumentDescription, ArgumentType::Standard },
Argument{ Execution::Args::Type::Sort, Resource::String::SortArgumentDescription, ArgumentType::Standard }.SetCountLimit(6),
Argument{ Execution::Args::Type::SortAscending, Resource::String::SortAscendingArgumentDescription, ArgumentType::Flag },
Argument{ Execution::Args::Type::SortDescending, Resource::String::SortDescendingArgumentDescription, ArgumentType::Flag },
};
Expand Down
121 changes: 121 additions & 0 deletions src/AppInstallerCLICore/Workflows/PackageTableSortHelper.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#include "pch.h"
#include "PackageTableSortHelper.h"

namespace AppInstaller::CLI::Workflow
{
SortablePackageEntry::SortablePackageEntry(
size_t originalIndex,
std::string_view name,
std::string_view id,
std::string_view installedVersion,
std::string_view availableVersion,
std::string_view source,
Settings::SortField fieldMask)
: OriginalIndex(originalIndex)
{
if (WI_IsFlagSet(fieldMask, Settings::SortField::Name))
{
FoldedName = Utility::FoldCase(name);
}
if (WI_IsFlagSet(fieldMask, Settings::SortField::Id))
{
FoldedId = Utility::FoldCase(id);
}
if (WI_IsFlagSet(fieldMask, Settings::SortField::Source))
{
FoldedSource = Utility::FoldCase(source);
}
if (WI_IsFlagSet(fieldMask, Settings::SortField::Version))
{
ParsedInstalledVersion = Utility::Version{ std::string{ installedVersion } };
}
if (WI_IsFlagSet(fieldMask, Settings::SortField::Available))
{
HasAvailableVersion = !availableVersion.empty();
if (HasAvailableVersion)
{
ParsedAvailableVersion = Utility::Version{ std::string{ availableVersion } };
}
}
}

Settings::SortField ComputeSortFieldMask(const std::vector<Settings::SortField>& sortFields)
{
Settings::SortField mask = Settings::SortField::Relevance;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is Relevance always included? There should be a None or similar for the 0 value.

for (const auto& f : sortFields)
{
mask |= f;
}
return mask;
}

int CompareByField(const SortablePackageEntry& a, const SortablePackageEntry& b, Settings::SortField field)
{
using Settings::SortField;

switch (field)
{
case SortField::Name:
return a.FoldedName.compare(b.FoldedName);
case SortField::Id:
return a.FoldedId.compare(b.FoldedId);
case SortField::Version:
{
if (a.ParsedInstalledVersion < b.ParsedInstalledVersion) return -1;
if (b.ParsedInstalledVersion < a.ParsedInstalledVersion) return 1;
return 0;
}
case SortField::Source:
return a.FoldedSource.compare(b.FoldedSource);
case SortField::Available:
{
if (a.HasAvailableVersion != b.HasAvailableVersion)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Converting to an optional gets automatic empty vs not-empty comparison along with the value comparison. Look it up to ensure it does what you want though, as it might put the empties at the wrong end from where you want.

{
// Ascending: has-update sorts before no-update
return a.HasAvailableVersion ? -1 : 1;
}
if (a.HasAvailableVersion && b.HasAvailableVersion)
{
if (a.ParsedAvailableVersion < b.ParsedAvailableVersion) return -1;
if (b.ParsedAvailableVersion < a.ParsedAvailableVersion) return 1;
}
return 0;
}
default:
return 0;
}
}

void SortEntries(
std::vector<SortablePackageEntry>& entries,
const std::vector<Settings::SortField>& sortFields,
Settings::SortDirection direction)
{
if (entries.size() <= 1 || sortFields.empty())
{
return;
}

// Relevance-only means no sorting
if (sortFields.size() == 1 && sortFields[0] == Settings::SortField::Relevance)
{
return;
}

std::stable_sort(entries.begin(), entries.end(),
[&sortFields, direction](const SortablePackageEntry& a, const SortablePackageEntry& b)
{
for (const auto& field : sortFields)
{
int cmp = CompareByField(a, b, field);
if (cmp != 0)
{
return direction == Settings::SortDirection::Ascending ? (cmp < 0) : (cmp > 0);
}
}
return false;
});
}
}
95 changes: 95 additions & 0 deletions src/AppInstallerCLICore/Workflows/PackageTableSortHelper.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#pragma once
#include <AppInstallerStrings.h>
#include <AppInstallerVersions.h>
#include <winget/UserSettings.h>

#include <algorithm>
#include <vector>

namespace AppInstaller::CLI::Workflow
{
// Lightweight sortable representation of a package row with precomputed sort keys.
// Decoupled from ICompositePackage/IPackageVersion to ease unit testing.
struct SortablePackageEntry
{
size_t OriginalIndex = 0;

// Precomputed case-folded sort keys
std::string FoldedName;
std::string FoldedId;
std::string FoldedSource;

// Precomputed parsed versions
Utility::Version ParsedInstalledVersion;
Utility::Version ParsedAvailableVersion;
bool HasAvailableVersion = false;
Comment on lines +26 to +27
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
Utility::Version ParsedAvailableVersion;
bool HasAvailableVersion = false;
std::optional<Utility::Version> ParsedAvailableVersion;


SortablePackageEntry() = default;

SortablePackageEntry(
size_t originalIndex,
std::string_view name,
std::string_view id,
std::string_view installedVersion,
std::string_view availableVersion,
std::string_view source,
Settings::SortField fieldMask);
};

// Compares two sortable entries by the given field using precomputed sort keys.
// Returns negative if a < b, positive if a > b, 0 if equal.
int CompareByField(const SortablePackageEntry& a, const SortablePackageEntry& b, Settings::SortField field);

// Sorts a vector of sortable entries by the given fields and direction.
void SortEntries(
std::vector<SortablePackageEntry>& entries,
const std::vector<Settings::SortField>& sortFields,
Settings::SortDirection direction);

// Computes a bitmask of all sort fields so the constructor can skip unused fields.
Settings::SortField ComputeSortFieldMask(const std::vector<Settings::SortField>& sortFields);

// Sorts a vector of arbitrary items by projecting each into a SortablePackageEntry
// via a caller-supplied converter, sorting the projections, then reordering the
// source vector to match. The converter signature is:
// SortablePackageEntry converter(const T& item, size_t index)
// The caller is responsible for pre-computing the SortFieldMask and capturing
// it in the converter closure so the SortablePackageEntry constructor only
// initializes the fields actually needed for comparison.
template <typename T, typename Converter>
void SortBy(
std::vector<T>& items,
Converter&& converter,
const std::vector<Settings::SortField>& sortFields,
Settings::SortDirection direction)
{
if (items.size() <= 1 || sortFields.empty())
{
return;
}

if (sortFields.size() == 1 && sortFields[0] == Settings::SortField::Relevance)
{
return;
}

std::vector<SortablePackageEntry> entries;
entries.reserve(items.size());
for (size_t i = 0; i < items.size(); ++i)
{
entries.push_back(converter(items[i], i));
}

SortEntries(entries, sortFields, direction);

std::vector<T> sorted;
sorted.reserve(items.size());
for (const auto& entry : entries)
{
sorted.push_back(std::move(items[entry.OriginalIndex]));
}
items = std::move(sorted);
Comment on lines +78 to +93
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not sort items directly? I imagine the idea is that we can avoid doing the case folding multiple times for each string if we do it all upfront, but I'm not sure if the performance improvement is worth the added complexity. The bottleneck is most likely going to be the search, and sorting at most a few hundred items here isn't going to take that long regardless of how slow each comparison is.

I would also think that having a case-insensitive comparison function could be way better in many cases. For example, if the first letters of each string are all different, we would end up only folding those, instead of the full strings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pre-fold was requested by JohnMcPMS in an earlier review round. At this scale (~100-200 items) the difference is negligible. Happy to revisit if profiling shows otherwise.

Copy link
Copy Markdown
Member

@JohnMcPMS JohnMcPMS May 6, 2026

Choose a reason for hiding this comment

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

At 200 items, a merge sort would expect 2125 comparisons (1.39 * n * log2(n)). Each comparison is two folds (4250), so the equal number of characters folds would occur at an average comparison length of 200 / 4250, or 4.7% of the average length. If you have to compare more characters than that on average, pre-folding will be better. At 100 items, this goes to 10.8%. And this doesn't take into account the relative cost of N function calls vs 2 * 1.39 * log2(N) function calls, which might be on the low end due to needing to potentially use the ICU break iterators to actually find the UTF-8 character sequences to individually fold.

I don't expect that we will have this many items regularly, but at the small numbers that are likely with a query I don't think any choice matters much. I would rather optimize for the worst case even if the best case gets a little worse.

}
}
Loading
Loading