Public Access
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c56f20a4 | ||
|
|
7f77539ba6 | ||
|
|
ca081af209 | ||
|
|
8c67fce32c | ||
|
|
0ffd259ccd | ||
|
|
9bc9069425 |
@@ -1750,15 +1750,18 @@ tap of an arrow key.
|
||||
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||||
keep typing on the software keyboard.
|
||||
|
||||
**Pass:** the keyboard does not change — not its layout, not its suggestion strip, not its height — and
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||||
keyboard throughout.
|
||||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||||
|
||||
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
||||
handed back. This is the half `Focusable = false` cannot reach — the platform moves its focus on the touch
|
||||
itself, before Avalonia decides anything — and the symptom chain is the keyboard swapping to its no-input
|
||||
layout and the inset churn parking it over the very row that was tapped. See
|
||||
`TerminalFocus` in the Android head's Platform folder.
|
||||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||||
|
||||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
||||
|
||||
|
||||
@@ -10,17 +10,24 @@ namespace DodoSSH.Client.Android.Platform;
|
||||
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
|
||||
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
|
||||
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
|
||||
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>: the
|
||||
/// tap lands on Avalonia's own input view, which is focusable-in-touch-mode because Avalonia's text boxes
|
||||
/// need it to be, and the platform hands that view focus on the way to delivering the touch. The WebView's
|
||||
/// input connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no
|
||||
/// text, and the inset churn that follows can leave it sitting on top of the very row that was tapped.
|
||||
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
|
||||
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
|
||||
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
|
||||
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
|
||||
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So each accessory key hands focus straight back once its byte is on the wire. The page inside the
|
||||
/// WebView never noticed anything — its own DOM focus never moved — so regaining native focus re-establishes
|
||||
/// the same input connection and the keyboard settles back to what it was. Skipped when the WebView is still
|
||||
/// focused, which makes the call free on any platform arrangement where the steal never happened.
|
||||
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
|
||||
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
|
||||
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
|
||||
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
|
||||
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
|
||||
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
|
||||
/// about to be made stale by the very mechanism this exists to counter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
|
||||
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
|
||||
@@ -39,10 +46,18 @@ internal static class TerminalFocus
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindWebView(decor) is { IsFocused: false } webView)
|
||||
if (FindWebView(decor) is not { } webView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webView.Post(() =>
|
||||
{
|
||||
if (!webView.IsFocused)
|
||||
{
|
||||
webView.RequestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static View? FindWebView(ViewGroup parent)
|
||||
|
||||
@@ -116,11 +116,27 @@
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
|
||||
<!--
|
||||
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
|
||||
|
||||
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
|
||||
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
|
||||
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
|
||||
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
|
||||
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
|
||||
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
|
||||
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
|
||||
|
||||
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
|
||||
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
|
||||
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
|
||||
stop filling hold only children that already centre themselves, so they land where they always did.
|
||||
-->
|
||||
<Style Selector="Button.row">
|
||||
<Setter Property="MinHeight" Value="54" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
|
||||
@@ -139,29 +139,27 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
{
|
||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||
|
||||
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
||||
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
||||
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
||||
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
||||
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
||||
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
|
||||
foreach (var (label, bytes, latches) in Keys)
|
||||
{
|
||||
var key = CreateKey(label);
|
||||
|
||||
// TerminalFocus.Return in both, after the key has done its work: by Click time the touch has
|
||||
// already moved Android's own focus onto Avalonia's input view, and leaving it there is what
|
||||
// swaps the keyboard out from over the terminal. Returning it is free when nothing moved.
|
||||
if (latches)
|
||||
{
|
||||
controlKey = key;
|
||||
key.Click += (_, _) =>
|
||||
{
|
||||
ToggleControl();
|
||||
TerminalFocus.Return();
|
||||
};
|
||||
key.Click += (_, _) => ToggleControl();
|
||||
}
|
||||
else
|
||||
{
|
||||
key.Click += (_, _) =>
|
||||
{
|
||||
SendAsync(bytes);
|
||||
TerminalFocus.Return();
|
||||
};
|
||||
key.Click += (_, _) => SendAsync(bytes);
|
||||
}
|
||||
|
||||
row.Children.Add(key);
|
||||
@@ -213,8 +211,9 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
||||
// decides about its element — taking the keyboard's input connection off the terminal and
|
||||
// swapping its layout mid-typing. The Click wiring in BuildAccessoryRow hands that half
|
||||
// back; see TerminalFocus for the whole story.
|
||||
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
||||
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
||||
// to be posted rather than done inline.
|
||||
Focusable = false,
|
||||
};
|
||||
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
@@ -463,6 +464,7 @@
|
||||
<Setter Property="Padding" Value="13,7" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
||||
@@ -482,6 +484,7 @@
|
||||
<Style Selector="Button.poprow">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="11,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="MinHeight" Value="20" />
|
||||
@@ -671,6 +674,7 @@
|
||||
-->
|
||||
<Style Selector="Button.choice">
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="LetterSpacing" Value="0.5" />
|
||||
@@ -1120,6 +1124,7 @@
|
||||
<Style Selector="Button.panechip">
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
|
||||
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
|
||||
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
|
||||
|
||||
── One line, several terminals ────────────────────────────────────────────────────────────────────────
|
||||
One bar for a window that now holds any number of tabs, and connecting writes to it — so "Connected to
|
||||
prod-db." outlived the tab it was about, and closing prod-db left that sentence sitting under whichever
|
||||
terminal the user looked at next. A connection's own lines are now owned by the attempt that wrote them
|
||||
and are cleared when its tab is closed; everything else the vault says stays, because closing a terminal
|
||||
is not an answer to a failed save. See VaultViewModel.ForgetConnectionStatus and
|
||||
MainWindowViewModel.CloseTabAsync.
|
||||
-->
|
||||
|
||||
<Border Height="24" Background="{StaticResource Chrome}"
|
||||
|
||||
@@ -1924,9 +1924,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// Closes one terminal, ending its shell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
|
||||
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
|
||||
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It takes the status line with it, when that line is this tab's.</b> The strip along the bottom is
|
||||
/// one line for the whole window — see <c>StatusBar.axaml</c> — and connecting writes to it, so closing
|
||||
/// prod-db used to leave "Connected to prod-db." reporting on a session that is gone, over whichever
|
||||
/// terminal the user looked at next. Only the sentence this attempt wrote goes; anything the vault has
|
||||
/// said since stays, because a failed save is not something closing a terminal answers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
||||
@@ -1960,6 +1969,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
RaiseTabState();
|
||||
|
||||
// Only this tab's own sentence, and only if that is what the bar currently holds — see the remark.
|
||||
Vault?.ForgetConnectionStatus(tab.AttemptId);
|
||||
|
||||
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
||||
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
|
||||
// terminal just went would keep a lit dot until something else happened to move the selection.
|
||||
@@ -3400,7 +3412,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
||||
{
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address) { AttemptId = e.AttemptId };
|
||||
attempts[e.AttemptId] = tab;
|
||||
|
||||
AdoptTab(tab);
|
||||
@@ -3429,6 +3441,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||
{
|
||||
AttemptId = e.AttemptId,
|
||||
StartedAt = clock.GetUtcNow(),
|
||||
Cipher = NullIfEmpty(e.Cipher),
|
||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||
|
||||
@@ -84,6 +84,18 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// </remarks>
|
||||
internal uint SessionId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The connection attempt this tab was opened for, or <see cref="Guid.Empty"/> for a tab nothing dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carried only so that closing this tab can take the vault's status line with it when the line is about
|
||||
/// this connection — see <c>VaultViewModel.ForgetConnectionStatus</c>. It is the attempt id and not the
|
||||
/// session id because the message can be written before a session exists ("Connecting to prod-db…") and
|
||||
/// after one has stopped existing, and because the two failure sentences a tab can end on never have a
|
||||
/// session id at all.
|
||||
/// </remarks>
|
||||
internal Guid AttemptId { get; init; }
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||
|
||||
@@ -2130,6 +2130,60 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The connection <see cref="Status"/> is currently about, or <see cref="Guid.Empty"/> when it is about
|
||||
/// something else — a save, a sync, a refusal that has nothing to do with a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The status line is one line for the whole window, and most of what goes through it is about the vault,
|
||||
/// which is one thing however many terminals are open. A connection is not: "Connected to prod-db." is
|
||||
/// about one tab, and it used to outlive that tab — close prod-db, look at the terminal beside it, and the
|
||||
/// strip along the bottom was still reporting on a session that is no longer there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ownership rather than a blanket clear on every close, because the alternative loses the sentences this
|
||||
/// bar exists for: a failed save or a refused sync is what the design deleted the bar's other four fields
|
||||
/// to make room for, and closing a terminal is no reason to take one away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reset by <see cref="OnStatusChanged"/> on every write, so the only messages that carry an owner are the
|
||||
/// ones <see cref="SayAbout"/> writes. Anything assigning <see cref="Status"/> in the ordinary way says,
|
||||
/// by doing so, that its message belongs to no tab.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private Guid statusAttemptId;
|
||||
|
||||
partial void OnStatusChanged(string value) => statusAttemptId = Guid.Empty;
|
||||
|
||||
/// <summary>Writes a status line that belongs to one connection attempt.</summary>
|
||||
/// <remarks>
|
||||
/// After the assignment, not before: <see cref="OnStatusChanged"/> clears the owner on every write, which
|
||||
/// is what makes "belongs to nobody" the default rather than something each of the other status writes
|
||||
/// would have to remember to say.
|
||||
/// </remarks>
|
||||
private void SayAbout(ConnectionAttemptEventArgs attempt, string message)
|
||||
{
|
||||
Status = message;
|
||||
statusAttemptId = attempt.AttemptId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the status line if what it is saying is about this connection, and leaves it alone otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called by the shell when a tab is closed — see <c>MainWindowViewModel.CloseTabAsync</c>. Cleared to
|
||||
/// empty rather than replaced with "Closed prod-db.": the tab going is the report, it is the report the
|
||||
/// user just made happen, and a bar that answers every close with a sentence is a bar people stop reading.
|
||||
/// </remarks>
|
||||
internal void ForgetConnectionStatus(Guid attemptId)
|
||||
{
|
||||
if (attemptId != Guid.Empty && statusAttemptId == attemptId)
|
||||
{
|
||||
Status = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private int pendingChanges;
|
||||
|
||||
@@ -10767,7 +10821,7 @@ internal sealed partial class VaultViewModel(
|
||||
Dialled(target, authentication));
|
||||
|
||||
ConnectionStarting?.Invoke(this, attempt);
|
||||
Status = $"Connecting to {target.Label}…";
|
||||
SayAbout(attempt, $"Connecting to {target.Label}…");
|
||||
|
||||
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -11042,7 +11096,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
||||
{
|
||||
Status = reason;
|
||||
// Owned by the attempt, unlike Answer's own line below: this is the case where the tab stays, carrying
|
||||
// the same reason, so closing that tab is exactly the moment the line stops being about anything.
|
||||
SayAbout(attempt, reason);
|
||||
|
||||
ConnectionFailed?.Invoke(
|
||||
this,
|
||||
@@ -11097,7 +11153,7 @@ internal sealed partial class VaultViewModel(
|
||||
// only record that machine was reached at all.
|
||||
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
||||
|
||||
Status = $"Connected to {target.Label}.";
|
||||
SayAbout(attempt, $"Connected to {target.Label}.");
|
||||
|
||||
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||||
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||||
@@ -11128,7 +11184,8 @@ internal sealed partial class VaultViewModel(
|
||||
// screen it is typed on says so.
|
||||
if (target.Row is { } row)
|
||||
{
|
||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
await RememberTypedPasswordAsync(attempt, row, authentication, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11160,6 +11217,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RememberTypedPasswordAsync(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
HostRowViewModel row,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -11175,8 +11233,10 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
||||
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
||||
SayAbout(
|
||||
attempt,
|
||||
$"Connected to {row.Label}. Its password was not saved: this host was written by a newer "
|
||||
+ "version of DodoSSH, and binding a credential would re-encode it.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11200,7 +11260,9 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
||||
SayAbout(
|
||||
attempt,
|
||||
$"Connected to {row.Label}, but its password could not be saved: {exception.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11211,8 +11273,10 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
||||
+ "will not be asked for again.";
|
||||
SayAbout(
|
||||
attempt,
|
||||
$"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it will "
|
||||
+ "not be asked for again.");
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The three button shapes centre their caption inside a button taller than the caption.
|
||||
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -41,6 +42,15 @@ namespace DodoSSH.Client.App.Layout.Tests;
|
||||
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move
|
||||
/// the day the face does. "Centred" survives both.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The five shapes beyond the original three were swept in afterwards, and none of them was misbehaving
|
||||
/// when it was: every one is content-sized everywhere it is used today, so <c>Stretch</c> and <c>Center</c>
|
||||
/// agreed and the change moved nothing — 113 buttons across 29 screens measured byte-identical before and
|
||||
/// after. What the sweep buys is that the day any of them is given a height, it is already right. That is
|
||||
/// also why <see cref="AStretchingShapeStillFillsItsButton"/> matters more than it looks: the same
|
||||
/// reasoning applied to <c>flat</c> or <c>cat</c> would break a pill and a strip that are currently
|
||||
/// correct.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ButtonCaptionTests
|
||||
{
|
||||
@@ -61,6 +71,11 @@ public sealed class ButtonCaptionTests
|
||||
[InlineData("ghost")]
|
||||
[InlineData("accent")]
|
||||
[InlineData("danger")]
|
||||
[InlineData("navuser")]
|
||||
[InlineData("poprow")]
|
||||
[InlineData("panechip")]
|
||||
[InlineData("chiptoggle")]
|
||||
[InlineData("choice")]
|
||||
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
|
||||
{
|
||||
await MeasureAsync(
|
||||
@@ -96,6 +111,72 @@ public sealed class ButtonCaptionTests
|
||||
"the caption should sit high, which is the defect this suite was written for"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>flat</c> and <c>cat</c> are excluded from the rule above, and must stay excluded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both stretch their content on purpose, and both would be silently broken by a later pass that
|
||||
/// "finished" the sweep the rest of these classes belong to — which is exactly why this is a test and
|
||||
/// not a comment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>flat</c> carries the titlebar's search pill, a <c>Border.searchpill</c> with no height of its own
|
||||
/// that is meant to fill all 35 pixels of the button; the usage states
|
||||
/// <c>HorizontalContentAlignment="Stretch"</c> and relies on the vertical default matching it. Centring
|
||||
/// from the style would shrink that pill to its caption's line box inside a button twice as tall.
|
||||
/// <c>cat</c> carries the keychain rail's accent strip, a <c>Border.rowmark</c> whose style sets
|
||||
/// <c>Width="2"</c> and no height at all — "at full row height", says the rule's own remark — so its
|
||||
/// height is the stretch and nothing else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted as "the content fills the button", not as "the caption is off-centre": what these two need
|
||||
/// is the fill, and a test phrased the other way would still pass if the fill broke in some new way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("flat")]
|
||||
[InlineData("cat")]
|
||||
public async Task AStretchingShapeStillFillsItsButton(string shape)
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
// A bare Border is what both of them actually hold: no height, sized only by its parent.
|
||||
var fill = new Border();
|
||||
var button = new Button { Content = fill, Height = FixedHeight };
|
||||
button.Classes.Add(shape);
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
// The slot read off the presenter rather than recomputed from the button's Padding:
|
||||
// these shapes differ in whether their presenter also draws a border, and a hand-rolled
|
||||
// sum was two pixels out on Button.cat for exactly that reason.
|
||||
var presenter = button.GetVisualDescendants()
|
||||
.OfType<ContentPresenter>()
|
||||
.Single(p => string.Equals(p.Name, "PART_ContentPresenter", StringComparison.Ordinal));
|
||||
|
||||
var slot = presenter.Bounds.Height
|
||||
- presenter.Padding.Top - presenter.Padding.Bottom
|
||||
- presenter.BorderThickness.Top - presenter.BorderThickness.Bottom;
|
||||
|
||||
fill.Bounds.Height.ShouldBe(
|
||||
slot,
|
||||
Tolerance,
|
||||
$"Button.{shape} must stretch its content — the search pill and the rail's accent "
|
||||
+ "strip have no height of their own");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
private static Task MeasureAsync(
|
||||
string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
|
||||
@@ -1254,6 +1254,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The status line is one line for the whole window, so a sentence about a session that has been closed is
|
||||
/// a sentence the user reads over some other terminal.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingATab_TakesItsOwnStatusLineWithIt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
||||
|
||||
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||
|
||||
vault.Status.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half, and the reason closing does not simply blank the bar: everything this application has
|
||||
/// to say about a save, a sync or a refusal goes through the same line — see <c>StatusBar.axaml</c> — and
|
||||
/// closing a terminal answers none of it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingATab_LeavesAStatusLineThatIsAboutSomethingElse()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
vault.Status = "The keychain could not be saved.";
|
||||
|
||||
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||
|
||||
vault.Status.ShouldBe("The keychain could not be saved.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Two sessions to the same host, which is the case a label could not tell apart: the line belongs to the
|
||||
/// attempt that wrote it, so closing the other one leaves it alone.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingATab_LeavesTheLineAnotherTabWrote()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
var first = shell.Tabs[0];
|
||||
|
||||
// The second connection's own line is what the bar holds now.
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
var reported = vault.Status;
|
||||
|
||||
await shell.CloseTabCommand.ExecuteAsync(first);
|
||||
|
||||
vault.Status.ShouldBe(reported);
|
||||
}
|
||||
|
||||
// ---- Connecting, while it is still happening ----
|
||||
//
|
||||
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
|
||||
|
||||
Reference in New Issue
Block a user