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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user