Compare commits

...
3 Commits
11 changed files with 322 additions and 65 deletions

No files matched your search

@@ -253,8 +253,20 @@
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8">
@@ -290,8 +302,7 @@
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</ItemsControl>
</Grid>
</Border>
@@ -230,9 +230,13 @@
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
who has found this box should know about the one that also connects on Enter.
One way, and passed back by the code-behind once the typing pauses: every change to the filter
rebuilds the board's sections and their cards, and a name typed at speed used to do that once a
letter. The view model still filters the moment it is told. See HostsScreen.OnFilterTyped.
-->
<Border Grid.Row="1" Margin="26,0,26,16">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
<TextBox x:Name="HostFilter" Text="{Binding HostFilter, Mode=OneWay}" Height="40" CornerRadius="10"
FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border>
@@ -106,6 +106,48 @@ internal sealed partial class HostsScreen : UserControl
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
WireTheBand();
HostFilter.TextChanged += OnFilterTyped;
}
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
private int typed;
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
/// <remarks>
/// <para>
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
/// </para>
/// <para>
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
/// change it makes to the box comes back here as the same text, which changes nothing.
/// </para>
/// </remarks>
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
private async Task PassTheFilterOnAsync(string text, int keystroke)
{
await Pause(FilterPause).ConfigureAwait(true);
// A later letter has its own wait, and it is the one that speaks.
if (keystroke != typed)
{
return;
}
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
{
vault.HostFilter = text;
}
}
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
@@ -131,8 +131,20 @@
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel">
<Border Classes="importrow" CornerRadius="8">
@@ -161,8 +173,7 @@
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</ItemsControl>
</Grid>
</Border>
@@ -1190,8 +1190,24 @@
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Transfers}">
<!--
Virtualised: a folder download queues a row per file, and a stack panel inside an outer scroller
laid out and drew every one of them, thousands deep. The scroller is the control's own so the panel
knows the viewport it has to fill and realises only those rows.
-->
<ItemsControl Grid.Row="2" ItemsSource="{Binding Transfers}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<!--
@@ -1239,7 +1255,6 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// An observable list whose whole contents can be swapped and announced once.
/// </summary>
/// <remarks>
/// <para>
/// For the lists rebuilt wholesale from the vault. They used to be emptied with <c>Clear</c> and refilled an
/// <c>Add</c> at a time, which announced every row separately — and two other view models listen to the host
/// list and redo their own work on every announcement: the transfers screen refilled its host picker, and the
/// shell walked every host against every tab. A reload of n hosts was n of each, so roughly n² rows touched;
/// three hundred hosts came to some ninety thousand notifications per reload.
/// </para>
/// <para>
/// One <see cref="NotifyCollectionChangedAction.Reset"/> instead, which is also exactly what <c>Clear</c> has
/// always said first, so a listener that coped with the old sequence copes with this. Nothing listening to
/// these lists reads the items an announcement carries; they all re-read the list.
/// </para>
/// </remarks>
internal sealed class ReplaceableCollection<T> : ObservableCollection<T>
{
/// <summary>Makes <paramref name="items"/> the whole of the list, and says so once.</summary>
internal void ReplaceAll(IEnumerable<T> items)
{
ArgumentNullException.ThrowIfNull(items);
CheckReentrancy();
Items.Clear();
foreach (var item in items)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
@@ -867,14 +867,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
/// be a second decrypted copy of a secret for no gain.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
/// <inheritdoc cref="Hosts" path="/remarks" />
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
internal ReplaceableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
[ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket;
@@ -3986,7 +3986,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
}
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
/// <remarks>
/// Only the picker the list feeds. Refilling both on either list's change made every reload refill the host
/// picker once for the hosts and again for the buckets.
/// </remarks>
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (vault is { } open && ReferenceEquals(sender, open.ObjectStores))
{
RefreshPickers(hosts: false, buckets: true);
}
else
{
RefreshPickers(hosts: true, buckets: false);
}
}
/// <summary>
/// Rebuilds both pickers from the vault's lists.
@@ -4004,34 +4018,34 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// connected to.
/// </para>
/// </remarks>
private void RefreshHosts()
private void RefreshHosts() => RefreshPickers(hosts: true, buckets: true);
private void RefreshPickers(bool hosts, bool buckets)
{
refreshingPickers = true;
try
{
Hosts.Clear();
if (vault is not { } open)
{
Hosts.ReplaceAll([]);
return;
}
foreach (var row in open.Hosts)
if (hosts)
{
Hosts.Add(row);
Hosts.ReplaceAll(open.Hosts);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
}
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
if (buckets)
{
Buckets.Add(row);
}
Buckets.ReplaceAll(open.ObjectStores);
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
SelectedBucket =
Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
}
}
finally
{
@@ -1663,7 +1663,7 @@ internal sealed partial class VaultViewModel(
/// host key list checks itself against, so a filter applied here would change what the application can
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
/// <remarks>
@@ -1677,7 +1677,7 @@ internal sealed partial class VaultViewModel(
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
/// </para>
/// </remarks>
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
internal ReplaceableCollection<HostRowViewModel> VisibleHosts { get; } = [];
/// <summary>Whether the grid has anything to draw.</summary>
/// <remarks>
@@ -1891,7 +1891,7 @@ internal sealed partial class VaultViewModel(
/// groupless keychain is one flat grid and nothing above it.
/// </para>
/// </remarks>
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = [];
internal ReplaceableCollection<HostSectionViewModel> HostSections { get; } = [];
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
/// <remarks>
@@ -2003,7 +2003,7 @@ internal sealed partial class VaultViewModel(
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
/// <summary>The buckets to show, unpushed local state included.</summary>
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
internal ReplaceableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
/// <remarks>
@@ -4714,18 +4714,14 @@ internal sealed partial class VaultViewModel(
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
Hosts.Clear();
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating.
foreach (var host in rows
// apart, which is why the row carries the name rather than the list deduplicating. Swapped in whole,
// and announced once: see ReplaceableCollection for what a row at a time cost.
Hosts.ReplaceAll(rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
Hosts.Add(host);
}
.ThenBy(row => row.Label, StringComparer.CurrentCulture));
SelectedHost = SelectionAfterReload(selectedId);
ApplyTheChosenHosts();
@@ -4816,13 +4812,9 @@ internal sealed partial class VaultViewModel(
var selectedId = SelectedObjectStore?.EntityId;
ObjectStores.Clear();
foreach (var store in listing.Items
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
{
ObjectStores.Add(new ObjectStoreRowViewModel(store));
}
ObjectStores.ReplaceAll(listing.Items
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture)
.Select(store => new ObjectStoreRowViewModel(store)));
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
@@ -5388,12 +5380,7 @@ internal sealed partial class VaultViewModel(
// a walk up the group tree, and this is the map that walk is made against.
var parents = EffectiveParents();
VisibleHosts.Clear();
foreach (var host in Hosts.Where(host => Matches(host, parents)))
{
VisibleHosts.Add(host);
}
VisibleHosts.ReplaceAll(Hosts.Where(host => Matches(host, parents)));
RebuildSidebarRows();
@@ -5403,7 +5390,7 @@ internal sealed partial class VaultViewModel(
// property directly, and a host that is not on screen is not one any of them should act on.
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the reset
// above is a null that has to be overwritten rather than read.
SelectedSidebarRow = SelectedHost;
@@ -5473,12 +5460,7 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private void RebuildHostSections()
{
HostSections.Clear();
foreach (var built in BuildHostSections())
{
HostSections.Add(built);
}
HostSections.ReplaceAll(BuildHostSections());
// Computed rather than stored, and none of the four has a change notification of its own — the same
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
@@ -520,6 +520,53 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
/// <remarks>
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
/// speed used to do that once a letter. The box now tells the vault once the typing pauses — and then
/// exactly once, with the whole of what was typed.
/// </remarks>
[Fact]
public async Task TypingIntoTheFindBox_RebuildsTheBoardOnceThePauseComes_NotOnceALetter()
{
await OnTheBoardAsync((screen, _) =>
{
// Each letter's wait, ended by hand: the pause is real time in the application, and this says
// when it is over instead of sleeping through it.
var pauses = new List<TaskCompletionSource>();
screen.Pause = _ =>
{
var pause = new TaskCompletionSource();
pauses.Add(pause);
return pause.Task;
};
var rebuilds = 0;
vault.HostSections.CollectionChanged += (_, _) => rebuilds++;
var box = screen.FindControl<TextBox>("HostFilter").ShouldNotBeNull();
box.Text = "p";
box.Text = "pr";
box.Text = "pro";
Dispatcher.UIThread.RunJobs();
vault.HostFilter.ShouldBeNullOrEmpty("nothing has reached the vault while the typing goes on");
rebuilds.ShouldBe(0);
foreach (var pause in pauses)
{
pause.SetResult();
}
Dispatcher.UIThread.RunJobs();
vault.HostFilter.ShouldBe("pro");
rebuilds.ShouldBe(1, "only the last letter's wait passes the text on");
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
});
}
// ---- Helpers ----
private static void RightClick(Visual card, Visual window)
@@ -1386,6 +1386,48 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// A folder download queues a row per file. The queue was a plain stack inside an outer scroller, which
/// measured and drew every row however deep — so the number of rows built here is the claim, not the
/// layout: a few screens' worth out of two thousand.
/// </remarks>
[Fact]
public async Task ADeepQueue_BuildsOnlyTheRowsOnScreen()
{
transfers.IsConnected = true;
for (var file = 0; file < 2000; file++)
{
Enqueue(TransferDirection.Download, $"asset-{file:0000}.png", 4096, 0, TransferState.Queued);
}
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen,
LayoutHarness.SessionScreenWidth(transfers.IsConnected),
LayoutHarness.SessionScreenHeight);
try
{
var built = screen.GetVisualDescendants()
.OfType<ProgressBar>()
.Count(bar => bar.Classes.Contains("transferbar"));
built.ShouldBeGreaterThan(0, "the rows on screen are still drawn");
built.ShouldBeLessThan(200, "the rest of the two thousand are not");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The edit strip, which shares the questions' Grid row and is the tallest thing that can be in it: a
/// heading, a path, a scrolling log of saves, the conflict card and a button. Its log is bounded for the
@@ -1632,11 +1674,24 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
try
{
var words = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.Classes.Contains("transferstatus"))
.Select(text => text.Text ?? string.Empty)
.ToList();
// Row by row, scrolled to: the queue is virtualised, so a row off the bottom of the strip is
// not built until it is scrolled to — see ADeepQueue_BuildsOnlyTheRowsOnScreen.
var queue = screen.GetVisualDescendants().OfType<ItemsControl>()
.First(control => ReferenceEquals(control.ItemsSource, transfers.Transfers));
var words = new List<string>();
for (var row = 0; row < expected.Length; row++)
{
queue.ScrollIntoView(row);
window.UpdateLayout();
words.Add(queue.ContainerFromIndex(row).ShouldNotBeNull()
.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("transferstatus"))
.Text ?? string.Empty);
}
words.ShouldBe(expected);
}
@@ -669,6 +669,38 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.StatusMessage.ShouldBe("Locked.");
}
/// <remarks>
/// A reload used to empty the host list and refill it a row at a time, announcing each — and the transfers
/// screen refilled its own picker, announcing each of those, on every one of them. Three hundred hosts came
/// to some ninety thousand notifications. Counted here as announcements reaching the two lists, which is
/// what the transfers screen and the board both answer.
/// </remarks>
[Fact]
public async Task ReloadingTheHosts_AnnouncesTheListOnce_NotOncePerHost()
{
await UnlockedAsync();
var vault = shell.Vault!;
foreach (var label in new[] { "prod-db", "prod-web", "stage-web", "dev-box" })
{
await AddHostAsync(vault, label);
}
var vaultAnnouncements = 0;
var pickerAnnouncements = 0;
vault.Hosts.CollectionChanged += (_, _) => vaultAnnouncements++;
shell.Transfers.Hosts.CollectionChanged += (_, _) => pickerAnnouncements++;
await vault.LoadAsync(Token);
vault.Hosts.Count.ShouldBe(4);
shell.Transfers.Hosts.Count.ShouldBe(4);
vaultAnnouncements.ShouldBe(1);
pickerAnnouncements.ShouldBe(1);
}
[Fact]
public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo()
{