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;
/// Which log the screen is showing.
internal enum LogSection
{
/// Connections that were made.
Connections,
/// Changes made to keychain items.
Activity,
}
/// One connection, as a row.
internal sealed class ConnectionLogRowViewModel(VaultItem entry, bool isLive)
{
internal Guid EntityId => entry.EntityId;
internal string HostLabel => entry.Secret.HostLabel;
internal string Address => entry.Secret.Address;
/// When it started, in the reader's own conventions.
///
/// 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. InvariantGlobalization is false in
/// the client csproj precisely so this works.
///
internal string Started =>
entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
///
/// How long it lasted, or that it has not finished.
///
///
/// "still open" and not a dash. 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 .
///
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;
/// Whether this was a terminal or the file browser.
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
/// The keychain host this was, if it was one.
///
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
/// data — see VaultViewModel.ConnectManuallyAsync. It is what lets the Connections screen offer
/// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own
/// authentication behind it, and an address has only the box it was typed into.
///
internal Guid? HostId => entry.Secret.HostId;
internal string DeviceName => entry.Secret.DeviceName;
///
/// 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.
///
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");
}
}
/// One keychain change, as a row.
internal sealed class ActivityLogRowViewModel(VaultItem 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",
};
/// ── v5b ── Whether the WHAT chip should draw in its "created" colour.
internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created;
/// ── v5b ── Whether the WHAT chip should draw in its "deleted" colour.
internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted;
///
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
/// Which fields changed. Never what they changed to.
internal string ChangedFields => entry.Secret.ChangedFields;
internal bool HasChangedFields => ChangedFields.Length > 0;
internal string DeviceName => entry.Secret.DeviceName;
}
///
/// What has been connected to, and what has been changed.
///
///
///
/// 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 now. 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.
///
///
/// Read on demand rather than kept in step. 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.
///
///
internal sealed partial class LogsViewModel : ObservableObject
{
private readonly VaultSession session;
private readonly Func> live;
/// The open vault, which holds both logs.
///
/// 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.
///
internal LogsViewModel(VaultSession session, Func> live)
{
this.session = session;
this.live = live;
}
/// Connections, newest first, with anything still open at the top.
internal ObservableCollection Connections { get; } = [];
/// Keychain changes, newest first.
internal ObservableCollection Activity { get; } = [];
///
/// 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.
///
[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.";
///
/// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log
/// actually records.
///
///
/// Per section rather than one line for the screen, and drawn from the same fact this type's own header
/// remarks already state: an entry is written once, at close, for a connection; and once per write, per
/// device, for an activity row. Not itself — that is a refresh outcome, cleared to
/// empty on success — so is the one the header actually binds, showing an
/// error over this fact when there is one to show.
///
internal string SectionFact => Section is LogSection.Connections
? "an entry is written once, when a connection closes"
: "one row per write, per device";
/// What the header's own status line shows: an error, if refreshing just produced one, else the fact.
internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact;
/// Shows one of the two logs.
[RelayCommand]
private void ShowSection(LogSection section) => Section = section;
/// Re-reads both logs.
[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;
}
}
/// Reads both logs into the lists.
internal async Task ReloadAsync(CancellationToken cancellationToken)
{
await ReloadConnectionsAsync(cancellationToken).ConfigureAwait(true);
var activity = await session.ActivityLog
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
Activity.Clear();
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
{
Activity.Add(new ActivityLogRowViewModel(entry));
}
OnPropertyChanged(nameof(HasActivity));
}
/// Reads the connection log alone.
///
/// Split out for the Connections screen, which offers the most recent of these as a way back to a
/// machine and has no use at all for the keychain's activity. Reading both there would double the
/// decryption for a list nobody on that screen is looking at — and this list is already the expensive
/// one, which is why the whole thing is read on demand rather than kept in step. See the remark on the
/// type.
///
internal async Task ReloadConnectionsAsync(CancellationToken cancellationToken)
{
var connections = await session.ConnectionLog
.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(
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));
}
OnPropertyChanged(nameof(HasConnections));
}
partial void OnSectionChanged(LogSection value)
{
OnPropertyChanged(nameof(ShowsConnections));
OnPropertyChanged(nameof(ShowsActivity));
OnPropertyChanged(nameof(EmptyMessage));
OnPropertyChanged(nameof(SectionFact));
OnPropertyChanged(nameof(HeaderStatusLine));
}
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine));
}
/// A connection that is open right now.
/// What the host is called.
/// The address as dialled.
/// When it opened.
/// This machine.
///
/// 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.
///
internal sealed record LiveConnection(
string HostLabel,
string Address,
DateTimeOffset StartedAt,
string DeviceName);