Public Access
Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The phone can now put things in a keychain rather than only read one. The floating + arrives three designs after it was first asked for. It was refused twice on the honest grounds that it would open an editor this head had not got — and that stopped being true when steps 1 to 4 landed. It raises a sheet rather than a form, because "add" on this screen has been two operations since groups existed: a host, or a group to file hosts under. Both editors are cards in the list's own row, swapped for the list rather than stacked over it, following SnippetsScreen and for the reason written there: a form on top of the list hides what it is about. There is still no dialog and no editor screen anywhere on this head. The port and username boxes may be left empty, and the greyed text inside each is what the host will actually use — following the group picker as it moves, so the form says what leaving a box blank will do rather than making it a guess. The back gesture gains a guard above the switch rather than a case inside it. An editor is not a screen and has no entry there, and it is strictly nearer: the sheet sits over the editor's own screen, so back lowers whatever is topmost before it considers moving between screens. A group editor needs a way in, and the phone draws no groups panel — so the heading in the host list gains a pencil. A + that adds groups with no way to correct one is the same strange thing to ship as one that adds hosts with no way to correct one, and worse: a group's defaults are what every host beneath it falls back to. Scouting step 6 turned up a bug worth more than the prose it was looking for. ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation while a group carried only a name — a teammate's host showed under UNGROUPED and nothing else was lost. Since a group began lending a port, a username and a binding, the same omission silently dropped all three: that host would dial 22 as nobody while the machine is on 2222 as deploy, with nothing on screen saying why. The resolution map now spans every readable vault. The editable list stays narrow, because a row shown across vaults needs a vault id for rename and delete and two vaults may hold groups with one name — but the map needs none of that, since it is only ever asked what an id says. An adversarial review of this change found something I had written a comment denying. The + was a child of the outer Panel rather than of the list's row, so it anchored to the bottom of the screen and sat squarely over the new EDIT button and the lower edge of CONNECT — and because a Panel hit-tests its last-declared child first, tapping the right end of EDIT would have raised the add sheet. The comment beside it claimed it was in the list's row precisely so that could not happen, and manual-checks 8.1 names that exact failure. It is in Grid.Row 2 now. The same pass caught a connect bar that was disabled rather than hidden while its own comment said hidden, a scrim that would have flashed its default pressed chrome across the screen, a fab comment claiming an accent fill no other button had when Button.primary has it, and a back-gesture remark naming two view-model properties that do not exist anywhere in the repo. That last one came from this plan, which invented IsVaultsSurface and IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has never existed. Both are corrected in the plan rather than quietly worked around, along with a fourth "groups are flat" site in README.md that step 1 missed. The phone's rectangles are not measurable and structurally never will be: the layout suite is net10.0, the Android head is net10.0-android, and Avalonia's application is a one-shot process global — so a second head cannot share the process even if the reference were possible. Everything the sheet and both editors bind to is shared and is tested headlessly here; the pixels go to docs/manual-checks.md phase 8, nine checks, which is where this project already sends what it cannot assert. Tags remain the one thing asked for that did not ship. Tag is a full item kind and TagIds merges, encodes and resolves, but no screen draws a chip or offers to add one, so the tags a client can store are ones nothing here can see. Both editors carry the set through a save untouched so a client that can set them does not lose them. Recorded as half-shipped in docs/design-import-gaps.md rather than quietly left out. Verified by the whole suite: 1402 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display — phase 8 is what that costs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1272,8 +1272,65 @@ internal sealed partial class VaultViewModel(
|
||||
// ---- The editor ----
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
private bool isEditing;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the group editor is open as a surface of its own.
|
||||
/// </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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Held here rather than on the phone's own control so that the two heads cannot disagree about
|
||||
/// whether an editor is open. The back gesture and the floating button both read it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
private bool isEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the add sheet is showing over the host list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The phone's answer to a <c>+</c> that has two things to offer. It is a separate flag from the two
|
||||
/// editors because it sits <em>before</em> either of them: the sheet asks which kind, and choosing
|
||||
/// closes the sheet and opens that kind's editor. See <see cref="OpenAddSheet"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
private bool isAddSheetOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether anything the host screen can put over its list is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One property rather than three tests at each call site, and it exists because two controls need
|
||||
/// exactly this question and would otherwise each answer it their own way: the floating <c>+</c> hides
|
||||
/// while any of them is up — a button that opens an editor on top of an open editor is a button that
|
||||
/// does nothing — and the back gesture closes them before it considers leaving the screen.
|
||||
/// </remarks>
|
||||
internal bool AnEditorIsOpen => IsAddSheetOpen || IsEditing || IsEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's connect bar has anything to be about.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A host is chosen and nothing is covering the list. Both halves are needed and the second is the one
|
||||
/// worth stating: the editor cards replace the list rather than floating over it, so a bar left showing
|
||||
/// underneath would carry CONNECT and EDIT for a host that is no longer on screen — and under the host
|
||||
/// editor, for the very record being typed into.
|
||||
/// </remarks>
|
||||
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorLabel = string.Empty;
|
||||
|
||||
@@ -1624,7 +1681,12 @@ internal sealed partial class VaultViewModel(
|
||||
internal bool HasGroups => Groups.Count > 0;
|
||||
|
||||
/// <summary>What the group panel's save button says.</summary>
|
||||
internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "RENAME";
|
||||
/// <remarks>
|
||||
/// "SAVE" rather than the "RENAME" it said while a group was only a name. A button that offers to
|
||||
/// rename, pressed after somebody has changed the default port beside it, describes one of the four
|
||||
/// things it is about to do.
|
||||
/// </remarks>
|
||||
internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "SAVE";
|
||||
|
||||
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
||||
/// <inheritdoc cref="ShowsHostActions" />
|
||||
@@ -2035,26 +2097,65 @@ internal sealed partial class VaultViewModel(
|
||||
/// <see cref="RebuildGroups"/>, which is where the two meet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The active vault only, unlike every other list on this screen.</b> Hosts, keys, credentials and
|
||||
/// pins are read across every vault this session holds a key for; groups are not, so a host in a team's
|
||||
/// vault that a teammate filed appears under UNGROUPED. That is the same thing the sidebar already shows
|
||||
/// for a group that has been deleted, and it is deliberate here rather than an oversight: reading them
|
||||
/// across vaults means a group row has to carry the vault it lives in — rename and delete both need it —
|
||||
/// and two vaults may hold groups with the same name, which the one-heading-per-group layout cannot tell
|
||||
/// apart. Both are worth doing and neither is a merge's business. Recorded in
|
||||
/// <c>docs/design-import-gaps.md</c>.
|
||||
/// <b>Two reads, and they cover different vaults on purpose.</b> The editable list — the rows the
|
||||
/// sidebar draws headings from and the group editor renames — is the active vault's alone. The
|
||||
/// resolution map is every readable vault's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which
|
||||
/// vault it lives in, because rename and delete both need it, and two vaults may hold groups with the
|
||||
/// same name, which the one-heading-per-group layout cannot tell apart. Both are worth doing and neither
|
||||
/// is a merge's business. Recorded in <c>docs/design-import-gaps.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The map could not stay narrow, and that changed with inheritance.</b> While a group was only a
|
||||
/// name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
|
||||
/// nothing else — the same thing the sidebar shows for a group that has been deleted. Since a group
|
||||
/// began lending a port, a username and a binding, the same omission silently drops all three: that host
|
||||
/// would dial 22 as nobody, while the machine it names is on 2222 as <c>deploy</c>, and nothing on
|
||||
/// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong
|
||||
/// place.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across
|
||||
/// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever
|
||||
/// asked "what does this id say", which is exactly the question a host's <c>GroupId</c> poses.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.HostGroups
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var unreadable = 0;
|
||||
var resolvable = new Dictionary<Guid, HostGroupSecret>();
|
||||
|
||||
groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
||||
groupsById = groupItems.ToDictionary(group => group.EntityId, group => group.Secret);
|
||||
// Emptied before the loop rather than assigned inside it, because the active vault may not be in
|
||||
// the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only
|
||||
// ever writes on a match would leave the last readable vault's groups on screen as though they were
|
||||
// still this one's.
|
||||
groupItems = [];
|
||||
|
||||
return listing.Unreadable;
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
{
|
||||
var listing = await session.HostGroups
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
foreach (var group in listing.Items)
|
||||
{
|
||||
resolvable[group.EntityId] = group.Secret;
|
||||
}
|
||||
|
||||
if (vault.VaultId == session.ActiveVaultId)
|
||||
{
|
||||
groupItems =
|
||||
[.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
||||
}
|
||||
}
|
||||
|
||||
groupsById = resolvable;
|
||||
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2733,10 +2834,43 @@ internal sealed partial class VaultViewModel(
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the sheet that asks whether the thing being added is a host or a group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two things behind one <c>+</c>, which is the design's arrangement and is also the honest one: a
|
||||
/// phone has room for one floating button, and "add" on this screen has genuinely been two operations
|
||||
/// since groups existed. The desktop asks the same question by having two buttons in two panels, which
|
||||
/// is what a 1280-pixel window can afford.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refuses while an editor is open rather than stacking on top of it. The button that raises this is
|
||||
/// hidden in that state — see <see cref="AnEditorIsOpen"/> — so this is the guard for the path the
|
||||
/// button does not control, which is a command invoked from anywhere else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void OpenAddSheet()
|
||||
{
|
||||
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsAddSheetOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Lowers the add sheet without choosing anything.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseAddSheet() => IsAddSheetOpen = false;
|
||||
|
||||
/// <summary>Starts a new host.</summary>
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
{
|
||||
IsAddSheetOpen = false;
|
||||
|
||||
if (AHostEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
@@ -2962,9 +3096,90 @@ internal sealed partial class VaultViewModel(
|
||||
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
|
||||
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
|
||||
|
||||
IsEditingGroup = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a group's editor from its heading in the host list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The phone's only route to <see cref="EditGroup"/>, and it exists because there is no other. The
|
||||
/// desktop reaches the group editor through the groups panel, which selects a
|
||||
/// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
|
||||
/// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
|
||||
/// not a thing to be selected. So the heading needs a button, and the button needs a command that takes
|
||||
/// the header rather than the selection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
|
||||
/// <c>+</c> that adds hosts with no way to correct one — and worse here, because a group's defaults are
|
||||
/// inherited: getting one wrong is wrong for every host beneath it at once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a
|
||||
/// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one
|
||||
/// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than
|
||||
/// opening an editor on nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void EditGroupFromHeading(SidebarGroupHeader? header)
|
||||
{
|
||||
if (header?.GroupId is not { } groupId
|
||||
|| Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedGroup = row;
|
||||
EditGroupCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>Starts a new group.</summary>
|
||||
/// <remarks>
|
||||
/// The desktop never needed this command: its group editor is a bar that is always on screen, so
|
||||
/// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its
|
||||
/// editor is a card that has to be raised — and raising it from a stale state would offer the last
|
||||
/// group's default key to the new one without anybody choosing it, which is what
|
||||
/// <see cref="ClearGroupEditor"/> prevents.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void NewGroup()
|
||||
{
|
||||
IsAddSheetOpen = false;
|
||||
|
||||
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClearGroupEditor();
|
||||
|
||||
IsEditingGroup = true;
|
||||
Status = "Adding a group.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the group editor has to be dealt with before another editor opens.
|
||||
/// </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.
|
||||
/// </remarks>
|
||||
private bool AGroupEditorIsInTheWay()
|
||||
{
|
||||
if (IsEditingGroup)
|
||||
{
|
||||
Status = "Finish or cancel the group you are editing first.";
|
||||
}
|
||||
|
||||
return IsEditingGroup;
|
||||
}
|
||||
|
||||
/// <summary>The group picker's selection, if it names something of this kind.</summary>
|
||||
private Guid? GroupBound(AuthenticationKind kind) =>
|
||||
GroupEditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
||||
@@ -3074,6 +3289,7 @@ internal sealed partial class VaultViewModel(
|
||||
private void ClearGroupEditor()
|
||||
{
|
||||
EditingGroupId = null;
|
||||
IsEditingGroup = false;
|
||||
GroupEditorLabel = string.Empty;
|
||||
GroupEditorDefaultPort = null;
|
||||
GroupEditorDefaultUsername = string.Empty;
|
||||
@@ -5184,6 +5400,7 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
OnPropertyChanged(nameof(ShowsConnectBar));
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user