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:
@@ -620,8 +620,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has eleven of them.
|
||||
/// Measured with an edit that touched several, because one field name fits anywhere.
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has thirteen of them —
|
||||
/// eleven until inheritance added "Password prompt" and tags added "Tags". Measured with an edit that
|
||||
/// touched several, because one field name fits anywhere. The count is stated rather than derived, so
|
||||
/// it has to be recounted against <c>HostKind.Changes</c> whenever a field is added; the literal in
|
||||
/// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheActivityLogFitsWithAnEditThatTouchedSeveralFields()
|
||||
@@ -1307,7 +1310,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
ItemId = Guid.CreateVersion7(),
|
||||
ItemLabel = "customer-a-production-database",
|
||||
Operation = ActivityOperation.Updated,
|
||||
ChangedFields = "Hostname, Port, Username, Options, Group",
|
||||
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags",
|
||||
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
||||
DeviceName = "jaap-jan-workstation",
|
||||
},
|
||||
|
||||
@@ -3526,6 +3526,267 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
.ShouldBe(["No group"], "estate cannot be its own parent, and production already sits under it");
|
||||
}
|
||||
|
||||
// ---- What the phone's + drives ----
|
||||
//
|
||||
// The phone's own pixels are not measurable here and cannot be: the layout suite is net10.0 and
|
||||
// DodoSSH.Client.Android is net10.0-android, so its views are unreachable by construction, and Avalonia's
|
||||
// application is a process global so a second head cannot share this process either. What IS shared is
|
||||
// every property and command the sheet and its two editors bind to, which is all of the behaviour — the
|
||||
// markup only decides where it is drawn. So the flow is tested here and the rectangles go to
|
||||
// docs/manual-checks.md, which is where this project already sends what it cannot assert.
|
||||
|
||||
[Fact]
|
||||
public async Task TheAddSheet_OffersTwoThingsAndOpensNeitherUntilOneIsChosen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
|
||||
vault.IsAddSheetOpen.ShouldBeTrue();
|
||||
vault.AnEditorIsOpen.ShouldBeTrue("the + hides while anything is over the list");
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
vault.IsEditingGroup.ShouldBeFalse();
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.IsAddSheetOpen.ShouldBeFalse("choosing lowers the sheet rather than stacking on it");
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAddSheet_ClosesWithoutOpeningAnything()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.CloseAddSheetCommand.Execute(null);
|
||||
|
||||
vault.AnEditorIsOpen.ShouldBeFalse();
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
vault.IsEditingGroup.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewGroupFromTheSheet_StartsEmptyRatherThanOnTheLastOneEdited()
|
||||
{
|
||||
// The failure this rules out is quiet: a group editor left holding the previous group's default key
|
||||
// would lend it to the next group somebody created without anybody choosing it.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy", key: "deploy");
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.IsAddSheetOpen.ShouldBeFalse();
|
||||
vault.EditingGroupId.ShouldBeNull("this is an add, not a rename");
|
||||
vault.GroupEditorLabel.ShouldBeEmpty();
|
||||
vault.GroupEditorDefaultPort.ShouldBeNull();
|
||||
vault.GroupEditorDefaultUsername.ShouldBeEmpty();
|
||||
vault.GroupEditorSelectedAuthentication.ShouldBe(AuthenticationChoice.NoDefault);
|
||||
vault.GroupEditorSelectedParent.ShouldBe(GroupChoice.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupAddedFromTheSheet_CarriesTheDefaultsTypedIntoIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = "production";
|
||||
vault.GroupEditorDefaultPort = 2222;
|
||||
vault.GroupEditorDefaultUsername = "deploy";
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeFalse("saving lowers the card");
|
||||
|
||||
var group = vault.Groups.Single();
|
||||
group.Group.Label.ShouldBe("production");
|
||||
group.Group.DefaultPort.ShouldBe(2222);
|
||||
group.Group.DefaultUsername.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheSheet_RefusesToOpenOverAnEditorRatherThanStackingOnIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
|
||||
vault.IsAddSheetOpen.ShouldBeFalse();
|
||||
vault.Status.ShouldContain("Finish or cancel", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewHostFromTheSheet_LeavesItsPortToWhicheverGroupItIsFiledUnder()
|
||||
{
|
||||
// The phone's add flow end to end, and the reason the port box opens empty: a host created under a
|
||||
// group that says 2222 wants 2222 without anybody typing it, and stays wanting whatever the group
|
||||
// says afterwards.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy");
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPort.ShouldBeNull("an empty box is what leaves the port to the group");
|
||||
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditorPortPlaceholder.ShouldBe("2222", "the form says what leaving it blank will get you");
|
||||
vault.EditorUsernamePlaceholder.ShouldBe("deploy");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = Host(vault, "prod-db");
|
||||
host.Host.Port.ShouldBeNull("nothing was typed, so nothing was pinned");
|
||||
host.Resolved.Port.Value.ShouldBe(2222);
|
||||
host.Resolved.Username.Value.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsParentPicker_LetsAGroupBeFiledUnderAnother()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
|
||||
vault.Groups
|
||||
.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal))
|
||||
.Group.ParentId
|
||||
.ShouldBe(vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal))
|
||||
.EntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostUnderANestedGroup_TakesTheNearestAnswerAndKeepsWalkingForTheRest()
|
||||
{
|
||||
// Two levels, and each field resolved on its own: a group that answers one question does not stop
|
||||
// the walk for the others.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "estate", username: "root");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222);
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorUsername = string.Empty;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = Host(vault, "prod-db");
|
||||
host.Resolved.Port.Value.ShouldBe(2222, "the nearer group answers the port");
|
||||
host.Resolved.Username.Value.ShouldBe("root", "and the walk carries on for the one it did not");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheConnectBar_GoesAwayWhileAnEditorIsUpRatherThanGreyingOut()
|
||||
{
|
||||
// The editors replace the list rather than floating over it, so a bar left in place 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. This was disabled rather than hidden first, which reads as a screen that
|
||||
// has broken rather than one that is busy.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
|
||||
vault.ShowsConnectBar.ShouldBeTrue();
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.ShowsConnectBar.ShouldBeFalse("the sheet is over the list");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.ShowsConnectBar.ShouldBeFalse("and the editor is in place of it");
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
vault.ShowsConnectBar.ShouldBeTrue("and it comes back with the list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheConnectBar_StaysAwayWithNoHostChosen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = null;
|
||||
|
||||
vault.ShowsConnectBar.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
|
||||
{
|
||||
// The phone's only route into a group editor: it draws no groups panel, and a heading's own
|
||||
// selection bounces back to the host on purpose. The command has to work off the heading it was
|
||||
// pressed on rather than off SelectedGroup, or pressing one heading would edit another.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupFromHeadingCommand.Execute(heading);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheUngroupedHeading_OpensNothing()
|
||||
{
|
||||
// It has no group behind it. The button is hidden there, so this is the guard for the path the
|
||||
// markup does not control.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
|
||||
.FirstOrDefault(row => row.GroupId is null);
|
||||
|
||||
ungrouped.ShouldNotBeNull("a vault with a group and an unfiled host draws an ungrouped heading");
|
||||
|
||||
vault.EditGroupFromHeadingCommand.Execute(ungrouped);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static async Task SetGroupDefaultsAsync(
|
||||
VaultViewModel vault,
|
||||
string group,
|
||||
|
||||
Reference in New Issue
Block a user