Public Access
Merge branch 'claude/host-detail-pane-design-e98621'
This commit is contained in:
@@ -315,6 +315,47 @@ internal sealed partial class HostRowViewModel(
|
||||
private string DisplayUsername =>
|
||||
string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value;
|
||||
|
||||
/// <summary>
|
||||
/// The one line under the name on a card: the transport, the account, and every tag, comma-separated.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address is deliberately not in it, and it used to be the whole line.</b> A card carrying
|
||||
/// <c>root@10.0.4.12:22</c> and a second row of tag chips is three facts and a wrap in a 232-pixel tile,
|
||||
/// and the two that a person scanning forty machines actually reads are the name and what kind of
|
||||
/// machine it is. The address is on the card's tooltip and in the drawer, which is where somebody
|
||||
/// checking an address is looking anyway. See <c>HostsScreen.axaml</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>"ssh" is a constant today and is printed anyway</b>, which is the one thing on this row worth
|
||||
/// arguing about — this codebase omits constants dressed up as readings, and by that rule the word
|
||||
/// should not be here. It is here because it is the first item of a list whose other items vary, and a
|
||||
/// list that begins with the account on one card and with a tag on the next has no shape to scan. It
|
||||
/// becomes a real fact the day a second transport exists; until then it is a label, not a reading.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The account is the <em>resolved</em> one, so a host taking its group's user says that user rather
|
||||
/// than nothing, and a host nobody has given one to is one item shorter rather than saying "—". Tags
|
||||
/// come last because there can be any number of them and the two before them are at most one each.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string Summary => string.Join(", ", SummaryParts());
|
||||
|
||||
private IEnumerable<string> SummaryParts()
|
||||
{
|
||||
yield return "ssh";
|
||||
|
||||
if (!string.IsNullOrEmpty(resolved.Username.Value))
|
||||
{
|
||||
yield return resolved.Username.Value;
|
||||
}
|
||||
|
||||
foreach (var tag in tagLabels)
|
||||
{
|
||||
yield return tag;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
|
||||
|
||||
internal bool IsBlocked => host.IsBlocked;
|
||||
@@ -1587,6 +1628,9 @@ internal sealed partial class VaultViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
|
||||
private bool isEditing;
|
||||
|
||||
/// <summary>
|
||||
@@ -1616,6 +1660,9 @@ internal sealed partial class VaultViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
|
||||
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
|
||||
private bool isEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
@@ -1639,10 +1686,83 @@ internal sealed partial class VaultViewModel(
|
||||
/// somebody moved the grid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null;
|
||||
internal bool IsDrawerOpen =>
|
||||
IsEditing || IsEditingGroup || (IsHostPaneOpen && 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;
|
||||
internal bool IsShowingHostDetail =>
|
||||
!IsEditing && !IsEditingGroup && IsHostPaneOpen && SelectedHost is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pane about one host has been asked for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A selection no longer opens the drawer, and this flag is the difference.</b> It used to:
|
||||
/// <see cref="IsDrawerOpen"/> read <c>SelectedHost is not null</c>, so touching any card took 304 pixels
|
||||
/// off the grid — which is the cost of choosing, paid every time somebody arrows through a list to find
|
||||
/// the machine they want. Selecting is now free, and the pane is opened by the pencil on the card, by
|
||||
/// the context menu, or by either editor being raised.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It <em>follows</em> the selection once it is open rather than pinning the host it was opened on. A
|
||||
/// pane that kept showing the previous machine while a different card was lit would be two answers to
|
||||
/// "which host is this about" on one screen; the rule is that opening is deliberate and tracking is not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cleared when the selection goes, in <see cref="OnSelectedHostChanged"/>. Without that a filter that
|
||||
/// matched nothing would leave this true, and the pane would spring open again on the next card
|
||||
/// somebody merely selected — which is the behaviour this exists to remove.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||||
private bool isHostPaneOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the detail pane's own actions are showing: CONNECT, and the menu holding EDIT and DELETE.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The detail pane and nothing else. With an editor open the menu would offer to open the editor, and
|
||||
/// while the deletion question is up it would offer to ask it again — which is the rule
|
||||
/// <see cref="ShowsHostActions"/> has always carried for the row of buttons these two replaced. The
|
||||
/// question takes CONNECT's place in the footer for the same reason it took DELETE's.
|
||||
/// </remarks>
|
||||
internal bool ShowsHostPaneActions => IsShowingHostDetail && !IsConfirmingHostDeletion;
|
||||
|
||||
/// <summary>
|
||||
/// What the drawer's header says it is about.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On the view model rather than as three exclusive headings in the markup, because the header is one
|
||||
/// row that outlives the panel under it: it carries the close button and the overflow menu, and three
|
||||
/// copies of that row would be three places to fix the day one of them moves.
|
||||
/// </remarks>
|
||||
internal string DrawerTitle => (IsEditing, IsEditingGroup) switch
|
||||
{
|
||||
(true, _) => editingEntityId is null ? "New host" : "Host details",
|
||||
(_, true) => EditingGroupId is null ? "New group" : "Group details",
|
||||
_ => "Host details",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The line under it: which keychain this is filed in, or what a group is for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
|
||||
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
|
||||
/// under different keys — so a control offering the move would be offering something no layer below
|
||||
/// this can do. Where a *new* item goes is chosen on the keychain screen's own picker; see
|
||||
/// <see cref="TargetVaults"/>.
|
||||
/// </remarks>
|
||||
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
|
||||
{
|
||||
(_, true) => "A heading, and what its hosts inherit",
|
||||
(true, _) when editingEntityId is null => SelectedTargetVault?.Name ?? string.Empty,
|
||||
_ => SelectedHost?.VaultName ?? string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the add sheet is showing over the host list.
|
||||
@@ -2313,6 +2433,63 @@ internal sealed partial class VaultViewModel(
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The port the drawer prints, which is the one this host would dial.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resolved rather than stored, like everything else the pane draws: a host that states no port of its
|
||||
/// own and sits under a group on 2222 shows 2222 here, because the question the pane answers is what
|
||||
/// happens when CONNECT is pressed. The editor shows the same number as a <em>placeholder</em> behind an
|
||||
/// empty box, which is the same fact said the other way round — see <see cref="EditorPortPlaceholder"/>.
|
||||
/// </remarks>
|
||||
internal string SelectedHostPortLabel =>
|
||||
SelectedHost?.Resolved.Port.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
/// <summary>Whether that port came from a group rather than from the host.</summary>
|
||||
/// <remarks>
|
||||
/// Drawn as a word beside the value rather than folded into it. "2222" and "2222, inherited" are the
|
||||
/// same connection and different edits: clearing the group's default moves the first host and the
|
||||
/// second, and only somebody who knows which is which can predict that.
|
||||
/// </remarks>
|
||||
internal bool SelectedHostPortIsInherited => SelectedHost?.Resolved.Port.IsInherited ?? false;
|
||||
|
||||
/// <inheritdoc cref="SelectedHostPortLabel" />
|
||||
/// <remarks>
|
||||
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
|
||||
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
|
||||
/// value that had not loaded.
|
||||
/// </remarks>
|
||||
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
|
||||
? user
|
||||
: "no account set";
|
||||
|
||||
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
||||
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// What the pane names in the credentials row: the key or password this host authenticates with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The item's own label, resolved here rather than carried on the row, because the answer changes when
|
||||
/// somebody renames a key on the keychain screen and the host row is not rebuilt for that.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A binding whose target the vault no longer holds says so instead of printing an id — the same rule
|
||||
/// <see cref="AuthenticationChoice.Missing"/> follows in the editor's picker, and for the same reason:
|
||||
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
|
||||
{
|
||||
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
|
||||
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
|
||||
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
|
||||
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
||||
?? "(a password that is no longer here)",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <remarks>
|
||||
/// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to
|
||||
/// search. A group that has since been deleted leaves the binding dangling, which the connect path
|
||||
@@ -3680,6 +3857,69 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void CloseAddSheet() => IsAddSheetOpen = false;
|
||||
|
||||
/// <summary>
|
||||
/// Opens the pane about one host, on the card the pencil was pressed on.
|
||||
/// </summary>
|
||||
/// <param name="row">
|
||||
/// The card, or null to open on whatever is already selected — which is what the context menu passes,
|
||||
/// since the code-behind has already selected the card the pointer was over.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The pencil takes the row as a parameter rather than relying on the click having selected the card
|
||||
/// first. A button inside a <c>ListBoxItem</c> handles the press itself, and whether the item is also
|
||||
/// selected by it is the theme's business rather than this application's — so a command reading
|
||||
/// <see cref="SelectedHost"/> would be opening the pane on whichever card happened to be lit, which on
|
||||
/// the first click of a session is none of them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It selects as well as opening, because the two have to agree: the pane is about one host and the grid
|
||||
/// marks one host, and a pane opened on a card the grid has not lit is the disagreement
|
||||
/// <see cref="IsHostPaneOpen"/> exists to prevent in the other direction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void OpenHostPane(HostRowViewModel? row)
|
||||
{
|
||||
if (row is not null)
|
||||
{
|
||||
SelectedHost = row;
|
||||
}
|
||||
|
||||
if (SelectedHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsHostPaneOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the drawer away, whichever of the three panels is in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One button for all three, because what it means is "give the grid its 304 pixels back" rather than
|
||||
/// "cancel". An open editor is abandoned by it — the same thing its own CANCEL does, and the same thing
|
||||
/// the arrow has to mean, since a header button that refused while a form was open would be a control
|
||||
/// that is sometimes furniture and sometimes a decision. The selection survives: the card stays lit and
|
||||
/// the pencil on it opens the pane again.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void CloseDrawer()
|
||||
{
|
||||
if (IsEditing)
|
||||
{
|
||||
CancelEditCommand.Execute(null);
|
||||
}
|
||||
|
||||
if (IsEditingGroup)
|
||||
{
|
||||
CancelGroupEditCommand.Execute(null);
|
||||
}
|
||||
|
||||
IsHostPaneOpen = false;
|
||||
}
|
||||
|
||||
/// <summary>Starts a new host.</summary>
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
@@ -3762,6 +4002,12 @@ internal sealed partial class VaultViewModel(
|
||||
grouped: row.Host.GroupId is not null);
|
||||
|
||||
IsEditing = true;
|
||||
|
||||
// So that saving lands on this host's own pane rather than closing the drawer. The editor is reached
|
||||
// from that pane most of the time and the flag is already true; it is not when EDIT was chosen from
|
||||
// the grid's context menu, and coming back to a collapsed column after a save reads as the edit
|
||||
// having been thrown away. See IsHostPaneOpen.
|
||||
IsHostPaneOpen = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
@@ -6723,9 +6969,27 @@ 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.
|
||||
// Every field the drawer's detail pane draws. They are properties of the vault rather than of the
|
||||
// row because two of them need the group chain read and one needs the keychain searched, and none of
|
||||
// that can be done from inside an item template.
|
||||
OnPropertyChanged(nameof(SelectedHostPortLabel));
|
||||
OnPropertyChanged(nameof(SelectedHostPortIsInherited));
|
||||
OnPropertyChanged(nameof(SelectedHostUsernameLabel));
|
||||
OnPropertyChanged(nameof(SelectedHostUsernameIsInherited));
|
||||
OnPropertyChanged(nameof(SelectedHostBindingLabel));
|
||||
OnPropertyChanged(nameof(DrawerSubtitle));
|
||||
|
||||
// A selection no longer opens the drawer, but losing one still closes it — and takes the flag with
|
||||
// it, so that the pane does not spring back open on the next card somebody merely selects. See
|
||||
// IsHostPaneOpen.
|
||||
if (value is null)
|
||||
{
|
||||
IsHostPaneOpen = false;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(IsDrawerOpen));
|
||||
OnPropertyChanged(nameof(IsShowingHostDetail));
|
||||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||||
|
||||
// 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
|
||||
@@ -6805,11 +7069,18 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
|
||||
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
||||
OnPropertyChanged(nameof(ShowsHostActions));
|
||||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||||
OnPropertyChanged(nameof(ShowsItemActions));
|
||||
}
|
||||
|
||||
partial void OnEditingGroupIdChanged(Guid? value) => OnPropertyChanged(nameof(GroupSaveLabel));
|
||||
partial void OnEditingGroupIdChanged(Guid? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(GroupSaveLabel));
|
||||
|
||||
// Which of the two things the group editor is doing, which its header says as well as its button.
|
||||
OnPropertyChanged(nameof(DrawerTitle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the question away when the selection it was asked about has moved on.
|
||||
|
||||
Reference in New Issue
Block a user