Public Access
Merge branch 'claude/main-page-group-hierarchy-3a3210'
This commit is contained in:
+42
-17
@@ -306,24 +306,46 @@ card, nothing saying the hosts are unfiled.
|
||||
**Failure means:** the "invisible until used" property is gone, and every existing user gets a section they
|
||||
did not ask for. `HasGroups` is what hides the row of group cards; `HostRowViewModel.HasGroup` hides the chip.
|
||||
|
||||
### 3.2 Filing hosts, and reading which group a card is in
|
||||
### 3.2 Filing hosts, and the grid being one level at a time
|
||||
|
||||
Make two groups and file some hosts into each through the host editor.
|
||||
Make two groups and file some hosts into each through the host editor, leaving at least one host unfiled.
|
||||
|
||||
**Pass:** every filed card carries the group's name as an accent chip under its address; the cards stay one
|
||||
flat wrap, in one order, with nothing between them. **There is no heading and no fold on the desktop** — the
|
||||
headings, their chevrons and UNGROUPED are the phone's, whose list has no room for a row of group cards.
|
||||
**Pass:** the filed hosts **leave the grid** as they are filed — a group is a place, not a label, and the
|
||||
grid holds one level of it the way a directory pane holds one directory. What is left is the unfiled hosts.
|
||||
**There is no heading and no fold on the desktop** — the headings, their chevrons and UNGROUPED are the
|
||||
phone's, whose list has no room for a row of group cards and draws the whole tree flat instead. With every
|
||||
host filed, the grid says so in a sentence rather than sitting empty.
|
||||
|
||||
**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid still holds
|
||||
every host, and EDIT and DELETE now aim at that group. **Then double-press it.** The group opens: the hosts
|
||||
narrow to the ones filed under it, the trail above the cards reads `ALL HOSTS › <name> ›`, and the card grid
|
||||
shows what is *inside* that group rather than every group in the keychain. Pressing ALL HOSTS brings the rest
|
||||
back.
|
||||
**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid is still the
|
||||
level it was, and EDIT and DELETE now aim at that group. **Then double-press it.** The group opens: its hosts
|
||||
are the grid, the trail above the cards reads `ALL HOSTS › <name> ›`, each card carrying the group's name as
|
||||
an accent chip, and the card grid shows what is *inside* that group rather than every group in the keychain.
|
||||
Pressing ALL HOSTS goes back to the outermost level.
|
||||
|
||||
**Then type a filed host's name into the find box at the top.** It is found from the outermost level,
|
||||
wherever it was filed and however deep, with the chip on its card saying which group it came out of. Clearing
|
||||
the box puts it away again. Inside a group the same box reaches that group and everything under it, and the
|
||||
empty-grid sentence there offers ALL HOSTS as the way to widen it.
|
||||
|
||||
**Failure means:** if one press still narrows the grid, the card `ListBox` is bound to the wrong property —
|
||||
`SelectedItem` is `SelectedGroup`, and only `OpenGroupCommand` writes `GroupFilter`. A full-width bar with a
|
||||
chevron between the cards is the old grouping coming back through `SidebarRows`; the desktop grid binds
|
||||
`VisibleHosts`. See `HostsScreen.axaml`.
|
||||
`SelectedItem` is `SelectedGroup`, and only `OpenGroupCommand` writes `GroupFilter`. Filed hosts still on the
|
||||
outermost level is `Matches` no longer comparing the host's group to the open one. A search that cannot find
|
||||
a filed host is worse than either: it is the box answering "no host matches that" about a machine the
|
||||
keychain has got. A full-width bar with a chevron between the cards is the old grouping coming back through
|
||||
`SidebarRows`; the desktop grid binds `VisibleHosts`. See `HostsScreen.axaml`.
|
||||
|
||||
### 3.2b Making something while standing inside a group
|
||||
|
||||
Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**.
|
||||
|
||||
**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on
|
||||
the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that
|
||||
group as its parent, so the new group is a card inside the one that is open.
|
||||
|
||||
**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut
|
||||
a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate
|
||||
difference between them: the host editor also takes a merely *selected* card as its group, the group editor
|
||||
takes only the group that is open.
|
||||
|
||||
### 3.2a A group inside a group, and the way back out
|
||||
|
||||
@@ -708,12 +730,15 @@ back.
|
||||
Make two groups and file a host into one. Drag a host card up onto the other group's card.
|
||||
|
||||
**Pass:** the group card under the pointer takes a two-pixel accent border while the pointer is over it, the
|
||||
cursor shows a move rather than a refusal, and the drop files the host — the chip on its card changes to the
|
||||
new group's name, the host counts under both group cards change, and the status line says where it went.
|
||||
cursor shows a move rather than a refusal, and the drop files the host — **the card leaves the grid**, going
|
||||
inside the group it was dropped on, the host counts under both group cards change, and the status line says
|
||||
where it went. That sentence is the only thing left saying so, which is why it is worth reading: the card
|
||||
itself is on the level below now, and nothing is selected once it has gone.
|
||||
|
||||
**Also check three refusals**, each of which must show the "no" cursor and mark nothing: over the card of the
|
||||
group the host is *already* in; over another **host** card, which is deliberately not a target now that there
|
||||
are no headings to say which group it would mean; and over the empty space around the cards.
|
||||
group the host is *already* in — type its name into the find box first, which is what brings a filed card
|
||||
back to this level; over another **host** card, which is deliberately not a target now that there are no
|
||||
headings to say which group it would mean; and over the empty space around the cards.
|
||||
|
||||
**The targets are the cards on screen, which are one level** — see 3.2a. Filing into a group nested under
|
||||
another means opening the outer one first, exactly as moving a file into a subfolder does.
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
connect path, the selection and the pinned host key list all read the unfiltered collection — so a
|
||||
filter left in the box can hide a host but can never break one.
|
||||
|
||||
◆ IT IS ALSO THE WAY OUT OF THE GROUPS. The grid below holds one level of the tree, and typing here
|
||||
searches that level and everything under it — every machine in the keychain while nothing is open.
|
||||
That is the one thing on this screen that crosses a group boundary, and it has to be: a search that
|
||||
looked only where you happen to be standing would say "no host matches that" about a host this
|
||||
keychain has got. See VaultViewModel.Matches.
|
||||
|
||||
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
||||
who has found this box should know about the one that also connects on Enter.
|
||||
|
||||
@@ -341,8 +347,17 @@
|
||||
taken back out of a group through its own editor, which is the one place "no group" can be said
|
||||
plainly.
|
||||
|
||||
◆ ONE LEVEL, AS A DIRECTORY PANE IS. A host filed under a group is inside that group and is
|
||||
not also here — the cards above are the way into it, and this grid holds what is loose at
|
||||
whatever level the trail names. It used to hold every host at once with the groups drawn as
|
||||
headings over them, which made opening a group an operation that could only ever subtract.
|
||||
See VaultViewModel.Matches, and the note on the find box above for the one thing that crosses
|
||||
a group boundary.
|
||||
|
||||
Which group a card is in is on the card, as a chip. That is what the heading above it used to
|
||||
say, and one chip per card says it without an accordion.
|
||||
say, and one chip per card says it without an accordion — and it is what tells a search result
|
||||
from a host that lives at this level, since searching is the one way a card from inside a
|
||||
group reaches this grid.
|
||||
|
||||
SelectedItem is still SelectedSidebarRow and deliberately not SelectedHost, though every item
|
||||
here is now a host. That property swallows the null the ListBox writes back whenever the
|
||||
|
||||
@@ -1145,11 +1145,17 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||
|
||||
/// <summary>The hosts the sidebar is showing: the filter applied, nothing else.</summary>
|
||||
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
|
||||
/// <remarks>
|
||||
/// A second collection rather than a filtered view over the first, because the sidebar's list has to be
|
||||
/// one <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
|
||||
/// <para>
|
||||
/// A second collection rather than a filtered view over the first, because the grid has to be one
|
||||
/// <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
|
||||
/// terminal gives it back, and neither of those survives being split across several lists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What "one level" means, and why the box escapes it, is in <see cref="Matches"/>. It is the desktop's
|
||||
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||||
|
||||
@@ -1165,16 +1171,21 @@ internal sealed partial class VaultViewModel(
|
||||
/// What the hosts grid says when it has nothing in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Four answers rather than one, because "there are no hosts", "you have set a vault aside", "this group
|
||||
/// is empty" and "nothing matches what you typed" are four different situations and only the first is an
|
||||
/// invitation to add something. Telling somebody with thirty machines to add their first one is
|
||||
/// answering a question they did not ask.
|
||||
/// One answer per reason the grid can be empty, because "there are no hosts", "you have set a vault
|
||||
/// aside", "this group is empty", "they are all filed away" and "nothing matches what you typed" are
|
||||
/// different situations and only the first is an invitation to add something. Telling somebody with
|
||||
/// thirty machines to add their first one is answering a question they did not ask.
|
||||
/// <para>
|
||||
/// The hidden-vault answer comes before the group and the search box, because it is the one an empty
|
||||
/// grid cannot otherwise explain: a filter the user typed is still in front of them, and an open group
|
||||
/// is still lit on a card, but a vault switched off in a menu two screens ago leaves nothing on screen
|
||||
/// to read.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fourth is what the grid holding one level of the tree cost: a keychain whose every host is filed
|
||||
/// under a group shows no host cards at the outermost level, and without a sentence saying so that is
|
||||
/// indistinguishable from a keychain that has lost them. See <see cref="Matches"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string NoVisibleHostsMessage =>
|
||||
(Hosts.Count, HasHiddenVaults, GroupFilter, HostFilter.Trim().Length) switch
|
||||
@@ -1188,6 +1199,12 @@ internal sealed partial class VaultViewModel(
|
||||
(_, _, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto "
|
||||
+ "this group's card — or choose the group in a host's own editor.",
|
||||
(_, _, null, 0) =>
|
||||
"Every host here is filed under a group. Double-press one of the cards above to open it, or "
|
||||
+ "type in the box at the top to search all of them at once.",
|
||||
(_, _, not null, _) =>
|
||||
"No host in this group, or in anything under it, matches that. Press ALL HOSTS above to "
|
||||
+ "search every machine.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
@@ -3281,9 +3298,13 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
var selected = SelectedHost;
|
||||
|
||||
// Built once and handed down rather than rebuilt inside the predicate: deciding where a host sits is
|
||||
// a walk up the group tree, and this is the map that walk is made against.
|
||||
var parents = EffectiveParents();
|
||||
|
||||
VisibleHosts.Clear();
|
||||
|
||||
foreach (var host in Hosts.Where(Matches))
|
||||
foreach (var host in Hosts.Where(host => Matches(host, parents)))
|
||||
{
|
||||
VisibleHosts.Add(host);
|
||||
}
|
||||
@@ -3331,9 +3352,16 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
SidebarRows.Clear();
|
||||
|
||||
// Its own pass over the hosts rather than a read of VisibleHosts, which has been one level of the
|
||||
// tree since the desktop's grid became a folder pane — see Matches. This list is the flat answer to
|
||||
// the same question: every group it has as a heading, every host filed under one of them, and no way
|
||||
// to go inside anything. The phone that draws it has no group cards and nowhere to open one into, so
|
||||
// a list narrowed to the outermost level would be a list showing only the hosts nobody had filed.
|
||||
var shown = Hosts.Where(MatchesFilters).ToArray();
|
||||
|
||||
if (Groups.Count == 0)
|
||||
{
|
||||
foreach (var host in VisibleHosts)
|
||||
foreach (var host in shown)
|
||||
{
|
||||
SidebarRows.Add(host);
|
||||
}
|
||||
@@ -3360,7 +3388,7 @@ internal sealed partial class VaultViewModel(
|
||||
Func<HostRowViewModel, bool> belongs,
|
||||
bool onlyWhenOccupied = false)
|
||||
{
|
||||
var members = VisibleHosts.Where(belongs).ToArray();
|
||||
var members = shown.Where(belongs).ToArray();
|
||||
|
||||
if (onlyWhenOccupied && members.Length == 0)
|
||||
{
|
||||
@@ -3496,28 +3524,51 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Whether one host belongs on the grid at the level it is currently showing.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The grid holds one level of the tree, the way a directory pane holds one directory.</b> A host
|
||||
/// filed under a group is inside that group and nowhere else — it is not also on the screen the group's
|
||||
/// own card sits on. While it was both, a card was a heading over a grid that already held everything
|
||||
/// underneath it, so opening one could only ever take hosts away; a card is now the only place its hosts
|
||||
/// are, which is what makes it a folder rather than a filter that happens to be switched off.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The find box is the exception, and deliberately.</b> Typed into, it searches the open group and
|
||||
/// everything under it — which, with nothing open, is every host in the keychain. A search that looked
|
||||
/// only in the level it was typed on would answer "no host matches that" about a machine this keychain
|
||||
/// has got, which is the one answer a search box must never give; and finding a machine without first
|
||||
/// remembering where it was filed is most of what the box is for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row, Dictionary<Guid, Guid?> parents)
|
||||
{
|
||||
if (!MatchesFilters(row))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var open = GroupFilter?.EntityId;
|
||||
var group = EffectiveGroupOf(row, parents);
|
||||
|
||||
return HostFilter.Trim().Length == 0 ? group == open : IsUnder(open, group, parents);
|
||||
}
|
||||
|
||||
/// <summary>Whether one host survives the vault switches and the find box — where it sits aside.</summary>
|
||||
/// <remarks>
|
||||
/// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
|
||||
/// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
|
||||
/// wrote down about a machine is often the only place its purpose is recorded.
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row)
|
||||
private bool MatchesFilters(HostRowViewModel row)
|
||||
{
|
||||
// First, and ahead of both the cards and the box, because it is not a search: a hidden vault's host
|
||||
// is out however the grid is narrowed, and a count taken after this reflects what is on screen.
|
||||
// First, and ahead of the box, because it is not a search: a hidden vault's host is out however the
|
||||
// grid is narrowed, and a count taken after this reflects what is on screen.
|
||||
if (!IsVaultShown(row.VaultId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The group cards, and they narrow before the box does — a host outside the chosen group is out
|
||||
// whatever was typed. The two are deliberately not one control: the box is what you type when you
|
||||
// know the name, and the cards are what you press when you do not.
|
||||
if (GroupFilter is { } group && row.Host.GroupId != group.EntityId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var filter = HostFilter.Trim();
|
||||
|
||||
if (filter.Length == 0)
|
||||
@@ -3531,6 +3582,45 @@ internal sealed partial class VaultViewModel(
|
||||
value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>The group a host is actually drawn under, or none.</summary>
|
||||
/// <remarks>
|
||||
/// An id this vault has not got reads as no group at all, which is what the chip on the card, the
|
||||
/// ungrouped heading and the group picker each already do with one — see
|
||||
/// <see cref="RebuildSidebarRows"/>. It matters more here than in any of them: a host naming a group
|
||||
/// deleted on another machine would otherwise sit at a level nothing on screen can open, and now that
|
||||
/// the grid is one level at a time there would be nothing left that ever drew it.
|
||||
/// </remarks>
|
||||
private static Guid? EffectiveGroupOf(HostRowViewModel row, Dictionary<Guid, Guid?> parents) =>
|
||||
row.Host.GroupId is { } id && parents.ContainsKey(id) ? id : null;
|
||||
|
||||
/// <summary>Whether a group is the open one or lies somewhere beneath it.</summary>
|
||||
/// <remarks>
|
||||
/// Nothing open means everything is under it, which is what makes the search box reach the whole keychain
|
||||
/// from the outermost level. The walk terminates because it is made against
|
||||
/// <see cref="EffectiveParents"/>, where anything caught in a cycle has already been promoted to a root.
|
||||
/// </remarks>
|
||||
private static bool IsUnder(Guid? open, Guid? group, Dictionary<Guid, Guid?> parents)
|
||||
{
|
||||
if (open is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var current = group;
|
||||
|
||||
while (current is { } id)
|
||||
{
|
||||
if (id == open)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = parents.GetValueOrDefault(id);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <returns>How many keys would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
||||
@@ -4042,10 +4132,13 @@ internal sealed partial class VaultViewModel(
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
|
||||
// A new host opens in whichever group is selected beside the list, if one is, because adding three
|
||||
// machines to the group somebody has just made is the ordinary case. Before the picker, because
|
||||
// whether there is a group to inherit from decides whether the picker offers to.
|
||||
BuildGroupChoices(SelectedGroup?.EntityId);
|
||||
// A new host opens in the group the screen is already about — the card that is selected, or failing
|
||||
// that the group whose contents are showing. Adding three machines to the group somebody has just
|
||||
// made is the ordinary case, and since the grid holds one level at a time the alternative is worse
|
||||
// than a default nobody chose: a host created inside a group and filed under none would vanish from
|
||||
// the screen it was created on. Before the picker, because whether there is a group to inherit from
|
||||
// decides whether the picker offers to.
|
||||
BuildGroupChoices(GroupTarget?.EntityId);
|
||||
|
||||
BuildAuthenticationChoices(
|
||||
boundKeyId: null,
|
||||
@@ -4308,13 +4401,28 @@ internal sealed partial class VaultViewModel(
|
||||
EditGroupCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>Starts a new group.</summary>
|
||||
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The parent is defaulted after that clearing rather than inside it, and only here. This is the one
|
||||
/// path that means "make one", and a group made inside the group that is open is what + NEW GROUP has to
|
||||
/// mean now that the cards are one level of a tree — filed at the outermost level it would disappear
|
||||
/// from the screen it was made on. The other two callers are a cancel and a save, and neither is asking
|
||||
/// for a group anywhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The group that is open, and deliberately not the card that is selected</b> — which is where this
|
||||
/// differs from <see cref="NewHost"/>. A selected card is what EDIT and DELETE are aimed at; reading it
|
||||
/// as "and the next group goes inside it" would nest one because somebody had highlighted something,
|
||||
/// while the open group is the screen everybody can see they are on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void NewGroup()
|
||||
@@ -4328,6 +4436,12 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
ClearGroupEditor();
|
||||
|
||||
// Falls back to no parent, which is both what the picker's first entry says and what the phone always
|
||||
// gets: it has no group cards and no way to go inside one, so nothing there is ever open.
|
||||
GroupEditorSelectedParent =
|
||||
GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == GroupFilter?.EntityId)
|
||||
?? GroupChoice.None;
|
||||
|
||||
IsEditingGroup = true;
|
||||
Status = "Adding a group.";
|
||||
}
|
||||
|
||||
@@ -233,7 +233,8 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
vault.SelectedGroup = production;
|
||||
|
||||
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
|
||||
vault.VisibleHosts.Count.ShouldBe(2, "so the grid still holds the hosts outside it");
|
||||
vault.VisibleHosts.Select(row => row.Label)
|
||||
.ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
|
||||
vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on");
|
||||
vault.ShowsGroupActions.ShouldBeTrue();
|
||||
|
||||
@@ -252,7 +253,8 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
// same job and went with the change.
|
||||
vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group);
|
||||
|
||||
vault.VisibleHosts.Count.ShouldBe(2, "ALL HOSTS brings back the hosts outside the group");
|
||||
vault.VisibleHosts.Select(row => row.Label)
|
||||
.ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
|
||||
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
|
||||
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
|
||||
}
|
||||
|
||||
@@ -344,7 +344,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// that spans the width is as inside it as a card is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured with every host filed, which is the shape that used to produce the most headings.
|
||||
/// Measured with every host filed and that group open, which is the shape that used to produce the most
|
||||
/// headings. Open, because the grid holds one level of the tree — a host inside a group is drawn inside
|
||||
/// that group and nowhere else — so measuring at the outermost level would be measuring an empty grid.
|
||||
/// See <c>VaultViewModel.Matches</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -358,6 +361,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
new HostGroupMove(host, vault.Groups[0].EntityId));
|
||||
}
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups[0]);
|
||||
|
||||
await OnTheHostsScreenAsync((screen, _) =>
|
||||
{
|
||||
var rows = screen.HostGrid
|
||||
@@ -370,7 +375,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
rows.ShouldAllBe(row => row is HostRowViewModel);
|
||||
});
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three groups and every host filed"));
|
||||
await MeasureHostsAsync(
|
||||
faults => faults.ShouldBeEmpty("with every host filed and the group holding them open"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -3584,12 +3584,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
|
||||
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
|
||||
/// machines through the editor is thirty rounds of open, pick, save.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The card goes into the group and off the level it was dragged from</b>, which is the whole of what
|
||||
/// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
|
||||
/// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
|
||||
/// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
|
||||
/// read that property, and none of them should be aimed at a card that has left the screen. See
|
||||
/// <c>VaultViewModel.Matches</c> and <c>RebuildVisibleHosts</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
|
||||
public async Task MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
@@ -3603,23 +3613,166 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
|
||||
vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
|
||||
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
|
||||
vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
|
||||
|
||||
// Under the group's own heading now, which is what the phone's list draws.
|
||||
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
|
||||
// tree flattened, so the host is still in it.
|
||||
vault.SidebarRows.OfType<SidebarGroupHeader>()
|
||||
.Single(header => header.GroupId == group)
|
||||
.Count.ShouldBe(1);
|
||||
|
||||
// And the name on the card, which is what the desktop's grid draws instead of that heading — the one
|
||||
// thing on screen that changes where the host was dropped rather than where it came from.
|
||||
// The name is on the card, which is what the desktop's grid draws instead of that heading, and what
|
||||
// says which group a searched-up card came out of.
|
||||
vault.Hosts.Single().GroupLabel.ShouldBe("production");
|
||||
vault.Hosts.Single().HasGroup.ShouldBeTrue();
|
||||
|
||||
// Opening the group is where it went, and the way to it.
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
// And back out again, which is what the host's own editor is for now that the drop has one target.
|
||||
vault.OpenGroupCommand.Execute(null);
|
||||
|
||||
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
|
||||
|
||||
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
|
||||
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
|
||||
|
||||
// Coming out of a group is the direction that lands the host back on this level, so here the
|
||||
// selection does survive the move.
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The grid holds one level of the tree, the way a directory pane holds one directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A host filed under a group is inside that group and is not also on the screen the group's card sits
|
||||
/// on. While it was both, opening a group could only ever take hosts away — the level above already had
|
||||
/// all of them — and the cards were headings rather than places. The phone's list is the deliberate
|
||||
/// exception and is asserted here beside it: it draws the whole tree flat under headings, because it has
|
||||
/// no cards to open and nowhere to open one into.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostFiledUnderAGroup_IsDrawnInsideItAndNotAtTheLevelAbove()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label)
|
||||
.ShouldBe(["stage-web"], "the outermost level is what nothing has been filed into");
|
||||
|
||||
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
|
||||
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
|
||||
|
||||
vault.OpenGroupCommand.Execute(null);
|
||||
|
||||
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The one thing on the screen that crosses a group boundary, and it has to be one. A search that looked
|
||||
/// only at the level it was typed on would answer "no host matches that" about a machine this keychain
|
||||
/// has got — and finding a machine without first remembering where it was filed is most of what the box
|
||||
/// is for. Typed at the outermost level it reaches everything; typed inside a group it reaches that
|
||||
/// group and what is under it, which is the same rule read from where you are standing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheFindBox_SearchesInsideTheGroupsRatherThanOnlyTheLevelOnScreen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddHostAsync(vault, "stage-web");
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.VisibleHosts.ShouldNotContain(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
|
||||
|
||||
vault.HostFilter = "prod-db";
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label
|
||||
.ShouldBe("prod-db", "two levels down, and the box reaches it");
|
||||
|
||||
// And inside a group it is that group's subtree: estate holds production, which holds the host.
|
||||
vault.OpenGroupCommand.Execute(
|
||||
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||
|
||||
vault.HostFilter = "stage";
|
||||
|
||||
vault.VisibleHosts.ShouldBeEmpty("stage-web is outside estate, so this search does not reach it");
|
||||
vault.NoVisibleHostsMessage.ShouldContain("ALL HOSTS", Case.Sensitive, "and it says how to widen it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A grid with no cards in it has to say why, and "they are all filed away" is a different sentence from
|
||||
/// "there are none" and from "nothing matches what you typed". It is the answer the level-at-a-time grid
|
||||
/// made reachable: before it, a keychain with hosts in it always drew some.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AKeychainWhoseHostsAreAllFiled_SaysSoRatherThanLookingEmpty()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.HasVisibleHosts.ShouldBeFalse();
|
||||
vault.NoVisibleHostsMessage.ShouldContain("filed under a group");
|
||||
vault.NoVisibleHostsMessage.ShouldNotContain(
|
||||
"No hosts yet", Case.Sensitive, "telling somebody with hosts to add their first one answers nothing");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
|
||||
/// inside a group and filed under none would vanish from the screen it was created on, which is the
|
||||
/// papercut that comes free with a grid holding one level — so the editor opens on the group the user
|
||||
/// is standing in, and the picker shows it before anything is saved.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
vault.OpenGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("production");
|
||||
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.VisibleHosts.ShouldHaveSingleItem().Label
|
||||
.ShouldBe("prod-db", "so it is on the screen it was made on");
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.GroupEditorSelectedParent.ShouldNotBeNull().Label
|
||||
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user