Merge branch 'claude/edit-screen-refresh-items-63a808'
ci / build and test (push) Successful in 2m6s
ci / android head (push) Successful in 3m11s
ci / desktop nightly (push) Successful in 45s
ci / api image (push) Successful in 31s

This commit is contained in:
2026-08-06 12:08:55 +02:00
5 changed files with 342 additions and 4 deletions
@@ -72,6 +72,11 @@ internal sealed partial class SnippetsViewModel : ObservableObject
vault.Snippets.CollectionChanged += OnSnippetsChanged;
// The editor's vault picker is a snapshot of this list — see BuildEditorVaultChoices — and a vault
// created on the Teams screen with a half-typed snippet open behind it would otherwise not be
// offered until the editor was closed and opened again.
vault.TargetVaults.CollectionChanged += OnTargetVaultsChanged;
Rebuild();
}
@@ -577,6 +582,21 @@ internal sealed partial class SnippetsViewModel : ObservableObject
private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
/// <summary>Refills the open editor's vault picker, landing back on the vault it was already writing to.</summary>
/// <remarks>
/// Only while the editor is open, because that is the only picker built from a copy of the list — the move
/// panel's is built when it opens and folds away with the selection it was opened about. Restored by the
/// latched <see cref="editorVaultId"/> rather than by what the control shows, so a vault arriving mid-edit
/// cannot move a half-typed snippet: the refill is invisible except for the entry it adds.
/// </remarks>
private void OnTargetVaultsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (IsEditing)
{
BuildEditorVaultChoices(editorVaultId);
}
}
private void Rebuild()
{
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
@@ -2629,10 +2629,19 @@ internal sealed partial class VaultViewModel(
/// What the authentication picker offers: a typed password, then every key, then every credential.
/// </summary>
/// <remarks>
/// Rebuilt when the editor opens rather than kept in step with the two lists. A background sync could pull
/// a new key while a host is being edited, and having the picker's contents change under the user mid-edit
/// is worse than the list being a minute stale — only one editor may be open at a time, so the only way to
/// add a key or a credential is to close this one anyway.
/// <para>
/// Filled when the editor opens and kept in step with the two lists from then on, by
/// <see cref="RefreshOpenEditors"/>. It used to be the snapshot alone, on the grounds that only one editor
/// could be open at a time and so the only way to add a key was to close this one — which stopped being
/// true when <see cref="AHostEditorIsInTheWay"/> was split from <see cref="AVaultEditorIsInTheWay"/>. A
/// host editor now sits open on the Hosts screen while a key is added on the Keychain screen, and a
/// picker that did not notice left the user cancelling an editor to see the key they had just made.
/// </para>
/// <para>
/// What the old note was protecting against is real, and is why the refill restores the selection by id
/// rather than rebuilding from the stored host: entries appear and disappear under the user, but what
/// they have chosen does not move.
/// </para>
/// </remarks>
internal ObservableCollection<AuthenticationChoice> EditorAuthenticationChoices { get; } = [];
@@ -3529,9 +3538,86 @@ internal sealed partial class VaultViewModel(
// After all four lists, because the table is a projection of three of them.
RebuildVaultItems();
// Last of the rebuilds, because every picker an open editor holds is drawn from one of the lists
// above and would otherwise be showing the vault as it was when that editor opened.
RefreshOpenEditors();
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Refills the pickers of whichever editor is open, so a key or a vault that has just arrived shows in it.
/// </summary>
/// <remarks>
/// <para>
/// The pickers are snapshots — see <see cref="EditorAuthenticationChoices"/> — and taking one when the
/// editor opens was correct while nothing could change a list without closing it first. Nothing about
/// that holds any more: the host editor lives on the Hosts screen and the keychain's editors live on the
/// Vault screen, so a key, a credential, a tag, a group or a whole vault can be added with a host editor
/// standing open behind it. Every one of them then failed to appear in the picker that exists to offer
/// it, and the only way to see it was to abandon the edit and start again.
/// </para>
/// <para>
/// Called from the one reload rather than from each of the twenty-odd places that write to the vault,
/// which is what makes a sync count as well as a save: a key pulled from another machine reaches the
/// open editor by the same path a key typed here does.
/// </para>
/// <para>
/// <b>Selections are carried across by id, and every typed field is left alone.</b> The refill has to be
/// invisible to somebody halfway through a form — an editor that reset its own bindings because a
/// background sync landed would be a worse bug than the stale list it fixes. So each picker is rebuilt
/// and then put back onto what it was already showing, including the placeholder entries that stand for
/// a binding whose target has gone: it is the same restore-by-id the open path does, from the editor's
/// current selection rather than from the stored item, because for as long as the editor is open the two
/// deliberately differ.
/// </para>
/// </remarks>
private void RefreshOpenEditors()
{
if (IsEditing)
{
// Read before anything is cleared. Rebuilding a bound collection makes the control null its own
// selection and write that back, so by the time the last picker is refilled these properties no
// longer say what the user chose.
var authentication = EditorSelectedAuthentication;
var groupId = EditorSelectedGroup?.EntityId;
BuildTagChoices();
// The same order the two commands that open this editor use: the vault picker first because a
// group belongs to one vault, then the groups, then the bindings — which offer "inherit from
// group" only where the group picker has landed on something.
BuildEditorVaultChoices(editingHostVaultId);
// The selection as it stands, not filtered through GroupInEditingVault: a group that has gone
// keeps its placeholder here for the reason it does on the open path, so that a sync arriving
// mid-edit cannot unfile the host when the form is saved.
BuildGroupChoices(groupId);
BuildAuthenticationChoices(
authentication?.Kind == AuthenticationKind.SshKey ? authentication.EntityId : null,
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
grouped: EditorSelectedGroup?.EntityId is not null);
}
if (IsEditingGroup)
{
var authentication = GroupEditorSelectedAuthentication;
var parentId = GroupEditorSelectedParent?.EntityId;
BuildGroupEditorVaultChoices(GroupEditorVaultId);
// Guid.Empty while creating, which is what EditingGroupId being null means and is the same
// stand-in ClearGroupEditor uses: a group that does not exist yet cannot be its own parent.
BuildGroupParentChoices(EditingGroupId ?? Guid.Empty, parentId);
BuildGroupAuthenticationChoices(
authentication?.Kind == AuthenticationKind.SshKey ? authentication.EntityId : null,
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null);
}
}
/// <summary>Redraws every list from the vault, without saying anything about it.</summary>
/// <remarks>
/// For the two things that change which vaults exist or which are drawn without going through this type