Let a tap on the phone's host list mean connect

Choosing a machine raised the connect bar over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. Five controls in the way of the
one thing a tap on a machine's name obviously means.

So the gestures split. A tap connects. A long press raises the bar, with all
five. The pencil in the phone's header — its only persistent chrome — edits
whichever host is chosen, which is the one of the five common enough to be worth
a control that is always in the same place.

The flag doing it is the desktop's own IsHostPaneOpen rather than a second one.
That head made exactly this move when a selection stopped opening its drawer, and
the question both are asking is "has somebody asked about this host" — answering
it twice is how two heads come to disagree about what a selection means.

One tap cannot finish: a host that authenticates with a typed password has
nowhere on a list to be given one. That tap raises the bar with the box in it and
says so, and a second tap with the box filled in connects. The branch is in the
view model rather than in the head, because "can this machine be reached without
asking for anything" is the same question the bar's own password box answers, and
a copy of it in a view would be a second reading of a binding chain that has one.

Two mechanics worth knowing. Avalonia raises Tapped on release whatever the press
lasted, so a long press would open the bar and then connect — one touch firing
both gestures — which is why HostsScreen tracks the hold and swallows the tap it
precedes. And Holding only fires once IsHoldingEnabled is set, so that and the
handler are attached together rather than one in markup and one in code.

ConnectToRecent now opens the pane rather than selecting the row. On the phone it
has to: a selection alone raises nothing now, so going back to a recent machine
would land on a screen with nothing to press.
This commit is contained in:
2026-08-05 08:43:33 +02:00
parent 69bc9e270b
commit 746711da9d
8 changed files with 412 additions and 26 deletions
+10 -1
View File
@@ -489,7 +489,16 @@ transfer starts, the download runs into the cache, and the finished bytes are co
chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the chose. That order has one visible cost, and the screen says it rather than leaving it to be discovered: the
picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The picker creates the file when you dismiss it, so a download that then fails leaves an empty one there. The
alternative is a picker that appears minutes later over whatever you moved on to — and often while the app alternative is a picker that appears minutes later over whatever you moved on to — and often while the app
is in the background, where Android will not show one at all. Hosts and groups are made, corrected and taken is in the background, where Android will not show one at all.
**On the phone's host list a tap connects.** It used to choose the machine and raise a bar over the bottom of
the list — a password box, CONNECT, EDIT, MOVE and DELETE — which put five controls in the way of the one
thing tapping a machine's name obviously means. So the gestures split: a tap connects, a **long press** raises
that bar, and the **pencil in the header** edits whichever host is chosen. The one tap that cannot finish is a
host that authenticates with a password you type, because there is nowhere on a list to type it — that tap
raises the bar with the box in it and says so, and tapping the row again once it is filled in connects.
Hosts and groups are made, corrected and taken
away here now, from a floating + on the Hosts screen and a menu on each group's heading, and both editors are away here now, from a floating + on the Hosts screen and a menu on each group's heading, and both editors are
cards in the list's own row rather than dialogs, so the form never covers the thing it is about. Every cards in the list's own row rather than dialogs, so the form never covers the thing it is about. Every
question a deletion asks is the desktop's, counted the same way and drawn in the place the buttons that question a deletion asks is the desktop's, counted the same way and drawn in the place the buttons that
+49 -7
View File
@@ -863,8 +863,8 @@ Run on a 360dp-wide device or emulator, unlocked, on HOSTS.
Look at the bottom right of the host list with no host selected. Look at the bottom right of the host list with no host selected.
**Pass:** a filled accent circle, 56×56, clear of the bottom edge and of the last row's text. Then select a **Pass:** a filled accent circle, 56×56, clear of the bottom edge and of the last row's text. Then press
host so the connect bar rises. and hold a host so the connect bar rises.
**Pass:** the + has moved up with the bar and is not over the CONNECT button. **Pass:** the + has moved up with the bar and is not over the CONNECT button.
@@ -1004,7 +1004,7 @@ intended answer, not a failure.
### 8.13 DELETE under a host is not beside EDIT ### 8.13 DELETE under a host is not beside EDIT
Select a host so the connect bar rises. Press and hold a host so the connect bar rises.
**Pass:** CONNECT, then EDIT and MOVE side by side, then DELETE full width in the danger colour on a row of **Pass:** CONNECT, then EDIT and MOVE side by side, then DELETE full width in the danger colour on a row of
its own beneath them. On a host with nowhere to move to, MOVE is absent and the row is EDIT alone. its own beneath them. On a host with nowhere to move to, MOVE is absent and the row is EDIT alone.
@@ -1017,6 +1017,46 @@ puts them back with the host still there.
**Failure means:** a bar that kept its buttons under the question is one where DELETE can be pressed twice, **Failure means:** a bar that kept its buttons under the question is one where DELETE can be pressed twice,
the second press landing on whatever the list rebuilt underneath. the second press landing on whatever the list rebuilt underneath.
### 8.14 A tap connects, and a long press is what raises the bar · **the one worth the most care**
On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once.
**Pass:** it connects, and the terminal takes the screen. **Nothing rises over the list on the way past**
no password box, no CONNECT, no EDIT/MOVE/DELETE. Come back to HOSTS: still nothing.
Now press and hold the same row.
**Pass:** the bar comes up, naming that host, with all of it. Press and hold a different row: the bar
follows to that one rather than staying on the first.
Now tap a host that authenticates with a **typed password**.
**Pass:** it does not dial. The bar rises with the password box in it and the status line says the host asks
for a password. Type it and press CONNECT — or tap the row a second time, which is the same thing — and it
connects.
**Failure means:** a tap that raises the bar is the old behaviour, where choosing a machine put five
controls over the bottom of the list in the way of the one thing a tap on its name means. A tap that dials
a password host with an empty box is worse: the connection fails somewhere the user cannot see why. A long
press that connects means `held` in `HostsScreen.axaml.cs` is not suppressing the `Tapped` that follows a
hold, so one touch fires both gestures.
### 8.15 The pencil in the header edits the chosen host
With a host chosen — by a tap that connected, or by a long press — look at the header beside LOCK.
**Pass:** a pencil. It opens that host's editor. With nothing chosen there is no pencil at all rather than a
greyed one, and while an editor is open it is gone.
Navigate to Settings and back.
**Pass:** the pencil is absent on every screen but HOSTS, including Settings and the keychain.
**Failure means:** a pencil visible over another screen is the `IsHostsShowing` wrapper gone — see
`PhoneShell.axaml`, which nests the two conditions because Avalonia's bindings have no "and". A pencil that
stays while the host editor is up is `CanEditSelectedHost` no longer reading `AnEditorIsOpen`.
--- ---
## Phase 9 — Tag chips and the picker ## Phase 9 — Tag chips and the picker
@@ -1216,12 +1256,14 @@ seriousness here, since this password is typed rather than stored and so is type
With at least one keychain host and one machine connected to by hand in the log, open Connections with With at least one keychain host and one machine connected to by hand in the log, open Connections with
nothing running. nothing running.
**Pass:** tapping the keychain one lands on HOSTS with that host selected and its connect bar up. Tapping **Pass:** tapping the keychain one lands on HOSTS with that host selected **and its connect bar up**.
the hand-typed one puts its address back in the box on this screen and leaves the password box **empty**. Tapping the hand-typed one puts its address back in the box on this screen and leaves the password box
Neither one dials anything on its own. **empty**. Neither one dials anything on its own.
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should **Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
not have. If the password came back, something is storing it — it never was. not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
with the host selected but **no bar**, `ConnectToRecent` is setting the selection rather than opening the
pane: since 8.14 a selection alone raises nothing, so that is a screen with nothing to press.
### 11.9 Settings holds the keychain, and back walks out of it ### 11.9 Settings holds the keychain, and back walks out of it
@@ -31,6 +31,23 @@
Both editors are cards in the list's own row, swapped for the list rather than stacked over it, which is Both editors are cards in the list's own row, swapped for the list rather than stacked over it, which is
what SnippetsScreen does and for the reason written there: a form on top of the list hides what it is what SnippetsScreen does and for the reason written there: a form on top of the list hides what it is
about. There is no dialog and no editor screen anywhere on this head. about. There is no dialog and no editor screen anywhere on this head.
── ◆ v4: A TAP CONNECTS, AND THE BAR IS WHAT A LONG PRESS IS FOR. ───────────────────────────────────────
Choosing a row used to raise the connect bar over the bottom of the list — a password box, CONNECT, EDIT,
MOVE and DELETE, five controls in the way of the one thing a tap on a machine's name means. So the
gestures split, and each one now does what it looks like it does:
· a tap connects, and the bar never appears;
· a long press asks about the host: the bar comes up, with all five;
· the pencil in the phone's header edits whatever is chosen, which is the one action common enough to
be worth a control that is always in the same place.
The one case a tap cannot finish is a host that authenticates with a typed password, since there is
nowhere to type it. That tap raises the bar with the box in it and says so — see
VaultViewModel.ConnectToRowAsync, which is where the branch lives and why it is not in this head.
It is the same move the desktop grid made when a selection stopped opening its drawer, and it reuses that
head's flag rather than inventing a second one; see VaultViewModel.IsHostPaneOpen.
--> -->
<!-- <!--
@@ -180,9 +197,20 @@
</StackPanel> </StackPanel>
<!-- ============ the list ============ --> <!-- ============ the list ============ -->
<ListBox Grid.Row="2" Margin="0,6,0,0" IsVisible="{Binding !AnEditorIsOpen}" <!--
◆ Named, because the long press is attached from code-behind: the gesture only fires at all once
IsHoldingEnabled is on, and setting the attached property and the handler together is what keeps those
two from drifting apart. Tapped stays here, beside the bindings it is about.
Both are on the list rather than on the row, and the row stays a plain Grid rather than becoming a
Button, for the reason FilesScreen writes out: a button as the item template swallows the press before
the list sees it, so nothing is ever selected and every control that reads the selection stops working.
Both handlers fire after the list has moved its selection, which is what lets them read it.
-->
<ListBox x:Name="Rows" Grid.Row="2" Margin="0,6,0,0" IsVisible="{Binding !AnEditorIsOpen}"
ItemsSource="{Binding SidebarRows}" ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}" SelectedItem="{Binding SelectedSidebarRow}"
Tapped="OnRowTapped"
Background="Transparent" BorderThickness="0"> Background="Transparent" BorderThickness="0">
<ListBox.Styles> <ListBox.Styles>
@@ -548,8 +576,10 @@
<!-- ============ connect ============ --> <!-- ============ connect ============ -->
<!-- <!--
Raised over the list when a host is chosen, because the desktop's right-hand column has nowhere to go Raised by a long press on a row, not by choosing one. A tap connects — see the v4 note at the top of
at this width. It names the host: the selection that is obvious on a wide window is not obvious here. this file — so what is left in here is everything a tap cannot be: the password for a host that wants
one, and EDIT, MOVE and DELETE. It names the host, because the selection that is obvious on a wide
window is not obvious under a thumb.
Gone entirely while an editor is up, rather than merely greyed. The editor replaces the list above it, Gone entirely while an editor is up, rather than merely greyed. The editor replaces the list above it,
so a bar left in place would be a set of buttons about a host that is no longer on screen — and the so a bar left in place would be a set of buttons about a host that is no longer on screen — and the
@@ -1,10 +1,109 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views; namespace DodoSSH.Client.Android.Views;
/// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary> /// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary>
/// <remarks>
/// <para>
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> A tap on a
/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
/// </para>
/// <para>
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> writes that
/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
/// events fire after the list has moved its selection, which is what lets these read it.
/// </para>
/// </remarks>
internal sealed partial class HostsScreen : UserControl internal sealed partial class HostsScreen : UserControl
{ {
public HostsScreen() => AvaloniaXamlLoader.Load(this); /// <summary>
/// Whether the press that is about to end was long enough to have meant something else.
/// </summary>
/// <remarks>
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
/// would open the bar and then connect — the two gestures firing one after the other on one touch, which
/// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
/// by the tap it suppresses, so it never survives the gesture that set it.
/// </remarks>
private bool held;
/// <remarks>
/// The long press is attached here rather than in the markup so that it sits beside the property that
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than assumed:
/// it is the whole of the gesture, and a default that changed would take it away silently — every tap
/// would go on working and nothing would ever open the bar again.
/// </remarks>
public HostsScreen()
{
AvaloniaXamlLoader.Load(this);
InputElement.SetIsHoldingEnabled(Rows, true);
Rows.Holding += OnRowHeld;
}
/// <summary>
/// Connects to the row that was tapped.
/// </summary>
/// <remarks>
/// <para>
/// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
/// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
/// before — see <c>VaultViewModel.SelectedSidebarRow</c> — so reading the selection here answers "a host,
/// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
/// a machine the user was not pointing at.
/// </para>
/// <para>
/// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
/// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
/// returning a task nothing observes.
/// </para>
/// </remarks>
private void OnRowTapped(object? sender, TappedEventArgs e)
{
if (held)
{
held = false;
return;
}
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
{
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
}
}
/// <summary>
/// Raises the bar about the row that was held.
/// </summary>
/// <remarks>
/// <para>
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
/// had been recognised, and the only feedback available on this list is the thing it does.
/// </para>
/// <para>
/// It goes through <c>OpenHostPaneCommand</c>, which is the desktop's own "ask about this host" — the two
/// heads raise different furniture from one flag rather than keeping a selection rule each.
/// </para>
/// </remarks>
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
{
if (e.HoldingState != HoldingState.Started)
{
return;
}
held = true;
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
{
vault.OpenHostPaneCommand.Execute(row);
}
}
} }
@@ -72,7 +72,7 @@
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}" <Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56" BorderThickness="0,0,0,1" Padding="14,0" Height="56"
IsVisible="{Binding !IsMoreSurface}"> IsVisible="{Binding !IsMoreSurface}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto"> <Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<!-- <!--
Filled rather than outlined since v2. The mark is the one thing on this header that is not a Filled rather than outlined since v2. The mark is the one thing on this header that is not a
@@ -100,7 +100,31 @@
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" /> <TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
</StackPanel> </StackPanel>
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}" <!--
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
happened: choosing a host on this head no longer raises a bar carrying EDIT, because that bar
was five controls over the bottom of the list in the way of a tap that means "connect". EDIT is
the one of the five common enough to be worth a control that is always in the same place, so it
is in the header — the phone's only piece of persistent chrome — and the long press still
reaches the other four. See HostsScreen.axaml.
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
two belong to different view models: which screen is showing is the shell's question, and
whether there is a host to edit is the vault's. That is the same arrangement the header itself
is wrapped in one level up.
Collapsed rather than disabled when there is nothing chosen. A greyed pencil sitting beside the
vault's name on every screen would be a permanent reminder of a control that is only ever about
one row.
-->
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
<Button Classes="icon" Content="✎" FontSize="15" Margin="4,0,0,0"
IsVisible="{Binding Vault.CanEditSelectedHost}"
Command="{Binding Vault.EditSelectedHostCommand}"
ToolTip.Tip="Edit the selected host" />
</Panel>
<Button Grid.Column="4" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
ToolTip.Tip="Lock the keychain"> ToolTip.Tip="Lock the keychain">
<TextBlock Text="LOCK" Classes="label" FontSize="8.5" <TextBlock Text="LOCK" Classes="label" FontSize="8.5"
Foreground="{StaticResource TextDim}" /> Foreground="{StaticResource TextDim}" />
@@ -1133,9 +1133,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a /// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
/// keychain host goes to that host, selected, on the hosts screen — which is where its connect bar is, /// keychain host goes to that host on the hosts screen, with the panel about it opened — the desktop's
/// with whatever authentication the keychain resolves for it and a password box only if it needs one. /// drawer, the phone's connect bar — carrying whatever authentication the keychain resolves for it and a
/// Connecting from here instead would be a third connect path that had to answer all of that again. /// password box only if it needs one. Connecting from here instead would be a third connect path that
/// had to answer all of that again.
/// </para>
/// <para>
/// ◆ <b>It opens that panel rather than merely selecting the row, and on the phone it has to.</b>
/// Choosing a host there no longer raises the bar — a tap on the list connects instead, see
/// <c>VaultViewModel.ShowsConnectBar</c> — so arriving with the host selected and nothing else would be
/// arriving at a screen with nothing to press. Asking to go back to a machine is exactly the deliberate
/// act that flag exists to distinguish from browsing.
/// </para> /// </para>
/// <para> /// <para>
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled — /// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
@@ -1163,7 +1171,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known && vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
&& vault.IsVaultShown(known.VaultId)) && vault.IsVaultShown(known.VaultId))
{ {
vault.SelectedHost = known; vault.OpenHostPaneCommand.Execute(known);
ShowScreen(ShellScreen.Hosts); ShowScreen(ShellScreen.Hosts);
return; return;
} }
@@ -1872,6 +1872,7 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))] [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))] [NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))] [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))] [NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
@@ -1904,6 +1905,7 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))] [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))] [NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))] [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))] [NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
@@ -1965,6 +1967,7 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))] [NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))] [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))] [NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
private bool isHostPaneOpen; private bool isHostPaneOpen;
/// <summary> /// <summary>
@@ -2128,6 +2131,7 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))] [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
private bool isAddSheetOpen; private bool isAddSheetOpen;
/// <summary> /// <summary>
@@ -2154,6 +2158,7 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))] [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(CanEditSelectedHost))]
[NotifyPropertyChangedFor(nameof(GroupSheetLabel))] [NotifyPropertyChangedFor(nameof(GroupSheetLabel))]
private SidebarGroupHeader? groupSheet; private SidebarGroupHeader? groupSheet;
@@ -2176,12 +2181,42 @@ internal sealed partial class VaultViewModel(
/// Whether the phone's connect bar has anything to be about. /// Whether the phone's connect bar has anything to be about.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A host is chosen and nothing is covering the list. Both halves are needed and the second is the one /// <para>
/// worth stating: the editor cards replace the list rather than floating over it, so a bar left showing /// ◆ <b>Selecting a host no longer raises this bar, and <see cref="IsHostPaneOpen"/> is the difference.</b>
/// underneath would carry CONNECT and EDIT for a host that is no longer on screen — and under the host /// It used to read <c>SelectedHost is not null</c>, so a tap on any row put a card over the bottom of the
/// editor, for the very record being typed into. /// list carrying a password box, CONNECT, EDIT, MOVE and DELETE — five controls in the way of the one
/// thing a tap on a machine's name means, which is connect to it. A tap connects now; a long press asks
/// for this. See <see cref="ConnectToRowAsync"/> and <see cref="OpenHostPane"/>.
/// </para>
/// <para>
/// It is the same flag and the same reasoning the desktop's drawer already used, which is why it is that
/// flag rather than a second one: the question both heads are asking is "has somebody asked about this
/// host", and answering it twice is how two heads come to disagree about what a selection means.
/// </para>
/// <para>
/// The second half stays and is still worth stating: the editor cards replace the list rather than
/// floating over it, so a bar left showing underneath would carry CONNECT and EDIT for a host that is no
/// longer on screen — and under the host editor, for the very record being typed into.
/// </para>
/// </remarks> /// </remarks>
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen; internal bool ShowsConnectBar => SelectedHost is not null && IsHostPaneOpen && !AnEditorIsOpen;
/// <summary>
/// Whether the phone's header should be offering the pencil that edits the selected host.
/// </summary>
/// <remarks>
/// The other half of the gesture change above. With the bar no longer raised by choosing a machine, EDIT
/// went behind a long press — so the header carries a pencil for the row that <em>is</em> chosen, which
/// is the one control a thumb can reach without opening anything. It is hidden rather than disabled while
/// an editor is up, because a pencil that opens the form already on screen is a control with nothing to
/// do.
/// <para>
/// It deliberately does not ask which screen is showing. That is the shell's question and this is the
/// vault's; the header wraps this in the shell's own <c>IsHostsShowing</c>, which is the arrangement
/// PhoneShell already uses everywhere Avalonia's bindings need an "and".
/// </para>
/// </remarks>
internal bool CanEditSelectedHost => SelectedHost is not null && !AnEditorIsOpen;
/// <summary> /// <summary>
/// Whether the phone's connect bar is showing its own controls rather than one of the two panels that /// Whether the phone's connect bar is showing its own controls rather than one of the two panels that
@@ -7376,6 +7411,59 @@ internal sealed partial class VaultViewModel(
[RelayCommand(AllowConcurrentExecutions = true)] [RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None); private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None);
/// <summary>
/// Connects to the host a tap landed on, or raises the phone's bar when it needs a password first.
/// </summary>
/// <remarks>
/// <para>
/// <b>The phone's tap, and it lives here rather than in the head because the branch is a product rule
/// rather than a gesture.</b> Which gesture means "open this" is the head's business — that is why the
/// files screen maps its own tap in code-behind — but <em>whether this machine can be reached without
/// asking for anything</em> is the same question the connect bar's own password box answers, and a copy
/// of it in a view would be a second reading of a binding chain that already has one.
/// </para>
/// <para>
/// The two outcomes are both "connect": one of them arrives, and the other needs a secret first and so
/// puts the bar up with the box in it and says so. What it must never do is quietly connect with no
/// password, or open a bar for a host that did not need one — that bar is five controls over the bottom
/// of the list, and it is what a tap used to raise for every machine.
/// </para>
/// <para>
/// A successful tap closes the bar. Tapping a second machine while the first one's bar is up would
/// otherwise leave the panel behind on the new selection, which is a bar nobody asked for, opened by the
/// gesture that exists to avoid opening one.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries at length.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectToRowAsync(HostRowViewModel? row)
{
if (row is null)
{
return Task.CompletedTask;
}
SelectedHost = row;
// Read after the assignment, because both are about the row that was just chosen. ConnectPassword
// is checked as well as the binding: a bar already up with a password typed into it is exactly the
// second tap this should honour rather than answer with the same instruction again.
if (SelectedHostAsksForAPassword && ConnectPassword.Length == 0)
{
IsHostPaneOpen = true;
Status = $"{row.Label} asks for a password. Type it below, then CONNECT.";
return Task.CompletedTask;
}
IsHostPaneOpen = false;
return ConnectToSelectedHostAsync(CancellationToken.None);
}
/// <inheritdoc cref="ConnectAsync" /> /// <inheritdoc cref="ConnectAsync" />
/// <param name="cancellationToken"> /// <param name="cancellationToken">
/// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own, /// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own,
@@ -8862,6 +8950,7 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedHostAsksForAPassword)); OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
OnPropertyChanged(nameof(SelectedHostAuthenticationNote)); OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
OnPropertyChanged(nameof(ShowsConnectBar)); OnPropertyChanged(nameof(ShowsConnectBar));
OnPropertyChanged(nameof(CanEditSelectedHost));
// Every field the drawer's detail pane draws. They are properties of the vault rather than of the // Every field the drawer's detail pane draws. They are properties of the vault rather than of the
// row because two of them need the group chain read and one needs the keychain searched, and none of // row because two of them need the group chain read and one needs the keychain searched, and none of
@@ -4474,7 +4474,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
var vault = shell.Vault!; var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
// The long press, which is the only thing that raises this bar. Selecting used to be enough.
vault.OpenHostPaneCommand.Execute(Host(vault, "prod-db"));
vault.ShowsConnectBar.ShouldBeTrue(); vault.ShowsConnectBar.ShouldBeTrue();
@@ -4500,6 +4502,89 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.ShowsConnectBar.ShouldBeFalse(); vault.ShowsConnectBar.ShouldBeFalse();
} }
/// <remarks>
/// ◆ <b>The gesture split, from the side that costs something to get wrong.</b> Choosing a host used to
/// raise the bar — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list — which
/// meant a tap on a machine's name put five controls in the way of the one thing it obviously means.
/// A tap connects now, and this pins that it raises nothing on the way past.
/// </remarks>
[Fact]
public async Task ATapOnAHost_ConnectsAndLeavesTheBarWhereItWas()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.SelectedHost = null;
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
vault.Status.ShouldContain("Connected", Case.Insensitive);
vault.SelectedHost.ShouldNotBeNull("the row a tap landed on is what was connected to");
vault.ShowsConnectBar.ShouldBeFalse("and nothing was raised over the list to do it");
}
/// <remarks>
/// The one tap that cannot finish, and the reason the bar still exists. A host that authenticates with a
/// typed password has nowhere to be given one from a list, so the tap raises the bar with the box in it
/// and says so. What it must never do is connect with no password, or leave somebody tapping a row that
/// silently does nothing.
/// </remarks>
[Fact]
public async Task ATapOnAHostThatWantsAPassword_RaisesTheBarInsteadOfConnecting()
{
var vault = await ReadyToConnectAsync();
vault.SelectedHost = null;
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
vault.ShowsConnectBar.ShouldBeTrue("there is nowhere else to type it");
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
vault.Status.ShouldContain("password");
ssh.Requests.ShouldBeEmpty("nothing was dialled with no password");
// The second tap, with the box filled in, is the one that goes through — otherwise the bar would be
// answering the instruction it just gave with the same instruction again.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ConnectPassword = "typed-in";
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<SshPasswordCredential>();
vault.ShowsConnectBar.ShouldBeFalse("and the bar goes with the tap that succeeded");
}
/// <remarks>
/// The pencil in the phone's header, which is where EDIT went when the bar stopped being raised by
/// choosing a machine. Both halves matter: it is offered for a chosen host, and it is taken away while an
/// editor is up — a pencil that opens the form already on screen is a control with nothing to do.
/// </remarks>
[Fact]
public async Task TheHeaderPencil_IsOfferedForAChosenHostAndNotOverAnEditor()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = null;
vault.CanEditSelectedHost.ShouldBeFalse("there is nothing for it to be about");
vault.SelectedHost = Host(vault, "prod-db");
vault.CanEditSelectedHost.ShouldBeTrue("and a tap is enough — it does not need the bar");
vault.EditSelectedHostCommand.Execute(null);
vault.CanEditSelectedHost.ShouldBeFalse("the form it opens is already on screen");
vault.CancelEditCommand.Execute(null);
vault.CanEditSelectedHost.ShouldBeTrue();
}
[Fact] [Fact]
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne() public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
{ {
@@ -4687,7 +4772,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts.Single(); vault.OpenHostPaneCommand.Execute(vault.Hosts.Single());
vault.ShowsConnectBar.ShouldBeTrue(); vault.ShowsConnectBar.ShouldBeTrue();
vault.ShowsConnectControls.ShouldBeTrue(); vault.ShowsConnectControls.ShouldBeTrue();
@@ -4712,7 +4797,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddHostAsync(vault, "prod-db"); await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts.Single(); vault.OpenHostPaneCommand.Execute(vault.Hosts.Single());
vault.DeleteHostCommand.Execute(null); vault.DeleteHostCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null); await vault.ConfirmDeleteCommand.ExecuteAsync(null);