Public Access
Give the hosts board its vaults, and the Hetzner import a vault and a key
Six things, all of them answers to a question a screen was deciding on somebody's behalf. The board draws a heading per vault and sorts each vault's hosts by that vault's own groups. A group is an item of one vault, so drawing every group of every vault in one run flattened away containment that already exists: two vaults each holding a "production" were two headings with the same word on them, told apart by a small grey name beside each. Single- vault sessions are unchanged. There is now one "No group" heading per vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts folding every other vault's away with them. A Ctrl-click starts its set with the card the board has lit. The board always has a selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card lit, nothing ticked" is the state this screen opens in, and ticking a second card there produced a set of one: the machine the user was looking at was left out of the count and out of everything Delete, Move and Change group then ran over. The card tooltip says the host's name as well as its address. 214 pixels of card trims the name, and the tooltip was the one place it was not said in full. The card's floor drops from 120 to 104. A card with no tags measures about 87, so the mock's number was thirty pixels of air under every card on a board that is scrolled. "Move to another vault…" joins the board's context menu for one host. Moving one machine meant opening its pane and finding the menu inside it, while moving six was a right click away — the one-host case was the harder of the two. And the Hetzner import asks which vault and which key. A project belongs to a team more often than to a person, so forty servers landing wherever the session happens to be standing is the wrong default; ImportHostsAsync takes the target vault, and the ssh_config importer passes null and is unchanged. The key is the half Hetzner cannot supply and the keychain usually already holds — the public half went to Hetzner when the servers were made — so the import points every host at it rather than bringing one. It defaults to no key rather than guessing, and says so when the key it was given lives in a different vault from the hosts.
This commit is contained in:
1 parent
ed936380b3
commit
c859705b83
9 files changed
+926
-84
No files matched your search
@@ -149,6 +149,52 @@
|
||||
ToolTip.Tip="The SSH port every imported host gets." />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
|
||||
decide on somebody's behalf: the active vault, and nothing.
|
||||
|
||||
A project belongs to a team more often than to a person, so the vault is asked rather than
|
||||
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
|
||||
this is its own selection rather than the keychain screen's standing preference. It is hidden
|
||||
at one vault, which is a question with one answer.
|
||||
|
||||
The key is the half Hetzner cannot supply and the keychain usually already has: the public
|
||||
half went to Hetzner when the servers were made, the private half never did. Pointing forty
|
||||
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
|
||||
picker is hidden entirely on a keychain with no keys in it.
|
||||
-->
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasVaultChoice}">
|
||||
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
|
||||
SelectedItem="{Binding SelectedVault}"
|
||||
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasKeyChoice}">
|
||||
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
|
||||
VerticalAlignment="Center" />
|
||||
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
|
||||
SelectedItem="{Binding SelectedKey}"
|
||||
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HetznerKeyChoice">
|
||||
<TextBlock Text="{Binding Label}" FontSize="12" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
|
||||
Text="{Binding AddressNote}" />
|
||||
|
||||
|
||||
@@ -457,11 +457,12 @@
|
||||
something that refuses it, leaving the window with nothing focused and the keystrokes going
|
||||
nowhere.
|
||||
|
||||
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
|
||||
the old grid held one level of a tree at a time, and this holds every group's cards at once, the
|
||||
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
|
||||
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
|
||||
feature invisible until it is used.
|
||||
◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
|
||||
than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
|
||||
cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
|
||||
anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
|
||||
which is what makes the feature invisible until it is used. A session holding one vault draws no
|
||||
vault headings either, for the same reason and by the same rule.
|
||||
-->
|
||||
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -472,6 +473,27 @@
|
||||
<DataTemplate x:DataType="vm:HostSectionViewModel">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<!--
|
||||
============ THE VAULT HEADING ============
|
||||
◆ THE BOARD'S OUTER LEVEL. A vault holds groups and a group holds hosts, and until this
|
||||
row existed the board drew only the inner two — every group of every vault in one run,
|
||||
told apart by a small grey vault name beside each heading. Two vaults each holding a
|
||||
"production" were two headings with the same word on them.
|
||||
|
||||
Drawn on the first section of each vault and nowhere else — see
|
||||
HostSectionViewModel.VaultHeading — and on no section at all where the board has one
|
||||
vault, which is what keeps a single-vault keychain looking exactly as it did. The rule
|
||||
to its right is the whole of the emphasis: this level is a divider with a name on it,
|
||||
not a heading competing with the group headings under it.
|
||||
-->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding StartsAVault}" Margin="0,2,0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding VaultHeading}" FontSize="11.5"
|
||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Grid.Column="1" Height="1" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Background="{StaticResource BorderSubtle}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
============ THE SECTION HEADING ============
|
||||
"{label} · {count}", the vault name beside it when more than one vault is in play, a
|
||||
@@ -569,6 +591,18 @@
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
|
||||
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
|
||||
|
||||
<!--
|
||||
◆ THE SAME ENTRY THE DRAWER'S ⋯ MENU HAS, on the board where the host is. Moving
|
||||
one machine used to mean opening its pane first and then finding the menu inside
|
||||
it, while moving six was a right click away — the one-host case was the harder of
|
||||
the two, which is backwards. The command raises the drawer itself (see
|
||||
VaultViewModel.MoveHost); the picker it opens lives in that footer and nowhere
|
||||
else, and an entry that armed a panel nobody could see would be a menu item that
|
||||
did nothing.
|
||||
-->
|
||||
<MenuItem Header="Move to another vault…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveHostCommand}" />
|
||||
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
|
||||
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
|
||||
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
|
||||
@@ -600,16 +634,26 @@
|
||||
The full address is on the tooltip and nowhere else on the card — the design's own
|
||||
rule, kept: a card read while scanning a section is read for its name and whether
|
||||
it is live, and an address is what somebody reads once they have found the machine,
|
||||
which the tooltip and the drawer both still say.
|
||||
which the tooltip and the drawer both still say. The name is on the tooltip too,
|
||||
and that is not duplication of the line six pixels above it: 214 pixels of card
|
||||
trims it, and the tooltip is where the untrimmed one lives. See
|
||||
HostRowViewModel.Tooltip.
|
||||
|
||||
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
|
||||
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
|
||||
card wearing four tags grows a second line rather than clipping the fourth one — a
|
||||
silent truncation is exactly the kind of thing the honesty rule in
|
||||
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
|
||||
|
||||
◆ 104 RATHER THAN THE MOCK'S 120, and the floor is the only thing that moved. What
|
||||
a card with no tags actually needs is about 87 — 20 of padding, 43 of name and
|
||||
address, 24 for the row along the bottom — so 120 was thirty pixels of air below
|
||||
every card on a board that is scrolled. It is still a floor with room in it: the
|
||||
number is deliberately above what the content measures, so the common card is a
|
||||
uniform height rather than each one shrink-wrapping its own last line.
|
||||
-->
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="120"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="104"
|
||||
ToolTip.Tip="{Binding Tooltip}">
|
||||
<Panel>
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
|
||||
@@ -263,7 +263,9 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
if (control)
|
||||
{
|
||||
vault.ToggleHostChoiceCommand.Execute(row);
|
||||
// Extend rather than toggle: the set this starts has the lit card in it, which is the half a
|
||||
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
|
||||
vault.ExtendHostChoiceCommand.Execute(row);
|
||||
anchor = row;
|
||||
|
||||
Board.Focus();
|
||||
|
||||
@@ -148,6 +148,38 @@ internal sealed partial class HetznerRowViewModel(HetznerServer server) : Observ
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the import's key picker: a key already in the keychain, or none at all.
|
||||
/// </summary>
|
||||
/// <param name="KeyId">The key, or null for "no key".</param>
|
||||
/// <param name="VaultId">The vault that key lives in, or null for the "no key" entry.</param>
|
||||
/// <param name="Label">What the picker shows.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>An id rather than the key, because nothing here reads key material.</b> The import writes
|
||||
/// <c>HostSecret.SshKeyId</c> and stops; the private half stays where it already was, in the one vault
|
||||
/// entry every host that uses it points at. That is the same argument <c>VaultViewModel.ImportHostsAsync</c>
|
||||
/// makes for storing one key per file rather than one per host, arrived at from the other end: here there
|
||||
/// is no file at all, only a key the keychain has had all along.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A sentinel entry for "no key" rather than a null selection, for the reason <c>GroupChoice.None</c> gives:
|
||||
/// a picker with nothing selected and a picker meaning "ask for a password" look identical and are not the
|
||||
/// same thing — and on this screen the second is a promise about how forty machines will behave.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
|
||||
{
|
||||
/// <summary>The "bind nothing" entry, always first and selected until somebody chooses otherwise.</summary>
|
||||
/// <remarks>
|
||||
/// The default, and deliberately: pointing an estate at a key is a claim about which key opens those
|
||||
/// machines, and Hetzner cannot tell this screen that — the API names a project's keys but has never
|
||||
/// held a private half. Guessed wrong it is forty hosts that fail to authenticate with a reason nobody
|
||||
/// typed. Chosen, it is one answer to one question the person running the import already knows.
|
||||
/// </remarks>
|
||||
internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Importing the servers in a Hetzner Cloud project.
|
||||
/// </summary>
|
||||
@@ -310,14 +342,76 @@ internal sealed partial class HetznerImportViewModel(
|
||||
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.
|
||||
/// Where the imported hosts go — see <see cref="VaultViewModel.ImportHostsAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fixed rather than offered as a picker, exactly as the ssh_config importer's is: the import goes
|
||||
/// through the same active vault every other bulk write does, and there is no per-import target to bind.
|
||||
/// Printed as a fact instead of drawn as a dropdown.
|
||||
/// <para>
|
||||
/// ◆ <b>A picker rather than the active vault, which is what the ssh_config importer still uses.</b>
|
||||
/// The two are not the same question. That screen reads <c>~/.ssh/config</c> — this machine's own
|
||||
/// account, on this machine — and "wherever I am standing" is a fair answer. This one reads a whole
|
||||
/// project out of a company's console, and a project is exactly the thing a team shares: forty servers
|
||||
/// filed into somebody's personal vault by default is either forty items nobody else can see, or forty
|
||||
/// items moved one at a time afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Its own selection rather than <see cref="VaultViewModel.TargetVaults"/>'s, on the reasoning
|
||||
/// <c>VaultViewModel.EditorVaultChoices</c> gives for the host editor's: that one is a standing
|
||||
/// preference about where new items go, and this is a field of the import in front of you. Filled from
|
||||
/// the same list, so it offers what every other "file this into" control offers — vaults this session
|
||||
/// can both read and write.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string VaultName => vault.VaultName;
|
||||
internal ObservableCollection<VaultChoiceViewModel> VaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedVault;
|
||||
|
||||
/// <summary>Whether there is more than one vault to choose between.</summary>
|
||||
/// <remarks>
|
||||
/// Hidden entirely at one rather than shown disabled, as the keychain screen's own picker is: a control
|
||||
/// offering one option is a question with no answer.
|
||||
/// </remarks>
|
||||
internal bool HasVaultChoice => VaultChoices.Count > 1;
|
||||
|
||||
/// <summary>The vault the import writes to, or null for the active one.</summary>
|
||||
internal Guid? TargetVaultId => SelectedVault?.VaultId;
|
||||
|
||||
/// <summary>What the summary line calls the vault the hosts are going into.</summary>
|
||||
internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
|
||||
|
||||
/// <summary>
|
||||
/// The keys every imported host could be bound to: "no key", then everything in the keychain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The one thing this import could never do, done by pointing rather than by bringing.</b> Hetzner
|
||||
/// has never held a private half, so no key can arrive <em>with</em> these hosts — but a key that is
|
||||
/// already in the keychain is almost always the key that opens them, because it is the one whose public
|
||||
/// half was handed to Hetzner when the servers were created. Binding it here is one answer for the whole
|
||||
/// estate; without it, it was forty visits to forty host editors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every readable vault's keys, not the target vault's. A binding resolves across vaults — that is what
|
||||
/// makes one key on twenty hosts in three vaults the arrangement keys exist for — so narrowing this to
|
||||
/// the destination would refuse a working answer. What it costs is a host whose colleagues cannot
|
||||
/// resolve its key, and that is said in <see cref="AuthenticationNote"/> rather than prevented here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HetznerKeyChoice> KeyChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
|
||||
|
||||
/// <summary>Whether the keychain has a key to offer at all.</summary>
|
||||
/// <remarks>
|
||||
/// The picker goes entirely on a keychain with no keys in it, rather than standing there offering only
|
||||
/// "no key" — which is a question with one answer, and an invitation to go looking for a control that
|
||||
/// would do something.
|
||||
/// </remarks>
|
||||
internal bool HasKeyChoice => KeyChoices.Count > 1;
|
||||
|
||||
/// <summary>The key every imported host is bound to, where one was chosen.</summary>
|
||||
internal Guid? BoundKeyId => SelectedKey?.KeyId;
|
||||
|
||||
/// <summary>How many ticked, out of how many, and where they land.</summary>
|
||||
internal string SelectionSummary
|
||||
@@ -331,18 +425,45 @@ internal sealed partial class HetznerImportViewModel(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sentence that says an imported host will not connect on its own.
|
||||
/// The sentence that says how an imported host will authenticate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives
|
||||
/// for its own version: whether the credential comes with the host is the difference between a bookmark
|
||||
/// that connects and one that asks for a password, and somebody who is not told will conclude the
|
||||
/// import was broken.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two sentences now rather than one, because the answer is a choice. The first half of the original —
|
||||
/// that Hetzner cannot hand over a private half — is true either way and stays in both, since it is the
|
||||
/// fact that explains why the key had to be picked from a list instead of arriving with the servers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static string AuthenticationNote =>
|
||||
"Hetzner has never held the private half of an SSH key, so nothing here can bring one. A server "
|
||||
+ "imported this way arrives asking for a password until you bind it to a key already in your "
|
||||
+ "keychain — from the host's own editor, or in bulk from the Hosts screen.";
|
||||
internal string AuthenticationNote => SelectedKey?.KeyId is null
|
||||
? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
|
||||
+ "chosen above, a server imported this way arrives asking for a password until you bind it to "
|
||||
+ "one — from the host's own editor, or in bulk from the Hosts screen."
|
||||
: $"Hetzner has never held the private half of an SSH key, so nothing here brings one: every server "
|
||||
+ $"imported is pointed at '{SelectedKey.Label}', which was already in your keychain. The key "
|
||||
+ $"itself is not copied and not read.{StrandedKeyNote}";
|
||||
|
||||
/// <summary>
|
||||
/// What is said when the chosen key sits in a different vault from the hosts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same fact the move panel names when a host leaves its key behind, and it matters more here
|
||||
/// because it is being done to a whole estate at once: a binding resolves across every vault
|
||||
/// <em>this</em> account can read, so the import connects perfectly from this machine and not at all
|
||||
/// for a colleague who holds only the destination's key. Stated rather than prevented — one key used by
|
||||
/// hosts in three vaults is what keys are for.
|
||||
/// </remarks>
|
||||
private string StrandedKeyNote =>
|
||||
SelectedKey?.VaultId is { } keyVault && TargetVaultId is { } target && keyVault != target
|
||||
? $" That key lives in another vault than {VaultName}. It resolves for you, so these hosts will "
|
||||
+ $"connect here — but anyone who can read {VaultName} and not the key's own vault will "
|
||||
+ "see hosts they cannot authenticate."
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>What the token step says about the token it is asking for.</summary>
|
||||
/// <remarks>
|
||||
@@ -363,31 +484,13 @@ internal sealed partial class HetznerImportViewModel(
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ClearRows();
|
||||
RefreshChoices();
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
var import = await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
foreach (var server in import.Servers)
|
||||
{
|
||||
var row = new HetznerRowViewModel(server);
|
||||
|
||||
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound
|
||||
// from the item template, because a template binding cannot reach the list it is inside
|
||||
// without a parent binding — and the count is on the parent by definition.
|
||||
row.PropertyChanged += OnRowChanged;
|
||||
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
foreach (var warning in import.Warnings)
|
||||
{
|
||||
Warnings.Add(warning);
|
||||
}
|
||||
|
||||
Refresh(resetSelection: true);
|
||||
Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
|
||||
|
||||
if (Rows.Count == 0)
|
||||
{
|
||||
@@ -445,28 +548,25 @@ internal sealed partial class HetznerImportViewModel(
|
||||
|
||||
try
|
||||
{
|
||||
// Key and KeyPath stay null and the binding goes on the host itself: those two are for key
|
||||
// material arriving with an import, and there is none — see HetznerKeyChoice. A null BoundKeyId
|
||||
// leaves the host bound to nothing, which is what it always was.
|
||||
var requests = chosen
|
||||
.Select(row => new ImportedHostRequest(
|
||||
row.Server.ToSecret(row.Resolved!, Username, port),
|
||||
row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
|
||||
Key: null,
|
||||
KeyPath: null))
|
||||
.ToList();
|
||||
|
||||
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
|
||||
var outcome = await vault.ImportHostsAsync(requests, TargetVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// Re-resolved rather than cleared, so a row that was just imported now says "already here" —
|
||||
// which is what makes coming back to the list and importing the rest of the project harmless
|
||||
// and visible rather than harmless and confusing.
|
||||
Refresh(resetSelection: true);
|
||||
|
||||
// This screen's own sentence rather than the vault's, which the push that follows an import
|
||||
// overwrites with a sync report — right on a screen somebody is about to leave, wrong on the one
|
||||
// they are still standing on.
|
||||
Status = outcome.Hosts == 1
|
||||
? "Imported 1 server. It is on the Hosts screen, asking for a password until you bind it "
|
||||
+ "to a key."
|
||||
: $"Imported {outcome.Hosts} servers. They are on the Hosts screen, asking for a password "
|
||||
+ "until you bind them to a key.";
|
||||
Status = WhatWasStored(outcome.Hosts);
|
||||
|
||||
Step = HetznerImportStep.Done;
|
||||
}
|
||||
@@ -538,6 +638,108 @@ internal sealed partial class HetznerImportViewModel(
|
||||
[RelayCommand]
|
||||
private void Cancel() => onCancel?.Invoke();
|
||||
|
||||
/// <summary>What the import did, in the sentence the screen keeps after it.</summary>
|
||||
/// <remarks>
|
||||
/// This screen's own sentence rather than the vault's, which the push that follows an import overwrites
|
||||
/// with a sync report — right on a screen somebody is about to leave, wrong on the one they are still
|
||||
/// standing on. It names the vault and the key because those are the two answers that were chosen above
|
||||
/// rather than assumed: which vault is what somebody will look for on the Hosts screen, and whether
|
||||
/// there is a key is what they would otherwise find out at the next connection.
|
||||
/// </remarks>
|
||||
private string WhatWasStored(int hosts) => (hosts == 1, SelectedKey?.KeyId is not null) switch
|
||||
{
|
||||
(true, true) =>
|
||||
$"Imported 1 server into {VaultName}. It is on the Hosts screen, bound to '{SelectedKey!.Label}'.",
|
||||
(true, false) =>
|
||||
$"Imported 1 server into {VaultName}. It is on the Hosts screen, asking for a password until "
|
||||
+ "you bind it to a key.",
|
||||
(false, true) =>
|
||||
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, bound to "
|
||||
+ $"'{SelectedKey!.Label}'.",
|
||||
_ =>
|
||||
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, asking for a "
|
||||
+ "password until you bind them to a key.",
|
||||
};
|
||||
|
||||
/// <summary>Fills the list from what the project said, and resolves every row against the choices.</summary>
|
||||
/// <remarks>
|
||||
/// Its own method rather than part of <see cref="ScanAsync"/>, which is a request, a failure path and a
|
||||
/// credential to forget already. Nothing here is stored: these rows are candidates until somebody ticks
|
||||
/// them and presses the button.
|
||||
/// </remarks>
|
||||
private void Fill(HetznerImport import)
|
||||
{
|
||||
foreach (var server in import.Servers)
|
||||
{
|
||||
var row = new HetznerRowViewModel(server);
|
||||
|
||||
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound from
|
||||
// the item template, because a template binding cannot reach the list it is inside without a
|
||||
// parent binding — and the count is on the parent by definition.
|
||||
row.PropertyChanged += OnRowChanged;
|
||||
|
||||
Rows.Add(row);
|
||||
}
|
||||
|
||||
foreach (var warning in import.Warnings)
|
||||
{
|
||||
Warnings.Add(warning);
|
||||
}
|
||||
|
||||
Refresh(resetSelection: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Run at the start of every scan rather than once in a constructor, because the wizard outlives a
|
||||
/// sync: a vault shared with this account, or a key a colleague pushed, between opening this screen and
|
||||
/// pressing SCAN is a real answer that a list built at construction time would not be offering.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Selections are carried across <em>by id</em>, which is the same rule the host list's own reload
|
||||
/// follows: the row objects are all new, so keeping the old ones would leave a picker showing a vault
|
||||
/// the import would not actually write to. Anything that has since gone falls back to the default —
|
||||
/// the active vault, and no key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshChoices()
|
||||
{
|
||||
var vaultId = SelectedVault?.VaultId ?? vault.TargetVaultId;
|
||||
var keyId = SelectedKey?.KeyId;
|
||||
|
||||
VaultChoices.Clear();
|
||||
|
||||
foreach (var choice in vault.TargetVaults)
|
||||
{
|
||||
VaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
SelectedVault = VaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId)
|
||||
?? VaultChoices.FirstOrDefault();
|
||||
|
||||
KeyChoices.Clear();
|
||||
KeyChoices.Add(HetznerKeyChoice.None);
|
||||
|
||||
foreach (var key in vault.Keys)
|
||||
{
|
||||
// The vault name where there is more than one to be in, exactly as the key list itself prints
|
||||
// it: two vaults may each hold a "deploy", and a picker that binds forty machines to one of them
|
||||
// has to say which. HasVaultBadge is that same test, already answered.
|
||||
KeyChoices.Add(new HetznerKeyChoice(
|
||||
key.EntityId,
|
||||
key.VaultId,
|
||||
key.HasVaultBadge ? $"{key.Label} · {key.VaultName}" : key.Label));
|
||||
}
|
||||
|
||||
SelectedKey = KeyChoices.FirstOrDefault(choice => choice.KeyId == keyId) ?? HetznerKeyChoice.None;
|
||||
|
||||
OnPropertyChanged(nameof(HasVaultChoice));
|
||||
OnPropertyChanged(nameof(HasKeyChoice));
|
||||
}
|
||||
|
||||
/// <summary>Drops the list, unsubscribing from what it held.</summary>
|
||||
/// <remarks>
|
||||
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would
|
||||
@@ -605,6 +807,21 @@ internal sealed partial class HetznerImportViewModel(
|
||||
OnPropertyChanged(nameof(StepTitle));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The vault is named in three places on this step — the summary line, the note under it and the
|
||||
/// sentence about a key left in another vault — and none of them is a control the picker owns.
|
||||
/// </remarks>
|
||||
partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(VaultName));
|
||||
OnPropertyChanged(nameof(SelectionSummary));
|
||||
OnPropertyChanged(nameof(AuthenticationNote));
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnSelectedVaultChanged" />
|
||||
partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
|
||||
OnPropertyChanged(nameof(AuthenticationNote));
|
||||
|
||||
partial void OnAddressKindChanged(HetznerAddressKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(UsesPublicIPv4));
|
||||
|
||||
@@ -371,7 +371,7 @@ internal sealed partial class ImportViewModel(
|
||||
|
||||
var requests = chosen.Select(BuildRequest).ToList();
|
||||
|
||||
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
|
||||
var outcome = await vault.ImportHostsAsync(requests, vaultId: null, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
||||
// pressing the button twice harmless and visible rather than harmless and confusing.
|
||||
|
||||
@@ -55,6 +55,22 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
|
||||
/// <summary>Whether this heading has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// What this heading is folded and unfolded by, in <c>VaultViewModel.collapsedGroups</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The group's id for a group, and something else for "No group" — which is why this is not
|
||||
/// <see cref="GroupId"/> with a fallback.</b> It was exactly that until the desktop's board grew a
|
||||
/// heading per vault: there is now one "No group" heading <em>per vault</em>, and every one of them
|
||||
/// answers <see cref="GroupId"/> with null. Keyed on <see cref="Guid.Empty"/> they were one heading as
|
||||
/// far as the folded set was concerned, so folding one vault's unfiled hosts away folded every other
|
||||
/// vault's with it. The desktop passes the vault's own id instead; the phone, whose list has a single
|
||||
/// UNGROUPED heading spanning every vault, goes on passing <see cref="Guid.Empty"/>. Neither can collide
|
||||
/// with a group id, and <see cref="Guid.Empty"/> is not a legal one — <c>HostSecret.TryValidate</c>
|
||||
/// refuses it.
|
||||
/// </remarks>
|
||||
internal Guid CollapseKey { get; init; }
|
||||
|
||||
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
||||
internal string Chevron => IsExpanded ? "▾" : "▸";
|
||||
|
||||
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
|
||||
internal bool HasHeader => header is not null;
|
||||
|
||||
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
|
||||
|
||||
/// <summary>
|
||||
/// The vault this section opens, or empty where it is not the first section of one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The board's outer level, and groups are the inner one.</b> A group belongs to exactly one vault
|
||||
/// — it is an item stored under that vault's key, like the hosts filed into it — so a board that drew
|
||||
/// every group in one run was flattening the containment that already exists. Two vaults each holding a
|
||||
/// "production" made the point: they were two headings with the same word on them and a small grey
|
||||
/// vault name beside each, distinguishable only by reading the second line. Vaults are now the headings
|
||||
/// and groups sort the hosts inside one, which is the shape the data has.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Carried on the first section of each vault rather than as a section of its own, because a vault
|
||||
/// heading is not a thing cards go under: every card is under a group heading, and the group headings
|
||||
/// are what a vault contains. Empty on every other section, and empty on all of them where the board
|
||||
/// draws one vault — a heading naming the only vault there is says nothing the screen does not already
|
||||
/// say, and is the same test <see cref="HostRowViewModel.VaultBadge"/> has always applied to itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string VaultHeading { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this section opens a vault and should draw its name above the group heading.</summary>
|
||||
internal bool StartsAVault => VaultHeading.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
|
||||
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
|
||||
|
||||
/// <summary>
|
||||
/// What a card says when the pointer rests on it: the name in full, and the address under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The name is here because the card trims it and the tooltip was the one place it was not said.</b>
|
||||
/// A tile is a fixed 214 wide (see <c>Border.tile</c> in App.axaml) and the name is drawn with
|
||||
/// <c>TextTrimming</c>, so anything past about twenty characters ends in an ellipsis — which is the
|
||||
/// honest thing for the card to do and leaves the machine unidentifiable on a board of hosts whose names
|
||||
/// share a prefix. The tooltip carried the address alone, which named the *host* but not the bookmark:
|
||||
/// six servers behind one address differ only by the half that was trimmed away.
|
||||
/// </remarks>
|
||||
internal string Tooltip => $"{Label}\n{Address}";
|
||||
|
||||
/// <remarks>
|
||||
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
|
||||
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
|
||||
@@ -5170,25 +5224,109 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(CollapseAllLabel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The board, vault by vault: every vault's own groups under its own heading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Two levels, and the outer one is the vault.</b> See
|
||||
/// <see cref="HostSectionViewModel.VaultHeading"/> for the argument. What it means here is a pass per
|
||||
/// vault rather than one over every group at once: each vault's unfiled hosts and each vault's groups
|
||||
/// are flattened on their own, and the first section that comes back wears the vault's name.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One vault on the board and this is exactly what it was.</b> Not a special case for tidiness — a
|
||||
/// heading naming the only vault there is would be a level of hierarchy with nothing to distinguish,
|
||||
/// and the single-vault board is what most of this application's users have. The count is of vaults
|
||||
/// that are readable <em>and</em> shown, so hiding the second vault takes the headings away with it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private List<HostSectionViewModel> BuildHostSections()
|
||||
{
|
||||
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
|
||||
var flat = FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group");
|
||||
var vaults = BoardVaults();
|
||||
|
||||
var sections = vaults.Count < 2
|
||||
? SectionsFrom(
|
||||
FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group"),
|
||||
vaultHeading: null)
|
||||
: vaults
|
||||
.SelectMany(vault => SectionsFrom(
|
||||
FlattenIntoSections(
|
||||
[.. shown.Where(row => row.VaultId == vault.VaultId)],
|
||||
ungroupedFirst: true,
|
||||
ungroupedLabel: "No group",
|
||||
vaultId: vault.VaultId),
|
||||
vault.Name.ToUpperInvariant()))
|
||||
.ToList();
|
||||
|
||||
// Marked on the first heading of the whole board, which is where the mock draws Collapse all /
|
||||
// Expand all — see IsFirstBoardSection and the XAML. The first *heading* rather than the first
|
||||
// section: a vault with no groups at all draws its cards under no heading, and there is nothing on
|
||||
// that row for the control to sit on.
|
||||
for (var index = 0; index < sections.Count; index++)
|
||||
{
|
||||
if (sections[index].Header is not { } first)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sections[index] = new HostSectionViewModel(
|
||||
first with { IsFirstBoardSection = true },
|
||||
sections[index].Hosts)
|
||||
{
|
||||
VaultHeading = sections[index].VaultHeading,
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/// <summary>The vaults the board draws a heading for, in the order it draws them.</summary>
|
||||
/// <remarks>
|
||||
/// The active vault first and then by name, which is the order <see cref="Groups"/>, the host list and
|
||||
/// the key list are all already sorted into — so the sections come out in the order the rows inside them
|
||||
/// were. Hidden vaults are left out here rather than filtered later, because their absence is what makes
|
||||
/// the headings disappear when one vault is left. See <see cref="IsVaultShown"/>.
|
||||
/// </remarks>
|
||||
private List<(Guid VaultId, string Name)> BoardVaults() =>
|
||||
[
|
||||
.. session.ReadableVaults
|
||||
.Where(vault => IsVaultShown(vault.VaultId))
|
||||
.OrderByDescending(vault => vault.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
|
||||
.Select(vault => (vault.VaultId, vault.Name)),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Regroups one flattened run — a whole board, or one vault's part of it — into sections.
|
||||
/// </summary>
|
||||
/// <param name="flat">Headings and rows in draw order, from <see cref="FlattenIntoSections"/>.</param>
|
||||
/// <param name="vaultHeading">
|
||||
/// The vault's name for the first section, or null where the board is not drawing vault headings. When
|
||||
/// it is given, the group headings drop their own vault badge: the name is already on the row above
|
||||
/// them, and printing it twice on every heading is how a two-level board reads as a cluttered one.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Rows arriving before any heading are the no-groups case — <see cref="FlattenIntoSections"/> hands
|
||||
/// back the hosts alone — and they become one section with a null header, which is what the board's XAML
|
||||
/// reads as "draw the cards and nothing above them". Per vault now rather than for the whole board: a
|
||||
/// vault nobody has made a group in draws its cards flat, under its own name, beside a vault that has.
|
||||
/// </remarks>
|
||||
private static List<HostSectionViewModel> SectionsFrom(List<ISidebarRow> flat, string? vaultHeading)
|
||||
{
|
||||
var sections = new List<HostSectionViewModel>();
|
||||
|
||||
if (flat.Count == 0)
|
||||
{
|
||||
return [];
|
||||
return sections;
|
||||
}
|
||||
|
||||
if (Groups.Count == 0)
|
||||
{
|
||||
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
|
||||
}
|
||||
|
||||
var sections = new List<HostSectionViewModel>();
|
||||
SidebarGroupHeader? header = null;
|
||||
var members = new List<HostRowViewModel>();
|
||||
var firstHeading = true;
|
||||
var loose = new List<HostRowViewModel>();
|
||||
|
||||
foreach (var row in flat)
|
||||
{
|
||||
@@ -5199,15 +5337,12 @@ internal sealed partial class VaultViewModel(
|
||||
sections.Add(new HostSectionViewModel(header, members));
|
||||
}
|
||||
|
||||
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
|
||||
// all — see IsFirstBoardSection and the XAML.
|
||||
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
|
||||
firstHeading = false;
|
||||
header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
|
||||
members = [];
|
||||
}
|
||||
else if (row is HostRowViewModel host)
|
||||
{
|
||||
members.Add(host);
|
||||
(header is null ? loose : members).Add(host);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5215,6 +5350,18 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
sections.Add(new HostSectionViewModel(header, members));
|
||||
}
|
||||
else
|
||||
{
|
||||
sections.Add(new HostSectionViewModel(null, loose));
|
||||
}
|
||||
|
||||
if (vaultHeading is { Length: > 0 })
|
||||
{
|
||||
sections[0] = new HostSectionViewModel(sections[0].Header, sections[0].Hosts)
|
||||
{
|
||||
VaultHeading = vaultHeading,
|
||||
};
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -5230,6 +5377,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
|
||||
/// </param>
|
||||
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
|
||||
/// <param name="vaultId">
|
||||
/// The vault this run is about, or null for a run over every vault at once. Given, it narrows the
|
||||
/// groups to that vault's own and becomes the fold key of its ungrouped heading — see
|
||||
/// <see cref="SidebarGroupHeader.CollapseKey"/>, which exists because there is now one such heading per
|
||||
/// vault. The phone's single list passes null and is unchanged by any of this.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
|
||||
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
|
||||
@@ -5240,17 +5393,27 @@ internal sealed partial class VaultViewModel(
|
||||
/// left empty.
|
||||
/// </remarks>
|
||||
private List<ISidebarRow> FlattenIntoSections(
|
||||
IReadOnlyList<HostRowViewModel> shown, bool ungroupedFirst, string ungroupedLabel)
|
||||
IReadOnlyList<HostRowViewModel> shown,
|
||||
bool ungroupedFirst,
|
||||
string ungroupedLabel,
|
||||
Guid? vaultId = null)
|
||||
{
|
||||
var rows = new List<ISidebarRow>();
|
||||
|
||||
if (Groups.Count == 0)
|
||||
// This vault's groups where a vault was named, every readable vault's where one was not. A group is
|
||||
// an item of one vault, so a run about one vault has no business drawing another's headings — and a
|
||||
// vault nobody has made a group in falls into the case below, drawing its cards flat.
|
||||
var groups = vaultId is { } only
|
||||
? Groups.Where(group => group.VaultId == only).ToList()
|
||||
: [.. Groups];
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
rows.AddRange(shown);
|
||||
return rows;
|
||||
}
|
||||
|
||||
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
||||
var known = groups.Select(group => group.EntityId).ToHashSet();
|
||||
|
||||
void AddUngrouped() => AddFlatSection(
|
||||
rows,
|
||||
@@ -5258,14 +5421,15 @@ internal sealed partial class VaultViewModel(
|
||||
null,
|
||||
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
||||
ungroupedLabel,
|
||||
onlyWhenOccupied: true);
|
||||
onlyWhenOccupied: true,
|
||||
ungroupedKey: vaultId ?? Guid.Empty);
|
||||
|
||||
if (ungroupedFirst)
|
||||
{
|
||||
AddUngrouped();
|
||||
}
|
||||
|
||||
foreach (var group in Groups)
|
||||
foreach (var group in groups)
|
||||
{
|
||||
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
|
||||
}
|
||||
@@ -5289,13 +5453,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// <param name="belongs">Which of the shown hosts fall under it.</param>
|
||||
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
|
||||
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
|
||||
/// <param name="ungroupedKey">
|
||||
/// What the ungrouped heading folds under, where this is one — the vault's id on the desktop's
|
||||
/// per-vault board, <see cref="Guid.Empty"/> on the phone's single list. See
|
||||
/// <see cref="SidebarGroupHeader.CollapseKey"/>.
|
||||
/// </param>
|
||||
private void AddFlatSection(
|
||||
List<ISidebarRow> rows,
|
||||
IReadOnlyList<HostRowViewModel> shown,
|
||||
HostGroupRowViewModel? group,
|
||||
Func<HostRowViewModel, bool> belongs,
|
||||
string ungroupedLabel,
|
||||
bool onlyWhenOccupied = false)
|
||||
bool onlyWhenOccupied = false,
|
||||
Guid ungroupedKey = default)
|
||||
{
|
||||
var members = shown.Where(belongs).ToArray();
|
||||
|
||||
@@ -5304,7 +5474,8 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
|
||||
var key = group?.EntityId ?? ungroupedKey;
|
||||
var expanded = !collapsedGroups.Contains(key);
|
||||
|
||||
rows.Add(new SidebarGroupHeader(
|
||||
group?.EntityId,
|
||||
@@ -5313,6 +5484,7 @@ internal sealed partial class VaultViewModel(
|
||||
expanded)
|
||||
{
|
||||
VaultBadge = group?.VaultBadge ?? string.Empty,
|
||||
CollapseKey = key,
|
||||
});
|
||||
|
||||
if (!expanded)
|
||||
@@ -5458,7 +5630,7 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
var key = header.GroupId ?? Guid.Empty;
|
||||
var key = header.CollapseKey;
|
||||
|
||||
if (!collapsedGroups.Remove(key))
|
||||
{
|
||||
@@ -5490,7 +5662,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
foreach (var header in headers)
|
||||
{
|
||||
var key = header.GroupId ?? Guid.Empty;
|
||||
var key = header.CollapseKey;
|
||||
|
||||
if (collapsing)
|
||||
{
|
||||
@@ -6259,6 +6431,69 @@ internal sealed partial class VaultViewModel(
|
||||
ApplyTheChosenHosts();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Ctrl-click: the card joins or leaves the set, and the set starts at whatever card is lit.
|
||||
/// </summary>
|
||||
/// <param name="row">The card that was Ctrl-clicked.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The lit card was not in the set, and every list this one is modelled on says it should be.</b>
|
||||
/// The board always has a selection — a fresh unlock lights the first host so that CONNECT has something
|
||||
/// under it, see <see cref="SelectionAfterReload"/> — so "one card lit, nothing ticked" is the state
|
||||
/// this screen opens in, not an edge case. Ctrl-clicking a second card there used to produce a set of
|
||||
/// one: the machine the user was looking at was silently left out, and the count said 1 while two cards
|
||||
/// were marked on screen. Delete, Move and Change group all read the set, so the omission was one the
|
||||
/// user could act on without noticing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only into an <em>empty</em> set, and never the card being clicked. The first is what makes it a
|
||||
/// starting point rather than a card that keeps re-adding itself: once anything is ticked the lit card
|
||||
/// is either already in the set or was deliberately taken out of it. The second is what keeps every
|
||||
/// Ctrl-click visibly doing something — folding the lit card in and then toggling that same card off
|
||||
/// again would leave the set empty and the card still lit, which is a gesture with no effect and no way
|
||||
/// to tell it was heard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Separate from <see cref="ToggleHostChoice"/> rather than folded into it, because that one is also the
|
||||
/// phone's tap and the phone has no lit card to fold: there a tap only ever reaches the set once a long
|
||||
/// press has already put something in it, and a selection nothing draws is not one anybody can be
|
||||
/// surprised to find in a count.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ExtendHostChoice(HostRowViewModel? row)
|
||||
{
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FoldTheLitCardIn(besides: row);
|
||||
ToggleHostChoice(row);
|
||||
}
|
||||
|
||||
/// <summary>Puts the lit card into an empty set, which is what a Ctrl gesture builds on.</summary>
|
||||
/// <param name="besides">A card to leave out — the one the gesture is about. See
|
||||
/// <see cref="ExtendHostChoice"/> for why.</param>
|
||||
/// <remarks>
|
||||
/// The board's order rather than <see cref="Hosts"/>, for the reason Ctrl+A takes it from there: a
|
||||
/// selected host inside a folded section is not on screen, and starting a set with a card nobody can see
|
||||
/// is the version of this that ends in a deletion. <see cref="ApplyTheChosenHosts"/> is left to the
|
||||
/// caller, which runs it once for the whole gesture.
|
||||
/// </remarks>
|
||||
private void FoldTheLitCardIn(HostRowViewModel? besides = null)
|
||||
{
|
||||
if (chosenHostIds.Count > 0
|
||||
|| SelectedHost is not { } lit
|
||||
|| ReferenceEquals(lit, besides)
|
||||
|| !HostBoardOrder.Contains(lit))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
chosenHostIds.Add(lit.EntityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
||||
/// </summary>
|
||||
@@ -6288,6 +6523,14 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
chosenHostIds.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
// A band dragged with Ctrl adds to what was already ticked, and on this board "what was already
|
||||
// ticked" starts at the card the grid has lit. Same rule as a Ctrl-click; see ExtendHostChoice
|
||||
// for the whole of the argument. No card is left out here, because a band adds rather than
|
||||
// toggles and cannot undo its own fold.
|
||||
FoldTheLitCardIn();
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
@@ -8283,12 +8526,25 @@ internal sealed partial class VaultViewModel(
|
||||
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
|
||||
/// too.
|
||||
/// </summary>
|
||||
/// <param name="requests">The hosts to store, and the key files any of them named.</param>
|
||||
/// <param name="vaultId">
|
||||
/// Which vault to write them into, or null for the active one.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
|
||||
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
|
||||
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
|
||||
/// be written and no second place for the sync wiring to be forgotten.
|
||||
/// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
|
||||
/// repositories, the same outbox and the same automatic push as saving one — an import screen decides
|
||||
/// <em>which</em> hosts and <em>where</em> and nothing else, so there is no second way for a host or a
|
||||
/// key to be written and no second place for the sync wiring to be forgotten.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The vault is a parameter rather than the active one, and null still means the active one.</b>
|
||||
/// The <c>ssh_config</c> importer reads a file belonging to this machine's own account and passes null;
|
||||
/// the Hetzner one reads somebody's estate and offers a picker, because forty servers belonging to a
|
||||
/// team are exactly the case where "wherever I happen to be standing" is the wrong answer. Everything
|
||||
/// written by one call goes to one vault — the hosts and any key material with them — so a batch cannot
|
||||
/// end up split across two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
||||
@@ -8315,10 +8571,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal async Task<ImportOutcome> ImportHostsAsync(
|
||||
IReadOnlyList<ImportedHostRequest> requests,
|
||||
Guid? vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var target = vaultId ?? session.ActiveVaultId;
|
||||
var hosts = 0;
|
||||
var keys = 0;
|
||||
var refused = 0;
|
||||
@@ -8340,7 +8598,8 @@ internal sealed partial class VaultViewModel(
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
|
||||
var (keyId, created) = await BindImportedKeyAsync(
|
||||
request, bound, target, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
@@ -8351,7 +8610,7 @@ internal sealed partial class VaultViewModel(
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts
|
||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||||
.CreateAsync(target, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
hosts++;
|
||||
@@ -8381,6 +8640,7 @@ internal sealed partial class VaultViewModel(
|
||||
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
|
||||
ImportedHostRequest request,
|
||||
Dictionary<string, Guid> bound,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Key is not { } key || request.KeyPath is not { } path)
|
||||
@@ -8407,7 +8667,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
var id = await session.SshKeys
|
||||
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
|
||||
.CreateAsync(vaultId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
bound[path] = id;
|
||||
@@ -8482,6 +8742,12 @@ internal sealed partial class VaultViewModel(
|
||||
// destroys it, is not a pane anybody should have to read carefully.
|
||||
PendingDeletion = null;
|
||||
movingHostId = row.EntityId;
|
||||
|
||||
// ◆ The panel is drawn in the drawer's footer and nowhere else, so raising it means raising the
|
||||
// drawer. Free when this was the drawer's own ⋯ menu — the pane is already open there — and the
|
||||
// whole of what makes the board's context menu entry work, since a right click on a card does not
|
||||
// open anything. Set before the flag, so the pane and its footer arrive in one layout pass.
|
||||
IsHostPaneOpen = true;
|
||||
IsMovingHost = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user