Public Access
Give the window its v5b chrome and each session surface its own shell
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Authentication;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -17,11 +18,13 @@ using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One vault, as a switch in the tab strip's vault menu.</summary>
|
||||
/// <summary>One vault, as a switch in the rail's user popover.</summary>
|
||||
/// <remarks>
|
||||
/// A record rebuilt per change rather than an observable row, which is the idiom the rest of these lists
|
||||
/// use: the menu is short, it is rebuilt whenever anything about the vault list moves, and a row with a
|
||||
/// settable property would be a second copy of a fact the cache already holds.
|
||||
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
|
||||
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
|
||||
/// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt
|
||||
/// whenever anything about the vault list moves, and a row with a settable property would be a second copy
|
||||
/// of a fact the cache already holds.
|
||||
/// </remarks>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
||||
@@ -37,6 +40,14 @@ internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPe
|
||||
/// </remarks>
|
||||
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
|
||||
|
||||
/// <summary>The one letter the rail's popover draws in this vault's own initial square.</summary>
|
||||
/// <remarks>
|
||||
/// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one
|
||||
/// would be inventing a fact rather than reading one — see the remark in <c>NavRail.axaml</c>. The
|
||||
/// letter is the honest half of the same badge.
|
||||
/// </remarks>
|
||||
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
|
||||
|
||||
/// <summary>Whether this vault can be switched off.</summary>
|
||||
/// <remarks>
|
||||
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
|
||||
@@ -328,6 +339,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
|
||||
private CancellationTokenSource? lastConnectedTick;
|
||||
|
||||
/// <summary>
|
||||
/// The loop that restrings <see cref="SessionElapsedText"/> once a minute, for as long as this shell runs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="lastConnectedTick"/>, this one is not started and stopped as a screen comes and goes
|
||||
/// — it runs for the shell's whole life, the same way <see cref="workspace"/> does. Gating it on
|
||||
/// <see cref="IsTerminalSurface"/>/<see cref="IsTransfersShowing"/> would save one restring a minute while
|
||||
/// on some other screen, at the cost of the same start/stop bookkeeping <see cref="StartLastConnectedTick"/>
|
||||
/// needs the hosts screen for — and <see cref="SessionElapsedText"/> is already re-read on every state
|
||||
/// change worth reacting to immediately; see <see cref="RaiseSessionState"/>. This loop only catches the
|
||||
/// case nothing else does: sitting still on a connected screen while a minute passes.
|
||||
/// </remarks>
|
||||
private readonly CancellationTokenSource sessionElapsedTick = new();
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -449,6 +474,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
|
||||
|
||||
_ = TellRendererTheFontSizeAsync();
|
||||
|
||||
StartSessionShellTracking();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The subscription is narrow on purpose: <see cref="SessionAddress"/> and <see cref="IsSessionConnected"/>
|
||||
/// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from
|
||||
/// <see cref="TransfersViewModel"/>, and neither used to be read from outside that screen at all — see
|
||||
/// <see cref="OnTransfersPropertyChanged"/>. The tick restrings <see cref="SessionElapsedText"/> once a
|
||||
/// minute for the shell's whole life; see the remark on <see cref="sessionElapsedTick"/> for why it is not
|
||||
/// started and stopped with a screen the way <see cref="StartLastConnectedTick"/> is.
|
||||
/// </remarks>
|
||||
private void StartSessionShellTracking()
|
||||
{
|
||||
transfers.PropertyChanged += OnTransfersPropertyChanged;
|
||||
|
||||
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -602,6 +647,65 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private string? accountName;
|
||||
|
||||
/// <summary>
|
||||
/// The signed-in account's own email, when the server sent one — for the rail's user popover.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A second field rather than a way to pull it back out of <see cref="AccountName"/>, which folds
|
||||
/// <c>DisplayName ?? Email ?? Subject</c> into one string and forgets which of the three it kept.
|
||||
/// Wherever <see cref="AccountName"/> is set from a profile or a <c>MeResponse</c>, this is set from the
|
||||
/// same object's own <c>Email</c> alongside it — so it is null exactly when the server has not sent one,
|
||||
/// never invented from the subject or the display name the way a naive fallback would.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string? email;
|
||||
|
||||
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
|
||||
/// <remarks>
|
||||
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
|
||||
/// <c>DisplayName ?? Email ?? Subject</c>, so an account with no display name still yields two letters
|
||||
/// out of its email's local part or its subject rather than a blank circle. Never padded past what the
|
||||
/// name itself holds: a one-word name gets one letter rather than a second one invented to fill the
|
||||
/// mock's own two-letter shape.
|
||||
/// </remarks>
|
||||
internal string AvatarInitials
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AccountName))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var words = AccountName.Split(
|
||||
[' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
return words switch
|
||||
{
|
||||
[] => string.Empty,
|
||||
[var only] => only[..1].ToUpperInvariant(),
|
||||
[var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials));
|
||||
|
||||
/// <summary>
|
||||
/// Sets <see cref="AccountName"/> and <see cref="Email"/> from one profile, in one place.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both the cached-profile read in <see cref="StartAsync"/> and the browser sign-in in
|
||||
/// <see cref="SignInAsync"/> land here rather than repeating the same two assignments, which is what
|
||||
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
|
||||
/// did not.
|
||||
/// </remarks>
|
||||
private void AdoptIdentity(string? displayName, string? emailAddress, string subject)
|
||||
{
|
||||
AccountName = displayName ?? emailAddress ?? subject;
|
||||
Email = emailAddress;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
@@ -1236,62 +1340,90 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
vault.ManualStatus = string.Empty;
|
||||
}
|
||||
|
||||
// ---- The desktop's fixed tabs ----
|
||||
// ---- The rail's own page grouping ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
|
||||
/// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
|
||||
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
|
||||
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
|
||||
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
|
||||
/// which is the screen you are looking at.
|
||||
/// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
|
||||
/// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
|
||||
/// three tabs, and the rail was drawn only under this one; see the file history for that version of
|
||||
/// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
|
||||
/// now, and the rail is permanent furniture beside every one of the three — but the partition this
|
||||
/// answers is still real and still asked in three places: <see cref="MainWindowViewModel.IsSshShowing"/>
|
||||
/// reads it under a new name for the switcher, the rail's mode-dependent first row reads
|
||||
/// <see cref="FirstRailItemLabel"/> which is built from the same three flags, and this one is still what
|
||||
/// the rail's own six rows below the switcher use to know a rail screen is the one on the page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
|
||||
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
|
||||
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
|
||||
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
|
||||
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
|
||||
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the nine screens underneath this tab.</b> The
|
||||
/// two are true together whenever somebody is looking at the vaults screen and are otherwise unrelated:
|
||||
/// this one is "the strip is on its first tab rather than on SFTP, S3 or a terminal".
|
||||
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
|
||||
/// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
|
||||
/// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
|
||||
|
||||
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
|
||||
/// <summary>The pages the rail's own rows reach, as opposed to SFTP or S3.</summary>
|
||||
private static bool IsVaultsPage(ShellScreen screen) =>
|
||||
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
|
||||
|
||||
/// <summary>
|
||||
/// Which page the Vaults tab returns to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
|
||||
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
|
||||
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
|
||||
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
|
||||
/// screen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
|
||||
/// worth stating because the two look alike. That one would have been a second copy of "which page",
|
||||
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
|
||||
/// remembering its page, the way any tab does — and nothing else reads it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private ShellScreen vaultsScreen = ShellScreen.Hosts;
|
||||
// ---- The rail's segmented switcher and its mode-dependent first entry ----
|
||||
//
|
||||
// v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
|
||||
// segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
|
||||
// IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
|
||||
// segments and one rail row instead, and the partition is the same one: exactly one of "a page under
|
||||
// the rail's own list", "the files screen" and "the buckets screen" is ever true.
|
||||
|
||||
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
|
||||
/// <summary>Whether the switcher's SSH segment is lit, and the rail's default "mode".</summary>
|
||||
/// <remarks>
|
||||
/// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not
|
||||
/// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is
|
||||
/// the complement of the other two rather than a read of <see cref="ShellSurface"/> on its own, so a
|
||||
/// page under the rail's list and an open terminal both light this segment, exactly as <c>IsVaultsTab</c>
|
||||
/// used to treat both as "not SFTP, not S3".
|
||||
/// </remarks>
|
||||
internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing;
|
||||
|
||||
/// <summary>The rail's first entry, which the design calls "mode-dependent" rather than fixed.</summary>
|
||||
/// <remarks>
|
||||
/// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read
|
||||
/// straight off the same three flags the switcher above lights, so the row and the segment can never
|
||||
/// name two different modes. See <see cref="FirstRailItemIcon"/> and <see cref="ShowFirstRailItem"/>
|
||||
/// for the matching glyph and the command the row runs.
|
||||
/// </remarks>
|
||||
internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal";
|
||||
|
||||
/// <summary>
|
||||
/// The glyph beside <see cref="FirstRailItemLabel"/>, by Material Icons codepoint — see
|
||||
/// <c>Palette.axaml</c>'s remark on <c>IconFont</c> for why this codebase spells glyphs that way.
|
||||
/// </remarks>
|
||||
internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : "";
|
||||
|
||||
/// <summary>Runs whichever of the three the row is currently naming.</summary>
|
||||
/// <remarks>
|
||||
/// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the
|
||||
/// row itself already reads the same three flags <see cref="ShowFiles"/> and <see cref="ShowTerminal"/>
|
||||
/// answer to, so asking again here would be a second place those three facts could disagree.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ShowVaults() => ShowScreen(vaultsScreen);
|
||||
private void ShowFirstRailItem()
|
||||
{
|
||||
if (IsBucketsShowing)
|
||||
{
|
||||
ShowFiles(RemoteKind.Bucket);
|
||||
}
|
||||
else if (IsTransfersShowing)
|
||||
{
|
||||
ShowFiles(RemoteKind.Host);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Which vaults this window is showing ----
|
||||
|
||||
@@ -1299,15 +1431,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
|
||||
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
|
||||
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
|
||||
/// than the keyring can.
|
||||
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it — the
|
||||
/// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
|
||||
/// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
|
||||
/// opened, so it cannot outlive the session any more than the keyring can.
|
||||
/// </remarks>
|
||||
private VaultVisibility? visibility;
|
||||
|
||||
/// <summary>
|
||||
/// One switch per readable vault, for the menu on the Vaults tab.
|
||||
/// One switch per readable vault, for the rail's user popover.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
|
||||
@@ -1779,7 +1911,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
|
||||
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
|
||||
ServerUrl = profile.ServerUrl;
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
||||
@@ -1840,7 +1972,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
.RefreshAsync(ServerUrl, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
|
||||
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
|
||||
StatusMessage = outcome.Message;
|
||||
|
||||
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
||||
@@ -2527,6 +2659,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void CancelSignOut() => IsConfirmingSignOut = false;
|
||||
|
||||
/// <summary>Starts a sign-out from the rail's user popover, from wherever the window is showing.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
|
||||
/// inline on the Preferences screen while the vault is unlocked — see <c>PreferencesScreen.axaml</c> —
|
||||
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
|
||||
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
|
||||
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
|
||||
/// nothing on screen to show it — a card raised nobody can see. Going to Preferences first is what the
|
||||
/// popover's own "New vault" and "New bucket" rows already do for the same reason; see
|
||||
/// <see cref="ShowNewVault"/>.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void SignOutFromPopover()
|
||||
{
|
||||
ShowScreen(ShellScreen.Preferences);
|
||||
SignOut();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
|
||||
/// </summary>
|
||||
@@ -2593,6 +2743,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
AccountName = null;
|
||||
Email = null;
|
||||
Passphrase = string.Empty;
|
||||
ConfirmPassphrase = string.Empty;
|
||||
RecoveryCode = null;
|
||||
@@ -2652,12 +2803,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||
|
||||
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
||||
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
|
||||
// object would otherwise leak.
|
||||
StopLastConnectedTick();
|
||||
|
||||
// The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather
|
||||
// than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends.
|
||||
await sessionElapsedTick.CancelAsync().ConfigureAwait(false);
|
||||
sessionElapsedTick.Dispose();
|
||||
|
||||
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
|
||||
// vault because the restart path disposes this whole object and then applies the update — so a
|
||||
// check still running would be writing into a view model the process is about to replace.
|
||||
@@ -2984,13 +3141,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
||||
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
|
||||
AdoptTab(adopted);
|
||||
RefreshConnectedHosts();
|
||||
return;
|
||||
}
|
||||
|
||||
tab.Opened(e.SessionId);
|
||||
|
||||
// From this moment, not from when the tab first appeared — connecting is not open, and the session
|
||||
// shell's elapsed timer is about a shell that is actually running.
|
||||
tab.StartedAt = clock.GetUtcNow();
|
||||
|
||||
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
||||
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
||||
RaiseTerminalState();
|
||||
@@ -3214,7 +3376,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
|
||||
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -3225,7 +3387,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
||||
/// of its own — every place a dot can go stale is a place the strip can too, so folding the two into one
|
||||
/// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one
|
||||
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
|
||||
/// sites for the list of moments that counts as.
|
||||
/// </para>
|
||||
@@ -3236,7 +3398,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
ActiveTabPinnedPaths.Clear();
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
RaiseSessionState();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3247,8 +3409,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
host.IsConnected = Tabs.Any(
|
||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
||||
|
||||
// The active tab's host, and only when it is actually connected: the strip is for a session that
|
||||
// is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
||||
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||
if (host.IsConnected
|
||||
&& SelectedTab is { } selected
|
||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
||||
@@ -3268,39 +3430,58 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
|
||||
/// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
|
||||
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled.
|
||||
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled. Kept
|
||||
/// under this name and this shape across v5b, which moved its one reader from a chip strip above the
|
||||
/// terminal to the session sidebar beside it — the fact and the command that acts on it did not change,
|
||||
/// only where they are drawn.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
|
||||
|
||||
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
|
||||
/// <summary>Whether the active tab's host has anything for QUICK ACCESS to draw.</summary>
|
||||
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pin strip draws at all.
|
||||
/// Whether the v5b session sidebar draws at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Terminal surface and nothing else: <see cref="ActiveTabPinnedPaths"/> is keyed to the selected tab
|
||||
/// rather than to which surface is showing, so without this the strip would sit over a page screen on
|
||||
/// the rare frame between selecting a terminal tab and navigating away from it.
|
||||
/// <para>
|
||||
/// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface && HasActiveTabPinnedPaths</c>
|
||||
/// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own
|
||||
/// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned
|
||||
/// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus":
|
||||
/// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's
|
||||
/// own "hides when no session is active".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The SFTP half reads <see cref="TransfersViewModel.IsConnected"/> rather than <see cref="SelectedTab"/>,
|
||||
/// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on
|
||||
/// <see cref="ActiveTabPinnedPaths"/>. That is a real seam: browsing a host on SFTP without ever having
|
||||
/// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows
|
||||
/// come from the tab list rather than from whichever host SFTP is connected to. Reusing
|
||||
/// <c>OpenPinnedPathCommand</c> unchanged, as asked, is what this trades for a second pins source.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
|
||||
internal bool ShowsQuickAccessSidebar =>
|
||||
(IsTerminalSurface && SelectedTab is not null)
|
||||
|| (IsTransfersShowing && Transfers.IsConnected);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pin strip's click handler. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||
/// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this
|
||||
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
||||
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the chip is there to click.
|
||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenPinnedPathAsync(string path)
|
||||
@@ -3316,6 +3497,271 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ----
|
||||
|
||||
/// <summary>
|
||||
/// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same shape as <see cref="ShowNewBucket"/>: land on the screen the new item belongs to, then run
|
||||
/// that screen's own "start one" command, rather than opening the editor from here and hoping the screen
|
||||
/// underneath it agrees what it is editing.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void AddSnippetFromSidebar()
|
||||
{
|
||||
ShowScreen(ShellScreen.Snippets);
|
||||
SnippetsScreen?.NewCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types a sidebar SNIPS row into the terminal the terminal surface is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Selects the row on <see cref="SnippetsScreen"/> and runs its own <c>InsertCommand</c> rather than
|
||||
/// writing to the renderer directly — that command already carries the whole safety story the snippets
|
||||
/// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one
|
||||
/// that runs. A second insert path here would be a second place that story could go stale.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="SnippetsViewModel.CanInsert"/> reads <see cref="CurrentInsertTarget"/>, which is
|
||||
/// <see cref="SelectedTab"/> — the sidebar only appears on the terminal surface with a tab selected, so
|
||||
/// this is ordinarily available. It can still be a tab that is still connecting, which has no session to
|
||||
/// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to
|
||||
/// that one gap, the same as clicking a row with nothing selected would otherwise be.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task InsertSnippetAsync(SnippetRowViewModel snip)
|
||||
{
|
||||
if (SnippetsScreen is not { } screen || snip is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
screen.Selected = snip;
|
||||
|
||||
if (screen.CanInsert)
|
||||
{
|
||||
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
ShowScreen(ShellScreen.Snippets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The closest honest affordance rather than a new one: this application has no way to open the host
|
||||
/// editor already scrolled to one card inside it, so what this does is what a person reaching for the same
|
||||
/// goal from the hosts screen already does — select the host and press EDIT. <see cref="VaultViewModel.EditSelectedHostCommand"/>
|
||||
/// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches;
|
||||
/// see <c>App.axaml</c>'s <c>Border.section</c> remark for that column's own QUICK ACCESS heading.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void PinFolderFromSidebar()
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| SelectedTab is not { } tab
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
vault.SelectedHost = host;
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application
|
||||
/// has no such architecture, and building one is out of this wave's scope. What it has instead is
|
||||
/// <see cref="GoToHostFilesAsync"/> — the same "Browse files" plumbing a pin click and the hosts screen's
|
||||
/// own action already use — so a click on the SFTP tab row honestly does the one thing this application
|
||||
/// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to
|
||||
/// it and land the remote pane there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="SelectedTab"/> is set here too, ahead of the navigation, which is what lets the SFTP tab
|
||||
/// row mark its active tab with the same <see cref="TerminalTabViewModel.IsSelected"/> flag the terminal
|
||||
/// row's own active mark already reads — see <c>SessionTabRow.axaml</c>. It is also what keeps the
|
||||
/// sidebar's QUICK ACCESS in step: that list is keyed to <see cref="SelectedTab"/>, on both surfaces, so
|
||||
/// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| tab is null
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedTab = tab;
|
||||
|
||||
await GoToHostFilesAsync(host, null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The mirror of <see cref="SelectFilesHostAsync"/> and named in the notes as the other of the two
|
||||
/// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction".
|
||||
/// Goes through <see cref="VaultViewModel.ConnectCommand"/> exactly as the quick-connect palette's own
|
||||
/// <see cref="ConnectToSearchResultAsync"/> does, rather than reusing an existing tab: SFTP's connection is
|
||||
/// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new
|
||||
/// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenTerminalForFilesHostAsync()
|
||||
{
|
||||
if (Vault is not { } vault || Transfers.SelectedHost is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account and endpoint the session shell's header and status bar are about right now, or null when
|
||||
/// neither surface has one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
|
||||
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
|
||||
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
|
||||
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
|
||||
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
|
||||
/// </remarks>
|
||||
internal string? SessionAddress => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.Address,
|
||||
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
|
||||
/// <remarks>
|
||||
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
|
||||
/// that is still connecting has an address — it is what the connecting card names — but no live shell
|
||||
/// behind it yet, and "CONNECTED" would be a claim <see cref="TerminalTabViewModel.IsLive"/> has not made.
|
||||
/// </remarks>
|
||||
internal bool IsSessionConnected => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.IsLive is true,
|
||||
_ when IsTransfersShowing => Transfers.IsConnected,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Restrung on every state change worth reacting to at once — see <see cref="RaiseSessionState"/> — and
|
||||
/// once a minute besides, by <see cref="RunSessionElapsedTickAsync"/>, for the case where nothing else
|
||||
/// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung
|
||||
/// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather
|
||||
/// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale
|
||||
/// between two ticks, exactly as "1 min ago" already is elsewhere in this shell.
|
||||
/// </remarks>
|
||||
internal string? SessionElapsedText
|
||||
{
|
||||
get
|
||||
{
|
||||
var startedAt = Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.StartedAt,
|
||||
_ when IsTransfersShowing => Transfers.ConnectedStartedAt,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (startedAt is not { } started)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var elapsed = clock.GetUtcNow() - started;
|
||||
|
||||
if (elapsed < TimeSpan.Zero)
|
||||
{
|
||||
// The clock this ran on and the clock the session opened on can disagree by a hair when both
|
||||
// are TimeProvider.System, which is close enough to "now" that a negative span is rounding
|
||||
// rather than a session that has not started yet.
|
||||
elapsed = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the three facts the session shell's header, status bar and tab rows depend on.</summary>
|
||||
/// <remarks>
|
||||
/// Its own method rather than three more lines folded into <see cref="RaiseTerminalState"/> and
|
||||
/// <see cref="RaiseSurfaceState"/>, because the SFTP tab row and header need it too and neither of those
|
||||
/// two methods otherwise has anything to do with <see cref="TransfersViewModel"/>.
|
||||
/// </remarks>
|
||||
private void RaiseSessionState()
|
||||
{
|
||||
OnPropertyChanged(nameof(SessionAddress));
|
||||
OnPropertyChanged(nameof(IsSessionConnected));
|
||||
OnPropertyChanged(nameof(SessionElapsedText));
|
||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The narrow subscription <see cref="RaiseSessionState"/>'s own remark on the shell's constructor
|
||||
/// promises: three properties this screen exposes, each already raised through <c>ObservableObject</c>,
|
||||
/// picked out by name rather than repainting on every change <see cref="TransfersViewModel"/> makes —
|
||||
/// the transfer queue's own rows tick several times a second while a download runs, and none of that is a
|
||||
/// fact the header, the status bar or the SFTP tab row's active mark reads.
|
||||
/// </remarks>
|
||||
private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||
or nameof(TransfersViewModel.IsConnected)
|
||||
or nameof(TransfersViewModel.Remote)
|
||||
or nameof(TransfersViewModel.ConnectedTo))
|
||||
{
|
||||
RaiseSessionState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restrings <see cref="SessionElapsedText"/> once a minute, for the shell's whole life.</summary>
|
||||
/// <remarks>See the remark on <see cref="sessionElapsedTick"/> for why this loop is not gated on a screen.</remarks>
|
||||
private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
OnPropertyChanged(nameof(SessionElapsedText));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The shell is closing; see DisposeAsync.
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnLiveSessionCountChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasLiveSessions));
|
||||
@@ -3355,12 +3801,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
RaiseSurfaceState();
|
||||
|
||||
// What the Vaults tab comes back to; see the field.
|
||||
if (IsVaultsPage(value))
|
||||
{
|
||||
vaultsScreen = value;
|
||||
}
|
||||
|
||||
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
||||
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
||||
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
||||
@@ -3633,11 +4073,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private void RaiseSurfaceState()
|
||||
{
|
||||
// ShowsPinStrip reads IsTerminalSurface, and Surface moves through here rather than through
|
||||
// RaiseTerminalState — see OnSurfaceChanged. Without this the strip's binding would go stale the
|
||||
// moment somebody navigated off a terminal tab to a page screen, even though the property itself
|
||||
// would answer correctly the next time anything else asked it.
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
|
||||
// IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
|
||||
// OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
|
||||
// off a terminal tab to a page screen, even though the property itself would answer correctly the
|
||||
// next time anything else asked it.
|
||||
RaiseSessionState();
|
||||
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
@@ -3665,6 +4106,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsBucketsShowing));
|
||||
OnPropertyChanged(nameof(IsMoreSurface));
|
||||
|
||||
// The rail's switcher and its mode-dependent first row read the same three flags above, so
|
||||
// whatever moved them has to repaint these too — see the remarks on each.
|
||||
OnPropertyChanged(nameof(IsSshShowing));
|
||||
OnPropertyChanged(nameof(FirstRailItemLabel));
|
||||
OnPropertyChanged(nameof(FirstRailItemIcon));
|
||||
|
||||
RaiseTerminalState();
|
||||
}
|
||||
|
||||
@@ -3684,9 +4131,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||
|
||||
// ShowsPinStrip reads IsTerminalSurface too, so anything that moves the surface has to repaint it —
|
||||
// otherwise the strip could stay drawn over a page reached by clicking away from a terminal tab.
|
||||
OnPropertyChanged(nameof(ShowsPinStrip));
|
||||
// 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
|
||||
// page reached by clicking away from a terminal tab.
|
||||
RaiseSessionState();
|
||||
|
||||
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
||||
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
||||
|
||||
Reference in New Issue
Block a user