Files
DodoSSH/src/DodoSSH.Client.App/Views/MainWindow.axaml
T

544 lines
34 KiB
XML

<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="using:System.Collections.Generic"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="DodoSSH"
Icon="/Assets/dodossh.ico"
Width="1180"
Height="760"
MinWidth="1081"
MinHeight="583"
Background="{StaticResource Canvas}"
SystemDecorations="BorderOnly"
Focusable="True">
<!--
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
which leaves every screen the same 826x464 it was designed against. That is the whole point of moving
it: the layout suite's premise is that everything fits at the minimum, and four of the tables stop
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
somewhere nobody was looking.
v5b moves it again, by exactly the same reasoning: the titlebar's fidelity pass takes it from 44 to 53
and the rail's from 190 to 255, so 1016x574 becomes 1081x583 — nine pixels and sixty-five pixels, added
straight onto the minimum rather than absorbed by shrinking a screen. <c>LayoutHarness.ScreenWidth</c>
stays unchanged at 826, because the rail is the only thing beside a page that grew.
<c>ScreenHeight</c> did move, and in the other direction: v5b's own fidelity pass also retires the
window-wide tab strip this comment used to describe — see the paragraph below — and every full-bleed
page gets that 42 pixels back rather than the window losing height to compensate. A screen this suite
measures is 826 pixels wide and taller than it was, by exactly the strip's own height; see
<c>LayoutHarness</c>'s own remark on the budget for the arithmetic.
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
near-black application is the one part of the window that would look borrowed.
SystemDecorations="BorderOnly" rather than extending the client area under the system chrome, which is
what this tried first and is measurably wrong on Avalonia 12: ExtendClientAreaChromeHints is gone from
that version, so extending leaves the caption buttons and the window title drawn on top of this
window's own — two sets of minimise/maximise/close, and "DodoSSH" printed twice, overlapping. BorderOnly
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
chrome worth having.
TWO SURFACES, ONE RECTANGLE — AND EACH ONE OWNS ITS OWN TAB ROW NOW.
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
<c>SessionTabRow.axaml</c> — draws its own, 38 pixels, inside its own 26-pixel padded column, per the
design; see the terminal surface's own Grid below and the SFTP one inside the pages Panel. A tab still
survives navigating away from either screen — that is what makes the terminal reachable from anywhere —
it simply is not drawn as chrome above every screen while it does.
The terminal and the pages still share the one rectangle beside the rail, and exactly one of them may
occupy it at a time: that is the whole of ShellSurface, an enum rather than two flags, so there is no way
to write the state where both are showing.
THE OCCLUSION RULE, which every arrangement in this file obeys.
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
above everything the parent paints — so no sibling in this visual tree can cover it, whatever the
z-order says. Layering a screen over it leaves that screen sliced at the WebView's left edge with its
buttons unreachable, which this window has shipped once already.
So anything that would occupy the terminal's rectangle collapses the terminal instead, and
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
only chooses between the pages.
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
fires only for its own IsVisible. v5b nests the WebView three levels deeper than it used to sit, inside
the terminal surface's own session shell — see that Grid's own remark below for why the rule still holds
with the control that much further from the Panel that used to be its only parent.
-->
<Grid RowDefinitions="Auto,*,Auto,Auto">
<views:TitleBar Grid.Row="0" IsVisible="{Binding !IsSettingsMode}" />
<Panel Grid.Row="1">
<!-- The unlocked application. -->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}"
IsEnabled="{Binding !IsSettingsMode}">
<!--
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
under a strip: v5b retired the window-wide tab strip entirely, and with it the row this Grid used
to give up its own first row to. See the remark below on where a session's tabs live now.
-->
<views:NavRail Grid.Column="0" />
<Panel Grid.Column="1">
<!-- ============ THE PAGES ============ -->
<Panel IsVisible="{Binding IsShowingPages}">
<!--
Wrapped, like every other screen here. It used to be bound directly — its data context was
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
its visibility goes on the wrapper.
-->
<Panel IsVisible="{Binding IsHostsScreen}">
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ SFTP ============ -->
<!--
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
a host header and a status bar around the screen's own content, and a 300px sidebar.
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
not the WebView", which is the occlusion question, and which mode the switcher is on is this
wrapper's own separate question.
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
this application, and building one is out of this wave's scope; see the notes' own open
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
reuses the same "Browse files" plumbing a pin click already does, honestly opening (or
reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session
exists that does not.
-->
<Panel IsVisible="{Binding IsTransfersScreen}">
<Grid RowDefinitions="Auto,*" Margin="26">
<views:SessionTabRow Grid.Row="0" Classes="sftp"
TabCommand="{Binding SelectFilesHostCommand}" />
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<!--
v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
screen is gone; the address and the "Open terminal" button it carried are in the
sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
binding, the Transfers.Status line it printed while no host was open, is not moved
either: TransfersScreen draws that same string itself, both in its own empty state and
beside the remote pane's DISCONNECT once something is open.
-->
<!--
Three rows since the disconnect strip arrived, and it is the same control the terminal
surface draws — see SessionEndedBar.axaml, which resolves which surface it is about
through the shell rather than through a binding of its own. Auto and empty except while
the file session has been lost, so an ordinary browse pays nothing for it.
Note the DataContext: this row is the shell's rather than the transfers screen's, which
is why the strip sits here and not inside TransfersScreen.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
drawn: the SFTP surface reaches this Panel before a host is chosen, and a sidebar with
an empty QUICK ACCESS and no host name to print would be furniture with nothing to say.
Collapsing frees the "Auto" column it sits in, so the pane column takes the width back.
-->
<views:SessionSidebar Grid.Column="1"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid>
</Border>
</Grid>
</Panel>
<!-- ============ S3 ============ -->
<!--
The same screen as SFTP above, over the same view model, because an object store and an
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
What differs is which picker is offered, and that is decided by the destination rather than
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
calls it now; see NavRail.axaml.
Deliberately not given the full session shell above: a bucket is not a host, has no terminal tab
to be the other end of a cross-surface button, and pins nothing the sidebar's QUICK ACCESS
could show — so no tab row, no host header, no status bar and no sidebar. Wave B's own scope
was the terminal and SFTP surfaces and left this plain; v5c-3 gives it the session shell's own
LOOK without its machinery — a 26px padded column and the same bordered, radius-12 container —
since design-notes/v5c-fidelity-notes.md calls for the fidelity pass here too, and there is
nothing this screen does that needs the parts still withheld.
-->
<Panel IsVisible="{Binding IsBucketsScreen}">
<Border Margin="26" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
ClipToBounds="True">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Border>
</Panel>
<!-- ============ KEYCHAIN ============ -->
<!--
Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
not exist.
-->
<Panel IsVisible="{Binding IsKeychainScreen}">
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ HOST KEYS ============ -->
<!--
Wrapped, like the two above and for the same reason: its data context is the screen's own
view model, where IsKnownHostsScreen does not exist.
-->
<Panel IsVisible="{Binding IsKnownHostsScreen}">
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
</Panel>
<!-- ============ SNIPPETS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsSnippetsScreen}">
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
</Panel>
<!-- ============ LOGS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsLogsScreen}">
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel>
<!--
v5c-2: the VAULTS panel that used to live here is gone. ShellScreen.Vaults now only ever shows
through settings mode's own SettingsVaultsPage — see MainWindowViewModel.ShowScreen, which
redirects it into EnterSettings before Screen is ever set — so IsVaultsScreen being true also
means IsSettingsMode is true, and this whole page-area Panel (bound to !IsSettingsMode, above)
is already hidden behind SettingsView by then. A Panel here would never have been drawn.
v5c-3: IMPORT left this Panel the same way, and for the same reason. Import.dc.html draws the
importer inside the settings chrome — SettingsNav lit on Preferences, a "Back to preferences"
titlebar rather than the ordinary rail and titlebar this Panel belongs to — so ImportScreen now
lives in SettingsView.axaml behind MainWindowViewModel.IsImportOpen, beside the seven settings
pages rather than among the screens here. See ShowScreen's own translation of
ShellScreen.Import for what still reaches it: the Preferences page's own "OPEN IMPORTER" row,
and anything else that names the same destination.
-->
</Panel>
<!-- ============ TERMINAL ============ -->
<!--
◆ v5b's session shell for the terminal surface, built the same way SFTP's own wrapper above is —
around the real terminal control rather than around a page.
Gated on IsTerminalSurface rather than on IsShowingPages: the two are always exclusive, because
Surface is a single ShellSurface value, so this Grid and the pages Panel above it are never both
visible at once. That is what keeps the occlusion rule intact with the WebView now nested inside
a padded column, a bordered container and a header row rather than sitting directly beside the
page area the way it used to.
THE RULE ITSELF DID NOT MOVE. NativeWebView's own IsVisible binding, below, is unchanged and is
not hoisted to this Grid — hoisting it is the one alternative the pin-strip era of this file
called out as unverified, and nesting the control deeper without touching its own binding is not
that: IsTerminalShowing already depends on IsTerminalSurface, so this Grid's own visibility and
the WebView's own visibility flip together on every surface change, driven by the same property
change rather than one waiting on the other.
The tab row's own click selects a terminal tab exactly as the old window-wide strip's did; see
SelectTabCommand. "+" keeps its current meaning, quick connect, on both this row and SFTP's own.
-->
<Grid RowDefinitions="Auto,*" Margin="26" IsVisible="{Binding IsTerminalSurface}">
<views:SessionTabRow Grid.Row="0" TabCommand="{Binding SelectTabCommand}" />
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<!--
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
this Grid is only drawn on the terminal surface, and the surface with no tab open already
answers for itself in the tab row's own "+" and in the connecting card below.
-->
<!--
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
and empty except while the selected tab's shell has stopped, so it costs the terminal
nothing in the ordinary case — and when it is up it takes its 34 pixels off the pane rather
than drawing over it, which is the only way Avalonia content can sit near the renderer at
all. See SessionEndedBar.axaml.
-->
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
<Panel Grid.Row="0" Background="{StaticResource Pane}">
<!--
The other thing that can be in the terminal's rectangle: a tab whose session does not
exist yet, or never will. Exclusive with the WebView below by construction — a
selected tab either has a session or it does not — which is what makes drawing it here
safe under the occlusion rule, the same way the page area is. See ConnectingCard.axaml.
-->
<views:ConnectingCard x:Name="ConnectingPane"
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
browser process tree, so twenty tabs would cost twenty of them.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue,
IsVisible then falls back to its default of true, and the occlusion comes back
silently. Not reachable at runtime — the DataContext is set before the window is shown
— but it is what the previewer does.
-->
<NativeWebView x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<views:SessionEndedBar Grid.Row="1" />
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
the terminal surface is reachable with no tab open at all (the rail's own SSH segment), and
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
"Auto" column it sits in, so the pane column takes the width back.
-->
<views:SessionSidebar Grid.Column="1" ShowsSnips="True" ShowsForwards="True"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid>
</Border>
</Grid>
</Panel>
</Grid>
<!--
v5c: the settings mode, which replaces the titlebar above and everything in this Panel below it with
its own — see SettingsView.axaml and TitleBar's own IsVisible next to it in this file. Later in the
Panel than the unlocked Grid, on the same reasoning the setup Border below gives for its own
position: Avalonia z-order draws a later sibling over an earlier one, and SettingsView's own
Background is opaque, so it covers the rail and the page area completely rather than needing them
hidden out from under it. The unlocked Grid keeps IsEnabled bound to the negation above so a Tab
press cannot walk keyboard focus into a control this mode has covered.
Earlier than the palette and the host-key decision, both further down this same Panel, so either can
still interrupt settings mode exactly as it already interrupts every other screen — a host key that
changes while the sync loop is running does not stop being worth answering because the window
happens to be showing Settings.
-->
<views:SettingsView IsVisible="{Binding IsSettingsMode}" />
<!--
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
which covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered;
see the occlusion rule above.
-->
<Border Background="{StaticResource Canvas}" IsVisible="{Binding !IsUnlocked}">
<Panel>
<Border Classes="card" IsVisible="{Binding IsStarting}">
<StackPanel Spacing="10">
<TextBlock Classes="heading" Text="DodoSSH" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Connect to your server" />
<TextBlock Classes="hint"
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
<!--
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
not replayed. See MainWindow.axaml.cs.
-->
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
PlaceholderText="https://dodossh.example">
<!-- Enter signs in, as it unlocks on the unlock card. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
<TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
<TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<!--
Shown once and impossible to skip. This is the only moment the code exists, and losing it
together with the passphrase means the keychain is unrecoverable — there is no server-side reset
by design.
-->
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Write this recovery code down" />
<TextBlock Classes="hint"
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="6" Padding="14">
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
FontSize="17" Foreground="{StaticResource Accent}"
TextWrapping="Wrap" />
</Border>
<!--
◆ COPY, beside the tick rather than inside the box. The code is selectable and has been
since this card existed, on the argument that refusing to let somebody take it only pushes
them to a screenshot — this is that argument finished, because "select 64 characters of
letter-spaced monospace without missing one" is a thing people get wrong silently.
It is the one secret this application offers to the clipboard on purpose; see
MainWindowViewModel.CopyRecoveryCode for why that is right here and refused for a private
key, and for what the status line below says afterwards.
-->
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Classes="ghost" Content="COPY CODE"
Command="{Binding CopyRecoveryCodeCommand}"
ToolTip.Tip="Puts the code on the clipboard. Paste it into your password manager now — it is shown once and the next thing you copy replaces it." />
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
Content="I have written it down somewhere safe"
VerticalAlignment="Center" />
</StackPanel>
<Button Classes="accent" Content="CONTINUE" Command="{Binding ConfirmRecoveryCodeCommand}"
HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
</StackPanel>
</Border>
<!--
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
-->
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
<views:UnlockCard x:Name="UnlockPane" />
</Border>
<!--
The confirmation, in place of the unlock card rather than under it: the card is already close
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
worse than one that shows one question at a time.
-->
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
<views:SignOutCard />
</Border>
</Panel>
</Border>
<!--
Last, so it is over everything Avalonia draws. It is over the terminal too, because opening it
collapses the terminal — see IsTerminalShowing.
-->
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
<!--
◆ Later still, and the order between these two is a ranking rather than an accident: the palette is
something the user opened and can close, and a host-key decision is a connection waiting on them. It
used to be two banners at the top of the hosts screen, which is why the shell navigated there before
the question could be asked — see HostKeyCard.axaml, and MainWindowViewModel.OnVaultConnectionFailed
for what that cost every other screen.
Wrapped, like every other child here whose data context is the vault: the visibility is the shell's
business — it is the flag that also collapses the WebView — and IsHostKeyDecisionShowing does not
exist on a VaultViewModel, which with compiled bindings is a build error rather than a card that
silently never appears.
-->
<Panel IsVisible="{Binding IsHostKeyDecisionShowing, FallbackValue=False}">
<views:HostKeyCard x:Name="HostKeyPane" DataContext="{Binding Vault}" />
</Panel>
</Panel>
<!--
A fourth row, and a row rather than an overlay for the reason the occlusion rule above gives: this
appears while a terminal may be open, and anything drawn in the WebView's rectangle is sliced. Taking
height from the row above moves the native control's bounds instead of covering it, which is the one
arrangement that works — the same one TitleBar and StatusBar already rely on.
It is a separate control because nothing in this file can be measured by a test, and a strip with two
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
edge. See UpdateBanner.axaml.
◆ THE DATA CONTEXT IS SET HERE, and the banner did nothing at all until it was.
Unlike the titlebar and the status bar, which are typed to this window's own view model and inherit its
context, the banner is typed to UpdateViewModel — it is one screen's control and its layout suite hosts
it over that view model alone. Inheriting the shell's context instead left every compiled binding
inside it resolving against the wrong type and failing silently: no headline, and both Commands null,
so the strip appeared, hovered and pressed like a real banner and neither button did anything.
IsVisible is unqualified because the context is set on this same element, which resolves it against
UpdateViewModel too — the rule the page area's wrappers above are wrapped for. It needs no wrapper: the
flag it binds is the banner's own, unlike IsHostsScreen and its siblings, which belong to the shell.
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
announcing an update that does not exist.
-->
<!--
v5c: wrapped rather than given a third condition of its own — UpdateBanner sets its own DataContext to
UpdateViewModel on this same element, so a binding against IsSettingsMode has to live on an ancestor
whose context is still the shell. The design's settings titlebar has no room for this strip; hiding it
here rather than teaching the banner about a mode it otherwise has no reason to know about.
-->
<Panel Grid.Row="2" IsVisible="{Binding !IsSettingsMode}">
<views:UpdateBanner DataContext="{Binding Updates}"
IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
</Panel>
<views:StatusBar Grid.Row="3" IsVisible="{Binding !IsSettingsMode}" />
</Grid>
</Window>