Public Access
Compare commits
3
Commits
a2362d1e3e
...
24c18205c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c18205c3 | ||
|
|
8759b12d47 | ||
|
|
7b072bcded |
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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user