Merge branch 'claude/host-detail-pane-design-e98621'

This commit is contained in:
2026-08-03 16:31:43 +02:00
7 changed files with 1219 additions and 382 deletions
@@ -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.