Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>Which log the screen is showing.</summary>
|
||||
internal enum LogSection
|
||||
{
|
||||
/// <summary>Connections that were made.</summary>
|
||||
Connections,
|
||||
|
||||
/// <summary>Changes made to keychain items.</summary>
|
||||
Activity,
|
||||
}
|
||||
|
||||
/// <summary>One connection, as a row.</summary>
|
||||
internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> entry, bool isLive)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string HostLabel => entry.Secret.HostLabel;
|
||||
|
||||
internal string Address => entry.Secret.Address;
|
||||
|
||||
/// <summary>When it started, in the reader's own conventions.</summary>
|
||||
/// <remarks>
|
||||
/// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
|
||||
/// difference is the reason each is right. There, two panes are read against one another and a
|
||||
/// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
|
||||
/// machine", which is a question about the reader's own day. <c>InvariantGlobalization</c> is false in
|
||||
/// the client csproj precisely so this works.
|
||||
/// </remarks>
|
||||
internal string Started =>
|
||||
entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// How long it lasted, or that it has not finished.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>"still open" and not a dash.</b> A dash reads as "nothing was recorded", and the two are opposite
|
||||
/// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
|
||||
/// session has no entry at all until it closes, so this state comes from the workspace rather than from
|
||||
/// the vault; see <see cref="LogsViewModel"/>.
|
||||
/// </remarks>
|
||||
internal string Duration => isLive
|
||||
? "still open"
|
||||
: Humanise(entry.Secret.Duration);
|
||||
|
||||
internal bool IsLive => isLive;
|
||||
|
||||
internal string Outcome => entry.Secret.Outcome switch
|
||||
{
|
||||
ConnectionOutcome.Failed => "failed",
|
||||
ConnectionOutcome.Refused => "host key refused",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
internal bool HasOutcome => Outcome.Length > 0;
|
||||
|
||||
/// <summary>Whether this was a terminal or the file browser.</summary>
|
||||
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
|
||||
/// <remarks>
|
||||
/// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
|
||||
/// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
|
||||
/// rounding themselves.
|
||||
/// </remarks>
|
||||
private static string Humanise(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
|
||||
}
|
||||
|
||||
if (duration < TimeSpan.FromHours(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
|
||||
}
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One keychain change, as a row.</summary>
|
||||
internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string ItemLabel => entry.Secret.ItemLabel;
|
||||
|
||||
internal string ItemKind => entry.Secret.ItemKind;
|
||||
|
||||
internal string Operation => entry.Secret.Operation switch
|
||||
{
|
||||
ActivityOperation.Created => "created",
|
||||
ActivityOperation.Deleted => "deleted",
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
||||
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Which fields changed. Never what they changed to.</summary>
|
||||
internal string ChangedFields => entry.Secret.ChangedFields;
|
||||
|
||||
internal bool HasChangedFields => ChangedFields.Length > 0;
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What has been connected to, and what has been changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
|
||||
/// section switch and one thing neither log knows: which connections are happening <em>now</em>. An entry is
|
||||
/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
|
||||
/// workspace, and this screen is where the two are put side by side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Read on demand rather than kept in step.</b> Unlike the host list, a log is not something a background
|
||||
/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
|
||||
/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
|
||||
/// nobody is looking at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class LogsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultSession session;
|
||||
private readonly Func<IReadOnlyList<LiveConnection>> live;
|
||||
|
||||
/// <param name="session">The open vault, which holds both logs.</param>
|
||||
/// <param name="live">
|
||||
/// The connections that are open right now. A function rather than a list, because tabs open and close
|
||||
/// while this screen is showing and it is not told about either.
|
||||
/// </param>
|
||||
internal LogsViewModel(VaultSession session, Func<IReadOnlyList<LiveConnection>> live)
|
||||
{
|
||||
this.session = session;
|
||||
this.live = live;
|
||||
}
|
||||
|
||||
/// <summary>Connections, newest first, with anything still open at the top.</summary>
|
||||
internal ObservableCollection<ConnectionLogRowViewModel> Connections { get; } = [];
|
||||
|
||||
/// <summary>Keychain changes, newest first.</summary>
|
||||
internal ObservableCollection<ActivityLogRowViewModel> Activity { get; } = [];
|
||||
|
||||
/// <remarks>
|
||||
/// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
|
||||
/// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
|
||||
/// a command can refuse it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private LogSection section;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool ShowsConnections => Section is LogSection.Connections;
|
||||
|
||||
internal bool ShowsActivity => Section is LogSection.Activity;
|
||||
|
||||
internal bool HasConnections => Connections.Count > 0;
|
||||
|
||||
internal bool HasActivity => Activity.Count > 0;
|
||||
|
||||
internal string EmptyMessage => Section is LogSection.Connections
|
||||
? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
|
||||
+ "top and gets its line when you close the tab."
|
||||
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
||||
+ "names of the fields that changed, never their contents.";
|
||||
|
||||
/// <summary>Shows one of the two logs.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowSection(LogSection section) => Section = section;
|
||||
|
||||
/// <summary>Re-reads both logs.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = string.Empty;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Leaving the screen.
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads both logs into the lists.</summary>
|
||||
internal async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connections = await session.ConnectionLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var activity = await session.ActivityLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Connections.Clear();
|
||||
|
||||
// The live ones first and above everything, because they are the only rows in this list that are
|
||||
// still changing. They carry no entity id — there is no vault item for them yet — which is why they
|
||||
// are built from a different source and marked as live rather than merged into the same shape.
|
||||
foreach (var open in live())
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(
|
||||
new VaultItem<ConnectionLogSecret>(
|
||||
Guid.Empty,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = open.HostLabel,
|
||||
Address = open.Address,
|
||||
StartedAt = open.StartedAt,
|
||||
DeviceName = open.DeviceName,
|
||||
},
|
||||
Version: 0,
|
||||
HasUnsyncedChanges: false,
|
||||
IsBlocked: false,
|
||||
IsReadOnly: false),
|
||||
isLive: true));
|
||||
}
|
||||
|
||||
foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
|
||||
}
|
||||
|
||||
Activity.Clear();
|
||||
|
||||
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
|
||||
{
|
||||
Activity.Add(new ActivityLogRowViewModel(entry));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasConnections));
|
||||
OnPropertyChanged(nameof(HasActivity));
|
||||
}
|
||||
|
||||
partial void OnSectionChanged(LogSection value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsConnections));
|
||||
OnPropertyChanged(nameof(ShowsActivity));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A connection that is open right now.</summary>
|
||||
/// <param name="HostLabel">What the host is called.</param>
|
||||
/// <param name="Address">The address as dialled.</param>
|
||||
/// <param name="StartedAt">When it opened.</param>
|
||||
/// <param name="DeviceName">This machine.</param>
|
||||
/// <remarks>
|
||||
/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
|
||||
/// that is still running has no entry there, because an entry is written once and at close — which is what
|
||||
/// keeps a synced log from needing a merge.
|
||||
/// </remarks>
|
||||
internal sealed record LiveConnection(
|
||||
string HostLabel,
|
||||
string Address,
|
||||
DateTimeOffset StartedAt,
|
||||
string DeviceName);
|
||||
Reference in New Issue
Block a user