Public Access
Merge main into the phone connections branch
Main had already taken this branch's first two commits, so what merged is the Connections work against three things that landed beside it. Four of the six conflicts were prose about arrangements both sides changed; two were real. **The phone hub gained a Teams row while this branch was moving the keychain onto it.** Both are additions to `IsMoreSurface` and both belong: teams because the desktop reaches them from its rail and the phone through the hub, the keychain because a bottom bar is for the places a session moves between. The membership test, the back gesture's first case and the hub's own arithmetic all take the union. The distinction is now written down rather than implied — teams is the design's count plus one, and the keychain is the only rearrangement of it: the bar lost a slot to gain that row. **`ConnectAndAnnounceAsync` was the real one.** Main gave it `RememberTypedPasswordAsync`, which binds the password that just worked to the host it worked on; this branch had replaced the `HostRowViewModel` that method needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a manual connection does with a password that succeeded, and the answer was already written on the screen it is typed into: nothing. There is no item to bind a credential to and none to bind it on, and that path saves nothing by design. So `ConnectionTarget` carries the row again — as a nullable, in place of the host id it had, with `HostId` derived from it. Two things read it and both are things that can only be done to a keychain item rather than to an address: naming the log entry, and keeping the password. Null is not missing data there; it is the whole of what makes the manual path different, and having one field rather than two keeps "was this a keychain host" a question with one answer. The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's "a rail with nine slots has room" was true when it was written this afternoon and is not now. It says the room rather than the number. Phase 11's four new device checks and main's Phase 12 on teams were the same conflict twice — two appends to the end of one file — and both are kept. Verified after resolving: the solution builds, the Android head builds clean, and 837 tests pass across the seven client suites, including main's own additions (233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39 storage).
This commit is contained in:
@@ -333,7 +333,13 @@ internal sealed partial class HostRowViewModel(
|
||||
/// <summary>What a host can authenticate with.</summary>
|
||||
internal enum AuthenticationKind
|
||||
{
|
||||
/// <summary>Typed at the moment of connecting, and never stored.</summary>
|
||||
/// <summary>Typed at the moment of connecting.</summary>
|
||||
/// <remarks>
|
||||
/// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it
|
||||
/// creates a credential and moves the host to <see cref="Credential"/>, so a stored password is always
|
||||
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
|
||||
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
|
||||
/// </remarks>
|
||||
Typed,
|
||||
|
||||
/// <summary>An SSH key in this vault.</summary>
|
||||
@@ -1022,6 +1028,34 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||||
|
||||
/// <summary>Whether the grid has anything to draw.</summary>
|
||||
/// <remarks>
|
||||
/// A property rather than <c>{Binding !VisibleHosts.Count}</c> in the markup. Avalonia's <c>!</c> is a
|
||||
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
|
||||
/// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts.
|
||||
/// </remarks>
|
||||
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// What the hosts grid says when it has nothing in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
|
||||
/// matches what you typed" are three different situations and only the first is an invitation to add
|
||||
/// something. Telling somebody with thirty machines to add their first one is answering a question they
|
||||
/// did not ask.
|
||||
/// </remarks>
|
||||
internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch
|
||||
{
|
||||
(0, _, _) =>
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||
+ "~/.ssh/config from Preferences.",
|
||||
(_, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Drag a host onto its heading in the grid, or choose the "
|
||||
+ "group in a host's own editor.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
||||
/// </summary>
|
||||
@@ -1068,10 +1102,6 @@ internal sealed partial class VaultViewModel(
|
||||
internal string HostsHeading =>
|
||||
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
|
||||
|
||||
/// <summary>Whether the host list under the heading is folded away.</summary>
|
||||
[ObservableProperty]
|
||||
private bool areHostsExpanded = true;
|
||||
|
||||
/// <summary>The SSH keys to show, unpushed local state included.</summary>
|
||||
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
|
||||
|
||||
@@ -1153,6 +1183,40 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? selectedGroup;
|
||||
|
||||
/// <summary>
|
||||
/// The group the hosts grid is narrowed to, or null for every host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
|
||||
/// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
|
||||
/// an explicit control rather than a second press on the chosen card: the cards are a
|
||||
/// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
|
||||
/// application uses, and a <c>ListBox</c> does not unselect on a second click.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
|
||||
/// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
|
||||
/// card means both, which is why the change handler assigns one from the other. They are not one
|
||||
/// property because the phone sets <see cref="SelectedGroup"/> on its own account:
|
||||
/// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
|
||||
/// would have made opening that editor silently filter the phone's host list to the group being renamed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? groupFilter;
|
||||
|
||||
/// <summary>Whether the grid is showing one group rather than every host.</summary>
|
||||
internal bool IsFilteredByGroup => GroupFilter is not null;
|
||||
|
||||
/// <summary>Shows every host again.</summary>
|
||||
[RelayCommand]
|
||||
private void ClearGroupFilter()
|
||||
{
|
||||
GroupFilter = null;
|
||||
SelectedGroup = null;
|
||||
}
|
||||
|
||||
/// <summary>What the group name box holds, for both creating and renaming.</summary>
|
||||
[ObservableProperty]
|
||||
private string groupEditorLabel = string.Empty;
|
||||
@@ -1424,6 +1488,8 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
private bool isEditing;
|
||||
|
||||
/// <summary>
|
||||
@@ -1431,10 +1497,17 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop does not need this: its group editor is a bar under the group list that is always there,
|
||||
/// and <see cref="EditingGroupId"/> is enough to tell adding from saving. The phone has no room for a
|
||||
/// permanent bar, so its group editor is a card that replaces the list — and "is the card showing" is a
|
||||
/// different question from "which group is being edited", because adding one has no id.
|
||||
/// It was the phone's alone. The phone has no room for a permanent bar, so its group editor is a card
|
||||
/// that replaces the list — and "is the card showing" is a different question from "which group is being
|
||||
/// edited", because adding one has no id.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The desktop sets it too now.</b> Its group editor used to be a bar under the group list that was
|
||||
/// always on screen, which is why <see cref="EditingGroupId"/> was enough there. The hosts screen has no
|
||||
/// such bar since it became a grid of cards: the group editor is a panel in the drawer, raised by
|
||||
/// <c>+ NEW GROUP</c> or by <c>EDIT</c>, and "is it raised" is exactly this. So
|
||||
/// <see cref="AGroupEditorIsInTheWay"/> now answers for both heads rather than being false on one of
|
||||
/// them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Held here rather than on the phone's own control so that the two heads cannot disagree about
|
||||
@@ -1444,8 +1517,36 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
private bool isEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the hosts screen's right-hand drawer is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The drawer is where everything that is about <em>one</em> thing lives: what a host is, the host
|
||||
/// editor, and the group editor. The grid beside it is about all of them. Splitting the screen that way
|
||||
/// is what let the 268-pixel host list go — the list was carrying both jobs, and neither at full size.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It stays open while a host deletion is in question, because the question is asked in the drawer and a
|
||||
/// deletion does not clear the selection. There is no separate term for that here: a pending deletion
|
||||
/// always has a selected host behind it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It occupies a column of the hosts screen rather than floating over it, which is the occlusion rule
|
||||
/// rather than a preference — see <c>MainWindow.axaml</c>. Nothing on this screen may be laid over the
|
||||
/// terminal's rectangle, and a drawer that slid over the grid would be doing exactly that on the day
|
||||
/// somebody moved the grid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null;
|
||||
|
||||
/// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary>
|
||||
internal bool IsShowingHostDetail => !IsEditing && !IsEditingGroup && SelectedHost is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the add sheet is showing over the host list.
|
||||
/// </summary>
|
||||
@@ -1984,10 +2085,11 @@ internal sealed partial class VaultViewModel(
|
||||
// ---- Connecting ----
|
||||
|
||||
/// <remarks>
|
||||
/// Typed per connection, never persisted, and now only reached by a host bound to nothing. It stays because
|
||||
/// not every password is worth storing — a one-off on a machine somebody will never open again, or one
|
||||
/// they would rather this vault did not hold — and because a credential has to be created before it can be
|
||||
/// bound, which means the first connection to a new host happens through this box.
|
||||
/// Typed per connection, not persisted unless <see cref="RemembersConnectPassword"/> says otherwise, and
|
||||
/// only reached by a host bound to nothing. It stays because not every password is worth storing — a
|
||||
/// one-off on a machine somebody will never open again, or one they would rather this vault did not hold
|
||||
/// — and because a credential has to be created before it can be bound, which means the first connection
|
||||
/// to a new host happens through this box.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string connectPassword = string.Empty;
|
||||
@@ -2030,6 +2132,27 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a password typed here should be kept, so this host stops asking for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a
|
||||
/// password can live. The two-step chore it replaces — add a password under Keychain, then open the host
|
||||
/// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand
|
||||
/// means typing the secret into a second screen while the first one already has it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Off by default, and it stays a decision.</b> The reason a typed password exists at all is that not
|
||||
/// every password belongs in a synchronised vault; remembering silently would move each of them there and
|
||||
/// tell nobody. It also only takes effect once the handshake has succeeded — see
|
||||
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
|
||||
/// the one not worth keeping.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool remembersConnectPassword;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected host will want something typed into the password box.
|
||||
/// </summary>
|
||||
@@ -2593,6 +2716,7 @@ internal sealed partial class VaultViewModel(
|
||||
private void RebuildGroups()
|
||||
{
|
||||
var selectedId = SelectedGroup?.EntityId;
|
||||
var filteredId = GroupFilter?.EntityId;
|
||||
|
||||
Groups.Clear();
|
||||
|
||||
@@ -2608,6 +2732,19 @@ internal sealed partial class VaultViewModel(
|
||||
// chose.
|
||||
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
// Re-resolved by id for the reason the selection above is: every row object here is replaced on
|
||||
// every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
|
||||
// longer in the list — and the card the user could press to clear it would be a different object
|
||||
// that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
|
||||
// grid comes back to every host rather than to none.
|
||||
//
|
||||
// This assignment is a new row object whenever there is a filter at all, so it always fires
|
||||
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
|
||||
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
|
||||
// the same thing — and it is left rather than dodged by writing the backing field, because writing
|
||||
// the field would skip SelectedGroup and IsFilteredByGroup with it.
|
||||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||||
|
||||
OnPropertyChanged(nameof(HasGroups));
|
||||
}
|
||||
|
||||
@@ -2643,6 +2780,11 @@ internal sealed partial class VaultViewModel(
|
||||
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
|
||||
// above is a null that has to be overwritten rather than read.
|
||||
SelectedSidebarRow = SelectedHost;
|
||||
|
||||
// The grid's empty state. Both of these are computed rather than stored, and neither has a change
|
||||
// notification of its own — VisibleHosts raises collection changes, which is not the same event.
|
||||
OnPropertyChanged(nameof(HasVisibleHosts));
|
||||
OnPropertyChanged(nameof(NoVisibleHostsMessage));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2842,6 +2984,14 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row)
|
||||
{
|
||||
// The group cards, and they narrow before the box does — a host outside the chosen group is out
|
||||
// whatever was typed. The two are deliberately not one control: the box is what you type when you
|
||||
// know the name, and the cards are what you press when you do not.
|
||||
if (GroupFilter is { } group && row.Host.GroupId != group.EntityId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = HostFilter.Trim();
|
||||
|
||||
if (filter.Length == 0)
|
||||
@@ -3429,9 +3579,10 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Folds the host list away, or brings it back.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
|
||||
// AreHostsExpanded and ToggleHosts were here, and they went with the control that used them. They folded
|
||||
// the sidebar's whole host list away under its one heading — an affordance that existed because that
|
||||
// list was 268 pixels wide and the editor beneath it needed the room. The grid has neither the heading
|
||||
// nor the problem. Folding one *group* away is a different thing and is still here: see ToggleGroup.
|
||||
|
||||
/// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
|
||||
/// <remarks>
|
||||
@@ -3590,9 +3741,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
|
||||
/// It answers only for the phone, where the group editor is a raised card. On the desktop the bar is
|
||||
/// always present and <see cref="IsEditingGroup"/> is never set by anything the user can reach, so this
|
||||
/// is false there and nothing is refused.
|
||||
/// It answered only for the phone while the desktop's group editor was a bar that was always present.
|
||||
/// Both heads raise a card now — the desktop's is the panel in the hosts drawer — so this refuses on
|
||||
/// both, which is what it was always meant to do.
|
||||
/// </remarks>
|
||||
private bool AGroupEditorIsInTheWay()
|
||||
{
|
||||
@@ -4950,7 +5101,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
await ConnectToAsync(
|
||||
new ConnectionTarget(row.Label, row.EntityId, row.Host.Hostname, row.Resolved.Port.Value),
|
||||
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
|
||||
authentication,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -5007,7 +5158,6 @@ internal sealed partial class VaultViewModel(
|
||||
// the log entry this also names.
|
||||
new ConnectionTarget(
|
||||
$"{endpoint.Username}@{endpoint.Hostname}",
|
||||
HostId: null,
|
||||
endpoint.Hostname,
|
||||
endpoint.Port),
|
||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
|
||||
@@ -5451,6 +5601,102 @@ internal sealed partial class VaultViewModel(
|
||||
sessionId,
|
||||
target.Label,
|
||||
Dialled(target, authentication)));
|
||||
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
//
|
||||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
||||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||||
// screen it is typed on says so.
|
||||
if (target.Row is { } row)
|
||||
{
|
||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns the password that just worked into a keychain credential bound to this host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Only after a handshake the remote accepted.</b> Storing a password the moment it is typed would
|
||||
/// bind whatever was in the box — including the typo that is about to be refused — and the host would
|
||||
/// then stop asking, leaving a machine that cannot be connected to until somebody works out that the
|
||||
/// keychain is where the wrong password now lives.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
|
||||
/// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type
|
||||
/// exists — bound to the other nineteen machines that share the account. See <see cref="HostSecret"/>
|
||||
/// on why the binding is an id and not a copy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The credential carries no username of its own, so it keeps taking the host's — which is what the
|
||||
/// connection that just succeeded did. Copying the resolved username into it would pin whatever the
|
||||
/// group happened to say at this moment, and quietly stop following the group afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every failure is reported and swallowed. The caller's <c>catch</c> blocks describe a connection that
|
||||
/// did not happen, and this one did: a vault write that fails here must not tell the user their terminal
|
||||
/// was abandoned, and a cancellation must not report it as cancelled.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RememberTypedPasswordAsync(
|
||||
HostRowViewModel row,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The password as dialled, not as the box currently reads: the two can differ by now, because a
|
||||
// handshake takes time and the box stays typeable throughout it.
|
||||
if (!RemembersConnectPassword
|
||||
|| row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword
|
||||
|| authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
||||
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
||||
return;
|
||||
}
|
||||
|
||||
var credential = new CredentialSecret { Label = row.Label, Password = typed.Password };
|
||||
|
||||
try
|
||||
{
|
||||
var credentialId = await session.Credentials
|
||||
.CreateAsync(row.VaultId, credential, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// Into the same vault as the host, deliberately: a credential in the personal vault bound to a
|
||||
// team's host is a binding every other member can see and none of them can resolve.
|
||||
await session.Hosts
|
||||
.UpdateAsync(
|
||||
row.VaultId,
|
||||
row.EntityId,
|
||||
row.Host with { CredentialId = credentialId, AsksForPassword = null },
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleared together. The box is about to disappear — the host answers "credential" now — and a tick
|
||||
// left behind would apply to the next host somebody selects.
|
||||
RemembersConnectPassword = false;
|
||||
ConnectPassword = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
||||
+ "will not be asked for again.";
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The address as actually dialled.</summary>
|
||||
@@ -5551,17 +5797,36 @@ internal sealed partial class VaultViewModel(
|
||||
/// The machine a connection is being made to, however it was named.
|
||||
/// </summary>
|
||||
/// <param name="Label">What to call it — a keychain host's alias, or what was typed.</param>
|
||||
/// <param name="HostId">The keychain item, or null for somewhere that is not in it.</param>
|
||||
/// <param name="Hostname">The address to dial.</param>
|
||||
/// <param name="Port">The port to dial, already resolved through any group.</param>
|
||||
/// <param name="Row">
|
||||
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||||
/// below the resolution needs four facts and a row carries dozens; taking the four is what let a
|
||||
/// below the resolution needs three facts and a row carries dozens; taking the three is what let a
|
||||
/// connection to an address that has no keychain item share the ladder rather than grow a second one.
|
||||
/// <see cref="ConnectionRecorder.Record"/> and <c>Identify</c> both take a nullable id already, so the
|
||||
/// log has always been able to hold a connection with no item behind it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The row is still here, and only two things read it.</b> Both are things that can only be done to
|
||||
/// a keychain item rather than to an address: identifying the log entry, and binding the password that
|
||||
/// just worked. Null is not missing data — it is the whole of what makes the manual path different, and
|
||||
/// having it here rather than as a separate id keeps "was this a keychain host" one question with one
|
||||
/// answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed record ConnectionTarget(string Label, Guid? HostId, string Hostname, int Port);
|
||||
private sealed record ConnectionTarget(
|
||||
string Label,
|
||||
string Hostname,
|
||||
int Port,
|
||||
HostRowViewModel? Row = null)
|
||||
{
|
||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out how a host authenticates, or says why it cannot.
|
||||
@@ -6200,6 +6465,10 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
OnPropertyChanged(nameof(ShowsConnectBar));
|
||||
|
||||
// The drawer opens on a selection and closes when there is none, so both of these move with it.
|
||||
OnPropertyChanged(nameof(IsDrawerOpen));
|
||||
OnPropertyChanged(nameof(IsShowingHostDetail));
|
||||
|
||||
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
|
||||
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
|
||||
// other.
|
||||
@@ -6245,6 +6514,20 @@ internal sealed partial class VaultViewModel(
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
|
||||
/// property. Assigning the same value again is a no-op, so this and
|
||||
/// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
|
||||
/// </remarks>
|
||||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||||
{
|
||||
SelectedGroup = value;
|
||||
|
||||
OnPropertyChanged(nameof(IsFilteredByGroup));
|
||||
|
||||
RebuildVisibleHosts();
|
||||
}
|
||||
|
||||
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||||
|
||||
Reference in New Issue
Block a user