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:
laurensandClaude Opus 5.5 committed 2026-10-20 16:26:32 +02:00
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