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:
@@ -200,6 +200,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
private readonly TeamsViewModel teams;
|
||||
|
||||
/// <summary>
|
||||
/// The tab standing in for each connection that has been asked for and has not answered yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two
|
||||
/// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives
|
||||
/// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and
|
||||
/// when the user closes the tab out from under it.
|
||||
/// </remarks>
|
||||
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -619,7 +630,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
|
||||
internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal half of the window is the half being shown, pane or no pane.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every condition in <see cref="IsTerminalShowing"/> except the one about there being a session, and it
|
||||
/// is worth its own name because a tab exists before its session does — see
|
||||
/// <see cref="TerminalTabViewModel"/>. This is what "the user is looking at the terminal" means; the
|
||||
/// other two say which of the two things that can be in that rectangle is drawn.
|
||||
/// </remarks>
|
||||
internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the card that stands in for a pane is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other half of <see cref="IsTerminalShowing"/>, and exclusive with it by construction: a selected
|
||||
/// tab either has a session or it does not. It covers both of the states in which it does not — still
|
||||
/// connecting, and failed — because both are a tab with something to say and nothing to draw it in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the
|
||||
/// terminal off rather than merely draw over it. See <see cref="IsTerminalShowing"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };
|
||||
|
||||
/// <inheritdoc cref="ShellSurface" />
|
||||
[ObservableProperty]
|
||||
@@ -721,6 +759,27 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// terminal just went would keep a lit dot until something else happened to move the selection.
|
||||
RefreshConnectedHosts();
|
||||
|
||||
// What the rectangle holds is decided by the selected tab's state, and the line above may well have
|
||||
// moved the selection from a card to a pane or the other way round.
|
||||
RaiseTerminalState();
|
||||
|
||||
if (!tab.HasSession)
|
||||
{
|
||||
// Nothing to end: this tab is a connection that has not happened, or one that never will. The
|
||||
// attempt is forgotten so a handshake still in flight does not come back and reopen a tab the
|
||||
// user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts
|
||||
// rather than drops, because a running shell nothing names is worse than a tab that reappears.
|
||||
foreach (var attemptId in attempts
|
||||
.Where(entry => ReferenceEquals(entry.Value, tab))
|
||||
.Select(entry => entry.Key)
|
||||
.ToArray())
|
||||
{
|
||||
attempts.Remove(attemptId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -810,11 +869,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
|
||||
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
|
||||
// screen — so connecting from the files screen without this would put the question behind the screen
|
||||
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
|
||||
// is what moves the surface to the terminal, and only if there is one.
|
||||
// The hosts page, because that is where this connection's questions get asked. An unknown or changed
|
||||
// host key is answered by a prompt drawn on that page and the palette opens from any screen, so
|
||||
// connecting from the files screen without this would leave the question behind the screen that asked
|
||||
// it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not
|
||||
// need to: a refusal that needs an answer puts the page back, which is where this leaves the screen.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
@@ -1816,13 +1875,29 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
if (oldValue is not null)
|
||||
{
|
||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
||||
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
||||
|
||||
// The three connection events are kept while an attempt is still in flight, and that is not an
|
||||
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
|
||||
// what holds both, and it outlives every vault — so a connection started just before a lock still
|
||||
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
|
||||
// tabs are this object's rather than the vault's. Detaching here would strand that tab on
|
||||
// "connecting…" for ever and leave the session it eventually opened with nothing in the window
|
||||
// naming it, and so no way to close it. The subscription dies with the vault once the attempt
|
||||
// resolves: the vault holds the handler, not the other way round.
|
||||
if (attempts.Count == 0)
|
||||
{
|
||||
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
||||
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||
}
|
||||
}
|
||||
|
||||
if (newValue is not null)
|
||||
{
|
||||
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
||||
newValue.ConnectionFailed += OnVaultConnectionFailed;
|
||||
newValue.SessionOpened += OnVaultSessionOpened;
|
||||
newValue.PropertyChanged += OnVaultPropertyChanged;
|
||||
|
||||
@@ -1878,29 +1953,118 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
|
||||
/// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
|
||||
/// </remarks>
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
|
||||
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
||||
|
||||
/// <summary>
|
||||
/// Takes a newly opened session into the tab strip and shows it.
|
||||
/// Puts a tab in the strip for a connection that has only just been asked for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One method rather than one per way of opening a session, so the order of these four steps is decided
|
||||
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
|
||||
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
|
||||
/// <para>
|
||||
/// This is what stops connecting looking like the application having stopped. The tab appears in the same
|
||||
/// turn as the click, carrying its own status, and the window switches to it — so a handshake against a
|
||||
/// machine that is asleep is a card that says which machine, rather than a status line under a window
|
||||
/// that does nothing for thirty seconds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Kept by attempt id rather than by label: several connections can be in flight now that one does not
|
||||
/// hold the vault, and two of them can perfectly well be to the same host.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
||||
{
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
||||
attempts[e.AttemptId] = tab;
|
||||
|
||||
AdoptTab(tab);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
|
||||
/// them and nothing more.
|
||||
/// </remarks>
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
|
||||
{
|
||||
if (!attempts.Remove(e.AttemptId, out var tab))
|
||||
{
|
||||
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
||||
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
||||
RefreshConnectedHosts();
|
||||
return;
|
||||
}
|
||||
|
||||
tab.Opened(e.SessionId);
|
||||
|
||||
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
||||
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
||||
RaiseTerminalState();
|
||||
|
||||
// Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was
|
||||
// no pane to name until this line.
|
||||
Activate(tab);
|
||||
|
||||
RefreshConnectedHosts();
|
||||
|
||||
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers a connection that did not become a session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
|
||||
/// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
|
||||
/// now, and the status line they are not looking at is not where a failure should end. A host key
|
||||
/// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put
|
||||
/// back where the question is being asked.
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
|
||||
{
|
||||
if (!attempts.Remove(e.AttemptId, out var tab))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.IsAwaitingAnAnswer)
|
||||
{
|
||||
tab.Failed(e.Reason);
|
||||
RaiseTerminalState();
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Tabs.IndexOf(tab);
|
||||
Tabs.Remove(tab);
|
||||
RaiseTabState();
|
||||
|
||||
if (ReferenceEquals(SelectedTab, tab))
|
||||
{
|
||||
// The neighbour, preferring the one on the left, exactly as closing a tab by hand does.
|
||||
SelectedTab = Tabs.Count == 0
|
||||
? null
|
||||
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||
}
|
||||
|
||||
// The screen the question is drawn on, and the page rather than a terminal. A connection can be
|
||||
// started from the palette on any screen, so without this the prompt would be behind whatever the
|
||||
// user was looking at, with the connection waiting on an answer they cannot reach.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes a tab into the strip and shows it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One method rather than one per way of opening a session, so the order of these steps is decided once.
|
||||
/// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs
|
||||
/// against a strip that already shows what it is about.
|
||||
/// </remarks>
|
||||
private void AdoptTab(TerminalTabViewModel tab)
|
||||
{
|
||||
Tabs.Add(tab);
|
||||
RaiseTabState();
|
||||
|
||||
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged. The page
|
||||
// also activates a newly created pane on its own, so this is belt and braces for the first session
|
||||
// and load-bearing for every one after it.
|
||||
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab
|
||||
// that has one. A tab that is still connecting has none, and selecting it shows the card instead.
|
||||
SelectedTab = tab;
|
||||
|
||||
// The surface, but deliberately not the screen. A session opened from the files screen shows its
|
||||
@@ -1908,19 +2072,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// clicking away comes back to the transfer that is presumably still running.
|
||||
Surface = ShellSurface.Terminal;
|
||||
|
||||
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
if (tab.HasSession)
|
||||
{
|
||||
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Fire-and-forget, and it has to be: this runs from a property setter, and a selection that awaited a
|
||||
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
|
||||
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
|
||||
/// an already-open socket and returns immediately when there is no renderer.
|
||||
/// </para>
|
||||
/// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the
|
||||
/// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the
|
||||
/// renderer. See <see cref="Activate"/> for why the last of those is not awaited.
|
||||
/// </remarks>
|
||||
partial void OnSelectedTabChanged(TerminalTabViewModel? value)
|
||||
{
|
||||
@@ -1929,6 +2090,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
tab.IsSelected = ReferenceEquals(tab, value);
|
||||
}
|
||||
|
||||
// Which of the two things can be in the terminal's rectangle depends on the selected tab having a
|
||||
// session, so moving the selection is one of the ways that answer changes. It also repaints the
|
||||
// strip's active mark, which follows the selection and the surface together.
|
||||
RaiseTerminalState();
|
||||
|
||||
RefreshConnectedHosts();
|
||||
|
||||
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
|
||||
@@ -1938,10 +2104,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
|
||||
Activate(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tells the renderer which pane to show.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a
|
||||
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
|
||||
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
|
||||
/// an already-open socket and returns immediately when there is no renderer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer
|
||||
/// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until
|
||||
/// the handshake finishes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void Activate(TerminalTabViewModel tab)
|
||||
{
|
||||
if (!tab.HasSession)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
|
||||
}
|
||||
|
||||
/// <summary>Which terminal a snippet would go into right now.</summary>
|
||||
/// <remarks>
|
||||
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
|
||||
@@ -2033,7 +2226,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsLocked));
|
||||
OnPropertyChanged(nameof(IsAskingForThePassphrase));
|
||||
OnPropertyChanged(nameof(IsUnlocked));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
RaiseTerminalState();
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
RaiseSyncState();
|
||||
|
||||
@@ -2105,10 +2298,34 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsSnippetsShowing));
|
||||
OnPropertyChanged(nameof(IsLogsShowing));
|
||||
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
RaiseTerminalState();
|
||||
}
|
||||
|
||||
partial void OnIsSearchingChanged(bool value) => OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
/// <summary>
|
||||
/// Re-reads what the terminal's rectangle should hold, and which tab is lit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One method for all four, because they are one fact read from four directions: the surface, the
|
||||
/// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn —
|
||||
/// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up
|
||||
/// pointing at something nobody can see.
|
||||
/// </remarks>
|
||||
private void RaiseTerminalState()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsTerminalSurface));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
|
||||
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
||||
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
||||
// on screen; see TerminalTabViewModel.IsShowing.
|
||||
foreach (var tab in Tabs)
|
||||
{
|
||||
tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
|
||||
|
||||
/// <remarks>
|
||||
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
||||
|
||||
@@ -3,7 +3,28 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// One open terminal, as a tab.
|
||||
/// How far along a tab's connection is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A tab exists before its session does — see <see cref="TerminalTabViewModel"/> — so "is there a pane
|
||||
/// behind this" is a question the strip and the window both have to be able to ask. Three states rather
|
||||
/// than a nullable session id, because <see cref="Failed"/> and <see cref="Connecting"/> are both "no
|
||||
/// session" and only one of them is still worth waiting for.
|
||||
/// </remarks>
|
||||
internal enum TerminalTabState
|
||||
{
|
||||
/// <summary>The connection is being made. There is no pane yet.</summary>
|
||||
Connecting = 0,
|
||||
|
||||
/// <summary>A session was opened, and the renderer has a pane for it.</summary>
|
||||
Open = 1,
|
||||
|
||||
/// <summary>The connection did not happen. There is no pane, and there never will be for this tab.</summary>
|
||||
Failed = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One terminal, as a tab: from the moment connecting starts to the moment the tab is closed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -13,24 +34,73 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
||||
/// is the WebView, and there is one of those however many tabs are open.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
|
||||
/// a DNS lookup and a handshake take, and the strip is where that is admitted to: the tab appears at the
|
||||
/// moment the user asks for it, carrying <see cref="Status"/> instead of a pane, and becomes a real terminal
|
||||
/// when <see cref="Opened"/> is called. Nothing about the rest of the application waits for that — which is
|
||||
/// the point, because the alternative is a window that does nothing visible for ten seconds.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Tabs belong to the shell, not to the vault.</b> Locking disposes the vault and every key it held, and
|
||||
/// deliberately leaves shells running — so a tab list rebuilt per unlock would lose track of sessions that
|
||||
/// are still connected, and the unlock screen's count of them would be the only place they appeared. The
|
||||
/// shell outlives every lock, and so does this.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">Who this is logged in as, and where.</param>
|
||||
internal sealed partial class TerminalTabViewModel(uint sessionId, string label, string address)
|
||||
: ObservableObject
|
||||
internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
{
|
||||
internal uint SessionId { get; } = sessionId;
|
||||
/// <summary>A tab for a connection that is still being made.</summary>
|
||||
/// <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>
|
||||
internal TerminalTabViewModel(string label, string address)
|
||||
{
|
||||
Label = label;
|
||||
Address = address;
|
||||
status = "connecting…";
|
||||
isLive = false;
|
||||
}
|
||||
|
||||
internal string Label { get; } = label;
|
||||
/// <summary>A tab for a session that is already open.</summary>
|
||||
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">Who this is logged in as, and where.</param>
|
||||
internal TerminalTabViewModel(uint sessionId, string label, string address)
|
||||
: this(label, address)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
state = TerminalTabState.Open;
|
||||
status = string.Empty;
|
||||
isLive = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifies this terminal to the renderer, or zero while there is no session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Zero is not a session id the workspace ever hands out — it counts from one — so it can stand for
|
||||
/// "not connected yet" without a nullable that every caller would have to unwrap. <see cref="HasSession"/>
|
||||
/// is what the window asks rather than this.
|
||||
/// </remarks>
|
||||
internal uint SessionId { get; private set; }
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||
internal string Address { get; } = address;
|
||||
internal string Address { get; }
|
||||
|
||||
/// <inheritdoc cref="TerminalTabState" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
|
||||
/// <summary>
|
||||
/// What this tab has to say for itself while it has no pane.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty once a session is open, because from then on the pane speaks for itself — anything written here
|
||||
/// would be a second, staler account of what the terminal is already showing.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string status;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the shell behind this tab is still running.
|
||||
@@ -41,7 +111,7 @@ internal sealed partial class TerminalTabViewModel(uint sessionId, string label,
|
||||
/// pane still holds the scrollback, and the last thing the remote said is usually why the shell ended.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isLive = true;
|
||||
private bool isLive;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the tab whose pane is showing.
|
||||
@@ -53,4 +123,58 @@ internal sealed partial class TerminalTabViewModel(uint sessionId, string label,
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this tab is the thing the window is currently showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not the same question as <see cref="IsSelected"/>, and the strip has to ask this one. The selection
|
||||
/// survives navigating away — that is what makes the strip a way back to a terminal rather than a way to
|
||||
/// lose it — so a tab that stayed lit while preferences filled the window would be a second "you are
|
||||
/// here" mark pointing at something nobody can see. The rail's own entries make exactly this distinction;
|
||||
/// see <c>MainWindowViewModel.IsHostsShowing</c>. The shell writes it, from the selection and the surface
|
||||
/// together.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isShowing;
|
||||
|
||||
/// <summary>Whether there is a pane behind this tab.</summary>
|
||||
internal bool HasSession => State is TerminalTabState.Open;
|
||||
|
||||
/// <summary>Whether this tab is still waiting on a connection.</summary>
|
||||
internal bool IsConnecting => State is TerminalTabState.Connecting;
|
||||
|
||||
/// <summary>Whether this tab is a connection that never happened.</summary>
|
||||
internal bool IsFailed => State is TerminalTabState.Failed;
|
||||
|
||||
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
|
||||
internal void Opened(uint sessionId)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
Status = string.Empty;
|
||||
IsLive = true;
|
||||
State = TerminalTabState.Open;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the connection this tab was opened for did not happen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tab stays, and that is deliberate: connecting no longer blocks the window, so by the time a
|
||||
/// refusal arrives the user is quite likely looking at something else — and a tab that vanished would
|
||||
/// take the only account of what went wrong with it. It is closed the way every other tab is.
|
||||
/// </remarks>
|
||||
internal void Failed(string reason)
|
||||
{
|
||||
Status = reason;
|
||||
IsLive = false;
|
||||
State = TerminalTabState.Failed;
|
||||
}
|
||||
|
||||
partial void OnStateChanged(TerminalTabState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSession));
|
||||
OnPropertyChanged(nameof(IsConnecting));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -482,6 +483,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
connectionLog = log;
|
||||
objectStores = buckets;
|
||||
|
||||
// Followed rather than copied once, which is the difference between a picker that is right at unlock
|
||||
// and one that is right afterwards. A host or a bucket added on this machine — or pulled in by a
|
||||
// synchronisation pass from another — rebuilds the vault's collections, and a screen that had taken a
|
||||
// snapshot at unlock went on offering the list as it was when the vault opened. Detached again in
|
||||
// Detach: these collections belong to a vault that is about to be disposed.
|
||||
openVault.Hosts.CollectionChanged += OnVaultListChanged;
|
||||
openVault.ObjectStores.CollectionChanged += OnVaultListChanged;
|
||||
|
||||
RefreshHosts();
|
||||
|
||||
// Read once per unlock rather than per navigation: a drive appearing while the application is open
|
||||
@@ -505,6 +514,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// </remarks>
|
||||
internal void Detach()
|
||||
{
|
||||
if (vault is { } open)
|
||||
{
|
||||
open.Hosts.CollectionChanged -= OnVaultListChanged;
|
||||
open.ObjectStores.CollectionChanged -= OnVaultListChanged;
|
||||
}
|
||||
|
||||
vault = null;
|
||||
knownHosts = null;
|
||||
|
||||
@@ -1189,8 +1204,29 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds both pickers from the vault's lists.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Called whenever either of those lists changes, not only at unlock — so a host created on the hosts
|
||||
/// screen, or a bucket added to the keychain, can be picked here without locking and unlocking first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The selection is re-found by id rather than kept.</b> The vault replaces every row on every reload,
|
||||
/// which a synchronisation pass does once a minute, so holding the object would leave the picker showing
|
||||
/// nothing at all: the row it points at is no longer one of the items in the list. Re-finding it also
|
||||
/// means a host deleted elsewhere falls back to the first entry rather than to a selection that cannot be
|
||||
/// connected to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void RefreshHosts()
|
||||
{
|
||||
var host = SelectedHost?.EntityId;
|
||||
var bucket = SelectedBucket?.EntityId;
|
||||
|
||||
Hosts.Clear();
|
||||
|
||||
if (vault is not { } open)
|
||||
@@ -1198,21 +1234,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var host in open.Hosts)
|
||||
foreach (var row in open.Hosts)
|
||||
{
|
||||
Hosts.Add(host);
|
||||
Hosts.Add(row);
|
||||
}
|
||||
|
||||
SelectedHost ??= Hosts.FirstOrDefault();
|
||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
|
||||
|
||||
Buckets.Clear();
|
||||
|
||||
foreach (var bucket in open.ObjectStores)
|
||||
foreach (var row in open.ObjectStores)
|
||||
{
|
||||
Buckets.Add(bucket);
|
||||
Buckets.Add(row);
|
||||
}
|
||||
|
||||
SelectedBucket ??= Buckets.FirstOrDefault();
|
||||
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
|
||||
@@ -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