Public Access
Merge branch 'claude/fingerprint-enrollment-support-a6c2f3'
This commit is contained in:
@@ -281,6 +281,20 @@ internal sealed partial class TeamsViewModel(
|
||||
[ObservableProperty]
|
||||
private TeamActionRequest? pendingAction;
|
||||
|
||||
/// <summary>Set while <see cref="ReloadAsync"/> reselects, so the handler does not read as well.</summary>
|
||||
private bool isReselecting;
|
||||
|
||||
/// <summary>
|
||||
/// Which selection read owns the lists below the team list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Selecting a second team before the first one's read has answered leaves two reads in flight
|
||||
/// against the same collections, and the one that started first can answer last — so the
|
||||
/// superseded read drops its answer instead of appending another team's members to the list. UI
|
||||
/// thread only, which is where every selection change and every continuation on this screen runs.
|
||||
/// </remarks>
|
||||
private int selectionGeneration;
|
||||
|
||||
/// <summary>Whether there is a server to talk to at all.</summary>
|
||||
internal bool IsOnline => connection() is not null;
|
||||
|
||||
@@ -367,8 +381,22 @@ internal sealed partial class TeamsViewModel(
|
||||
Teams.Add(new TeamRowViewModel(team));
|
||||
}
|
||||
|
||||
SelectedTeam =
|
||||
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
||||
// The assignment reselects the same team through a new row object, so the selection handler
|
||||
// would start its own read of the very lists this method is about to read — two reads
|
||||
// clearing and then appending into the same collections, which draws every member, invitation
|
||||
// and vault twice. Suppressed rather than deduplicated, because the read below is awaited and
|
||||
// the handler's is not: this is the one that has to be the reload's.
|
||||
isReselecting = true;
|
||||
|
||||
try
|
||||
{
|
||||
SelectedTeam =
|
||||
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
||||
}
|
||||
finally
|
||||
{
|
||||
isReselecting = false;
|
||||
}
|
||||
|
||||
RaiseState();
|
||||
|
||||
@@ -892,6 +920,11 @@ internal sealed partial class TeamsViewModel(
|
||||
PendingAction = null;
|
||||
IsEditingTeam = false;
|
||||
|
||||
if (isReselecting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||
// from a list box, which has no cancellation token and no way to await. Failures land in Status
|
||||
// through RunAsync exactly as a command's would.
|
||||
@@ -947,6 +980,8 @@ internal sealed partial class TeamsViewModel(
|
||||
/// <summary>Reads the selected team's members, invitations and vaults.</summary>
|
||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var generation = ++selectionGeneration;
|
||||
|
||||
Members.Clear();
|
||||
Invitations.Clear();
|
||||
Vaults.Clear();
|
||||
@@ -964,6 +999,11 @@ internal sealed partial class TeamsViewModel(
|
||||
.ListTeamMembersAsync(team.TeamId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (generation != selectionGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
|
||||
@@ -973,6 +1013,11 @@ internal sealed partial class TeamsViewModel(
|
||||
.ListTeamInvitationsAsync(team.TeamId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (generation != selectionGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var invitation in invitations)
|
||||
{
|
||||
Invitations.Add(new TeamInvitationRowViewModel(invitation));
|
||||
@@ -982,17 +1027,24 @@ internal sealed partial class TeamsViewModel(
|
||||
|
||||
OnPropertyChanged(nameof(HasInvitations));
|
||||
|
||||
if (open is null)
|
||||
if (open is not null)
|
||||
{
|
||||
return;
|
||||
ListVaults(open, team.TeamId);
|
||||
}
|
||||
}
|
||||
|
||||
// Read from the session rather than from a team-vaults endpoint, because the interesting fact
|
||||
// about a team vault here is whether *this* machine can open it — which is a property of the
|
||||
// keyring and not something the server can answer.
|
||||
/// <summary>Fills the vault list for a team, from what this machine can see.</summary>
|
||||
/// <remarks>
|
||||
/// Read from the session rather than from a team-vaults endpoint, because the interesting fact
|
||||
/// about a team vault here is whether <em>this</em> machine can open it — which is a property of the
|
||||
/// keyring and not something the server can answer. No await, so it needs no generation guard: it
|
||||
/// runs to completion inside the read that called it.
|
||||
/// </remarks>
|
||||
private void ListVaults(VaultSession open, Guid teamId)
|
||||
{
|
||||
var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
|
||||
|
||||
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
|
||||
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == teamId))
|
||||
{
|
||||
Vaults.Add(new TeamVaultRowViewModel(
|
||||
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
|
||||
|
||||
Reference in New Issue
Block a user