Public Access
Stop making people wait for a handshake, and give the host list a pointer
Connecting held the vault's busy gate, which meant a window that did nothing visible for as long as a machine took to answer — and against one that is merely asleep, that is the whole timeout. The gate is gone from that one command. A tab now appears in the strip in the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's rectangle draws a card naming the host and the address being dialled. Every other screen stays usable, and two connections can be in flight at once. That splits the vault's one connection event into three, carrying an attempt id, because "which tab is this about" can no longer be answered by "the most recent one". The id also buys the two kinds of not-connecting their different endings: a refusal stays in the strip as a tab holding its reason, since by then the user is quite likely three screens away and a status line they are not looking at is not where a failure should end; a host key question takes the tab away and puts the window back on HOSTS, because the prompt is drawn there and a tab claiming failure would be competing with the thing about to resume it. ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than tidying. A [RelayCommand] over a method that takes one generates a command that cancels the previous execution's token on every invocation — so asking for a second machine silently abandoned the first, measured as the first tab disappearing with "Cancelled." the instant the second was asked for. Giving up on a connection is closing its tab, and a session that lands after that is adopted rather than dropped: a shell running with nothing naming it cannot be closed at all. A tab is marked active on IsShowing rather than IsSelected. The selection survives navigating away — that is what makes the strip a way back to a terminal instead of a way to lose one — so a tab lit while preferences filled the window was a second "you are here" mark pointing at something nobody could see. The nav rail's own entries have always made this distinction. The host list grows the two gestures it looked like it already had. A right click selects the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is on the list rather than in the item template, so its entries are the vault's own commands and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it out of a group; the write is one field of one host through the same repository a save uses, refused while the editor is open because a drop is a gesture on the list and not on a half-typed form. Clicking a result in the palette connects, which is what a list of hosts under a search box looks like it does. It went through the shell's own command, so the pointer and Enter take one path. And the files screen's two pickers followed the vault's lists once, at unlock: a host or a bucket created afterwards could not be picked until the keychain had been locked and opened again, with nothing on screen explaining why the machine plainly in the host list was missing. They follow the collections now, re-finding the selection by id across the rebuild a sync pass causes every minute. 165 shell tests and 69 layout tests green, including the connecting tab, both failure endings, two connections at once, a connection in flight across a lock, and the right click acting on the row under the pointer rather than on the selection. The drag itself is in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform drag, and a test that claimed to have dropped something would pass while confirming nothing.
This commit is contained in:
@@ -82,6 +82,16 @@ internal sealed record GroupChoice(Guid? EntityId, string Label)
|
||||
internal static GroupChoice None { get; } = new(null, "No group");
|
||||
}
|
||||
|
||||
/// <summary>One host, and the group it is being filed under.</summary>
|
||||
/// <param name="Host">The host to move.</param>
|
||||
/// <param name="GroupId">The group it should end up in, or null for none.</param>
|
||||
/// <remarks>
|
||||
/// A pair rather than two command parameters, because a command takes one — and a pair rather than the two
|
||||
/// ids, because the host row is what the caller is holding: it is the thing that was dragged, and it already
|
||||
/// carries the vault the edit has to return to.
|
||||
/// </remarks>
|
||||
internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
|
||||
|
||||
/// <summary>One snippet, as a row in the list.</summary>
|
||||
/// <remarks>
|
||||
/// Carries the decrypted <see cref="SnippetSecret"/> so opening the editor needs no second decryption, in
|
||||
@@ -432,8 +442,16 @@ internal static class ItemBadge
|
||||
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">The account and endpoint actually dialled.</param>
|
||||
internal sealed class TerminalSessionEventArgs(uint sessionId, string label, string address) : EventArgs
|
||||
internal sealed class TerminalSessionEventArgs(
|
||||
Guid attemptId,
|
||||
uint sessionId,
|
||||
string label,
|
||||
string address) : EventArgs
|
||||
{
|
||||
/// <summary>Which attempt this session came out of.</summary>
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||
internal Guid AttemptId { get; } = attemptId;
|
||||
|
||||
internal uint SessionId { get; } = sessionId;
|
||||
|
||||
internal string Label { get; } = label;
|
||||
@@ -441,6 +459,55 @@ internal sealed class TerminalSessionEventArgs(uint sessionId, string label, str
|
||||
internal string Address { get; } = address;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">Who this will be logged in as, and where.</param>
|
||||
/// <remarks>
|
||||
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
||||
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
||||
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
||||
/// has no vault to read it from.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
|
||||
{
|
||||
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
||||
/// <remarks>
|
||||
/// Carried by all three events, because several connections can be in flight at once now that one no
|
||||
/// longer blocks the window — so "which tab is this about" cannot be answered by "the most recent one".
|
||||
/// </remarks>
|
||||
internal Guid AttemptId { get; } = attemptId;
|
||||
|
||||
internal string Label { get; } = label;
|
||||
|
||||
internal string Address { get; } = address;
|
||||
}
|
||||
|
||||
/// <summary>A connection that was asked for and did not happen.</summary>
|
||||
/// <param name="attemptId">The attempt that has just ended.</param>
|
||||
/// <param name="reason">What to say about it, in the tab.</param>
|
||||
/// <param name="isAwaitingAnAnswer">
|
||||
/// Whether the connection stopped on a question rather than on a failure.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The two kinds are genuinely different and the shell treats them differently. A refusal is a dead end and
|
||||
/// the tab keeps it: connecting no longer blocks the window, so the user may well be looking at something
|
||||
/// else by now, and a tab that vanished would take the only account of what went wrong with it. An unknown
|
||||
/// or changed host key is not a dead end — it is a prompt drawn on the hosts screen, and the connection
|
||||
/// resumes the moment it is answered — so the tab goes and the window shows the question instead.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, bool isAwaitingAnAnswer)
|
||||
: EventArgs
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
|
||||
internal Guid AttemptId { get; } = attemptId;
|
||||
|
||||
internal string Reason { get; } = reason;
|
||||
|
||||
/// <inheritdoc cref="ConnectionFailedEventArgs" path="/param[@name='isAwaitingAnAnswer']" />
|
||||
internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer;
|
||||
}
|
||||
|
||||
/// <summary>A conflict, as a row.</summary>
|
||||
internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
||||
{
|
||||
@@ -1417,6 +1484,22 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;
|
||||
|
||||
/// <summary>
|
||||
/// Raised the moment a connection is asked for, before anything has been dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The other half of <see cref="SessionOpened"/>, and the reason connecting no longer makes the window
|
||||
/// sit still: the shell opens a tab from this, so the strip shows what is being connected to while the
|
||||
/// handshake is still happening, and every other screen stays usable. Exactly one of
|
||||
/// <see cref="SessionOpened"/> and <see cref="ConnectionFailed"/> follows it, carrying the same
|
||||
/// <c>AttemptId</c>.
|
||||
/// </remarks>
|
||||
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
|
||||
|
||||
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
|
||||
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
||||
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
||||
|
||||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||||
|
||||
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||
@@ -1893,6 +1976,93 @@ internal sealed partial class VaultViewModel(
|
||||
SelectedSidebarRow = SelectedHost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Files one host under one group, or under none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What dragging a row onto a heading does, and the only thing in this application that changes a host
|
||||
/// without opening the editor. That is the justification for it existing at all: filing thirty imported
|
||||
/// machines meant thirty rounds of open, pick, save, and the field being changed is the one field of a
|
||||
/// host that is about arrangement rather than about the machine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
|
||||
/// drop is a gesture on the list, not on the form: rewriting the item under a half-typed edit of the same
|
||||
/// host would be a save the user never asked for, and one they would then be unable to cancel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A group id that is not in this vault is not refused — it is treated as no group at all, which is what
|
||||
/// the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
|
||||
/// method that takes one cancels the previous execution's token on every invocation, and two drops in
|
||||
/// quick succession are two writes rather than one superseding the other. This is one row's one field
|
||||
/// and it is over in a moment.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="request">The host to move, and where to.</param>
|
||||
[RelayCommand]
|
||||
private async Task MoveHostToGroupAsync(HostGroupMove? request)
|
||||
{
|
||||
if (request is not { Host: { } row })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
// The same refusal editing makes, and for the same reason: re-encoding an item a newer client
|
||||
// wrote would drop the fields this build has no concept of.
|
||||
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsEditing)
|
||||
{
|
||||
Status = "Finish or cancel the host you are editing first.";
|
||||
return;
|
||||
}
|
||||
|
||||
Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted)
|
||||
? wanted
|
||||
: null;
|
||||
|
||||
if (row.Host.GroupId == target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var moved = row.Host with { GroupId = target };
|
||||
var name = target is null
|
||||
? "no group"
|
||||
: Groups.First(group => group.EntityId == target).Label;
|
||||
|
||||
await RunAsync(
|
||||
$"Filing {row.Label} under {name}…",
|
||||
async () =>
|
||||
{
|
||||
await session.Hosts
|
||||
.UpdateAsync(row.VaultId, row.EntityId, moved, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
// Re-found rather than kept: the reload replaces every row, so the object that was dragged is
|
||||
// no longer the one in the list, and leaving the selection pointing at it would light nothing.
|
||||
SelectedHost = Hosts.FirstOrDefault(candidate => candidate.EntityId == row.EntityId);
|
||||
|
||||
Status = target is null
|
||||
? $"'{row.Label}' is no longer in a group."
|
||||
: $"Filed '{row.Label}' under '{name}'.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
// Pushed straight away, as a save from the editor is: this is a save from the editor, minus the
|
||||
// editor.
|
||||
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
|
||||
/// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
|
||||
@@ -3488,9 +3658,46 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens a terminal on the selected host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Opens a terminal on the selected host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Deliberately not inside <see cref="RunAsync"/>, unlike every other command here.</b> That gate is
|
||||
/// what makes the vault do one thing at a time, and connecting is the one operation that must not hold
|
||||
/// it: a handshake is a network round trip against a machine that may be asleep, and holding the gate
|
||||
/// for it means a window in which nothing else can be saved, edited or even connected to. The strip
|
||||
/// carries the feedback instead — <see cref="ConnectionStarting"/> puts a tab there before anything is
|
||||
/// dialled — so the wait is visible without being in the way. Everything <c>RunAsync</c> would have done
|
||||
/// for the failures is done by <see cref="OpenSessionAsync"/>, which reports every one of them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Several connections can therefore be in flight at once, which is why an attempt has an id and why
|
||||
/// this command allows concurrent executions. That is a feature rather than a tolerated race: opening
|
||||
/// three machines is one of the ordinary things to do with a tabbed client, and it used to mean waiting
|
||||
/// for each in turn. Without the flag the generated command refuses a second call outright while the
|
||||
/// first is running — silently, as a no-op — which would be the old one-at-a-time behaviour with none of
|
||||
/// the explanation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It takes no cancellation token, and that is what makes the flag above mean anything.</b> A
|
||||
/// <c>[RelayCommand]</c> over a method that takes one generates a command which cancels the previous
|
||||
/// execution's token every time it is invoked — so a second connection would quietly abandon the first,
|
||||
/// which is the exact opposite of what opening two machines at once is supposed to do. Measured: the
|
||||
/// first tab disappeared with "Cancelled." the instant the second was asked for. What is given up by not
|
||||
/// having one is a way to abort a handshake from here; closing the tab is that, and the session it
|
||||
/// abandons is adopted rather than lost. See <c>MainWindowViewModel.CloseTabAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc cref="ConnectAsync" />
|
||||
/// <param name="cancellationToken">
|
||||
/// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own,
|
||||
/// which is a different command's and so is not cancelled by anyone else connecting.
|
||||
/// </param>
|
||||
private async Task ConnectToSelectedHostAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedHost is not { } row)
|
||||
{
|
||||
@@ -3510,9 +3717,18 @@ internal sealed partial class VaultViewModel(
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Connecting to {row.Label}…",
|
||||
() => OpenSessionAsync(row, authentication, cancellationToken)).ConfigureAwait(true);
|
||||
// Before the first await, so the tab is in the strip in the same turn the user asked for it. The
|
||||
// address is the one that will actually be dialled — a bound credential can supply the username —
|
||||
// rather than the host's own fields, so the tab does not rename itself on connecting.
|
||||
var attempt = new ConnectionAttemptEventArgs(
|
||||
Guid.CreateVersion7(),
|
||||
row.Label,
|
||||
Dialled(row, authentication));
|
||||
|
||||
ConnectionStarting?.Invoke(this, attempt);
|
||||
Status = $"Connecting to {row.Label}…";
|
||||
|
||||
await OpenSessionAsync(attempt, row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -3549,7 +3765,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
PendingHostKey = null;
|
||||
|
||||
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
||||
await ConnectToSelectedHostAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After connecting, not before. A pin is worth pushing straight away — the same host on another
|
||||
// machine should not ask again — but not at the cost of delaying the connection the user asked for.
|
||||
@@ -3671,63 +3887,106 @@ internal sealed partial class VaultViewModel(
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Connects, and turns every way of not connecting into something a tab can carry.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
|
||||
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
|
||||
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
|
||||
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The timeout is translated rather than reported.</b> <see cref="TimeoutException"/> says only "The
|
||||
/// operation has timed out", and the one thing worth saying is where to look: a runtime this application
|
||||
/// does not install.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An unknown host key is not a failure and is deliberately not logged.</b> Nothing was refused and
|
||||
/// nothing broke — the connection is paused on a question, and it becomes a session the moment the user
|
||||
/// answers it. An entry here would record a failure that did not happen, once per new host. A changed
|
||||
/// key <em>is</em> logged, and it is the entry the connection log most exists for: it is refused outright
|
||||
/// with no way past it, so the only trace it would otherwise leave is a status line the user dismisses.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything else is caught by shape rather than by type.</b> This project's SSH layer defines only
|
||||
/// the two host-key exceptions; an unreachable host, a rejected password and a key the remote will not
|
||||
/// take all arrive from SSH.NET, which the client deliberately does not reference. Each is recorded
|
||||
/// before it is reported — the log is an observer here and must never become the thing that swallows an
|
||||
/// error. Cancellation is excluded from that, because a user who gave up did not fail to connect.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task OpenSessionAsync(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
HostRowViewModel row,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAndAnnounceAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
await ConnectAndAnnounceAsync(attempt, row, authentication, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// The renderer never attached, so nothing was connected. Reported here rather than left to
|
||||
// RunAsync's generic handler because TimeoutException says only "The operation has timed out",
|
||||
// and the one thing worth saying is where to look: a runtime this application does not install.
|
||||
Status = "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
||||
+ "runtime is probably missing or blocked; install it and try again.";
|
||||
Abandon(
|
||||
attempt,
|
||||
"The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
||||
+ "runtime is probably missing or blocked; install it and try again.");
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact. The user has to decide, and they need the fingerprint to do it.
|
||||
//
|
||||
// Deliberately not logged. Nothing was refused and nothing failed — the connection is paused on a
|
||||
// question, and it becomes a session the moment the user answers it. An entry here would record a
|
||||
// failure that did not happen, once per new host.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
Answer(attempt, "This host has not been seen before.");
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
// Logged, and this is the entry the connection log most exists for. A changed host key is
|
||||
// refused outright with no way past it, so the only trace it would otherwise leave is a status
|
||||
// line the user dismisses — and a run of these against one machine is what somebody reviewing a
|
||||
// log needs to see.
|
||||
RecordFailure(row, authentication, ConnectionOutcome.Refused);
|
||||
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. The connection was refused.";
|
||||
Answer(attempt, "The host key has changed. The connection was refused.");
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Everything else: an unreachable host, a rejected password, a key the remote will not take.
|
||||
// Caught by shape rather than by type because this project's SSH layer defines only the two
|
||||
// host-key exceptions above and everything else arrives from SSH.NET, which the client
|
||||
// deliberately does not reference.
|
||||
//
|
||||
// Recorded and rethrown, so RunAsync goes on reporting it exactly as it did. The log is an
|
||||
// observer here and must never become the thing that swallows an error. Cancellation is excluded
|
||||
// because a user who gave up did not fail to connect.
|
||||
RecordFailure(row, authentication, ConnectionOutcome.Failed);
|
||||
throw;
|
||||
Answer(attempt, "Cancelled.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
RecordFailure(row, authentication, ConnectionOutcome.Failed);
|
||||
Abandon(attempt, exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
|
||||
/// <remarks>
|
||||
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
|
||||
/// looking, and the tab is where somebody who navigated away will find it — which is now the ordinary
|
||||
/// case, because connecting does not hold the window still any more.
|
||||
/// </remarks>
|
||||
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
||||
{
|
||||
Status = reason;
|
||||
|
||||
ConnectionFailed?.Invoke(
|
||||
this,
|
||||
new ConnectionFailedEventArgs(attempt.AttemptId, reason, isAwaitingAnAnswer: false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same, for an attempt that stopped on something the user has to answer rather than on a failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The difference is what the shell does with the tab: a refusal keeps it, and a question takes it away
|
||||
/// so the window can show the question instead. See <see cref="ConnectionFailedEventArgs"/>. Cancelling
|
||||
/// counts as a question in the sense that matters here — the tab is going either way, and nothing about
|
||||
/// it is worth keeping on screen.
|
||||
/// </remarks>
|
||||
private void Answer(ConnectionAttemptEventArgs attempt, string status)
|
||||
{
|
||||
Status = status;
|
||||
|
||||
ConnectionFailed?.Invoke(
|
||||
this,
|
||||
new ConnectionFailedEventArgs(attempt.AttemptId, status, isAwaitingAnAnswer: true));
|
||||
}
|
||||
|
||||
/// <summary>Opens the session and tells the shell about it. Every failure is a throw.</summary>
|
||||
@@ -3736,6 +3995,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// happy path, and everything above it is one <c>catch</c> per way of not having one.
|
||||
/// </remarks>
|
||||
private async Task ConnectAndAnnounceAsync(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
HostRowViewModel row,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -3765,7 +4025,11 @@ internal sealed partial class VaultViewModel(
|
||||
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
||||
SessionOpened?.Invoke(
|
||||
this,
|
||||
new TerminalSessionEventArgs(sessionId, row.Label, Dialled(row, authentication)));
|
||||
new TerminalSessionEventArgs(
|
||||
attempt.AttemptId,
|
||||
sessionId,
|
||||
row.Label,
|
||||
Dialled(row, authentication)));
|
||||
}
|
||||
|
||||
/// <summary>The address as actually dialled.</summary>
|
||||
|
||||
Reference in New Issue
Block a user