Public Access
Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
This commit is contained in:
@@ -177,15 +177,13 @@
|
||||
|
||||
<!--
|
||||
The group's own two actions, beside the group cards rather than in the toolbar, because they
|
||||
act on the selected card and this is where the selection is made. Hidden rather than
|
||||
disabled while DELETE's question is up, as every other pair in this application is, so it
|
||||
cannot be pressed twice — see VaultViewModel.ShowsGroupActions.
|
||||
act on the card that is selected — and this is where the selection is made. Hidden rather
|
||||
than disabled while DELETE's question is up, as every other pair in this application is, so
|
||||
it cannot be pressed twice, and hidden again when there is nothing for them to act on. What
|
||||
that is, with no card selected, is the group the trail below ends with; see
|
||||
VaultViewModel.GroupTarget.
|
||||
-->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SHOW ALL"
|
||||
Command="{Binding ClearGroupFilterCommand}"
|
||||
IsVisible="{Binding IsFilteredByGroup}"
|
||||
ToolTip.Tip="Stops narrowing the grid to one group." />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditGroupCommand}"
|
||||
IsVisible="{Binding ShowsGroupActions}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteGroupCommand}"
|
||||
@@ -193,6 +191,43 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
============ WHERE YOU ARE ============
|
||||
The trail, drawn exactly as the transfers screen draws a directory path, because it is the same
|
||||
control answering the same question — and a window with two breadcrumbs that look different has
|
||||
two ideas of what a path is. See TransfersScreen.axaml.
|
||||
|
||||
It exists because the cards below are one level of a tree rather than every group at once, and
|
||||
a level nobody can name is a grid that has quietly hidden things. The first crumb is always
|
||||
there and always goes back to every host, which is what the SHOW ALL button used to be: a
|
||||
button that only says "stop" beside a trail that says where you are is the second control for
|
||||
the same job, and this one also gets you halfway back rather than all the way.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding GroupTrail}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel Orientation="Horizontal" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupCrumbViewModel">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<!--
|
||||
The command is the vault's and the item is a crumb, so it is reached through the
|
||||
ItemsControl's data context — the same way every other templated command on the
|
||||
desktop is bound.
|
||||
-->
|
||||
<Button Classes="flat" Padding="3,1"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).OpenGroupCommand}"
|
||||
CommandParameter="{Binding Group}">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
<TextBlock Classes="mono" Text="›" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
|
||||
IsVisible="{Binding IsConfirmingGroupDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
@@ -204,21 +239,28 @@
|
||||
every other list in this application draws a selected row. Buttons would have needed a
|
||||
Classes.active binding per card and a second copy of "which one".
|
||||
|
||||
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
|
||||
GroupFilter assigns SelectedGroup — but only one of them may narrow the grid; see the property
|
||||
for why the phone's own use of SelectedGroup must not.
|
||||
◆ ONE CLICK SELECTS, TWO OPEN. SelectedItem is the selection and nothing else now — the
|
||||
gesture that opens a group is a double-click, wired in the code-behind beside the one that
|
||||
opens a shell on a host. One click used to mean both, and a card was then the only place a
|
||||
group could be named while also being the control that threw the rest of the grid away.
|
||||
|
||||
◆ THIS IS ONE LEVEL, NOT EVERY GROUP. It binds VisibleGroups: what is inside the group the
|
||||
trail above ends with, or the outermost groups when it ends at ALL HOSTS. Folded away entirely
|
||||
at a group with nothing inside it, which is an ordinary thing to open — the trail and the two
|
||||
buttons stay, because leaving it is a gesture and editing it is a button.
|
||||
|
||||
◆ THESE CARDS ARE THE DROP TARGET. A host card dragged onto one is filed under that group, and
|
||||
that is the whole of what a drag does on this screen. It used to be a heading inside the host
|
||||
grid, and the headings are gone — see the note on the host grid below. A group is a thing that
|
||||
is drawn once, at the top, where the eye already goes to narrow the grid; a target that is also
|
||||
the label of what you are aiming at needs no explaining. See the code-behind for the gesture.
|
||||
is drawn once, at the top, where the eye already goes; a target that is also the label of what
|
||||
you are aiming at needs no explaining. See the code-behind for the gesture.
|
||||
-->
|
||||
<ListBox Classes="tiles" x:Name="GroupGrid"
|
||||
DragDrop.AllowDrop="True"
|
||||
ItemsSource="{Binding Groups}"
|
||||
SelectedItem="{Binding GroupFilter}"
|
||||
ToolTip.Tip="Press a group to show only its hosts. Drag a host card onto one to file it there.">
|
||||
IsVisible="{Binding HasVisibleGroups}"
|
||||
ItemsSource="{Binding VisibleGroups}"
|
||||
SelectedItem="{Binding SelectedGroup}"
|
||||
ToolTip.Tip="Press a group to select it, double-press to open it. Drag a host card onto one to file it there.">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
|
||||
@@ -83,6 +83,13 @@ internal sealed partial class HostsScreen : UserControl
|
||||
// password box above it, and a host that asks for a password still needs it typed first.
|
||||
HostGrid.DoubleTapped += OnHostActivated;
|
||||
|
||||
// And a group card opens the group, on the same gesture, for the same reason: going inside
|
||||
// something by double-clicking it is what the transfers screen's directories do and what every file
|
||||
// manager does. One click used to open a group, which made the card that names a group and the
|
||||
// control that narrows the grid to it the same press — so there was no way to select a group in
|
||||
// order to rename it without also losing sight of every host outside it.
|
||||
GroupGrid.DoubleTapped += OnGroupActivated;
|
||||
|
||||
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
|
||||
// Bubbling would work for the drag but not for the menu: by then the control has already decided
|
||||
// what is selected, and the menu is about to open against it.
|
||||
@@ -154,6 +161,22 @@ internal sealed partial class HostsScreen : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the group card that was double-clicked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
|
||||
/// being guarded against: an unguarded handler would open whichever group happened to be selected when
|
||||
/// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
|
||||
/// </remarks>
|
||||
private void OnGroupActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
|
||||
{
|
||||
vault.OpenGroupCommand.Execute(group);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the menu at whatever was right-clicked.
|
||||
/// </summary>
|
||||
|
||||
@@ -67,6 +67,17 @@ internal sealed class HostGroupRowViewModel(VaultItem<HostGroupSecret> group, in
|
||||
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
||||
}
|
||||
|
||||
/// <summary>One step of the path into the groups, as a button in a breadcrumb trail.</summary>
|
||||
/// <param name="Name">What the step is called.</param>
|
||||
/// <param name="Group">The group it opens, or null for the step that shows every host again.</param>
|
||||
/// <remarks>
|
||||
/// The same shape the transfers screen's trail uses — see <c>CrumbViewModel</c> — and drawn the same way,
|
||||
/// because it answers the same question: a directory pane and a grid of groups both have to say where the
|
||||
/// thing on screen came from and offer a way back out. The row rather than its id, because
|
||||
/// <see cref="VaultViewModel.GroupFilter"/> holds a row, and an id would only be looked up again.
|
||||
/// </remarks>
|
||||
internal sealed record GroupCrumbViewModel(string Name, HostGroupRowViewModel? Group);
|
||||
|
||||
/// <summary>An entry in the host editor's group picker.</summary>
|
||||
/// <param name="EntityId">The group, or null for "no group".</param>
|
||||
/// <param name="Label">What to show.</param>
|
||||
@@ -1076,8 +1087,8 @@ internal sealed partial class VaultViewModel(
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||
+ "~/.ssh/config from Preferences.",
|
||||
(_, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Press SHOW ALL, then drag a host card onto this group's "
|
||||
+ "card — or choose the group in a host's own editor.",
|
||||
"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.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
@@ -1100,8 +1111,51 @@ internal sealed partial class VaultViewModel(
|
||||
internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];
|
||||
|
||||
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
|
||||
/// <remarks>
|
||||
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
|
||||
/// from; <see cref="VisibleGroups"/> is the desktop's one level of it.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The group cards the desktop is drawing: what is inside the group that is open, or the outermost
|
||||
/// groups when none is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Groups"/> is to this what <see cref="Hosts"/> is to <see cref="VisibleHosts"/>: the whole
|
||||
/// collection beside the part of it on screen. The grid used to draw every group at once, which was the
|
||||
/// only honest thing to do while pressing a card meant nothing but "narrow the list" — a card was a
|
||||
/// filter, and every filter has to be reachable. Opening one is navigation, so the cards became the
|
||||
/// contents of wherever the trail says you are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A group whose parent this vault has not got is drawn at the outermost level rather than nowhere, and
|
||||
/// so is one caught in a parent cycle. Both are states two offline edits can produce and neither can be
|
||||
/// repaired from a screen that will not draw the group — see <see cref="HostGroupSecret.ParentId"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostGroupRowViewModel> VisibleGroups { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The path down to the group that is open: every host, then each group above it, then it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Always at least one crumb, and the first one is the way back to every host — which is what it is for.
|
||||
/// A grid whose cards are one level of a tree needs somewhere to say which level, and the same control
|
||||
/// is the way out of it; without that the only way back would be a button that says so, which is what
|
||||
/// SHOW ALL was and what this replaces.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<GroupCrumbViewModel> GroupTrail { get; } = [];
|
||||
|
||||
/// <summary>Whether there are any group cards to draw at this level.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="HasGroups"/>, which is about the vault. A group with nothing inside it is an
|
||||
/// ordinary thing to open, and the trail and the group's own buttons have to stay on screen when it is —
|
||||
/// so it is the card grid alone that folds away, not the panel around it.
|
||||
/// </remarks>
|
||||
internal bool HasVisibleGroups => VisibleGroups.Count > 0;
|
||||
|
||||
/// <summary>The saved commands in this vault, unpushed local state included.</summary>
|
||||
/// <remarks>
|
||||
/// Held here rather than on the screen that shows them, for the reason every other list is: this is where
|
||||
@@ -1205,41 +1259,59 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private ISidebarRow? selectedSidebarRow;
|
||||
|
||||
/// <summary>
|
||||
/// The group card that is selected, or null when none is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One click, and nothing more than a highlight: it is what the group's own EDIT and DELETE act on. What
|
||||
/// it deliberately no longer does is narrow the grid — see <see cref="OpenGroup"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? selectedGroup;
|
||||
|
||||
/// <summary>
|
||||
/// The group the hosts grid is narrowed to, or null for every host.
|
||||
/// The group that is open: the one whose contents the screen is showing, or null for every host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
|
||||
/// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
|
||||
/// an explicit control rather than a second press on the chosen card: the cards are a
|
||||
/// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
|
||||
/// application uses, and a <c>ListBox</c> does not unselect on a second click.
|
||||
/// Set by <see cref="OpenGroup"/> and by nothing else. It decides three things at once — which hosts the
|
||||
/// grid holds, which groups the cards hold, and what the trail says — which is what makes it "where you
|
||||
/// are" rather than a filter that happens to be on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
|
||||
/// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
|
||||
/// card means both, which is why the change handler assigns one from the other. They are not one
|
||||
/// property because the phone sets <see cref="SelectedGroup"/> on its own account:
|
||||
/// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
|
||||
/// would have made opening that editor silently filter the phone's host list to the group being renamed.
|
||||
/// <b>Separate from <see cref="SelectedGroup"/>, and no longer sets it.</b> The two answer different
|
||||
/// questions — "what is on screen" and "what would EDIT and DELETE act on" — and while one click meant
|
||||
/// both there was no way to name a group without also narrowing the grid to it. Two gestures, two
|
||||
/// properties; <see cref="GroupTarget"/> is where the two meet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? groupFilter;
|
||||
|
||||
/// <summary>Whether the grid is showing one group rather than every host.</summary>
|
||||
internal bool IsFilteredByGroup => GroupFilter is not null;
|
||||
|
||||
/// <summary>Shows every host again.</summary>
|
||||
/// <summary>
|
||||
/// Opens a group, or every host when handed null.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A double-click on a card, or a press on a crumb of the trail. Deliberately not a single click, which
|
||||
/// is what it was: a card is the only place a group can be selected, and a gesture that both selected a
|
||||
/// group and threw the rest of the grid away left no way to rename one without first losing sight of
|
||||
/// everything else. Double-clicking to go inside something is what the transfers screen's directories do
|
||||
/// and what the host cards beneath these do to open a shell, so the grid now has one vocabulary rather
|
||||
/// than one per list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The selection is dropped first, and it has to be: the cards are about to be redrawn one level along,
|
||||
/// and a selection pointing at a card that is no longer on screen would aim EDIT and DELETE at something
|
||||
/// nobody can see. Null is a real argument here rather than a missing one — it is the trail's first
|
||||
/// crumb, and it is the way back out.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ClearGroupFilter()
|
||||
private void OpenGroup(HostGroupRowViewModel? group)
|
||||
{
|
||||
GroupFilter = null;
|
||||
SelectedGroup = null;
|
||||
GroupFilter = group;
|
||||
}
|
||||
|
||||
/// <summary>What the group name box holds, for both creating and renaming.</summary>
|
||||
@@ -2089,8 +2161,26 @@ internal sealed partial class VaultViewModel(
|
||||
/// <inheritdoc cref="IsConfirmingHostDeletion" />
|
||||
internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group;
|
||||
|
||||
/// <summary>
|
||||
/// What the group panel's EDIT and DELETE act on: the card that is selected, or the group that is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two answers, because a card is no longer where the user is. Selecting one aims the buttons at it,
|
||||
/// which is what a click has always done; with nothing selected they aim at the group whose contents are
|
||||
/// on screen — the one the trail ends with. Without that fallback a group with no groups inside it could
|
||||
/// be opened and then never edited, because opening a group is exactly what takes its own card off the
|
||||
/// screen. It is also what a file manager does: act on the selection, and on the current folder when
|
||||
/// there is none.
|
||||
/// </remarks>
|
||||
internal HostGroupRowViewModel? GroupTarget => SelectedGroup ?? GroupFilter;
|
||||
|
||||
/// <summary>Whether the group panel's buttons are showing.</summary>
|
||||
internal bool ShowsGroupActions => !IsConfirmingGroupDeletion;
|
||||
/// <remarks>
|
||||
/// Hidden with nothing to act on as well as while the question is up. A pair of buttons over a grid of
|
||||
/// cards nobody has chosen between is a pair whose subject the user has to work out, and the answer
|
||||
/// would be "neither" — see <see cref="GroupTarget"/>.
|
||||
/// </remarks>
|
||||
internal bool ShowsGroupActions => GroupTarget is not null && !IsConfirmingGroupDeletion;
|
||||
|
||||
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
|
||||
internal bool HasGroups => Groups.Count > 0;
|
||||
@@ -2769,27 +2859,148 @@ internal sealed partial class VaultViewModel(
|
||||
Groups.Add(new HostGroupRowViewModel(group, count));
|
||||
}
|
||||
|
||||
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
|
||||
// RENAME and DELETE aim at, and a background sync that picked a group would point them at one nobody
|
||||
// chose.
|
||||
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
// Re-resolved by id for the reason the selection above is: every row object here is replaced on
|
||||
// every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
|
||||
// longer in the list — and the card the user could press to clear it would be a different object
|
||||
// that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
|
||||
// grid comes back to every host rather than to none.
|
||||
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
|
||||
// group holding the old one would go on showing a group that is no longer in the list — and the
|
||||
// crumb the user could press to leave it would be a different object that never matched. A group
|
||||
// deleted by a sync closes itself, which is the honest answer: the screen comes back to every host
|
||||
// rather than to none.
|
||||
//
|
||||
// This assignment is a new row object whenever there is a filter at all, so it always fires
|
||||
// This assignment is a new row object whenever a group is open at all, so it always fires
|
||||
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
|
||||
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
|
||||
// the same thing — and it is left rather than dodged by writing the backing field, because writing
|
||||
// the field would skip SelectedGroup and IsFilteredByGroup with it.
|
||||
// the field would skip the cards, the trail and GroupTarget with it.
|
||||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||||
|
||||
// Unconditionally, because the assignment above is a no-op — and fires nothing — whenever no group
|
||||
// was open, and the cards still have to be rebuilt out of the row objects this pass just made.
|
||||
RebuildGroupLevel();
|
||||
|
||||
// Out of the cards on screen rather than out of every group, and after the level has been rebuilt:
|
||||
// this is the card ListBox's own selection, and a row it is not showing is one the control would
|
||||
// null straight back out again.
|
||||
//
|
||||
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
|
||||
// EDIT and DELETE aim at, and a background sync that picked a group would point them at one nobody
|
||||
// chose.
|
||||
SelectedGroup = VisibleGroups.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasGroups));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the group cards and the trail from whichever group is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both together because they are two halves of one answer: the cards are what is inside the open group
|
||||
/// and the trail is how it was reached, and a pass that rebuilt one without the other would draw a level
|
||||
/// under a path that does not lead to it.
|
||||
/// </remarks>
|
||||
private void RebuildGroupLevel()
|
||||
{
|
||||
var parents = EffectiveParents();
|
||||
var open = GroupFilter?.EntityId;
|
||||
|
||||
VisibleGroups.Clear();
|
||||
|
||||
foreach (var row in Groups.Where(row => parents.GetValueOrDefault(row.EntityId) == open))
|
||||
{
|
||||
VisibleGroups.Add(row);
|
||||
}
|
||||
|
||||
GroupTrail.Clear();
|
||||
|
||||
// Always first, always there, and it is the way out — see GroupTrail. The name is what the grid
|
||||
// below shows when nothing is open, rather than the vault's, because that is the choice being
|
||||
// offered: this crumb widens the screen back to every machine in it.
|
||||
GroupTrail.Add(new GroupCrumbViewModel("ALL HOSTS", null));
|
||||
|
||||
foreach (var row in Ancestry(open, parents))
|
||||
{
|
||||
GroupTrail.Add(new GroupCrumbViewModel(row.Label, row));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasVisibleGroups));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which group each one sits under, with anything a walk upwards cannot get out of promoted to the
|
||||
/// outermost level.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two things are promoted, and both are states this application has decided to survive rather than
|
||||
/// prevent. A parent id this vault has not got is a group deleted on another machine — the reference is
|
||||
/// allowed to dangle, because preventing it would mean one delete rewriting every item naming the
|
||||
/// deleted thing. A cycle is two clients each re-parenting A under B and B under A while offline, which
|
||||
/// no merge can see because the pointer is inside the payload. See
|
||||
/// <see cref="HostGroupSecret.ParentId"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both have to end up somewhere the user can reach.</b> The repair for either is the group's own
|
||||
/// editor, and the editor is opened from the card — so a group left inside a card nobody can open, or
|
||||
/// inside a cycle no walk terminates in, would be a broken state with the fix locked inside it. Promoting
|
||||
/// to a root is the same degradation the resolver's visited set produces for inheritance: a cycle reads
|
||||
/// as a flat run of top-level groups.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private Dictionary<Guid, Guid?> EffectiveParents()
|
||||
{
|
||||
var stated = Groups.ToDictionary(row => row.EntityId, row => row.Group.ParentId);
|
||||
var parents = new Dictionary<Guid, Guid?>(stated.Count);
|
||||
|
||||
foreach (var (id, parent) in stated)
|
||||
{
|
||||
parents[id] = parent is { } wanted && stated.ContainsKey(wanted) ? wanted : null;
|
||||
}
|
||||
|
||||
foreach (var id in stated.Keys)
|
||||
{
|
||||
if (!ReachesTheTop(id))
|
||||
{
|
||||
parents[id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return parents;
|
||||
|
||||
bool ReachesTheTop(Guid id)
|
||||
{
|
||||
var visited = new HashSet<Guid>();
|
||||
Guid? current = id;
|
||||
|
||||
while (current is { } step && visited.Add(step))
|
||||
{
|
||||
current = parents[step];
|
||||
}
|
||||
|
||||
return current is null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The groups from the outermost down to the one that is open, or nothing when none is.</summary>
|
||||
/// <remarks>
|
||||
/// Walked against <see cref="EffectiveParents"/> rather than against the stated parents, so the trail
|
||||
/// cannot lead through a group the cards will not draw — and so that it terminates, which is what the
|
||||
/// promotion above buys: a cycle has no parent left to follow.
|
||||
/// </remarks>
|
||||
private List<HostGroupRowViewModel> Ancestry(Guid? open, Dictionary<Guid, Guid?> parents)
|
||||
{
|
||||
var trail = new List<HostGroupRowViewModel>();
|
||||
var current = open;
|
||||
|
||||
while (current is { } id
|
||||
&& Groups.FirstOrDefault(row => row.EntityId == id) is { } row)
|
||||
{
|
||||
trail.Add(row);
|
||||
current = parents.GetValueOrDefault(id);
|
||||
}
|
||||
|
||||
trail.Reverse();
|
||||
|
||||
return trail;
|
||||
}
|
||||
|
||||
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
|
||||
/// <remarks>
|
||||
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
|
||||
@@ -3690,11 +3901,12 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Loads the selected group's name into the box, so saving renames it.</summary>
|
||||
/// <summary>Loads the group being acted on into the box, so saving renames it.</summary>
|
||||
/// <remarks>The selected card, or the open group when no card is selected. See <see cref="GroupTarget"/>.</remarks>
|
||||
[RelayCommand]
|
||||
private void EditGroup()
|
||||
{
|
||||
if (SelectedGroup is not { } row)
|
||||
if (GroupTarget is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -3923,17 +4135,21 @@ internal sealed partial class VaultViewModel(
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Asks whether the selected group should go.</summary>
|
||||
/// <summary>Asks whether the group being acted on should go.</summary>
|
||||
/// <remarks>
|
||||
/// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
|
||||
/// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
|
||||
/// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
|
||||
/// to move, and that none of them are going anywhere else.
|
||||
/// <para>
|
||||
/// Aims where EDIT does: at the selected card, or at the open group when no card is selected. See
|
||||
/// <see cref="GroupTarget"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void DeleteGroup()
|
||||
{
|
||||
if (SelectedGroup is not { } row)
|
||||
if (GroupTarget is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -6553,20 +6769,33 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
|
||||
{
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
||||
|
||||
OnPropertyChanged(nameof(GroupTarget));
|
||||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
|
||||
/// property. Assigning the same value again is a no-op, so this and
|
||||
/// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
|
||||
/// <para>
|
||||
/// Leaves the selection alone, which is the change that split one click into two gestures: this fires
|
||||
/// from <see cref="OpenGroup"/>, and that command has already dropped the selection before assigning
|
||||
/// here — see the property.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cards are rebuilt before the hosts because both are the same move, and the deletion is disarmed
|
||||
/// against <see cref="GroupTarget"/> rather than against the group itself: a reload hands this a new row
|
||||
/// object for the group already open, and taking a question away from under somebody because the row
|
||||
/// behind it was replaced is exactly what <see cref="DisarmIfAimedElsewhere"/> compares ids to avoid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||||
{
|
||||
SelectedGroup = value;
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
||||
|
||||
OnPropertyChanged(nameof(IsFilteredByGroup));
|
||||
OnPropertyChanged(nameof(GroupTarget));
|
||||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||||
|
||||
RebuildGroupLevel();
|
||||
RebuildVisibleHosts();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user