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:
2026-07-31 11:52:13 +02:00
parent 240aadb746
commit 91438fb382
11 changed files with 979 additions and 48 deletions
@@ -0,0 +1,52 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
x:Class="DodoSSH.Client.App.Views.ConfirmDeleteCard"
x:DataType="vm:VaultViewModel">
<!--
The question in front of deleting something in the vault.
One control used in two places — the host sidebar, where it takes the place of the row of buttons that
opened it, and the vault screen's detail pane, where it takes the place of EDIT and DELETE. The two
moments are different and what has to be said is not, which is why this is a shared control rather than
two blocks that would drift apart. The sign-out confirmation is the same arrangement, for the same
reason; see SignOutCard.
A bare StackPanel and not a card, because the two hosts frame it themselves: the sidebar puts it in the
strip along its bottom edge, and the vault screen in a column that scrolls.
Everything it says is something the view model can answer. The question names the item, the consequence
knows whether this machine can push a tombstone yet, and the line in the box is a count of the hosts
that actually authenticate with the thing about to go — see VaultViewModel.HostsBoundTo. A confirmation
that only asked "are you sure?" would be a click to train people out of.
-->
<StackPanel Spacing="8">
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Question}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Consequence}" />
<!--
What else in this vault leans on it. In a box of its own because it is the line that changes the
answer: everything above is true of every deletion, and this is about the one being made.
-->
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8,6"
IsVisible="{Binding PendingDeletion.HasUsage, FallbackValue=False}">
<TextBlock Foreground="{StaticResource Info}" FontSize="11" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Usage}" />
</Border>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -0,0 +1,15 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The question in front of deleting a host, a key or a password.
/// </summary>
/// <remarks>
/// Its data context is the <c>VaultViewModel</c>, in both of the places it is shown, so every binding in the
/// markup is a property of the vault. See <see cref="HostSidebar"/> and <see cref="VaultScreen"/>.
/// </remarks>
internal sealed partial class ConfirmDeleteCard : UserControl
{
public ConfirmDeleteCard() => InitializeComponent();
}
+15 -1
View File
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostSidebar"
x:DataType="vm:VaultViewModel">
@@ -175,7 +176,7 @@
</Border>
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding !IsEditing}">
IsVisible="{Binding ShowsHostActions}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
@@ -183,6 +184,19 @@
</StackPanel>
</Border>
<!--
The question DELETE asks, in the place the buttons were rather than under them. This strip is at the
bottom edge of a column whose middle is a list that has already taken every spare pixel, so a second
block below the first would push its own buttons off the window — the same reasoning that swaps the
unlock card for the sign-out card rather than stacking them. Swapping also means DELETE cannot be
pressed again while its own question is up; see VaultViewModel.ShowsHostActions.
-->
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsConfirmingDeletion}">
<views:ConfirmDeleteCard />
</Border>
</Grid>
</UserControl>
@@ -1,4 +1,6 @@
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -11,7 +13,29 @@ namespace DodoSSH.Client.App.Views;
/// </remarks>
internal sealed partial class HostSidebar : UserControl
{
public HostSidebar() => InitializeComponent();
public HostSidebar()
{
InitializeComponent();
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
// every other client of this kind does, and the CONNECT button stays: it is the one that has the
// password box beside it, and a host that asks for a password still needs it typed first.
HostList.DoubleTapped += OnHostActivated;
}
/// <remarks>
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
/// returning a task nothing observes.
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
if (DataContext is VaultViewModel vault)
{
_ = vault.ConnectCommand.ExecuteAsync(null);
}
}
/// <summary>
/// Where the keyboard should land when the terminal hands it back.
@@ -197,7 +197,7 @@
</Border>
<!-- ==== The host ==== -->
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
@@ -209,12 +209,40 @@
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding IsConnected}" />
IsEnabled="{Binding CanDeleteRemote}" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
<!--
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
still on screen and still selected while it is being answered — and it names the full path rather
than the file, because a name is the half that does not identify anything.
This is the strongest warning on any of these screens, and deliberately: everything else this
application deletes is a tombstone against a copy the server still has, and a file on somebody's
host is bytes with nothing behind them.
-->
<Border Grid.Row="1" Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConfirmingRemoteDeletion}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<SelectableTextBlock Classes="mono" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.FullPath}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="DELETE ON THE HOST"
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -249,14 +277,14 @@
</StackPanel>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<Grid Grid.Row="3" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
</Grid>
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
<ListBox Grid.Row="4" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
@@ -276,7 +304,7 @@
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
<StackPanel Grid.Row="4" Spacing="10" Margin="24" MaxWidth="300"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding !HasRemoteEntries}">
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
+14 -1
View File
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.VaultScreen"
x:DataType="vm:VaultViewModel">
@@ -220,11 +221,23 @@
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding SelectedItemIsEditable}">
IsVisible="{Binding ShowsItemActions}">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
<!--
The question DELETE asks, in the place those two buttons were. Here rather than over the
screen, because this pane is where the item being deleted is described: the name, the kind and
what is stored are all still on screen above it, which is most of what somebody checks before
answering. See ConfirmDeleteCard.
-->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="4" Padding="10" Margin="0,14,0,0"
IsVisible="{Binding IsConfirmingDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
a pin appears because somebody approved a fingerprint at the moment of connecting, which is