Merge branch 'main' into the group's move and its deletion question

Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
This commit is contained in:
2026-08-04 17:11:06 +02:00
38 changed files with 3416 additions and 165 deletions
@@ -10,6 +10,7 @@ using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Shell.ViewModels;
@@ -1087,13 +1088,29 @@ internal sealed partial class VaultViewModel(
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
{
/// <remarks>
/// <para>
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
/// server almost nothing; the number that matters is how stale a teammate's change may look, and a
/// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake,
/// and a change made on this machine does not wait for the timer anyway — saving pushes immediately.
/// </para>
/// <para>
/// Unchanged by the push channel, and deliberately so. The socket makes a pass <em>early</em>; this is
/// what makes one happen at all, for a client whose network eats WebSockets, whose server has the
/// feature off, or whose notice was dropped. See <see cref="WaitForWorkAsync"/> and ADR 0012.
/// </para>
/// </remarks>
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
/// <summary>How long a pushed notice waits, in case more are on their way.</summary>
/// <remarks>
/// A quarter of a second, which is below what anybody perceives and above the gap between the
/// notices one person's save produces — a host and its activity log entry are two items in one
/// push, and a colleague clearing a folder is a burst. Without it each notice would run its own
/// full pass, and the pass a burst deserves is one.
/// </remarks>
private static readonly TimeSpan NoticeDebounce = TimeSpan.FromMilliseconds(250);
/// <summary>How often the logs are pruned, at most.</summary>
/// <remarks>
/// Hours rather than minutes, because pruning writes tombstones that sync. Retention is measured in days
@@ -1487,7 +1504,7 @@ internal sealed partial class VaultViewModel(
/// </para>
/// <para>
/// <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
/// questions — "what is on screen" and "which card is chosen" — 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>
@@ -2015,13 +2032,18 @@ internal sealed partial class VaultViewModel(
/// Whether the panel asking which vault to move the group to is up.
/// </summary>
/// <remarks>
/// The host's panel again — see <see cref="IsMovingHost"/> — in the one place a group can be acted on,
/// which is the GROUPS section of the hosts screen rather than the drawer. It takes that section's two
/// buttons the way the deletion question does, and for the same reason: a MOVE that could be pressed
/// again while its own picker was open would be a second panel about the same shelf.
/// <para>
/// The host's panel again — see <see cref="IsMovingHost"/> — under the GROUPS heading rather than in the
/// drawer, because a group has no drawer of its own: the pane beside this screen is about one machine.
/// </para>
/// <para>
/// It has no <c>ShowsGroupActions</c> to turn off, and does not need one. A group's actions are the
/// card's right-click menu now, which is not on screen while this panel is: opening it is what draws it.
/// The deletion question below it is disarmed by <see cref="MoveGroup"/> and folds this away in return,
/// so the section shows at most one of the two.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsGroupActions))]
private bool isMovingGroup;
/// <summary>Which group the open move panel is about. Null when it is closed.</summary>
@@ -2034,24 +2056,11 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveGroupVault;
/// <summary>
/// Whether the group the buttons are aimed at has anywhere to move to.
/// </summary>
/// <remarks>
/// <para>
/// Counted per group rather than asked once of the session, exactly as <see cref="CanMoveSelectedHost"/>
/// is: a group already in the only other writable vault has nowhere to go.
/// </para>
/// <para>
/// The read-only test is the group's own and not the whole subtree's. That is deliberate rather than
/// sloppy — this decides whether a button is worth drawing, and walking every descendant and every host
/// under them to answer it would be that walk on every selection. The subtree is checked once, where it
/// decides something, in <see cref="ConfirmMoveGroupAsync"/>.
/// </para>
/// </remarks>
internal bool CanMoveGroupTarget =>
GroupTarget is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
// There is deliberately no CanMoveGroup to match CanMoveSelectedHost. That one exists so the phone can
// leave a button out rather than draw one that answers with a refusal; a group is reached through the
// card's right-click menu, which is not drawn until it is opened and whose entries do not move. The one
// place the question decides anything is MoveGroup, which asks it by building the picker and saying so
// when it comes back empty.
/// <summary>
/// What the drawer's header says it is about.
@@ -2675,31 +2684,22 @@ internal sealed partial class VaultViewModel(
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.
/// What a group command with no argument acts 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>
/// <remarks>
/// Hidden with nothing to act on as well as while the question is up. A row of buttons over a grid of
/// cards nobody has chosen between is a row whose subject the user has to work out, and the answer
/// would be "none of them" — see <see cref="GroupTarget"/>.
/// Two answers, because a card is no longer where the user is. Selecting one aims at it, which is what a
/// click has always done; with nothing selected the answer is the group whose contents are on screen —
/// the one the trail ends with. That fallback is what makes + NEW HOST open on the group somebody is
/// standing in rather than on none, and it is what a file manager does: act on the selection, and on the
/// current folder when there is none.
/// <para>
/// The move panel takes the row as well, on the same reasoning the host pane's menu hides for it: both
/// panels are drawn where the buttons were, and leaving them there would offer to open a panel that is
/// already open.
/// The desktop's Edit and Delete reach this through the card menu, which selects whatever was
/// right-clicked first, so the fallback is not what they read — see <c>HostsScreen.OnGroupContextRequested</c>.
/// They used to be a pair of buttons beside the GROUPS heading, which had no card under a pointer to
/// mean and so leaned on it.
/// </para>
/// </remarks>
internal bool ShowsGroupActions =>
GroupTarget is not null && !IsConfirmingGroupDeletion && !IsMovingGroup;
internal HostGroupRowViewModel? GroupTarget => SelectedGroup ?? GroupFilter;
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
internal bool HasGroups => Groups.Count > 0;
@@ -4386,10 +4386,16 @@ internal sealed partial class VaultViewModel(
/// <para>
/// <b>This is the whole of how a shared vault arrives.</b> Sharing is two acts on two machines: the
/// person sharing wraps the vault key to the recipient, and the recipient's own client has to notice.
/// The recipient is handed nothing — there is no push channel — so without this the vault list stayed
/// exactly as it was cached at sign-in, and a vault shared with somebody appeared on their machine only
/// if they happened to sign in through the browser again. Everything else was already right, which is
/// why it looked like sharing was broken rather than like a list that was never re-read.
/// Without this the vault list stayed exactly as it was cached at sign-in, and a vault shared with
/// somebody appeared on their machine only if they happened to sign in through the browser again.
/// Everything else was already right, which is why it looked like sharing was broken rather than like a
/// list that was never re-read.
/// </para>
/// <para>
/// The server now says when this is worth doing — a <c>vaults.changed</c> notice wakes the pass, so the
/// vault turns up as it is shared rather than within the minute — but that only decides <em>when</em>.
/// This call is still what discovers the vault, on the notice and on every timed pass alike, because a
/// client with no socket has to arrive at the same place. See ADR 0012.
/// </para>
/// <para>
/// A failure is left to the caller, which treats it as the pass failing: the call is to the same server
@@ -4436,6 +4442,7 @@ internal sealed partial class VaultViewModel(
private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(AutoSyncInterval);
var waits = new AutoSyncWaits();
try
{
@@ -4450,7 +4457,7 @@ internal sealed partial class VaultViewModel(
// user is doing something.
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
while (await WaitForWorkAsync(timer, waits, cancellationToken).ConfigureAwait(true))
{
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
@@ -4461,6 +4468,98 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// Waits for the timer to come round, or for the server to say there is something to fetch.
/// </summary>
/// <returns>Whether to run a pass. False means the loop is over.</returns>
/// <remarks>
/// <para>
/// The timer is unchanged and is still what guarantees a pass. The socket only makes one
/// <em>early</em>, which is why nothing here treats its absence as a problem: no connection, a
/// server without the feature, a network that eats WebSockets, or a notice dropped under
/// backpressure all leave a loop that behaves exactly as it did before this existed. See ADR 0012.
/// </para>
/// <para>
/// <b>Both waits are held across iterations, and that is load-bearing rather than an
/// optimisation.</b> <see cref="PeriodicTimer"/> permits only one outstanding
/// <c>WaitForNextTickAsync</c> and throws on a second, and an abandoned channel read stays
/// registered and consumes the next notice written — which would silently lose exactly the wake-up
/// this is for. Whichever wait did not win is kept and awaited again.
/// </para>
/// </remarks>
private async Task<bool> WaitForWorkAsync(
PeriodicTimer timer,
AutoSyncWaits waits,
CancellationToken cancellationToken)
{
// Re-read every time, because signing out and back in replaces the connection — and with it
// the stream. A read still pending against the old one is left to be cancelled with it.
var stream = connection()?.Events;
if (!ReferenceEquals(stream, waits.Watching))
{
waits.Watching = stream;
waits.Notice = null;
}
waits.Tick ??= timer.WaitForNextTickAsync(cancellationToken).AsTask();
waits.Notice ??= stream?.ReadAsync(cancellationToken).AsTask();
if (waits.Notice is null)
{
var only = waits.Tick;
waits.Tick = null;
return await only.ConfigureAwait(true);
}
var first = await Task.WhenAny(waits.Tick, waits.Notice).ConfigureAwait(true);
if (ReferenceEquals(first, waits.Tick))
{
var ticked = waits.Tick;
waits.Tick = null;
return await ticked.ConfigureAwait(true);
}
// Observed so a faulted read does not go unhandled, and so a stream that has been disposed
// ends this wait rather than being asked again.
await waits.Notice.ConfigureAwait(true);
waits.Notice = null;
// A burst — one person's save is two items, and a colleague tidying a folder is a dozen —
// deserves one pass rather than one each.
await Task.Delay(NoticeDebounce, cancellationToken).ConfigureAwait(true);
while (stream!.TryRead(out _))
{
// Swallowed on purpose. Every notice means the same thing, which is what the pass about to
// run already does; what they say about *which* vault is not read, because a pass syncs
// every vault this session can reach anyway.
}
return true;
}
/// <summary>The two waits the background loop keeps alive between passes.</summary>
/// <remarks>
/// A class rather than three locals because <see cref="WaitForWorkAsync"/> has to hand them back
/// changed, and a method that took three <c>ref</c> parameters could not be <c>async</c>. See that
/// method for why abandoning either of them is a defect rather than a tidiness question.
/// </remarks>
private sealed class AutoSyncWaits
{
/// <summary>The pending timer tick, or null when the last one has been consumed.</summary>
internal Task<bool>? Tick { get; set; }
/// <summary>The pending read from the server's push channel.</summary>
internal Task<VaultEvent>? Notice { get; set; }
/// <summary>The stream <see cref="Notice"/> was taken from, to notice a reconnection.</summary>
internal IVaultEventStream? Watching { get; set; }
}
/// <summary>Shows one kind of item, if nothing is being edited.</summary>
/// <remarks>
/// Takes the section rather than there being one command per kind, so a third kind is an enum member and
@@ -4840,8 +4939,9 @@ internal sealed partial class VaultViewModel(
/// <summary>Loads the group being acted on into the box, so saving renames it.</summary>
/// <param name="group">
/// The group to edit, or null for whatever the screen is aimed at — the selected card, or the open group
/// when no card is selected. See <see cref="GroupTarget"/>. The desktop's EDIT button passes nothing and
/// means the second; the phone has no card to select and passes the group its heading names.
/// when no card is selected. See <see cref="GroupTarget"/>. The desktop's card menu passes nothing and
/// means the card that was right-clicked, which opening the menu has already selected; the phone has no
/// card to select and passes the group its heading names.
/// </param>
/// <remarks>
/// Taking it as an argument is what keeps the phone from having to select a group in order to edit one.
@@ -4868,7 +4968,7 @@ internal sealed partial class VaultViewModel(
// The group's own vault, and no save moves it — the same rule an existing host's follows, and the
// same reason: the two are encrypted under different keys, so saving anywhere else would leave a
// copy behind rather than move anything. The picker is not drawn for an existing group at all, and
// the move that does work is MoveGroup, from the buttons over the cards.
// the move that does work is MoveGroup, from the card's own menu.
editingGroupVaultId = row.VaultId;
editingGroupVaultName = row.HasVaultBadge ? row.VaultName : string.Empty;
@@ -5225,7 +5325,7 @@ internal sealed partial class VaultViewModel(
/// <see cref="MoveHostToGroupAsync"/> refuses for the same reason.
/// </para>
/// <para>
/// Aims where EDIT does: at the selected card, or at the open group when no card is selected. See
/// Aims where Edit does: at the selected card, which on the desktop is the one the menu opened on. See
/// <see cref="GroupTarget"/>.
/// </para>
/// </remarks>
@@ -8678,8 +8778,6 @@ internal sealed partial class VaultViewModel(
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
OnPropertyChanged(nameof(ShowsGroupActions));
OnPropertyChanged(nameof(CanMoveGroupTarget));
}
/// <remarks>
@@ -8701,8 +8799,6 @@ internal sealed partial class VaultViewModel(
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
OnPropertyChanged(nameof(ShowsGroupActions));
OnPropertyChanged(nameof(CanMoveGroupTarget));
RebuildGroupLevel();
RebuildVisibleHosts();
@@ -8735,7 +8831,6 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
OnPropertyChanged(nameof(ShowsHostActions));
OnPropertyChanged(nameof(ShowsHostPaneActions));
OnPropertyChanged(nameof(ShowsGroupActions));
OnPropertyChanged(nameof(ShowsItemActions));
}