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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 60 additions & 4 deletions src/Modules/Tasks/Backlog.Modules.Tasks.UI/TasksDesktopState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1416,7 +1416,8 @@ public bool CanSortVisibleByStatus

/// <summary>
/// Re-ranks the rows the filters show by status — done, in progress, ready,
/// draft, archived — and keeps the hand-made order inside each status.
/// draft, archived — and inside each status by the number a title starts
/// with, keeping the hand-made order among the unnumbered rows after them.
/// <para>
/// Only among the slots those rows already hold in the whole list: a row the
/// filters hide keeps its exact position, so narrowing the list and sorting it
Expand Down Expand Up @@ -1457,8 +1458,9 @@ private IEnumerable<EntryRow> SortableVisibleRows() =>
/// stopping at a done or archived predecessor — that one is no longer waited
/// for, and sorts by its own status. A predecessor takes the best rank of
/// anything waiting on it, so a draft first step of in-progress work rises
/// with it instead of sinking the work below the drafts. Ties keep the
/// hand-made order; a cycle, which only a hand edit can write, is broken at
/// with it instead of sinking the work below the drafts. Ties go by the
/// title's leading number (<see cref="TitleNumber"/>), then the hand-made
/// order; a cycle, which only a hand edit can write, is broken at
/// its best-ranked row rather than refusing to sort.
/// </para>
/// </summary>
Expand All @@ -1485,13 +1487,19 @@ private List<EntryRow> StatusSortOrder(List<EntryRow> visible)
}
}

var number = visible.ToDictionary(row => row, row => TitleNumber(row.PreviewTitle));
var remaining = visible.ToList();
var placed = new HashSet<EntryRow>();
var sorted = new List<EntryRow>(visible.Count);
while (remaining.Count > 0)
{
var unblocked = remaining.Where(row => waitsFor[row].All(placed.Contains)).ToList();
var next = (unblocked.Count > 0 ? unblocked : remaining).MinBy(row => rank[row])!;
var next = (unblocked.Count > 0 ? unblocked : remaining)
.Aggregate((best, row) =>
rank[row] < rank[best]
|| (rank[row] == rank[best] && CompareTitleNumbers(number[row], number[best]) < 0)
? row
: best);

remaining.Remove(next);
placed.Add(next);
Expand Down Expand Up @@ -1544,6 +1552,54 @@ private static HashSet<EntryRow> OpenVisiblePredecessors(
_ => 4,
};

/// <summary>
/// The number <paramref name="title"/> starts with, as its dot-separated
/// parts — "9 - Land" is [9], "1.2. Step" is [1, 2] — or null when it starts
/// with none. The number must end at a space, punctuation or the title's
/// end, so "3D printer" is unnumbered.
/// </summary>
private static int[]? TitleNumber(string? title)
{
var text = (title ?? string.Empty).TrimStart().TrimStart('#');
var parts = new List<int>();
var i = 0;
while (true)
{
var start = i;
while (i < text.Length && char.IsAsciiDigit(text[i])) i++;
if (i == start || !int.TryParse(text.AsSpan(start, i - start), NumberStyles.None, CultureInfo.InvariantCulture, out var part))
{
return null;
}

parts.Add(part);
if (i + 1 < text.Length && text[i] == '.' && char.IsAsciiDigit(text[i + 1]))
{
i++;
continue;
}

return i == text.Length || char.IsWhiteSpace(text[i]) || char.IsPunctuation(text[i]) || char.IsSymbol(text[i])
? [.. parts]
: null;
}
}

/// <summary>Orders title numbers part by part, a prefix first; a numbered
/// title before an unnumbered one; two unnumbered titles as equal.</summary>
private static int CompareTitleNumbers(int[]? left, int[]? right)
{
if (left is null || right is null) return (left is null ? 1 : 0) - (right is null ? 1 : 0);

for (var i = 0; i < Math.Min(left.Length, right.Length); i++)
{
var compared = left[i].CompareTo(right[i]);
if (compared != 0) return compared;
}

return left.Length.CompareTo(right.Length);
}

// --- Sub-items -------------------------------------------------------

/// <summary>Toggles a rendered checklist item directly from read mode. This
Expand Down
26 changes: 26 additions & 0 deletions tests/Backlog.Desktop.UI.UnitTests/TasksDetailPaneTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,32 @@ public async Task Sorting_by_status_keeps_a_row_below_what_it_waits_for()
Assert.False(host.State.CanSortVisibleByStatus);
}

/// <summary>
/// Within one status, a title that starts with a number sorts by it —
/// numerically, so 10 follows 9 — ahead of the unnumbered rows, which keep
/// their hand-made order. What a row waits for still comes first.
/// </summary>
[Fact]
public async Task Sorting_by_status_orders_numbered_titles_within_a_status()
{
using var host = await TasksPaneHost.CreateAsync();
await host.WriteEntryAsync("# Unnumbered\n`task` `!ready`\n");
await host.WriteEntryAsync("# 3D printer\n`task` `!draft`\n");
var ten = await host.WriteEntryAsync("# 10 - Move\n`task` `!ready`\n");
await host.WriteEntryAsync("# 9 - Land\n`task` `!ready`\n");
await host.WriteEntryAsync("# 2. Decide\n`task` `!draft`\n");
await host.WriteEntryAsync("# 1.2 - Sub step\n`task` `!ready`\n");
await host.WriteEntryAsync($"# 3 - Waits on ten\n`task` `!ready` `after:{ten.Id!.Value}`\n");
await host.WriteEntryAsync("# 1 Plain number\n`task` `!ready`\n");

await host.State.SortVisibleByStatusAsync();

Assert.Equal(
["1 Plain number", "1.2 - Sub step", "9 - Land", "10 - Move", "3 - Waits on ten", "Unnumbered", "2. Decide", "3D printer"],
host.State.Rows.Select(row => row.PreviewTitle));
Assert.False(host.State.CanSortVisibleByStatus);
}

[Fact]
public async Task The_sort_button_reorders_the_list_and_disables_once_sorted()
{
Expand Down
Loading