Public Access
Merge origin/main into claude/spelunk-api-key-tenant-6373cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
6f6e92f315
25 files changed
+2667
-417
No files matched your search
@@ -1529,10 +1529,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// to the same value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
|
||||
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
|
||||
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
|
||||
/// Avalonia draws and nothing occludes.
|
||||
/// <b>Not gated on there being a tab, because the last clause already is.</b> A selected tab with a
|
||||
/// session is what this needs, so the surface with nothing open — which the rail's Terminal entry reaches
|
||||
/// deliberately — has the WebView collapsed, and that is the rectangle <see cref="IsConnectPromptShowing"/>
|
||||
/// draws its prompt in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
||||
@@ -1860,16 +1860,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <summary>Switches to the terminal surface.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
|
||||
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
|
||||
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
|
||||
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
|
||||
/// button, and a button needs a command.
|
||||
/// The other half of <see cref="ShowScreenCommand"/>: the phone's bottom bar and the desktop rail's
|
||||
/// Terminal entry both name the terminal beside the pages, so the destination needs a button, and a
|
||||
/// button needs a command.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
|
||||
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
|
||||
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
|
||||
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — both
|
||||
/// heads draw the connect prompt there; see <see cref="IsConnectPromptShowing"/> — which is worth being
|
||||
/// able to reach deliberately.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
@@ -1956,57 +1954,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasRecentConnections));
|
||||
}
|
||||
|
||||
/// <summary>Goes back to a machine that has been connected to before.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
|
||||
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
|
||||
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
|
||||
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
|
||||
/// path that had to answer all of that again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
|
||||
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
|
||||
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
|
||||
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
|
||||
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
|
||||
/// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
|
||||
/// is deliberately not restored is the password: it was never stored, which is the whole point of the
|
||||
/// manual path, and a field that filled itself in would be claiming otherwise.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
|
||||
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
|
||||
/// out — arriving at the hosts screen with nothing selected and no explanation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ConnectToRecent(ConnectionLogRowViewModel row)
|
||||
{
|
||||
if (row is null || Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.HostId is { } hostId
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||
&& vault.IsVaultShown(known.VaultId))
|
||||
{
|
||||
vault.AskAboutHostCommand.Execute(known);
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
return;
|
||||
}
|
||||
|
||||
vault.ManualTarget = row.Address;
|
||||
vault.ManualStatus = string.Empty;
|
||||
}
|
||||
|
||||
// ---- The rail's own page grouping ----
|
||||
|
||||
/// <summary>
|
||||
@@ -2373,7 +2320,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
internal bool HasTabs => Tabs.Count > 0;
|
||||
|
||||
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
|
||||
private void RaiseTabState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTabs));
|
||||
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes one terminal, ending its shell.
|
||||
@@ -2444,6 +2395,343 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- The empty terminal's prompt ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's rectangle is asking what to connect to: the terminal surface, with nothing open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The desktop's answer to pressing Terminal with no session.</b> The surface used to draw an empty
|
||||
/// frame there — the tab row's "+" was the only thing on it that did anything. A terminal with nothing in
|
||||
/// it should do what a terminal does, which is ask: so the frame holds a prompt that takes a saved host's
|
||||
/// name, or <c>user@host</c> for a machine that is not saved, and asks for a password when one is needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Exclusive with the renderer by construction: the WebView shows only for a selected tab with a session,
|
||||
/// and this only while there are no tabs at all. That is what lets Avalonia draw it in the WebView's
|
||||
/// rectangle, the way <c>ConnectingCard</c> already is. See <see cref="IsTerminalShowing"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsConnectPromptShowing => IsTerminalSurface && !HasTabs;
|
||||
|
||||
/// <summary>What was typed at the prompt: a saved host's name, or <c>user@host[:port]</c>.</summary>
|
||||
[ObservableProperty]
|
||||
private string promptText = string.Empty;
|
||||
|
||||
/// <summary>The password typed at the prompt's second line.</summary>
|
||||
/// <remarks>
|
||||
/// Cleared the moment it is used or abandoned. It is never written into the vault's own password box
|
||||
/// either: that box carries a remember-this tick the prompt never offered — see
|
||||
/// <c>VaultViewModel.ConnectToHostAsync</c>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string promptPassword = string.Empty;
|
||||
|
||||
/// <summary>Whether the prompt has moved on to asking for a password.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPromptAskingForTarget))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsPromptMatches))]
|
||||
private bool isPromptAskingForPassword;
|
||||
|
||||
/// <summary>Whether the prompt is still asking what to connect to.</summary>
|
||||
internal bool IsPromptAskingForTarget => !IsPromptAskingForPassword;
|
||||
|
||||
/// <summary>The second line's prompt, in <c>ssh</c>'s own words: whose password it is.</summary>
|
||||
[ObservableProperty]
|
||||
private string promptPasswordLabel = string.Empty;
|
||||
|
||||
/// <summary>Why the prompt refused what was typed, or empty.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasPromptRefusal))]
|
||||
private string promptRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to print under the prompt.</summary>
|
||||
internal bool HasPromptRefusal => PromptRefusal.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Saved hosts whose name or address matches what has been typed so far, best first; Tab takes the first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty for a query with an <c>@</c> in it, which is an address rather than a name, and for an empty
|
||||
/// one: a list of every host under a prompt nobody has typed into yet is the hosts screen, drawn worse.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> PromptMatches { get; } = [];
|
||||
|
||||
/// <summary>Whether there are matches to print under the prompt.</summary>
|
||||
internal bool HasPromptMatches => PromptMatches.Count > 0;
|
||||
|
||||
/// <summary>Whether the matches are drawn: only while the name is being asked for, not under its password.</summary>
|
||||
internal bool ShowsPromptMatches => HasPromptMatches && !IsPromptAskingForPassword;
|
||||
|
||||
/// <summary>The saved host the password line is for, when it is for one.</summary>
|
||||
private HostRowViewModel? promptHost;
|
||||
|
||||
/// <summary>The typed address the password line is for, when it is for one.</summary>
|
||||
private VaultViewModel.ManualEndpoint? promptEndpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Enter at the prompt: takes what was typed, or — on the password line — connects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A name is matched exactly, ignoring case, because Enter on a name is a decision and not a search: the
|
||||
/// matches printed under the prompt are how a partial name becomes a whole one, by Tab or by a click.
|
||||
/// Two hosts with the same name in two vaults are refused rather than guessed between, and pointed at
|
||||
/// the palette, which shows which vault each came from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Concurrent executions allowed, for the reason <see cref="ConnectToAdHocTargetAsync"/> gives.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task SubmitPromptAsync()
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsPromptAskingForPassword)
|
||||
{
|
||||
await ConnectFromPromptAsync(vault).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var typed = PromptText.Trim();
|
||||
|
||||
if (typed.Length == 0)
|
||||
{
|
||||
PromptRefusal = "Type the name of a saved host, or user@host for a machine that is not saved.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (typed.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
if (!VaultViewModel.TryParseManualTarget(typed, out var endpoint, out var reason))
|
||||
{
|
||||
PromptRefusal = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
AskForPromptPassword(endpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
var named = vault.Hosts
|
||||
.Where(host => string.Equals(host.Label, typed, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(2)
|
||||
.ToList();
|
||||
|
||||
switch (named.Count)
|
||||
{
|
||||
case 0:
|
||||
PromptRefusal = PromptMatches.Count > 0
|
||||
? $"No saved host is called \"{typed}\". Press Tab for {PromptMatches[0].Label}, or type user@host."
|
||||
: $"No saved host is called \"{typed}\". Type user@host to reach a machine that is not saved.";
|
||||
return;
|
||||
|
||||
case > 1:
|
||||
PromptRefusal =
|
||||
$"More than one saved host is called \"{typed}\". Press Ctrl+K to choose between them.";
|
||||
return;
|
||||
|
||||
default:
|
||||
await TakePromptHostAsync(vault, named[0]).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A click on one of the matches printed under the prompt.</summary>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ChoosePromptMatchAsync(HostRowViewModel? host)
|
||||
{
|
||||
if (Vault is not { } vault || host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = host.Label;
|
||||
await TakePromptHostAsync(vault, host).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A click on one of the recent connections printed under the prompt.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two answers, because a recent row is one of two different things.</b> A row naming a host still in
|
||||
/// the keychain goes to that host, with whatever the keychain knows about it — its key, its saved
|
||||
/// password, its refusals — exactly as typing its name would. One that names none was typed in as an
|
||||
/// address, and the log stored exactly what was dialled, <c>user@host:port</c>, which is the grammar the
|
||||
/// prompt takes. So it goes back onto the prompt and asks for its password the way a typed one does; the
|
||||
/// password was never stored, which is the whole point of that path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||
/// user has switched off, and for the same reason rather than by accident: taking it would reach a host
|
||||
/// the rest of the application is deliberately not showing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
|
||||
{
|
||||
if (Vault is not { } vault || row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.HostId is { } hostId
|
||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||
&& vault.IsVaultShown(known.VaultId))
|
||||
{
|
||||
PromptText = known.Label;
|
||||
await TakePromptHostAsync(vault, known).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = row.Address;
|
||||
|
||||
if (VaultViewModel.TryParseManualTarget(row.Address, out var endpoint, out var reason))
|
||||
{
|
||||
AskForPromptPassword(endpoint);
|
||||
}
|
||||
else
|
||||
{
|
||||
PromptRefusal = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escape at the prompt: off the password line back to the name, or clears the name.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void CancelPrompt()
|
||||
{
|
||||
if (IsPromptAskingForPassword)
|
||||
{
|
||||
IsPromptAskingForPassword = false;
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
promptHost = null;
|
||||
promptEndpoint = null;
|
||||
return;
|
||||
}
|
||||
|
||||
PromptText = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Tab at the prompt: completes to the best match, if there is one.</summary>
|
||||
/// <returns>Whether anything was completed, so the key can move the focus as usual when it was not.</returns>
|
||||
internal bool CompletePrompt()
|
||||
{
|
||||
if (IsPromptAskingForPassword || PromptMatches.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PromptText = PromptMatches[0].Label;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Connects to a saved host, or asks for its password first if it needs one.</summary>
|
||||
private async Task TakePromptHostAsync(VaultViewModel vault, HostRowViewModel host)
|
||||
{
|
||||
if (VaultViewModel.AsksForAPassword(host))
|
||||
{
|
||||
promptHost = host;
|
||||
promptEndpoint = null;
|
||||
PromptPasswordLabel = $"{host.Address}'s password:";
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
IsPromptAskingForPassword = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ResetPrompt();
|
||||
|
||||
// No password to offer and nothing to remember: the binding supplies the credential. Any refusal —
|
||||
// a key deleted on another machine, a host with no username — lands on the status line, as it does
|
||||
// for the palette.
|
||||
await vault.ConnectToHostAsync(host, string.Empty, mayRememberPassword: false, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Moves the prompt on to the password for a typed address.</summary>
|
||||
private void AskForPromptPassword(VaultViewModel.ManualEndpoint endpoint)
|
||||
{
|
||||
promptHost = null;
|
||||
promptEndpoint = endpoint;
|
||||
PromptPasswordLabel = $"{endpoint.Address}'s password:";
|
||||
PromptPassword = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
IsPromptAskingForPassword = true;
|
||||
}
|
||||
|
||||
/// <summary>The password line's Enter: dials whichever of the two the line is for.</summary>
|
||||
private async Task ConnectFromPromptAsync(VaultViewModel vault)
|
||||
{
|
||||
if (PromptPassword.Length == 0)
|
||||
{
|
||||
PromptRefusal = "Type the password, or press Esc to go back.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = PromptPassword;
|
||||
var host = promptHost;
|
||||
var endpoint = promptEndpoint;
|
||||
|
||||
// Before the dial, so the password does not wait in the box for the length of a handshake. The tab the
|
||||
// connection opens replaces the prompt anyway; this is what is there the next time nothing is open.
|
||||
ResetPrompt();
|
||||
|
||||
if (host is not null)
|
||||
{
|
||||
await vault.ConnectToHostAsync(host, password, mayRememberPassword: false, CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else if (endpoint is not null)
|
||||
{
|
||||
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Puts the prompt back to an empty first line.</summary>
|
||||
private void ResetPrompt()
|
||||
{
|
||||
IsPromptAskingForPassword = false;
|
||||
PromptPassword = string.Empty;
|
||||
PromptText = string.Empty;
|
||||
PromptRefusal = string.Empty;
|
||||
promptHost = null;
|
||||
promptEndpoint = null;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A refusal is about what was typed, so typing again clears it; the matches follow every keystroke.
|
||||
/// </remarks>
|
||||
partial void OnPromptTextChanged(string value)
|
||||
{
|
||||
PromptRefusal = string.Empty;
|
||||
|
||||
PromptMatches.Clear();
|
||||
|
||||
var query = value.Trim();
|
||||
|
||||
if (Vault is { } vault && query.Length > 0 && !query.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
foreach (var host in HostsMatching(vault, query).Take(5))
|
||||
{
|
||||
PromptMatches.Add(host);
|
||||
}
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasPromptMatches));
|
||||
OnPropertyChanged(nameof(ShowsPromptMatches));
|
||||
}
|
||||
|
||||
// ---- Quick connect ----
|
||||
|
||||
/// <summary>Whether the quick-connect palette is open over the window.</summary>
|
||||
@@ -2479,6 +2767,102 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal bool HasSearchResults => SearchResults.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The machine the palette's query names, when what was typed reads as <c>user@host[:port]</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
|
||||
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
|
||||
/// saving it first. The query already is a box, and <c>user@host</c> is the grammar people type into
|
||||
/// one, so the palette reads it rather than growing a second field.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the connect prompt's parser and
|
||||
/// the phone's hosts search's too, so every box accepts exactly the same addresses and refuses the rest
|
||||
/// with the same sentences.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
|
||||
/// row offering to dial whatever three letters were typed so far would be noise on every keystroke.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasAdHocTarget))]
|
||||
[NotifyPropertyChangedFor(nameof(AdHocAddress))]
|
||||
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
|
||||
private VaultViewModel.ManualEndpoint? adHocTarget;
|
||||
|
||||
/// <summary>Whether the palette is offering to connect to a typed address.</summary>
|
||||
internal bool HasAdHocTarget => AdHocTarget is not null;
|
||||
|
||||
/// <summary>The typed address as it will be dialled, port and all.</summary>
|
||||
internal string AdHocAddress => AdHocTarget?.Address ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Why a query with an <c>@</c> in it is not an address yet, or empty.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Said under the box rather than swallowed, because a query that looks like an address and produces no
|
||||
/// row to connect to is otherwise a palette that silently ignored the one thing it was asked. It doubles
|
||||
/// as the password step's own refusal line.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasAdHocRefusal))]
|
||||
[NotifyPropertyChangedFor(nameof(HasNothingToOffer))]
|
||||
private string adHocRefusal = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a refusal to show under the box.</summary>
|
||||
internal bool HasAdHocRefusal => AdHocRefusal.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the highlight is on the typed address rather than on a saved host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A flag beside <see cref="SelectedSearchResult"/> rather than a row in <see cref="SearchResults"/>,
|
||||
/// because that list is the vault's own host rows and every reader of it — the ranking, the clicks, the
|
||||
/// tests — is entitled to find a host in each. At most one of the two is ever set; see
|
||||
/// <see cref="OnIsAdHocSelectedChanged"/> and <see cref="OnSelectedSearchResultChanged"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isAdHocSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the palette has moved on from the query to asking for the typed address's password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A typed password and nothing else</b>, which is the manual path's own rule and for its reason: a
|
||||
/// key is the reason to save the machine as a host, and offering the keychain's keys here would be a
|
||||
/// second binding resolution beside the one saved hosts already go through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second step inside the palette rather than a dialog, so the whole thing stays on the keyboard:
|
||||
/// type the address, Enter, type the password, Enter. Escape goes back to the query rather than closing,
|
||||
/// because a mistyped address is the likeliest reason to want out of this step.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAskingForSearchQuery))]
|
||||
private bool isAskingForAdHocPassword;
|
||||
|
||||
/// <summary>Whether the palette is showing its query box and results, rather than the password step.</summary>
|
||||
internal bool IsAskingForSearchQuery => !IsAskingForAdHocPassword;
|
||||
|
||||
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Cleared whenever the palette closes or goes back to the query, on the same terms the connect prompt
|
||||
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
|
||||
/// last one's password already in the box.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string adHocPassword = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the palette has nothing to offer at all — no host, no typed address and no reason why not.
|
||||
/// </summary>
|
||||
internal bool HasNothingToOffer => !HasSearchResults && !HasAdHocTarget && !HasAdHocRefusal;
|
||||
|
||||
/// <summary>Opens the palette, or closes it if it is already open.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleSearch()
|
||||
@@ -2504,11 +2888,174 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private void CloseSearch()
|
||||
{
|
||||
IsSearching = false;
|
||||
IsAskingForAdHocPassword = false;
|
||||
AdHocPassword = string.Empty;
|
||||
SearchText = string.Empty;
|
||||
SearchResults.Clear();
|
||||
SelectedSearchResult = null;
|
||||
IsAdHocSelected = false;
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = string.Empty;
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the palette's highlight by one row, the typed address first when there is one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Clamped rather than wrapped: a list that jumps from the last row to the first loses people. Here
|
||||
/// rather than in the palette's code-behind because the typed address is a row that is not in
|
||||
/// <see cref="SearchResults"/>, and which of the two highlights to set is the view model's to know.
|
||||
/// </remarks>
|
||||
internal void MoveSearchSelection(int delta)
|
||||
{
|
||||
var offset = HasAdHocTarget ? 1 : 0;
|
||||
var count = offset + SearchResults.Count;
|
||||
|
||||
if (count == 0 || IsAskingForAdHocPassword)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = IsAdHocSelected
|
||||
? 0
|
||||
: SelectedSearchResult is { } selected
|
||||
? offset + SearchResults.IndexOf(selected)
|
||||
: -1;
|
||||
|
||||
var next = Math.Clamp(current + delta, 0, count - 1);
|
||||
|
||||
if (offset == 1 && next == 0)
|
||||
{
|
||||
IsAdHocSelected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedSearchResult = SearchResults[next - offset];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the typed address, for a click on its row: the pointer's version of Enter.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void ChooseAdHocTarget()
|
||||
{
|
||||
if (!HasAdHocTarget)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsAdHocSelected = true;
|
||||
AskForAdHocPassword();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the palette back on its query, leaving what was typed there and dropping the password.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void BackToSearchQuery()
|
||||
{
|
||||
IsAskingForAdHocPassword = false;
|
||||
AdHocPassword = string.Empty;
|
||||
AdHocRefusal = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the typed address with the typed password, saving neither.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the connect prompt's own path,
|
||||
/// so a machine reached from here gets the same tab, the same host-key question and the same
|
||||
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
|
||||
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
|
||||
/// exactly the one whose key nobody has seen before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The palette closes before the dial rather than after it, as it does for a saved host: the tab the
|
||||
/// connection opens is where its progress and its failure are drawn, and a palette left up for the length
|
||||
/// of a handshake would sit over the very card that reports it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Concurrent executions allowed, for the reason <c>VaultViewModel.ConnectAsync</c> gives: this command is
|
||||
/// still running for as long as the handshake takes, and without the flag a second address typed into the
|
||||
/// palette meanwhile would be refused as a silent no-op.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ConnectToAdHocTargetAsync()
|
||||
{
|
||||
if (Vault is not { } vault || AdHocTarget is not { } endpoint)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (AdHocPassword.Length == 0)
|
||||
{
|
||||
AdHocRefusal = $"Type the password for {endpoint.Address}.";
|
||||
return;
|
||||
}
|
||||
|
||||
var password = AdHocPassword;
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// No token, for the reason VaultViewModel.ConnectAsync gives at length: a connection is abandoned by
|
||||
// closing its tab, not by anything here.
|
||||
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Moves the palette on to asking for the typed address's password.</summary>
|
||||
private void AskForAdHocPassword()
|
||||
{
|
||||
AdHocPassword = string.Empty;
|
||||
AdHocRefusal = string.Empty;
|
||||
IsAskingForAdHocPassword = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the query as an address, if it has an <c>@</c> in it, and says why not if it cannot be one.
|
||||
/// </summary>
|
||||
private void RefreshAdHocTarget(string query)
|
||||
{
|
||||
if (!query.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
if (VaultViewModel.TryParseManualTarget(query, out var endpoint, out var reason))
|
||||
{
|
||||
AdHocTarget = endpoint;
|
||||
AdHocRefusal = string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = reason;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>Clears the other half of the highlight, so at most one row is ever lit.</remarks>
|
||||
partial void OnIsAdHocSelectedChanged(bool value)
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
SelectedSearchResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnIsAdHocSelectedChanged" />
|
||||
partial void OnSelectedSearchResultChanged(HostRowViewModel? value)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
IsAdHocSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2523,6 +3070,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private async Task ConnectToSearchResultAsync()
|
||||
{
|
||||
// The typed address has no keychain item behind it, so nothing can be resolved for it: the password
|
||||
// it needs has to be asked for first. See IsAskingForAdHocPassword.
|
||||
if (IsAdHocSelected && HasAdHocTarget)
|
||||
{
|
||||
AskForAdHocPassword();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Vault is not { } vault || SelectedSearchResult is not { } row)
|
||||
{
|
||||
return;
|
||||
@@ -2560,13 +3115,52 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
SelectedSearchResult = null;
|
||||
IsAdHocSelected = false;
|
||||
AdHocTarget = null;
|
||||
AdHocRefusal = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
var query = SearchText.Trim();
|
||||
|
||||
var matches = query.Length == 0
|
||||
? vault.Hosts.AsEnumerable()
|
||||
foreach (var host in HostsMatching(vault, query).Take(8))
|
||||
{
|
||||
SearchResults.Add(host);
|
||||
}
|
||||
|
||||
RefreshAdHocTarget(query);
|
||||
|
||||
// The typed address is highlighted when there is one, unless a saved host dials exactly that address:
|
||||
// that host is the same machine with whatever the keychain knows about it — a key, a saved password —
|
||||
// and Enter should reach it the way it was set up to be reached. The typed row is still one arrow
|
||||
// away for somebody who wants the password prompt instead.
|
||||
var saved = AdHocTarget is { } typed
|
||||
? SearchResults.FirstOrDefault(host =>
|
||||
string.Equals(host.Address, typed.Address, StringComparison.OrdinalIgnoreCase))
|
||||
: null;
|
||||
|
||||
if (HasAdHocTarget && saved is null)
|
||||
{
|
||||
IsAdHocSelected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsAdHocSelected = false;
|
||||
SelectedSearchResult = saved ?? SearchResults.FirstOrDefault();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||
}
|
||||
|
||||
/// <summary>The vault's hosts that match what was typed, best first; every host for an empty query.</summary>
|
||||
/// <remarks>
|
||||
/// Shared by the palette and the empty terminal's prompt, so a name typed into either finds the same
|
||||
/// machines in the same order.
|
||||
/// </remarks>
|
||||
private static IEnumerable<HostRowViewModel> HostsMatching(VaultViewModel vault, string query) =>
|
||||
query.Length == 0
|
||||
? vault.Hosts
|
||||
: vault.Hosts
|
||||
.Select(host => (host, rank: Rank(host, query)))
|
||||
.Where(candidate => candidate.rank < int.MaxValue)
|
||||
@@ -2574,16 +3168,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
||||
.Select(candidate => candidate.host);
|
||||
|
||||
foreach (var host in matches.Take(8))
|
||||
{
|
||||
SearchResults.Add(host);
|
||||
}
|
||||
|
||||
SelectedSearchResult = SearchResults.FirstOrDefault();
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
}
|
||||
|
||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -5656,6 +6240,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||
|
||||
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
|
||||
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
|
||||
|
||||
Reference in new issue
Block a user