Public Access
Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
panel kept showing the previous vault's people under the new name. The read now starts regardless;
the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
Keychain names trim inside their column instead of printing over TYPE, and the Security page's
caption wraps inside its card.
This commit is contained in:
1 parent
0e8fe4fc80
commit
a4b81da597
11 files changed
+314
-68
No files matched your search
@@ -566,7 +566,7 @@ internal sealed partial class HetznerImportViewModel(
|
||||
// and visible rather than harmless and confusing.
|
||||
Refresh(resetSelection: true);
|
||||
|
||||
Status = WhatWasStored(outcome.Hosts);
|
||||
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
|
||||
|
||||
Step = HetznerImportStep.Done;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,27 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
||||
/// </param>
|
||||
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
||||
|
||||
/// <summary>What an import stored.</summary>
|
||||
/// <summary>What an import stored, and what it did not.</summary>
|
||||
/// <param name="Hosts">Hosts written.</param>
|
||||
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
||||
internal sealed record ImportOutcome(int Hosts, int Keys);
|
||||
/// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
|
||||
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
|
||||
/// <remarks>
|
||||
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
|
||||
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
|
||||
/// </remarks>
|
||||
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
|
||||
{
|
||||
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
|
||||
internal string Caveats =>
|
||||
(Refused switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => " 1 was skipped because it is not a host this keychain can connect to.",
|
||||
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
|
||||
})
|
||||
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||
/// <remarks>
|
||||
@@ -409,7 +426,7 @@ internal sealed partial class ImportViewModel(
|
||||
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
||||
};
|
||||
|
||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
|
||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1795,6 +1795,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ActiveSettingsPage = null;
|
||||
IsImportOpen = false;
|
||||
IsHetznerImportOpen = false;
|
||||
|
||||
// Drawn over Settings → Vaults. Left open, it was still up the next time Settings opened, over
|
||||
// whichever page that was.
|
||||
vaults.IsMembersPanelOpen = false;
|
||||
Screen = settingsReturnScreen;
|
||||
Surface = settingsReturnSurface;
|
||||
}
|
||||
@@ -5107,6 +5111,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Settings is a mode of the unlocked window, and it brings its own title bar while the main one
|
||||
// is hidden. Locking or signing out from it — LOCK NOW is on Preferences, sign-out on Account —
|
||||
// left the window in settings mode under the lock card: no title bar, so nothing to drag, minimise
|
||||
// or close, and an unlock that came back to Settings rather than to Hosts.
|
||||
LeaveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -8838,53 +8838,94 @@ internal sealed partial class VaultViewModel(
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var target = vaultId ?? session.ActiveVaultId;
|
||||
var hosts = 0;
|
||||
var keys = 0;
|
||||
var refused = 0;
|
||||
var tally = new ImportTally();
|
||||
var ran = false;
|
||||
string? failure = null;
|
||||
|
||||
await RunAsync(
|
||||
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
||||
async () =>
|
||||
{
|
||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||
// front: the second host naming a file has to land on the id the first one produced, and
|
||||
// nothing is reloaded until the batch finishes.
|
||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
ran = true;
|
||||
|
||||
foreach (var request in requests)
|
||||
try
|
||||
{
|
||||
if (!request.Host.TryValidate(out _))
|
||||
{
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(
|
||||
request, bound, target, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
{
|
||||
keys++;
|
||||
}
|
||||
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts
|
||||
.CreateAsync(target, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
hosts++;
|
||||
await ImportEachAsync(requests, target, tally, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
// Reported, and the hosts written before it are still reloaded below: they are in the
|
||||
// keychain whether or not the rest followed, and the screen has to say both.
|
||||
failure = exception.Message;
|
||||
}
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = DescribeImport(hosts, keys, refused);
|
||||
Status = DescribeImport(tally.Hosts, tally.Keys, tally.Refused)
|
||||
+ (failure is null ? string.Empty : $" The import stopped early: {failure}");
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
if (!ran)
|
||||
{
|
||||
// RunAsync returns at once while anything else is running — a sync pass, a save — and the
|
||||
// screen asking would otherwise have said it imported nothing as though that were the result.
|
||||
failure = "the keychain was busy with something else. Nothing was imported; try again.";
|
||||
}
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return new ImportOutcome(hosts, keys);
|
||||
return new ImportOutcome(tally.Hosts, tally.Keys, tally.Refused, failure);
|
||||
}
|
||||
|
||||
/// <summary>Writes each host of an import, and any key it brought, counting as it goes.</summary>
|
||||
/// <remarks>
|
||||
/// Counted into <paramref name="tally"/> rather than returned, so that when a write fails partway the
|
||||
/// caller still knows how many landed before it — which is what it has to tell the person.
|
||||
/// </remarks>
|
||||
private async Task ImportEachAsync(
|
||||
IReadOnlyList<ImportedHostRequest> batch,
|
||||
Guid into,
|
||||
ImportTally tally,
|
||||
CancellationToken token)
|
||||
{
|
||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||
// front: the second host naming a file has to land on the id the first one produced, and nothing
|
||||
// is reloaded until the batch finishes.
|
||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var request in batch)
|
||||
{
|
||||
if (!request.Host.TryValidate(out _))
|
||||
{
|
||||
tally.Refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, into, token)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
{
|
||||
tally.Keys++;
|
||||
}
|
||||
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts.CreateAsync(into, host, token).ConfigureAwait(true);
|
||||
|
||||
tally.Hosts++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What one import has written so far.</summary>
|
||||
private sealed class ImportTally
|
||||
{
|
||||
internal int Hosts { get; set; }
|
||||
|
||||
internal int Keys { get; set; }
|
||||
|
||||
internal int Refused { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
||||
|
||||
@@ -1639,11 +1639,32 @@ internal sealed partial class VaultsViewModel(
|
||||
}
|
||||
|
||||
// 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. Wrapped here rather than
|
||||
// inside the read, because the read is also awaited from inside a reload that already holds the
|
||||
// busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
|
||||
// members list entirely.
|
||||
_ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
|
||||
// from a list box, which has no cancellation token and no way to await.
|
||||
//
|
||||
// ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
|
||||
// a share, a reload — so a vault chosen in that moment never had its people read, and the panel
|
||||
// went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
|
||||
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
|
||||
// and every answer for an earlier selection is discarded by it.
|
||||
_ = LoadSelectionAsync();
|
||||
}
|
||||
|
||||
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
|
||||
private async Task LoadSelectionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPendingActionChanged(VaultActionRequest? value)
|
||||
|
||||
Reference in new issue
Block a user