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
|
||||
|
||||
Reference in New Issue
Block a user