Public Access
Ask before deleting, and connect a host by double-clicking it
DELETE on a host, an SSH key, a stored password or a file on the host now puts a question where the button was, and only answering it deletes anything. It is a state rather than a dialog, which is the arrangement signing out already had and for the same reason: this is the moment that has to be able to say what is about to go before it goes. What the question says is counted rather than generic, because a confirmation that only asks whether you are sure is a click to train people out of. A key names the hosts that authenticate with it and says they will refuse to connect afterwards rather than falling back to a typed password, which is what the connect path actually does. A host discloses a terminal open on it, because deleting the host does not close the session. Every vault deletion says how far it travels and whether this machine can push the tombstone yet or is queuing it. Deleting on the host carries the strongest warning of the four on purpose: everything else here is a tombstone against a copy the server still holds, and a file on somebody's machine is bytes with nothing behind them — so that one names the full path, since a bare name identifies nothing. The armed request carries the item's entity id, so nothing that moves the selection between the question and the answer can redirect it, and answering about something that has since gone says so instead of doing nothing quietly. Disarming compares ids rather than rows, which is the subtle half: a reload replaces every row object, so the naive rule would have let the pass that runs every minute take the card away from somebody halfway through reading it. Forgetting a pinned host key is deliberately still unguarded. It costs one fingerprint check on the next connection and it is the safe direction to be wrong in — the dangerous button there is the one that adds trust, and that one is already a prompt at connect time. Discarding a stopped transfer is likewise unguarded: it removes a resumable part file and leaves the source alone. Double-clicking a host in the sidebar connects to it, wired as a gesture in the control exactly as the transfers screen opens a directory. CONNECT stays, since it is the button with the password box beside it. Ten existing delete call sites now go through arm-and-confirm helpers, and eight new flow tests cover asking first, cancelling, the counted warning, disarming on a selection change and on an editor opening, surviving a sync, and the stale-item guard. Three layout tests measure the new shapes — the sidebar card is the one card in the application a user cannot scroll — and one of them also asserts the card renders its text, because a card whose compiled bindings did not resolve would lay out perfectly as empty rows. The double-click test performs the real gesture and proves it reached the connect command through a refusal that never touches a network. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 853 tests, including the end-to-end suite against real containers.
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
@@ -223,6 +226,88 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The strip along the sidebar's bottom edge with the question in it instead of the three buttons. Its
|
||||
/// tallest shape is a host with a terminal open on it, which adds a disclosure the ordinary case has
|
||||
/// not got — in a 268-pixel column whose middle is a list that has already taken every spare pixel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Worth measuring rather than assuming, because this is the one card in the application a user cannot
|
||||
/// scroll: the sidebar's only <c>ScrollViewer</c> is inside the host list, so a button pushed past the
|
||||
/// bottom edge here would leave the question unanswerable in either direction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostSidebarFitsWithADeletionInQuestion()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.SelectedHost.IsConnected = true;
|
||||
|
||||
vault.DeleteHostCommand.Execute(null);
|
||||
vault.IsConfirmingDeletion.ShouldBeTrue();
|
||||
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
|
||||
|
||||
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
|
||||
/// The gesture is wired in the control rather than bound in the markup, which is exactly the sort of
|
||||
/// wiring that compiles whether or not it is connected to anything — so it is worth a test that
|
||||
/// performs the gesture.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Proved through a connection that is refused before any network is involved. The host is left bound
|
||||
/// to a key that has been deleted, which <c>TryBuildAuthentication</c> turns into a sentence on the
|
||||
/// status line rather than a socket — so what this asserts is that the command ran, with nothing
|
||||
/// timing out to make it flaky.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DoubleClickingAHostConnectsToIt()
|
||||
{
|
||||
var keyId = vault.Keys[0].EntityId;
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedKey = vault.Keys.Single(row => row.EntityId == keyId);
|
||||
vault.DeleteKeyCommand.Execute(null);
|
||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = null;
|
||||
vault.Status = string.Empty;
|
||||
|
||||
await OnTheSidebarAsync((sidebar, window) =>
|
||||
{
|
||||
var row = sidebar.HostList.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.First();
|
||||
|
||||
var centre = row.TranslatePoint(
|
||||
new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the row is not in this window's tree");
|
||||
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
|
||||
vault.Status.ShouldContain(
|
||||
"not in this vault any more",
|
||||
Case.Insensitive,
|
||||
"the double-click has to reach the connect command");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
@@ -283,6 +368,53 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
|
||||
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
|
||||
/// application.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsWithADeletionInQuestion()
|
||||
{
|
||||
var keyId = vault.Keys[0].EntityId;
|
||||
|
||||
foreach (var host in vault.Hosts.Take(4).ToList())
|
||||
{
|
||||
vault.SelectedHost = host;
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
vault.Section = VaultSection.Keys;
|
||||
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == keyId);
|
||||
|
||||
vault.DeleteSelectedItemCommand.Execute(null);
|
||||
|
||||
vault.PendingDeletion.ShouldNotBeNull().HasUsage
|
||||
.ShouldBeTrue("four bound hosts are what makes this the long shape");
|
||||
|
||||
await OnTheVaultAsync((screen, window) =>
|
||||
{
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||
|
||||
// And it says something. A card whose bindings did not resolve would lay out perfectly as three
|
||||
// empty rows, which is the one failure a fit test cannot see: compiled bindings against the
|
||||
// wrong data type are a logged message rather than an exception.
|
||||
var card = screen.GetVisualDescendants().OfType<ConfirmDeleteCard>().ShouldHaveSingleItem();
|
||||
|
||||
var said = string.Join(
|
||||
" ",
|
||||
card.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text));
|
||||
|
||||
said.ShouldContain("key-0", Case.Insensitive, "the question has to name what is going");
|
||||
said.ShouldContain("4 hosts authenticate with it");
|
||||
said.ShouldContain("no undo");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
|
||||
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
|
||||
@@ -375,6 +507,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
||||
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
||||
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
||||
{
|
||||
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||
"2026-07-30",
|
||||
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||
IsDirectory: true);
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
// ---- The chrome ----
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user