Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f43436218a | ||
|
|
5b2e3f3651 | ||
|
|
988248fa57 |
No files matched your search
@@ -510,7 +510,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
|
|||||||
| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
|
| Org chip `dodotech / platform` | contracts + server | An *organisation* above teams, which does not exist — `VaultSummary.TeamId` is no longer always null since M3, but a team is not an org and there is exactly one tenant per deployment. | The vault's own name, and the account this machine is enrolled as. Team names are on the VAULTS screen, where they are about something. |
|
||||||
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
|
| `SYNCED` dot, always green | client-app | Nothing — the design's claim is simply unconditional. | Green **only** when a connection is held, the last sync pass actually reached the server, and the outbox is empty; otherwise `UNREACHABLE`, the count of changes still waiting, or `OFFLINE`. Holding an `IVaultServer` proves a sign-in once succeeded and nothing more, so a laptop whose lid has been shut all afternoon still has one — reachability comes from the outcome of the last pass. A permanently green light is the same as no light. |
|
||||||
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
|
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
|
||||||
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
|
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter, and reads a typed `user@host[:port]` as a machine to connect to with a password, without saving it. The box says "search hosts, or type user@host", not "search hosts · run command". |
|
||||||
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
|
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
|
||||||
| Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
|
| Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
|
||||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||||
|
|||||||
+34
-1
@@ -1388,6 +1388,22 @@ Ctrl+K, then click a result with the mouse rather than pressing Enter.
|
|||||||
|
|
||||||
**Pass:** the palette closes and the connection starts.
|
**Pass:** the palette closes and the connection starts.
|
||||||
|
|
||||||
|
### 7.10a Connecting to a machine that is not saved
|
||||||
|
|
||||||
|
Ctrl+K, type `user@host` for a reachable machine that is **not** on HOSTS, press Enter, type its password and
|
||||||
|
press Enter again. Do it once against a machine whose host key this vault has never seen.
|
||||||
|
|
||||||
|
**Pass:** the palette asks for the password in place, with the caret already in the box. On the second Enter
|
||||||
|
it closes, a tab named `user@host` opens, and the fingerprint card asks about the key; approving it connects.
|
||||||
|
Afterwards HOSTS has nothing new on it, and the connection log has an entry for `user@host:22`. Escape from
|
||||||
|
the password box goes back to the address with what was typed still in it.
|
||||||
|
|
||||||
|
**Failure means:** a new row on HOSTS is the palette saving what it promised not to. A tab that fails with
|
||||||
|
"authentication failed" for a password known to be right is worth comparing against the phone's CONNECT TO A
|
||||||
|
MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsync` — so a difference between
|
||||||
|
the two is in how the palette hands the password over, not in the connection. The headless half of this is
|
||||||
|
`QuickConnectTests`; what it cannot do is dial.
|
||||||
|
|
||||||
### 7.11 A host or a bucket made now can be picked on FILES now
|
### 7.11 A host or a bucket made now can be picked on FILES now
|
||||||
|
|
||||||
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
||||||
@@ -1421,7 +1437,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
|
|||||||
|
|
||||||
## Phase 8 — Adding and removing on the phone's host list
|
## Phase 8 — Adding and removing on the phone's host list
|
||||||
|
|
||||||
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite
|
Twenty-two checks, and the reason there are twenty-two rather than none is worth stating: **the layout suite
|
||||||
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||||||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||||||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||||||
@@ -1794,6 +1810,23 @@ follow, and try disconnecting and reconnecting instead.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 8.20 Connecting from the search box to a machine that is not saved
|
||||||
|
|
||||||
|
With a terminal already open on some other machine, go back to HOSTS and type `user@host` for a reachable
|
||||||
|
machine that is **not** in the list into the search box at the top. Type its password into the card that
|
||||||
|
appears, and press CONNECT.
|
||||||
|
|
||||||
|
**Pass:** the card appears only once there is an `@` in the box, names the address with `:22` on the end, and
|
||||||
|
says NOT SAVED. CONNECT opens a second tab for `user@host` — the first is still there — and the fingerprint
|
||||||
|
card asks about an unknown key over the terminal. Back on HOSTS the search box is empty, the list is the whole
|
||||||
|
list again, and there is no new host in it. A port the parser refuses (`root@box:70000`) gives a sentence in
|
||||||
|
amber under the box rather than a card.
|
||||||
|
|
||||||
|
**Failure means:** the card sitting *over* the list rather than pushing it down is the panels' arrangement
|
||||||
|
having been lost — see the remark above `IsSendingChosenHostsToAVault` in `HostsScreen.axaml`. A password that
|
||||||
|
shows in the keyboard's suggestion strip is `Classes="secret"` missing from the box. Everything behind the
|
||||||
|
card is `ShellFlowTests`' hosts-search cases; this is the rectangles, and the software keyboard.
|
||||||
|
|
||||||
## Phase 9 — Tag chips and the picker
|
## Phase 9 — Tag chips and the picker
|
||||||
|
|
||||||
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
|
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
<Grid ColumnDefinitions="Auto,*">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<TextBlock Grid.Column="0" Classes="icon" Text="" Foreground="{StaticResource TextFaint}"
|
<TextBlock Grid.Column="0" Classes="icon" Text="" Foreground="{StaticResource TextFaint}"
|
||||||
FontSize="20" Margin="12,0,0,0" />
|
FontSize="20" Margin="12,0,0,0" />
|
||||||
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
|
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
|
||||||
Padding="9,0" VerticalContentAlignment="Center"
|
Padding="9,0" VerticalContentAlignment="Center"
|
||||||
Background="Transparent" BorderThickness="0"
|
Background="Transparent" BorderThickness="0"
|
||||||
Foreground="{StaticResource Text}" FontSize="14" />
|
Foreground="{StaticResource Text}" FontSize="14" />
|
||||||
@@ -82,6 +82,42 @@
|
|||||||
<!-- ============ sync + conflicts ============ -->
|
<!-- ============ sync + conflicts ============ -->
|
||||||
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
|
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
|
||||||
|
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
|
||||||
|
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
|
||||||
|
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
|
||||||
|
|
||||||
|
Above the list rather than over it, like the panels below: the hosts that also match what was typed
|
||||||
|
stay in view, and one of them may be the same machine with a key already bound to it.
|
||||||
|
|
||||||
|
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
|
||||||
|
what keeps the password out of the software keyboard's dictionary, not only off the screen.
|
||||||
|
-->
|
||||||
|
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
|
||||||
|
<StackPanel Spacing="9">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
|
||||||
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Button Classes="primary" Height="44" Content="CONNECT"
|
||||||
|
Command="{Binding ConnectToFilterAddressCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||||
|
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
|
||||||
|
|
||||||
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
|
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -1342,6 +1342,19 @@
|
|||||||
<Style Selector="TextBlock.qcrow-subtext">
|
<Style Selector="TextBlock.qcrow-subtext">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!--
|
||||||
|
The palette's typed-address row, which sits above the list rather than in it and so has no ListBoxItem
|
||||||
|
to take :pointerover and :selected from. The same two states, on the row itself.
|
||||||
|
-->
|
||||||
|
<Style Selector="Border.qcrow.adhoc:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.qcrow.adhoc.selected, Border.qcrow.adhoc.selected:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.qcrow.adhoc.selected TextBlock.qcrow-text, Border.qcrow.adhoc.selected TextBlock.qcrow-subtext">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="ListBoxItem:selected TextBlock.qcrow-text, ListBoxItem:selected TextBlock.qcrow-subtext">
|
<Style Selector="ListBoxItem:selected TextBlock.qcrow-text, ListBoxItem:selected TextBlock.qcrow-subtext">
|
||||||
<!--
|
<!--
|
||||||
AccentInk rather than a literal white: it is v5's name for "the colour text sits in on top of the
|
AccentInk rather than a literal white: it is v5's name for "the colour text sits in on top of the
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
x:DataType="vm:MainWindowViewModel">
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Ctrl+K: type a few characters of a host's name and press Enter to connect to it.
|
Ctrl+K: type a few characters of a host's name and press Enter to connect to it — or type user@host, press
|
||||||
|
Enter, and give a password, to connect to a machine that is not saved at all.
|
||||||
|
|
||||||
The design's box promises "search hosts · run command". Only the first half is here, because there is no
|
The design's box promises "search hosts · run command". Only the first half is here, because there is no
|
||||||
snippet or saved-command item type in the vault for the second half to run — see
|
snippet or saved-command item type in the vault for the second half to run — see
|
||||||
@@ -35,93 +36,171 @@
|
|||||||
BorderThickness="1" CornerRadius="14">
|
BorderThickness="1" CornerRadius="14">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<!--
|
<StackPanel x:Name="QueryStep" IsVisible="{Binding IsAskingForSearchQuery}">
|
||||||
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
|
||||||
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
<!--
|
||||||
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
||||||
it already has a real one.
|
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
||||||
-->
|
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
||||||
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
it already has a real one.
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
-->
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||||
PlaceholderText="search hosts" Margin="10,0,0,0"
|
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||||
Background="Transparent" BorderThickness="0" />
|
PlaceholderText="search hosts, or type user@host" Margin="10,0,0,0"
|
||||||
</Grid>
|
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||||
</Border>
|
Background="Transparent" BorderThickness="0" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The typed address, above the saved hosts: what was typed was an address, so the row that dials it
|
||||||
|
is the one the query is most plainly about. It is lit by IsAdHocSelected rather than by the list's
|
||||||
|
own selection, because it is not a host and the list below holds hosts only — see the remark on
|
||||||
|
MainWindowViewModel.IsAdHocSelected. Classes="qcrow adhoc" shares the list rows' look; the
|
||||||
|
selected state is a class here because there is no ListBoxItem around it to carry :selected.
|
||||||
|
|
||||||
|
"not saved" in the corner, where a saved row says how it authenticates: that is this row's answer
|
||||||
|
to the same question, and the one thing worth knowing before choosing it.
|
||||||
|
-->
|
||||||
|
<Border x:Name="AdHocRow" Classes="qcrow adhoc" Classes.selected="{Binding IsAdHocSelected}"
|
||||||
|
Height="44" CornerRadius="9" Padding="10,0" Margin="8,6,8,2"
|
||||||
|
IsVisible="{Binding HasAdHocTarget}" Cursor="Hand">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono qcrow-text" Text=">_" FontSize="12"
|
||||||
|
FontWeight="Bold" VerticalAlignment="Center" />
|
||||||
|
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono qcrow-text" Text="{Binding AdHocAddress}" FontSize="13"
|
||||||
|
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="mono qcrow-subtext" Text="connect with a password" FontSize="11.5" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="not saved" FontSize="11"
|
||||||
|
Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Why a query that looks like an address has no row to dial it — a missing port number, say. -->
|
||||||
|
<TextBlock Classes="hint" FontSize="12" Margin="18,12,18,4"
|
||||||
|
Foreground="{StaticResource WarnText}"
|
||||||
|
Text="{Binding AdHocRefusal}"
|
||||||
|
IsVisible="{Binding HasAdHocRefusal}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
||||||
|
took focus would take the arrow keys away from the box being typed into.
|
||||||
|
|
||||||
|
A click connects rather than merely selecting, which is what a list of hosts under a search box
|
||||||
|
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
||||||
|
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
||||||
|
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
||||||
|
|
||||||
|
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
||||||
|
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
||||||
|
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
||||||
|
shows its corners.
|
||||||
|
-->
|
||||||
|
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
||||||
|
ItemsSource="{Binding SearchResults}"
|
||||||
|
SelectedItem="{Binding SelectedSearchResult}">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||||
|
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<!--
|
||||||
|
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
||||||
|
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
||||||
|
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
||||||
|
-->
|
||||||
|
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||||
|
Width="8" Height="8" VerticalAlignment="Center" />
|
||||||
|
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
||||||
|
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
<!--
|
||||||
|
Two readings stacked against the two on the left, and in the same order: what this row
|
||||||
|
IS above, how it is reached below.
|
||||||
|
|
||||||
|
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||||
|
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||||
|
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||||
|
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||||
|
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||||
|
vault name would otherwise take its width out of the host name beside it.
|
||||||
|
|
||||||
|
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||||
|
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||||
|
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||||
|
HorizontalAlignment="Right" MaxWidth="150"
|
||||||
|
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||||
|
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||||
|
FontSize="11" HorizontalAlignment="Right" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Names the other thing the box takes, at the moment somebody is most likely to want it: a name that
|
||||||
|
matches nothing is often a machine that was never saved.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
||||||
|
Text="No host matches that. Type user@host to connect without saving it."
|
||||||
|
IsVisible="{Binding HasNothingToOffer}" />
|
||||||
|
|
||||||
|
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||||
|
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Arrow keys move the selection and Enter takes it; the code-behind owns both, because a ListBox that
|
============ the typed address's password ============
|
||||||
took focus would take the arrow keys away from the box being typed into.
|
|
||||||
|
|
||||||
A click connects rather than merely selecting, which is what a list of hosts under a search box
|
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
|
||||||
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
|
||||||
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
|
||||||
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in.
|
||||||
|
|
||||||
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
|
||||||
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
|
||||||
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
|
||||||
shows its corners.
|
|
||||||
-->
|
-->
|
||||||
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
|
||||||
ItemsSource="{Binding SearchResults}"
|
|
||||||
SelectedItem="{Binding SelectedSearchResult}">
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
|
||||||
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
|
||||||
<!--
|
|
||||||
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
|
||||||
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
|
||||||
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
|
||||||
-->
|
|
||||||
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
|
||||||
Width="8" Height="8" VerticalAlignment="Center" />
|
|
||||||
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
|
||||||
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
|
||||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
|
||||||
TextTrimming="CharacterEllipsis" />
|
|
||||||
</StackPanel>
|
|
||||||
<!--
|
|
||||||
Two readings stacked against the two on the left, and in the same order: what this row
|
|
||||||
IS above, how it is reached below.
|
|
||||||
|
|
||||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding AdHocAddress}" Margin="10,0,0,0"
|
||||||
vault name would otherwise take its width out of the host name beside it.
|
FontSize="14" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
<StackPanel Margin="16,14" Spacing="8">
|
||||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
<TextBox x:Name="Password" Text="{Binding AdHocPassword}" PasswordChar="•"
|
||||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
PlaceholderText="password" FontSize="14" Height="38" />
|
||||||
-->
|
|
||||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
|
||||||
HorizontalAlignment="Right" MaxWidth="150"
|
|
||||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
|
||||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
|
||||||
FontSize="11" HorizontalAlignment="Right" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
|
||||||
Text="No host matches that."
|
Text="{Binding AdHocRefusal}" IsVisible="{Binding HasAdHocRefusal}" />
|
||||||
IsVisible="{Binding !HasSearchResults}" />
|
|
||||||
|
|
||||||
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
<TextBlock Classes="hint" FontSize="12"
|
||||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
Text="Nothing here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key." />
|
||||||
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
</StackPanel>
|
||||||
</Border>
|
|
||||||
|
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||||
|
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="ENTER to connect · ESC to go back" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -39,11 +39,25 @@ internal sealed partial class QuickConnect : UserControl
|
|||||||
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
|
// drag across the list — and the ListBox has moved its own selection by the time a tap completes,
|
||||||
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
|
// which is what makes the handler below a matter of reading the selection rather than hit-testing.
|
||||||
Results.Tapped += OnResultTapped;
|
Results.Tapped += OnResultTapped;
|
||||||
|
AdHocRow.Tapped += OnAdHocRowTapped;
|
||||||
|
|
||||||
|
// The two steps swap in place, and whichever one has just appeared takes the caret: typing an address
|
||||||
|
// and pressing Enter should leave somebody typing a password, not looking for the box to click into.
|
||||||
|
// Watched on the steps themselves rather than on the view model, for the reason TakeKeyboard gives —
|
||||||
|
// the view model's change arrives ahead of the binding that reveals the step.
|
||||||
|
QueryStep.PropertyChanged += OnStepChanged;
|
||||||
|
PasswordStep.PropertyChanged += OnStepChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
/// <summary>The box, so the caret can be put in it the moment the palette opens.</summary>
|
||||||
internal TextBox QueryBox => Query;
|
internal TextBox QueryBox => Query;
|
||||||
|
|
||||||
|
/// <summary>The typed address's password box, which takes the caret when the palette asks for it.</summary>
|
||||||
|
internal TextBox PasswordBox => Password;
|
||||||
|
|
||||||
|
/// <summary>The row offering to dial what was typed, when it reads as an address.</summary>
|
||||||
|
internal Control AdHocTargetRow => AdHocRow;
|
||||||
|
|
||||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -63,6 +77,29 @@ internal sealed partial class QuickConnect : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The password step answers only Enter and Escape, and Escape there goes back to the address: a
|
||||||
|
// mistyped address is the likeliest reason to want out of it, and closing would throw that away too.
|
||||||
|
if (shell.IsAskingForAdHocPassword)
|
||||||
|
{
|
||||||
|
switch (e.Key)
|
||||||
|
{
|
||||||
|
case Key.Escape:
|
||||||
|
shell.BackToSearchQueryCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.Enter:
|
||||||
|
_ = shell.ConnectToAdHocTargetCommand.ExecuteAsync(null);
|
||||||
|
e.Handled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (e.Key)
|
switch (e.Key)
|
||||||
{
|
{
|
||||||
case Key.Escape:
|
case Key.Escape:
|
||||||
@@ -76,12 +113,12 @@ internal sealed partial class QuickConnect : UserControl
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.Down:
|
case Key.Down:
|
||||||
Move(shell, 1);
|
shell.MoveSearchSelection(1);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.Up:
|
case Key.Up:
|
||||||
Move(shell, -1);
|
shell.MoveSearchSelection(-1);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -129,23 +166,38 @@ internal sealed partial class QuickConnect : UserControl
|
|||||||
NativeKeyboardFocus.ReturnTo(window);
|
NativeKeyboardFocus.ReturnTo(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
Query.Focus();
|
if (PasswordStep.IsVisible)
|
||||||
|
{
|
||||||
|
Password.Focus();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Query.Focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
/// <summary>Hands the caret to whichever step has just been revealed.</summary>
|
||||||
private static void Move(MainWindowViewModel shell, int delta)
|
/// <remarks>Posted, for the same reason the palette's own opening is: see <see cref="TakeKeyboard"/>.</remarks>
|
||||||
|
private void OnStepChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (shell.SearchResults.Count == 0)
|
if (e.Property == IsVisibleProperty && e.GetNewValue<bool>() && IsVisible)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the typed address, for a click on its row: the pointer's version of Enter on it.
|
||||||
|
/// </summary>
|
||||||
|
private void OnAdHocRowTapped(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Shell is not { IsSearching: true, HasAdHocTarget: true } shell)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var current = shell.SelectedSearchResult is { } selected
|
e.Handled = true;
|
||||||
? shell.SearchResults.IndexOf(selected)
|
shell.ChooseAdHocTargetCommand.Execute(null);
|
||||||
: -1;
|
|
||||||
|
|
||||||
shell.SelectedSearchResult =
|
|
||||||
shell.SearchResults[Math.Clamp(current + delta, 0, shell.SearchResults.Count - 1)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -2471,6 +2471,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool HasSearchResults => SearchResults.Count > 0;
|
internal bool HasSearchResults => SearchResults.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The machine the palette's query names, when what was typed reads as <c>user@host[:port]</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
|
||||||
|
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
|
||||||
|
/// saving it first — the phone has had a manual box on its terminal screen all along. The query already
|
||||||
|
/// is a box, and <c>user@host</c> is the grammar people type into one, so the palette reads it rather
|
||||||
|
/// than growing a second field.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the phone's box's own parser, so
|
||||||
|
/// the two heads accept exactly the same addresses and refuse the rest with the same sentences.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
|
||||||
|
/// row offering to dial whatever three letters were typed so far would be noise on every keystroke.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasAdHocTarget))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(AdHocAddress))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
|
||||||
|
private VaultViewModel.ManualEndpoint? adHocTarget;
|
||||||
|
|
||||||
|
/// <summary>Whether the palette is offering to connect to a typed address.</summary>
|
||||||
|
internal bool HasAdHocTarget => AdHocTarget is not null;
|
||||||
|
|
||||||
|
/// <summary>The typed address as it will be dialled, port and all.</summary>
|
||||||
|
internal string AdHocAddress => AdHocTarget?.Address ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why a query with an <c>@</c> in it is not an address yet, or empty.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Said under the box rather than swallowed, because a query that looks like an address and produces no
|
||||||
|
/// row to connect to is otherwise a palette that silently ignored the one thing it was asked. It doubles
|
||||||
|
/// as the password step's own refusal line.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasAdHocRefusal))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
|
||||||
|
private string adHocRefusal = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether there is a refusal to show under the box.</summary>
|
||||||
|
internal bool HasAdHocRefusal => AdHocRefusal.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the highlight is on the typed address rather than on a saved host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A flag beside <see cref="SelectedSearchResult"/> rather than a row in <see cref="SearchResults"/>,
|
||||||
|
/// because that list is the vault's own host rows and every reader of it — the ranking, the clicks, the
|
||||||
|
/// tests — is entitled to find a host in each. At most one of the two is ever set; see
|
||||||
|
/// <see cref="OnIsAdHocSelectedChanged"/> and <see cref="OnSelectedSearchResultChanged"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isAdHocSelected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the palette has moved on from the query to asking for the typed address's password.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A typed password and nothing else</b>, which is the manual path's own rule and for its reason: a
|
||||||
|
/// key is the reason to save the machine as a host, and offering the keychain's keys here would be a
|
||||||
|
/// second binding resolution beside the one saved hosts already go through.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A second step inside the palette rather than a dialog, so the whole thing stays on the keyboard:
|
||||||
|
/// type the address, Enter, type the password, Enter. Escape goes back to the query rather than closing,
|
||||||
|
/// because a mistyped address is the likeliest reason to want out of this step.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsAskingForSearchQuery))]
|
||||||
|
private bool isAskingForAdHocPassword;
|
||||||
|
|
||||||
|
/// <summary>Whether the palette is showing its query box and results, rather than the password step.</summary>
|
||||||
|
internal bool IsAskingForSearchQuery => !IsAskingForAdHocPassword;
|
||||||
|
|
||||||
|
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box
|
||||||
|
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
|
||||||
|
/// last one's password already in the box.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string adHocPassword = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the palette has nothing to offer at all — no host, no typed address and no reason why not.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HasNothingToOffer => !HasSearchResults && !HasAdHocTarget && !HasAdHocRefusal;
|
||||||
|
|
||||||
/// <summary>Opens the palette, or closes it if it is already open.</summary>
|
/// <summary>Opens the palette, or closes it if it is already open.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleSearch()
|
private void ToggleSearch()
|
||||||
@@ -2496,11 +2592,174 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
private void CloseSearch()
|
private void CloseSearch()
|
||||||
{
|
{
|
||||||
IsSearching = false;
|
IsSearching = false;
|
||||||
|
IsAskingForAdHocPassword = false;
|
||||||
|
AdHocPassword = string.Empty;
|
||||||
SearchText = string.Empty;
|
SearchText = string.Empty;
|
||||||
SearchResults.Clear();
|
SearchResults.Clear();
|
||||||
SelectedSearchResult = null;
|
SelectedSearchResult = null;
|
||||||
|
IsAdHocSelected = false;
|
||||||
|
AdHocTarget = null;
|
||||||
|
AdHocRefusal = string.Empty;
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasSearchResults));
|
OnPropertyChanged(nameof(HasSearchResults));
|
||||||
|
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves the palette's highlight by one row, the typed address first when there is one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Clamped rather than wrapped: a list that jumps from the last row to the first loses people. Here
|
||||||
|
/// rather than in the palette's code-behind because the typed address is a row that is not in
|
||||||
|
/// <see cref="SearchResults"/>, and which of the two highlights to set is the view model's to know.
|
||||||
|
/// </remarks>
|
||||||
|
internal void MoveSearchSelection(int delta)
|
||||||
|
{
|
||||||
|
var offset = HasAdHocTarget ? 1 : 0;
|
||||||
|
var count = offset + SearchResults.Count;
|
||||||
|
|
||||||
|
if (count == 0 || IsAskingForAdHocPassword)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = IsAdHocSelected
|
||||||
|
? 0
|
||||||
|
: SelectedSearchResult is { } selected
|
||||||
|
? offset + SearchResults.IndexOf(selected)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
var next = Math.Clamp(current + delta, 0, count - 1);
|
||||||
|
|
||||||
|
if (offset == 1 && next == 0)
|
||||||
|
{
|
||||||
|
IsAdHocSelected = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SelectedSearchResult = SearchResults[next - offset];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the typed address, for a click on its row: the pointer's version of Enter.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ChooseAdHocTarget()
|
||||||
|
{
|
||||||
|
if (!HasAdHocTarget)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsAdHocSelected = true;
|
||||||
|
AskForAdHocPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts the palette back on its query, leaving what was typed there and dropping the password.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void BackToSearchQuery()
|
||||||
|
{
|
||||||
|
IsAskingForAdHocPassword = false;
|
||||||
|
AdHocPassword = string.Empty;
|
||||||
|
AdHocRefusal = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects to the typed address with the typed password, saving neither.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the phone's manual box's own
|
||||||
|
/// path, so a machine reached from here gets the same tab, the same host-key question and the same
|
||||||
|
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
|
||||||
|
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
|
||||||
|
/// exactly the one whose key nobody has seen before.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The palette closes before the dial rather than after it, as it does for a saved host: the tab the
|
||||||
|
/// connection opens is where its progress and its failure are drawn, and a palette left up for the length
|
||||||
|
/// of a handshake would sit over the very card that reports it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Concurrent executions allowed, for the reason <c>VaultViewModel.ConnectAsync</c> gives: this command is
|
||||||
|
/// still running for as long as the handshake takes, and without the flag a second address typed into the
|
||||||
|
/// palette meanwhile would be refused as a silent no-op.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
|
private async Task ConnectToAdHocTargetAsync()
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault || AdHocTarget is not { } endpoint)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AdHocPassword.Length == 0)
|
||||||
|
{
|
||||||
|
AdHocRefusal = $"Type the password for {endpoint.Address}.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var password = AdHocPassword;
|
||||||
|
|
||||||
|
CloseSearch();
|
||||||
|
|
||||||
|
// No token, for the reason VaultViewModel.ConnectAsync gives at length: a connection is abandoned by
|
||||||
|
// closing its tab, not by anything here.
|
||||||
|
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the palette on to asking for the typed address's password.</summary>
|
||||||
|
private void AskForAdHocPassword()
|
||||||
|
{
|
||||||
|
AdHocPassword = string.Empty;
|
||||||
|
AdHocRefusal = string.Empty;
|
||||||
|
IsAskingForAdHocPassword = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the query as an address, if it has an <c>@</c> in it, and says why not if it cannot be one.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshAdHocTarget(string query)
|
||||||
|
{
|
||||||
|
if (!query.Contains('@', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
AdHocTarget = null;
|
||||||
|
AdHocRefusal = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VaultViewModel.TryParseManualTarget(query, out var endpoint, out var reason))
|
||||||
|
{
|
||||||
|
AdHocTarget = endpoint;
|
||||||
|
AdHocRefusal = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AdHocTarget = null;
|
||||||
|
AdHocRefusal = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>Clears the other half of the highlight, so at most one row is ever lit.</remarks>
|
||||||
|
partial void OnIsAdHocSelectedChanged(bool value)
|
||||||
|
{
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
SelectedSearchResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="OnIsAdHocSelectedChanged" />
|
||||||
|
partial void OnSelectedSearchResultChanged(HostRowViewModel? value)
|
||||||
|
{
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
IsAdHocSelected = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2515,6 +2774,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ConnectToSearchResultAsync()
|
private async Task ConnectToSearchResultAsync()
|
||||||
{
|
{
|
||||||
|
// The typed address has no keychain item behind it, so nothing can be resolved for it: the password
|
||||||
|
// it needs has to be asked for first. See IsAskingForAdHocPassword.
|
||||||
|
if (IsAdHocSelected && HasAdHocTarget)
|
||||||
|
{
|
||||||
|
AskForAdHocPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Vault is not { } vault || SelectedSearchResult is not { } row)
|
if (Vault is not { } vault || SelectedSearchResult is not { } row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -2552,6 +2819,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (Vault is not { } vault)
|
if (Vault is not { } vault)
|
||||||
{
|
{
|
||||||
SelectedSearchResult = null;
|
SelectedSearchResult = null;
|
||||||
|
IsAdHocSelected = false;
|
||||||
|
AdHocTarget = null;
|
||||||
|
AdHocRefusal = string.Empty;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2571,9 +2841,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
SearchResults.Add(host);
|
SearchResults.Add(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedSearchResult = SearchResults.FirstOrDefault();
|
RefreshAdHocTarget(query);
|
||||||
|
|
||||||
|
// The typed address is highlighted when there is one, unless a saved host dials exactly that address:
|
||||||
|
// that host is the same machine with whatever the keychain knows about it — a key, a saved password —
|
||||||
|
// and Enter should reach it the way it was set up to be reached. The typed row is still one arrow
|
||||||
|
// away for somebody who wants the password prompt instead.
|
||||||
|
var saved = AdHocTarget is { } typed
|
||||||
|
? SearchResults.FirstOrDefault(host =>
|
||||||
|
string.Equals(host.Address, typed.Address, StringComparison.OrdinalIgnoreCase))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (HasAdHocTarget && saved is null)
|
||||||
|
{
|
||||||
|
IsAdHocSelected = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IsAdHocSelected = false;
|
||||||
|
SelectedSearchResult = saved ?? SearchResults.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(HasSearchResults));
|
OnPropertyChanged(nameof(HasSearchResults));
|
||||||
|
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||||
|
|||||||
@@ -4278,6 +4278,54 @@ internal sealed partial class VaultViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string manualStatus = string.Empty;
|
private string manualStatus = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal
|
||||||
|
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while
|
||||||
|
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the
|
||||||
|
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so
|
||||||
|
/// it reads the address rather than the screen growing a second field.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
|
||||||
|
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
|
||||||
|
/// would arrive on every keystroke.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasFilterAddress))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(FilterAddressText))]
|
||||||
|
private ManualEndpoint? filterAddress;
|
||||||
|
|
||||||
|
/// <summary>Whether the search box holds an address that can be connected to.</summary>
|
||||||
|
internal bool HasFilterAddress => FilterAddress is not null;
|
||||||
|
|
||||||
|
/// <summary>The typed address as it will be dialled, port and all.</summary>
|
||||||
|
internal string FilterAddressText => FilterAddress?.Address ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why a search with an <c>@</c> in it is not an address, or why its CONNECT was refused; empty otherwise.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasFilterAddressRefusal))]
|
||||||
|
private string filterAddressRefusal = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether there is a refusal to show under the search box.</summary>
|
||||||
|
internal bool HasFilterAddressRefusal => FilterAddressRefusal.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
|
||||||
|
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a
|
||||||
|
/// password offered on the other. Cleared when the address changes, so the next machine typed never
|
||||||
|
/// arrives with the last one's password already in the box.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string filterAddressPassword = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
|
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -11480,7 +11528,103 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
ManualStatus = string.Empty;
|
ManualStatus = string.Empty;
|
||||||
|
|
||||||
await ConnectToAsync(
|
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
|
||||||
|
/// entry are the ones the manual box and the desktop palette produce, and nothing is written to the
|
||||||
|
/// keychain.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The search box and the password are cleared before the dial, from copies taken first. The list left
|
||||||
|
/// filtered to an address would be a list of nothing to come back to, and the password is a secret nobody
|
||||||
|
/// asked to keep.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||||||
|
/// <see cref="ConnectAsync"/> carries.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
|
private async Task ConnectToFilterAddressAsync()
|
||||||
|
{
|
||||||
|
if (FilterAddress is not { } endpoint)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FilterAddressPassword.Length == 0)
|
||||||
|
{
|
||||||
|
FilterAddressRefusal = $"Type the password for {endpoint.Address}.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var password = FilterAddressPassword;
|
||||||
|
|
||||||
|
// In this order: clearing the filter clears the address and the password with it — see
|
||||||
|
// OnHostFilterChanged — and the endpoint and password above are what is dialled.
|
||||||
|
HostFilter = string.Empty;
|
||||||
|
|
||||||
|
await ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the hosts search box as an address, if it has an <c>@</c> in it.</summary>
|
||||||
|
private void RefreshFilterAddress(string filter)
|
||||||
|
{
|
||||||
|
var previous = FilterAddress;
|
||||||
|
var trimmed = filter.Trim();
|
||||||
|
|
||||||
|
if (!trimmed.Contains('@', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
FilterAddress = null;
|
||||||
|
FilterAddressRefusal = string.Empty;
|
||||||
|
}
|
||||||
|
else if (TryParseManualTarget(trimmed, out var endpoint, out var reason))
|
||||||
|
{
|
||||||
|
FilterAddress = endpoint;
|
||||||
|
FilterAddressRefusal = string.Empty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
FilterAddress = null;
|
||||||
|
FilterAddressRefusal = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different machine, or none, is a different question: the password typed for the last one goes.
|
||||||
|
if (FilterAddress != previous)
|
||||||
|
{
|
||||||
|
FilterAddressPassword = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a terminal on a typed address with a typed password, saving neither.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's
|
||||||
|
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into —
|
||||||
|
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands
|
||||||
|
/// over what it read rather than writing into the phone's fields to have them read back here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The caller has already refused an unreadable address and an empty password, which is why neither is
|
||||||
|
/// checked again: both refusals are sentences about a box, and only the caller knows which box.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal Task ConnectToEndpointAsync(
|
||||||
|
ManualEndpoint endpoint,
|
||||||
|
string password,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(endpoint);
|
||||||
|
|
||||||
|
return ConnectToAsync(
|
||||||
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
|
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
|
||||||
// different connections, and a strip showing the same name twice would be the tab equivalent of
|
// different connections, and a strip showing the same name twice would be the tab equivalent of
|
||||||
// the log entry this also names.
|
// the log entry this also names.
|
||||||
@@ -11488,8 +11632,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
$"{endpoint.Username}@{endpoint.Hostname}",
|
$"{endpoint.Username}@{endpoint.Hostname}",
|
||||||
endpoint.Hostname,
|
endpoint.Hostname,
|
||||||
endpoint.Port),
|
endpoint.Port),
|
||||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
|
new HostAuthentication(endpoint.Username, new SshPasswordCredential(password)),
|
||||||
cancellationToken).ConfigureAwait(true);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -11514,7 +11658,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// would make this the only field in the product with its own address grammar.
|
/// would make this the only field in the product with its own address grammar.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static bool TryParseManualTarget(
|
internal static bool TryParseManualTarget(
|
||||||
string typed,
|
string typed,
|
||||||
[NotNullWhen(true)] out ManualEndpoint? endpoint,
|
[NotNullWhen(true)] out ManualEndpoint? endpoint,
|
||||||
[NotNullWhen(false)] out string? reason)
|
[NotNullWhen(false)] out string? reason)
|
||||||
@@ -11673,7 +11817,16 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// credential rather than from the host — so a username on the shared record would be a field that is
|
/// credential rather than from the host — so a username on the shared record would be a field that is
|
||||||
/// null for every connection but this one.
|
/// null for every connection but this one.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
|
internal sealed record ManualEndpoint(string Username, string Hostname, int Port)
|
||||||
|
{
|
||||||
|
/// <summary>The address as it will be dialled, in the grammar it was typed in.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same shape as <see cref="HostRowViewModel.Address"/> and the connection log's own entries, so
|
||||||
|
/// the palette can compare what was typed against a saved host's address with a plain string match.
|
||||||
|
/// </remarks>
|
||||||
|
public string Address =>
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"{Username}@{Hostname}:{Port}");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
|
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -13470,6 +13623,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
partial void OnHostFilterChanged(string value)
|
partial void OnHostFilterChanged(string value)
|
||||||
{
|
{
|
||||||
|
RefreshFilterAddress(value);
|
||||||
RebuildVisibleHosts();
|
RebuildVisibleHosts();
|
||||||
RebuildHostSections();
|
RebuildHostSections();
|
||||||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||||||
|
|||||||
@@ -321,6 +321,263 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
|||||||
Token);
|
Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- A machine that is not saved ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The palette's answer to an address somebody has just been given: the query reads as one, a row offers
|
||||||
|
/// to dial it, and that row is the one Enter takes — nothing saved answers an address nobody has saved.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TypingAnAddressOffersToConnectWithoutSavingIt()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
shell.SearchText = "root@10.0.0.5:2222";
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
shell.HasAdHocTarget.ShouldBeTrue();
|
||||||
|
shell.IsAdHocSelected.ShouldBeTrue("the typed address is what the query is plainly about");
|
||||||
|
shell.SelectedSearchResult.ShouldBeNull("exactly one row is lit");
|
||||||
|
|
||||||
|
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeTrue();
|
||||||
|
VisibleTexts(palette.AdHocTargetRow).ShouldContain("root@10.0.0.5:2222", StringComparer.Ordinal);
|
||||||
|
VisibleTexts(palette.AdHocTargetRow).ShouldContain("not saved", StringComparer.Ordinal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The default port is printed rather than implied, because it is what will be dialled — and it is what
|
||||||
|
/// makes the row comparable with a saved host's address, which always carries one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAddressWithNoPortIsOfferedOnTwentyTwo()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((_, _) =>
|
||||||
|
{
|
||||||
|
shell.SearchText = "root@box";
|
||||||
|
|
||||||
|
shell.AdHocAddress.ShouldBe("root@box:22");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A query with no <c>@</c> is a search and nothing else, so a name that matches no host offers no row to
|
||||||
|
/// dial — but it does say what the box would take instead, because a name that matches nothing is often a
|
||||||
|
/// machine that was never saved.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ANameThatMatchesNothingSaysHowToReachAnUnsavedMachine()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
shell.SearchText = "nothing-is-called-this";
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
shell.HasAdHocTarget.ShouldBeFalse();
|
||||||
|
palette.AdHocTargetRow.IsEffectivelyVisible.ShouldBeFalse();
|
||||||
|
VisibleTexts(palette).ShouldContain(
|
||||||
|
text => text.Contains("user@host", StringComparison.Ordinal),
|
||||||
|
"the hint names the other thing the box takes");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A query that looks like an address and cannot be one says why under the box, rather than producing no
|
||||||
|
/// row and no reason — which would read as a palette that ignored the one thing it was asked.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAddressThatCannotBeReadSaysWhy()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
shell.SearchText = "root@box:99999";
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
shell.HasAdHocTarget.ShouldBeFalse();
|
||||||
|
VisibleTexts(palette).ShouldContain(
|
||||||
|
text => text.Contains("port", StringComparison.Ordinal),
|
||||||
|
"the refusal is the manual parser's own sentence");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The password is asked for in the palette itself, and the caret goes with the question: typing an
|
||||||
|
/// address and pressing Enter has to leave somebody typing a password, not hunting for the box.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EnterOnTheAddressAsksForItsPasswordInThePalette()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
palette.QueryBox.Focus().ShouldBeTrue();
|
||||||
|
shell.SearchText = "root@10.0.0.5";
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
shell.IsSearching.ShouldBeTrue("nothing has been dialled yet");
|
||||||
|
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||||
|
palette.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||||
|
palette.QueryBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||||
|
palette.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Escape from the password goes back to the address with what was typed intact, because a mistyped
|
||||||
|
/// address is the likeliest reason to want out — and drops the password, which is a secret nobody asked
|
||||||
|
/// to keep.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EscapeFromThePasswordGoesBackToTheAddress()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
palette.QueryBox.Focus().ShouldBeTrue();
|
||||||
|
shell.SearchText = "root@10.0.0.5";
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
shell.AdHocPassword = "hunter2";
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
shell.IsSearching.ShouldBeTrue("Escape here steps back rather than closing");
|
||||||
|
shell.IsAskingForAdHocPassword.ShouldBeFalse();
|
||||||
|
shell.SearchText.ShouldBe("root@10.0.0.5");
|
||||||
|
shell.AdHocPassword.ShouldBeEmpty();
|
||||||
|
palette.QueryBox.IsFocused.ShouldBeTrue("the caret goes back to the address");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Enter with no password typed is refused in the palette rather than dialled: an SSH server asked for a
|
||||||
|
/// password with an empty one answers "authentication failed", which is a sentence about the machine
|
||||||
|
/// when the problem is the box.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEmptyPasswordIsRefusedRatherThanDialled()
|
||||||
|
{
|
||||||
|
var started = new List<ConnectionAttemptEventArgs>();
|
||||||
|
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||||
|
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
palette.QueryBox.Focus().ShouldBeTrue();
|
||||||
|
shell.SearchText = "root@10.0.0.5";
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
|
||||||
|
shell.IsSearching.ShouldBeTrue();
|
||||||
|
shell.HasAdHocRefusal.ShouldBeTrue();
|
||||||
|
started.ShouldBeEmpty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The whole path: an address, a password, and a tab for that address — with nothing added to the
|
||||||
|
/// keychain. The dial itself fails in this suite, as every connect here does, and inside the vault's own
|
||||||
|
/// handling; what the palette promises is the attempt and the closing.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePasswordConnectsToTheAddressAndSavesNothing()
|
||||||
|
{
|
||||||
|
var started = new List<ConnectionAttemptEventArgs>();
|
||||||
|
vault.ConnectionStarting += (_, attempt) => started.Add(attempt);
|
||||||
|
var hostsBefore = vault.Hosts.Count;
|
||||||
|
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
palette.QueryBox.Focus().ShouldBeTrue();
|
||||||
|
shell.SearchText = "root@10.0.0.5:2222";
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
shell.AdHocPassword = "hunter2";
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
|
||||||
|
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as it does for a saved host");
|
||||||
|
shell.AdHocPassword.ShouldBeEmpty("the password does not outlive the palette");
|
||||||
|
|
||||||
|
var attempt = started.ShouldHaveSingleItem();
|
||||||
|
attempt.Address.ShouldBe("root@10.0.0.5:2222");
|
||||||
|
attempt.HostId.ShouldBeNull("nothing in the keychain stands behind this tab");
|
||||||
|
});
|
||||||
|
|
||||||
|
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The one time the typed address does not take the highlight: a saved host dials exactly that address,
|
||||||
|
/// so it is the same machine with whatever the keychain knows about it, and Enter should reach it the way
|
||||||
|
/// it was set up to be reached. The typed row is still offered, one arrow away.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAddressASavedHostDialsHighlightsThatHost()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((_, _) =>
|
||||||
|
{
|
||||||
|
shell.SearchText = "deploy@host-3.internal";
|
||||||
|
|
||||||
|
shell.HasAdHocTarget.ShouldBeTrue();
|
||||||
|
shell.IsAdHocSelected.ShouldBeFalse();
|
||||||
|
shell.SelectedSearchResult.ShouldNotBeNull().Label.ShouldBe("host-3");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The typed row sits above the list and the arrows treat it as the list's first row, clamped as the rest
|
||||||
|
/// are — and the caret stays in the box throughout, for the reason the plain arrows test gives.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheArrowsMoveBetweenTheAddressAndTheSavedHosts()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
palette.QueryBox.Focus().ShouldBeTrue();
|
||||||
|
|
||||||
|
// Every seeded host's address starts with this, and none of them is exactly it.
|
||||||
|
shell.SearchText = "deploy@host";
|
||||||
|
shell.SearchResults.Count.ShouldBeGreaterThan(1, "an empty list would prove nothing");
|
||||||
|
shell.IsAdHocSelected.ShouldBeTrue();
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
||||||
|
shell.IsAdHocSelected.ShouldBeFalse();
|
||||||
|
shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||||
|
shell.IsAdHocSelected.ShouldBeTrue();
|
||||||
|
shell.SelectedSearchResult.ShouldBeNull();
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
|
||||||
|
shell.IsAdHocSelected.ShouldBeTrue("clamped rather than wrapped");
|
||||||
|
|
||||||
|
palette.QueryBox.IsFocused.ShouldBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>The pointer's version of Enter on the typed row: straight to its password.</remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClickingTheAddressAsksForItsPassword()
|
||||||
|
{
|
||||||
|
await OnThePaletteAsync((palette, window) =>
|
||||||
|
{
|
||||||
|
shell.SearchText = "root@10.0.0.5";
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
var row = palette.AdHocTargetRow;
|
||||||
|
window.MouseDown(Centre(row, window), MouseButton.Left);
|
||||||
|
window.MouseUp(Centre(row, window), MouseButton.Left);
|
||||||
|
|
||||||
|
shell.IsAskingForAdHocPassword.ShouldBeTrue();
|
||||||
|
shell.IsSearching.ShouldBeTrue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
/// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ public sealed partial class ShellFlowTests
|
|||||||
|
|
||||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
|
||||||
|
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
|
||||||
|
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
|
||||||
|
"the session's end should have reached its forwards");
|
||||||
|
|
||||||
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
||||||
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
||||||
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
||||||
|
|||||||
@@ -1130,6 +1130,116 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
ssh.Requests.ShouldBeEmpty();
|
ssh.Requests.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
|
||||||
|
/// and for nothing else. A plain search must not grow one on every keystroke.
|
||||||
|
/// </remarks>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("root@box", "root@box:22")]
|
||||||
|
[InlineData(" deploy@build.internal:2222 ", "deploy@build.internal:2222")]
|
||||||
|
public async Task AnAddressTypedIntoTheHostsSearch_IsOfferedAsAMachineToConnectTo(
|
||||||
|
string typed,
|
||||||
|
string offered)
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
vault.HostFilter = "prod";
|
||||||
|
vault.HasFilterAddress.ShouldBeFalse("a search without an @ is a search");
|
||||||
|
vault.HasFilterAddressRefusal.ShouldBeFalse();
|
||||||
|
|
||||||
|
vault.HostFilter = typed;
|
||||||
|
|
||||||
|
vault.HasFilterAddress.ShouldBeTrue();
|
||||||
|
vault.FilterAddressText.ShouldBe(offered);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The whole path from the search box: the address and the password typed there are what is dialled, a tab
|
||||||
|
/// opens for it, and the keychain is exactly as it was — that is the point of connecting this way. The box
|
||||||
|
/// and the password are both empty afterwards, so the list is not left filtered to a machine it does not
|
||||||
|
/// hold and the password does not wait in the box for the next one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHostsSearchConnectsToATypedAddress_AndSavesNothing()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
var hostsBefore = vault.Hosts.Count;
|
||||||
|
|
||||||
|
vault.HostFilter = "deploy@build.internal:2222";
|
||||||
|
vault.FilterAddressPassword = "hunter2";
|
||||||
|
|
||||||
|
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
|
request.Host.ShouldBe("build.internal");
|
||||||
|
request.Port.ShouldBe(2222);
|
||||||
|
request.Username.ShouldBe("deploy");
|
||||||
|
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
|
||||||
|
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
|
||||||
|
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
|
||||||
|
|
||||||
|
vault.HostFilter.ShouldBeEmpty();
|
||||||
|
vault.FilterAddressPassword.ShouldBeEmpty();
|
||||||
|
vault.HasFilterAddress.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the
|
||||||
|
/// address stays where it was typed, so filling the password in is all that is left to do.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
vault.HostFilter = "root@box";
|
||||||
|
|
||||||
|
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.FilterAddressRefusal.ShouldContain("password");
|
||||||
|
vault.HostFilter.ShouldBe("root@box");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
shell.Tabs.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A search that looks like an address and cannot be one says why, in the manual parser's own words,
|
||||||
|
/// rather than offering nothing and leaving the box looking ignored.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAddressInTheHostsSearchThatCannotBeRead_SaysWhy()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
vault.HostFilter = "root@box:70000";
|
||||||
|
|
||||||
|
vault.HasFilterAddress.ShouldBeFalse();
|
||||||
|
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
|
||||||
|
/// away, or the next machine typed would be dialled with somebody else's password.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangingTheAddressInTheHostsSearch_DropsThePasswordTypedForTheLastOne()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
vault.HostFilter = "root@box";
|
||||||
|
vault.FilterAddressPassword = "hunter2";
|
||||||
|
|
||||||
|
vault.HostFilter = "root@other-box";
|
||||||
|
|
||||||
|
vault.FilterAddressPassword.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
|
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
|
||||||
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
|
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
|
||||||
|
|||||||
Reference in new issue
Block a user