Public Access
Make the host pane something you ask for, and draw it as cards
THE DRAWER USED TO ARRIVE WITH THE SELECTION. IsDrawerOpen read "a host is selected", so touching any card took 304 pixels off the grid — including every card arrowed past on the way to the one somebody wanted. Choosing among forty machines was charged the price of the pane for one of them. A pencil now appears on the card under the pointer and on the selected card, and that is what opens it; IsHostPaneOpen is the flag, and the grid's context menu gained Details… so the pane is reachable without a pointer, which a hover-only control is not. Once open it follows the selection rather than pinning the host it was opened on: a pane about one host beside a grid marking a different one is two answers to the same question. Losing the selection closes it and clears the flag, or a filter matching nothing would leave the pane armed to spring open again on the next card merely selected — which is the behaviour the pencil exists to remove. The pencil is drawn over the card rather than in a column of its own. A column would have cost the name 30 pixels of a 232-pixel tile, permanently, for a control that is only there while the pointer is; the dot and the pencil stack in the two corners of that edge instead. IsVisible and not opacity, because a button at zero opacity still takes the click and the card underneath does not. A HEADER, A BODY THAT SCROLLS, AND A FOOTER, which is the one structural change in the pane. The header names what the drawer is about and carries the two things true of every panel; the footer carries the one thing each panel is for — CONNECT, or SAVE, or the question about deleting. Only the middle scrolls, so the button somebody came here to press can no longer be below the fold, which CONNECT could be on a host with fifteen tags. That also widens what the layout harness certifies: it skips anything inside a ScrollViewer, and the control each panel exists to offer is now outside one. THE SAME THREE CARDS TWICE. Address, General, Connection — first as rows stating what the host is, then as boxes for changing it. The detail pane's rows are buttons that open the editor: the design draws every fact as a filled box, and rather than draw an input that refuses the pointer, pressing one leads to the same card with a real box in it. Nothing here saves as you type, and that is not timidity — saving validates the key-or-credential exclusion and writes one encrypted payload, so a box committing per keystroke would be a save per character and a half-typed hostname on the wire. Every value the pane prints is the resolved one, and says "inherited" beside it where a group supplied it. The number is the same either way and the edit is not: clearing a group's default moves every host that never overrode it. A HOST CARD IS TWO LINES AND NO CHIPS. The subtitle is now "ssh, root, pci, eu-west-1" — the transport, the resolved account, then every tag — replacing both the user@host:port line and the wrapped row of tag chips under it. The address went to the card's tooltip rather than nowhere: a card is read while scanning forty machines, where the name and the kind of machine are what is being looked for, and an address is what you read once you have found it. "ssh" is a constant today and is printed anyway, which is the one thing here that argues with this codebase's own rule about constants dressed as readings. It is the first item of a list whose other items vary, and a list beginning with the account on one card and a tag on the next has no shape to scan. The remark on HostRowViewModel.Summary says so rather than leaving it to be discovered. WHAT THE DESIGN DRAWS AND THIS PANE HAS NOT GOT: Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", the backspace-key mapping row, the vault picker's chevron and Show more. Sharing is per vault and not per item, every session here is an SSH channel, there are no identity or certificate item types, nothing carries a terminal setting to the renderer, and an item cannot be moved between vaults at all. Six controls with nothing behind them, listed in docs/design-import-gaps.md with what ships instead, and none drawn disabled. The credentials row is marked with ◆ rather than the ⚿ the nav rail uses for the keychain. U+26BF is outside both faces this application substitutes for the design's fonts, so it lands on whatever the platform's fallback has; every other glyph in the pane is from Geometric Shapes, which both carry.
This commit is contained in:
@@ -304,6 +304,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;
|
||||
@@ -1515,6 +1556,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>
|
||||
@@ -1544,6 +1588,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>
|
||||
@@ -1567,10 +1614,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.
|
||||
@@ -2223,6 +2343,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
|
||||
@@ -3469,6 +3646,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()
|
||||
@@ -3551,6 +3791,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}.";
|
||||
}
|
||||
|
||||
@@ -6507,9 +6753,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
|
||||
@@ -6576,11 +6840,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