Public Access
Give hosts and terminals their own screen, and the rest of the vault another
Rebuilds the client's shell from an imported design: a titlebar and nav rail it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host search, and a vault screen that merges keys, passwords and pinned host keys into one table. Hosts left the vault column for their own screen beside the terminal, which is what the design asks for and turned out to be the better split anyway. Two screens the design shows have nothing behind them yet — file transfer and teams — and say so plainly rather than rendering invented data; every other gap between the design and this build is recorded in docs/design-import-gaps.md.
This commit is contained in:
@@ -9,11 +9,84 @@
|
||||
and then cannot read the text.
|
||||
-->
|
||||
|
||||
<Application.Resources>
|
||||
|
||||
<!--
|
||||
The palette, named for what a colour is for rather than for what it looks like. Every one of these
|
||||
is from the design; the names are this codebase's, because "#0C0F0E" appearing in nine files is how a
|
||||
surface ends up two shades off in the tenth.
|
||||
|
||||
Five near-black surfaces rather than one, and the difference between them is real work: the window is
|
||||
the darkest so the terminal reads as the lit thing, chrome sits one step up so the titlebar and status
|
||||
bar frame it, and the sidebars sit between the two so a list does not look like part of either.
|
||||
-->
|
||||
<Color x:Key="CanvasColor">#0A0C0B</Color>
|
||||
<SolidColorBrush x:Key="Canvas" Color="{StaticResource CanvasColor}" />
|
||||
<SolidColorBrush x:Key="Chrome" Color="#0D100F" />
|
||||
<SolidColorBrush x:Key="Sidebar" Color="#0C0F0E" />
|
||||
<SolidColorBrush x:Key="Panel" Color="#0F1211" />
|
||||
<SolidColorBrush x:Key="Raised" Color="#111514" />
|
||||
<SolidColorBrush x:Key="Field" Color="#121615" />
|
||||
|
||||
<!-- Row hover, and the heavier one the chrome's own buttons use. -->
|
||||
<SolidColorBrush x:Key="Hover" Color="#141817" />
|
||||
<SolidColorBrush x:Key="ChromeHover" Color="#1A1F1D" />
|
||||
|
||||
<!--
|
||||
Three border weights, and they are not interchangeable. Strong separates one region of the window from
|
||||
another, subtle separates rows inside one region, and mid is what a control draws around itself.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Border" Color="#1E2422" />
|
||||
<SolidColorBrush x:Key="BorderSubtle" Color="#171C1A" />
|
||||
<SolidColorBrush x:Key="BorderMid" Color="#2A312E" />
|
||||
<SolidColorBrush x:Key="BorderHover" Color="#3A423E" />
|
||||
<SolidColorBrush x:Key="BorderFaint" Color="#232927" />
|
||||
|
||||
<!--
|
||||
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
|
||||
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
|
||||
-->
|
||||
<SolidColorBrush x:Key="Text" Color="#DCE3DF" />
|
||||
<SolidColorBrush x:Key="TextDim" Color="#7E8A84" />
|
||||
<SolidColorBrush x:Key="TextFaint" Color="#566059" />
|
||||
|
||||
<!--
|
||||
The accent, and the three colours that are allowed to disagree with it. Green means live, connected or
|
||||
yours; amber means a caveat worth reading; red means refused or destructive; blue is for the one thing
|
||||
that is neither — a directory, a distinct scope — and is deliberately rare.
|
||||
-->
|
||||
<Color x:Key="AccentColor">#3CE88F</Color>
|
||||
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="AccentSoft" Color="#3CE88F" Opacity="0.35" />
|
||||
<SolidColorBrush x:Key="AccentWash" Color="#3CE88F" Opacity="0.06" />
|
||||
<SolidColorBrush x:Key="Warn" Color="#E8B44C" />
|
||||
<SolidColorBrush x:Key="WarnSoft" Color="#E8B44C" Opacity="0.35" />
|
||||
<SolidColorBrush x:Key="WarnWash" Color="#E8B44C" Opacity="0.06" />
|
||||
<SolidColorBrush x:Key="WarnText" Color="#B9A26B" />
|
||||
<SolidColorBrush x:Key="Danger" Color="#E85D5D" />
|
||||
<SolidColorBrush x:Key="DangerSoft" Color="#E85D5D" Opacity="0.3" />
|
||||
<SolidColorBrush x:Key="DangerWash" Color="#E85D5D" Opacity="0.08" />
|
||||
<SolidColorBrush x:Key="Info" Color="#5DA9E8" />
|
||||
|
||||
<!--
|
||||
The design asks for IBM Plex Mono and IBM Plex Sans. Neither ships with this application and neither is
|
||||
on a stock Windows install, so requesting them by name would render as whatever the font fallback chose
|
||||
that day — which is worse than choosing deliberately. Inter is embedded by the host and is what the
|
||||
window already draws with; the monospace stack is the one every other view here already names, so the
|
||||
terminal's own font and the chrome's agree.
|
||||
|
||||
Named as resources rather than repeated, because the substitution is the sort of thing that gets
|
||||
reversed later and should be reversible in one place. See docs/design-import-gaps.md.
|
||||
-->
|
||||
<FontFamily x:Key="MonoFont">ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
|
||||
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
|
||||
<!--
|
||||
Application-wide rather than on MainWindow, which is where these three lived until the vault column
|
||||
Application-wide rather than on MainWindow, which is where the first three lived until the vault column
|
||||
became its own control. A Window's styles reach its whole tree, so the move changes nothing about how
|
||||
the application renders — but a control laid out on its own, as the layout harness lays the column out,
|
||||
would otherwise lose them. That matters more than it looks: "hint" carries TextWrapping, and text that
|
||||
@@ -21,22 +94,416 @@
|
||||
is the one way this kind of test lies quietly.
|
||||
-->
|
||||
<Style Selector="TextBlock.hint">
|
||||
<Setter Property="Foreground" Value="#7b8394" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.heading">
|
||||
<Setter Property="Foreground" Value="#e6e9f0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="#171b24" />
|
||||
<Setter Property="Background" Value="{StaticResource Chrome}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="24" />
|
||||
<Setter Property="MaxWidth" Value="520" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Monospace, which in this design is not a decoration for code. Host names, fingerprints, addresses and
|
||||
counts are all things people compare character by character, and a proportional font is what makes two
|
||||
similar fingerprints look alike.
|
||||
-->
|
||||
<Style Selector="TextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
</Style>
|
||||
<Style Selector="SelectableTextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The small tracked-out capitals the design uses for every section heading. Letter spacing rather than
|
||||
bold: at this size weight closes the counters up and the word turns into a smudge, whereas spacing is
|
||||
what makes six characters read as a label instead of as an abbreviation.
|
||||
-->
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A chip: a bordered scrap of text stating one fact. The design uses them for tags, roles, scopes and
|
||||
shortcut hints, which have nothing in common except that each is a noun the eye should be able to skip.
|
||||
-->
|
||||
<Style Selector="Border.chip">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip > TextBlock">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="LetterSpacing" Value="0.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.accent">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.accent > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.warn">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource WarnSoft}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.warn > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The buttons. Fluent's own chrome is a rounded grey slab, and one of those beside a hairline-bordered
|
||||
chip reads as belonging to a different application — so the three shapes this design uses are declared
|
||||
here rather than restyled per view.
|
||||
|
||||
Each sets its brushes on the content presenter, not on the button. That is where the Fluent theme puts
|
||||
its own, so a Background set on the button is a TemplateBinding the theme's :pointerover rule overrides
|
||||
— which shows up as a button that is styled until the pointer arrives and then is not.
|
||||
-->
|
||||
<Style Selector="Button.ghost /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderFaint}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Opacity" Value="0.85" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
|
||||
</Style>
|
||||
<!--
|
||||
A disabled accent button keeps its shape and loses its fill. Fluent's disabled state greys the
|
||||
foreground and leaves the background, which on a green slab is unreadable rather than merely dim.
|
||||
-->
|
||||
<Style Selector="Button.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderFaint}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DangerWash}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Danger}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderFaint}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!-- Every button in this window is small, mono and tracked out; only the colours differ. -->
|
||||
<Style Selector="Button.ghost, Button.accent, Button.danger">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="0.8" />
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Buttons that are not shaped like buttons: a group heading, a nav-rail destination, a terminal tab, a
|
||||
window control. All four are clickable regions that read as part of the chrome, so they carry no
|
||||
border and no fill until the pointer is over them — and all four are buttons rather than borders with
|
||||
a gesture, because a button is focusable, keyboard-activatable and announced as a control.
|
||||
-->
|
||||
<Style Selector="Button.flat">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button.flat /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button.flat:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
</Style>
|
||||
<Style Selector="Button.flat:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.grouphead">
|
||||
<Setter Property="Padding" Value="10,7,10,4" />
|
||||
</Style>
|
||||
|
||||
<!-- A category in the vault's rail: the same accent strip a selected row carries, at full row height. -->
|
||||
<Style Selector="Button.cat">
|
||||
<Setter Property="Padding" Value="0,6,14,6" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="LetterSpacing" Value="0.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.cat /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.cat.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<!--
|
||||
The nav rail. The active destination is marked with an accent bar down its left edge and a wash
|
||||
behind it, which is the design's whole idiom for "you are here" — the same two marks a selected list
|
||||
row carries, so the window has one vocabulary for selection rather than one per control.
|
||||
-->
|
||||
<Style Selector="Button.nav">
|
||||
<Setter Property="Padding" Value="0,15" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="9" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="2,0,0,0" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A terminal tab. The active one is marked along its top edge rather than its left, because the strip
|
||||
runs horizontally and a left bar on a row of tabs reads as a divider between them.
|
||||
-->
|
||||
<Style Selector="Button.tab">
|
||||
<Setter Property="Padding" Value="12,0" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="0,2,1,0" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Canvas}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="0,2,0,0" />
|
||||
</Style>
|
||||
|
||||
<!-- The close box on a tab, and the window controls. Square, quiet, and red only where it means it. -->
|
||||
<Style Selector="Button.close /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.close:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DangerWash}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Text input. Fluent draws a filled box with a thick focus underline; this design draws a hairline field
|
||||
that changes border colour, and the two do not sit together in one row.
|
||||
-->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="SelectionBrush" Value="{StaticResource AccentSoft}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="MinHeight" Value="28" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="TextBox /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:pointerover /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="TextBox /template/ TextBlock#PART_Watermark">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
every row as a second, disagreeing highlight.
|
||||
-->
|
||||
<Style Selector="ListBox">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The status dot, in one place rather than as a converter in code.
|
||||
|
||||
Two states and not the design's three. Green means connected — a terminal is open on this host, or a
|
||||
connection to the server is held with nothing waiting to be sent. Grey means it is not. The design also
|
||||
has an amber dot, which would have to mean "reachable but not connected", and nothing in this
|
||||
application ever checks whether a host is reachable; a colour for a state nobody computes would be
|
||||
decoration that reads as information.
|
||||
|
||||
A style rather than a value converter so that the two colours are the palette's, once. A converter
|
||||
would have had to name #3CE88F in C#, which is how a green ends up one shade off in the fourth place
|
||||
it appears.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot">
|
||||
<Setter Property="Width" Value="6" />
|
||||
<Setter Property="Height" Value="6" />
|
||||
<Setter Property="Fill" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Ellipse.dot.live">
|
||||
<Setter Property="Fill" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
|
||||
item's presenter is the thing the theme keeps repainting.
|
||||
-->
|
||||
<Style Selector="Border.rowmark">
|
||||
<Setter Property="Width" Value="2" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected Border.rowmark">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The same strip on the vault's category rail, which is buttons rather than list items — so the class
|
||||
the rail sets stands in for the :selected pseudo-class.
|
||||
|
||||
It has to come after Border.rowmark, and that is not tidiness. Avalonia has no CSS specificity: two
|
||||
class selectors matching one element carry the same priority, and the later declaration wins. Declared
|
||||
above, "Background: Transparent" from Border.rowmark overrode this and the active category had no
|
||||
strip at all — which looks exactly like a rail that was never given one.
|
||||
-->
|
||||
<Style Selector="Button.cat Border.catmark">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.cat.active Border.catmark">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="MinHeight" Value="28" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="NumericUpDown">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="MinHeight" Value="28" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<!--
|
||||
The foreground goes on the content presenter as well as on the control, for the same reason the
|
||||
buttons' brushes do: the Fluent theme sets its own there, and a Foreground set only on the CheckBox is
|
||||
a TemplateBinding the theme's own value wins over — which showed up as a label in the theme's blue-grey
|
||||
beside a hint in this palette's.
|
||||
-->
|
||||
<Style Selector="CheckBox">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="CheckBox /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="CheckBox:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Security.Authentication;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
@@ -38,6 +42,41 @@ internal enum ShellState
|
||||
Unlocked = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which of the unlocked application's screens the nav rail is pointing at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Only meaningful while <see cref="ShellState.Unlocked"/>. The setup and unlock screens are
|
||||
/// <see cref="ShellState"/>, and the two are deliberately different things: one is how far through getting
|
||||
/// in you are, the other is what you are looking at once you are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Transfers"/> and <see cref="Team"/> are in this list without anything behind them, which is
|
||||
/// stated on the screens themselves rather than hidden by dropping them from the rail. See
|
||||
/// <c>docs/design-import-gaps.md</c>: file transfer is M2 and teams are M3, and a rail that quietly had
|
||||
/// three entries would make the eventual arrival of the other two look like a new product rather than a
|
||||
/// milestone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ShellScreen
|
||||
{
|
||||
/// <summary>The host list and the terminals, which is where the application opens.</summary>
|
||||
Hosts = 0,
|
||||
|
||||
/// <summary>File transfer. Nothing implements it yet.</summary>
|
||||
Transfers = 1,
|
||||
|
||||
/// <summary>Everything in the vault that is not a host.</summary>
|
||||
Vault = 2,
|
||||
|
||||
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
|
||||
Team = 3,
|
||||
|
||||
/// <summary>Preferences.</summary>
|
||||
Preferences = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
|
||||
/// </summary>
|
||||
@@ -111,6 +150,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.signIn = signIn;
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -134,6 +177,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private bool canForgetDevice;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this machine can neither register a device key nor withdraw one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are
|
||||
/// independent: a machine with no TPM cannot register, and a machine already registered has nothing to
|
||||
/// register either — and only the second has something to take back. Both false at once is the one case
|
||||
/// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences
|
||||
/// screen where the alternative is a section with no controls in it and no explanation.
|
||||
/// </remarks>
|
||||
internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;
|
||||
|
||||
/// <remarks>
|
||||
/// The address <c>dotnet run --project src/DodoSSH.Api</c> actually serves, so the first launch after
|
||||
/// a clone works without the user having to know a port. This was <c>https://localhost:7217</c>, which
|
||||
@@ -212,6 +267,299 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <summary>Whether a connection to the server is currently held.</summary>
|
||||
internal bool IsOnline => connection is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether everything this machine has changed has reached the server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version
|
||||
/// of that claim, and it is deliberately conservative: true only while a connection is held, the last
|
||||
/// pass actually reached the server, and the outbox is empty.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The middle condition is the one that is easy to leave out, and was. Holding an <c>IVaultServer</c>
|
||||
/// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a
|
||||
/// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely
|
||||
/// the shape of a green light that is lying. See <c>VaultViewModel.LastSyncFailed</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a
|
||||
/// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this
|
||||
/// machine can reach the server and has nothing stuck.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };
|
||||
|
||||
/// <summary>The same fact as a word, for the titlebar.</summary>
|
||||
internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch
|
||||
{
|
||||
(false, _, _) => "OFFLINE",
|
||||
(true, true, _) => "UNREACHABLE",
|
||||
(true, false, 0) => "SYNCED",
|
||||
(true, false, 1) => "1 PENDING",
|
||||
(true, false, var pending) => $"{pending} PENDING",
|
||||
};
|
||||
|
||||
// ---- Which screen is showing ----
|
||||
|
||||
[ObservableProperty]
|
||||
private ShellScreen screen;
|
||||
|
||||
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsVaultScreen => Screen is ShellScreen.Vault;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsTeamScreen => Screen is ShellScreen.Team;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's WebView may be on screen at this instant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is an occlusion rule, not a styling one.</b> The WebView is a native child window on Windows,
|
||||
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
|
||||
/// screens all use the full width), and the quick-connect palette.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a
|
||||
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
|
||||
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to
|
||||
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control
|
||||
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The
|
||||
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
|
||||
/// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect
|
||||
/// while this is false; only the bounds are withheld. Removing the control from the tree would not be
|
||||
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
|
||||
|
||||
/// <summary>Points the nav rail at a screen.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowScreen(ShellScreen target) => Screen = target;
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
/// <summary>
|
||||
/// Every terminal that has been opened this run, in the order they were opened.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On the shell rather than on the vault, and that follows from the lock policy rather than from
|
||||
/// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would
|
||||
/// lose sessions that are still connected — the very sessions <see cref="LiveSessionCount"/> exists to
|
||||
/// admit to. This object is the window's data context for the life of the process, and so is this list.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<TerminalTabViewModel> Tabs { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private TerminalTabViewModel? selectedTab;
|
||||
|
||||
internal bool HasTabs => Tabs.Count > 0;
|
||||
|
||||
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
|
||||
|
||||
/// <summary>
|
||||
/// Closes one terminal, ending its shell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
||||
{
|
||||
if (tab is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
|
||||
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
|
||||
// way out is repainted as disconnected.
|
||||
var index = Tabs.IndexOf(tab);
|
||||
Tabs.Remove(tab);
|
||||
|
||||
if (ReferenceEquals(SelectedTab, tab))
|
||||
{
|
||||
// The neighbour, preferring the one on the left, which is where the eye already is.
|
||||
SelectedTab = Tabs.Count == 0
|
||||
? null
|
||||
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||
}
|
||||
|
||||
RaiseTabState();
|
||||
|
||||
// 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.
|
||||
RefreshConnectedHosts();
|
||||
|
||||
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- Quick connect ----
|
||||
|
||||
/// <summary>Whether the quick-connect palette is open over the window.</summary>
|
||||
/// <remarks>
|
||||
/// It has to collapse the terminal while it is open — see <see cref="IsTerminalShowing"/> — which is why
|
||||
/// this is shell state rather than something a view could hold on its own.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isSearching;
|
||||
|
||||
[ObservableProperty]
|
||||
private string searchText = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The hosts the palette is offering, best match first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The design's box says "search hosts · run command". Only the first half is here: a command palette
|
||||
/// needs commands to run, and this application has no snippet or saved-command item type — see
|
||||
/// <c>docs/design-import-gaps.md</c>. Offering an empty command list under a box that promised one is
|
||||
/// worse than a box that promises only what it does.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> SearchResults { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedSearchResult;
|
||||
|
||||
/// <summary>Whether the palette has anything to offer.</summary>
|
||||
/// <remarks>
|
||||
/// A property rather than <c>{Binding !SearchResults.Count}</c> in the markup. Avalonia's <c>!</c> is a
|
||||
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
|
||||
/// its default of true, and "No host matches that" is shown permanently — under a list of matches.
|
||||
/// </remarks>
|
||||
internal bool HasSearchResults => SearchResults.Count > 0;
|
||||
|
||||
/// <summary>Opens the palette, or closes it if it is already open.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleSearch()
|
||||
{
|
||||
if (IsSearching)
|
||||
{
|
||||
CloseSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsUnlocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SearchText = string.Empty;
|
||||
RefreshSearchResults();
|
||||
IsSearching = true;
|
||||
}
|
||||
|
||||
/// <summary>Dismisses the palette without connecting.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseSearch()
|
||||
{
|
||||
IsSearching = false;
|
||||
SearchText = string.Empty;
|
||||
SearchResults.Clear();
|
||||
SelectedSearchResult = null;
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects the highlighted host and connects to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Goes through the vault's own <c>ConnectCommand</c> rather than opening a session directly, so the
|
||||
/// palette inherits every refusal that path already makes — a dangling key binding, a host with no
|
||||
/// username, a host key that has changed. A second connect path would be a second place for those to be
|
||||
/// forgotten.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConnectToSearchResultAsync()
|
||||
{
|
||||
if (Vault is not { } vault || SelectedSearchResult is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CloseSearch();
|
||||
|
||||
Screen = ShellScreen.Hosts;
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
|
||||
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
|
||||
// generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its
|
||||
// own source — so passing this one would read as cancellation plumbing that is not there.
|
||||
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
|
||||
/// that merely contains it, and both come before a match found only in the address. Typing three
|
||||
/// characters of a name people use daily should not put that host third.
|
||||
/// </remarks>
|
||||
private void RefreshSearchResults()
|
||||
{
|
||||
SearchResults.Clear();
|
||||
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
SelectedSearchResult = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var query = SearchText.Trim();
|
||||
|
||||
var matches = query.Length == 0
|
||||
? vault.Hosts.AsEnumerable()
|
||||
: vault.Hosts
|
||||
.Select(host => (host, rank: Rank(host, query)))
|
||||
.Where(candidate => candidate.rank < int.MaxValue)
|
||||
.OrderBy(candidate => candidate.rank)
|
||||
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
||||
.Select(candidate => candidate.host);
|
||||
|
||||
foreach (var host in matches.Take(8))
|
||||
{
|
||||
SearchResults.Add(host);
|
||||
}
|
||||
|
||||
SelectedSearchResult = SearchResults.FirstOrDefault();
|
||||
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
}
|
||||
|
||||
private static int Rank(HostRowViewModel host, string query)
|
||||
{
|
||||
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 2
|
||||
: int.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings the schema up to date and works out which screen to show.
|
||||
/// </summary>
|
||||
@@ -285,6 +633,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
connection = await signIn(url, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
var outcome = await Provisioner()!
|
||||
.RefreshAsync(ServerUrl, cancellationToken)
|
||||
@@ -551,6 +900,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
|
||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
||||
// second old.
|
||||
RefreshConnectedHosts();
|
||||
|
||||
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
||||
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
||||
// observable collections single-threaded.
|
||||
@@ -615,6 +969,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
disposed = true;
|
||||
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
|
||||
knownHosts.Close();
|
||||
|
||||
if (Vault is { } open)
|
||||
@@ -731,16 +1087,138 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (oldValue is not null)
|
||||
{
|
||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
||||
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
||||
}
|
||||
|
||||
if (newValue is not null)
|
||||
{
|
||||
newValue.SessionOpened += OnVaultSessionOpened;
|
||||
newValue.PropertyChanged += OnVaultPropertyChanged;
|
||||
|
||||
// The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
|
||||
// disconnected — so without this the status dots go out once a minute underneath terminals that
|
||||
// are still open. The rows belong to the vault and the connection state belongs to the shell,
|
||||
// which is exactly why the shell has to repaint them rather than the vault carrying the flag.
|
||||
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
|
||||
}
|
||||
|
||||
RaiseSyncState();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox
|
||||
/// depth, which lives on the vault, and re-raising the shell's two derived properties on every
|
||||
/// notification a busy vault produces would repaint the titlebar on every keystroke in an editor.
|
||||
/// </remarks>
|
||||
private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal)
|
||||
|| string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal))
|
||||
{
|
||||
RaiseSyncState();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultSessionOpened(object? sender, EventArgs e) =>
|
||||
TerminalSessionOpened?.Invoke(this, e);
|
||||
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||
RefreshConnectedHosts();
|
||||
|
||||
private void RaiseSyncState()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsFullySynced));
|
||||
OnPropertyChanged(nameof(SyncLabel));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
|
||||
/// runs against a tab strip that already shows the session it is focusing.
|
||||
/// </remarks>
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
|
||||
{
|
||||
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
|
||||
|
||||
Tabs.Add(tab);
|
||||
RaiseTabState();
|
||||
|
||||
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged. The page
|
||||
// also activates a newly created pane on its own, so this is belt and braces for the first session
|
||||
// and load-bearing for every one after it.
|
||||
SelectedTab = tab;
|
||||
|
||||
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Fire-and-forget, and it has to be: this runs from a property setter, and a selection that awaited a
|
||||
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
|
||||
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
|
||||
/// an already-open socket and returns immediately when there is no renderer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
partial void OnSelectedTabChanged(TerminalTabViewModel? value)
|
||||
{
|
||||
foreach (var tab in Tabs)
|
||||
{
|
||||
tab.IsSelected = ReferenceEquals(tab, value);
|
||||
}
|
||||
|
||||
RefreshConnectedHosts();
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Brings one terminal's pane to the front.</summary>
|
||||
[RelayCommand]
|
||||
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
|
||||
/// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
|
||||
/// the scrollback, and the renderer has already written the reason into it.
|
||||
/// </remarks>
|
||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||
{
|
||||
tab.IsLive = false;
|
||||
}
|
||||
|
||||
RefreshConnectedHosts();
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the host list's status dots from the tab list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
||||
/// wrong than a dot that goes dark when the vault is reopened.
|
||||
/// </remarks>
|
||||
private void RefreshConnectedHosts()
|
||||
{
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var host in vault.Hosts)
|
||||
{
|
||||
host.IsConnected = Tabs.Any(
|
||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnLiveSessionCountChanged(int value)
|
||||
{
|
||||
@@ -756,5 +1234,39 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsShowingRecoveryCode));
|
||||
OnPropertyChanged(nameof(IsLocked));
|
||||
OnPropertyChanged(nameof(IsUnlocked));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
RaiseSyncState();
|
||||
|
||||
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
|
||||
// The hosts screen is what this application is for.
|
||||
if (value is ShellState.Unlocked)
|
||||
{
|
||||
Screen = ShellScreen.Hosts;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Every screen flag, on every change, for the same reason the vault column raises all four of its
|
||||
/// section flags: a rail lighting the current screen and a body showing it are one fact read from two
|
||||
/// directions, and raising only the one that became true leaves the old button lit.
|
||||
/// </remarks>
|
||||
partial void OnScreenChanged(ShellScreen value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
OnPropertyChanged(nameof(IsVaultScreen));
|
||||
OnPropertyChanged(nameof(IsTeamScreen));
|
||||
OnPropertyChanged(nameof(IsPreferencesScreen));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
}
|
||||
|
||||
partial void OnIsSearchingChanged(bool value) => OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
|
||||
partial void OnCanRegisterDeviceChanged(bool value) =>
|
||||
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
|
||||
|
||||
partial void OnCanForgetDeviceChanged(bool value) =>
|
||||
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
|
||||
|
||||
partial void OnSearchTextChanged(string value) => RefreshSearchResults();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// One open terminal, as a tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
|
||||
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
|
||||
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
|
||||
/// is the WebView, and there is one of those however many tabs are open.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Tabs belong to the shell, not to the vault.</b> Locking disposes the vault and every key it held, and
|
||||
/// deliberately leaves shells running — so a tab list rebuilt per unlock would lose track of sessions that
|
||||
/// are still connected, and the unlock screen's count of them would be the only place they appeared. The
|
||||
/// shell outlives every lock, and so does this.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">Who this is logged in as, and where.</param>
|
||||
internal sealed partial class TerminalTabViewModel(uint sessionId, string label, string address)
|
||||
: ObservableObject
|
||||
{
|
||||
internal uint SessionId { get; } = sessionId;
|
||||
|
||||
internal string Label { get; } = label;
|
||||
|
||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||
internal string Address { get; } = address;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the shell behind this tab is still running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cleared when the workspace says the session ended, never inferred from the tab being closed — closing
|
||||
/// a tab removes it, and a removed tab has nothing left to report. A dead tab is kept on purpose: its
|
||||
/// pane still holds the scrollback, and the last thing the remote said is usually why the shell ended.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isLive = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the tab whose pane is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A flag on the tab as well as a selection on the shell, because the strip is an
|
||||
/// <c>ItemsControl</c> of buttons rather than a control that owns a selection — and a button has no
|
||||
/// <c>:selected</c> pseudo-class to style against. The shell writes it; nothing else does.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isSelected;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ namespace DodoSSH.Client.App.ViewModels;
|
||||
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
|
||||
/// an item a newer client wrote that must not be re-encoded here.
|
||||
/// </remarks>
|
||||
internal sealed class HostRowViewModel(VaultItem<HostSecret> host)
|
||||
internal sealed partial class HostRowViewModel(VaultItem<HostSecret> host) : ObservableObject
|
||||
{
|
||||
internal Guid EntityId => host.EntityId;
|
||||
|
||||
@@ -53,6 +53,24 @@ internal sealed class HostRowViewModel(VaultItem<HostSecret> host)
|
||||
|
||||
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
|
||||
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a terminal is open on this host right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one thing on a host row that is not a property of the host. It is written by the shell, which owns
|
||||
/// the tab list, because a session outlives the vault that opened it — so the vault cannot be the one
|
||||
/// holding the answer. The design's status dot is this, and it is the reason the row is observable at
|
||||
/// all: everything else here is fixed for the row's lifetime and a reload replaces the row outright.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability
|
||||
/// would be a claim this application never checks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
}
|
||||
|
||||
/// <summary>What a host can authenticate with.</summary>
|
||||
@@ -257,6 +275,8 @@ internal sealed class KnownHostRowViewModel(VaultItem<KnownHostSecret> pin, bool
|
||||
/// </remarks>
|
||||
internal bool IsDialledByAHost { get; } = isDialledByAHost;
|
||||
|
||||
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
|
||||
|
||||
internal string Badge => IsDialledByAHost
|
||||
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
|
||||
: "no host uses this";
|
||||
@@ -280,6 +300,21 @@ internal static class ItemBadge
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A terminal session that has just opened.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">The account and endpoint actually dialled.</param>
|
||||
internal sealed class TerminalSessionEventArgs(uint sessionId, string label, string address) : EventArgs
|
||||
{
|
||||
internal uint SessionId { get; } = sessionId;
|
||||
|
||||
internal string Label { get; } = label;
|
||||
|
||||
internal string Address { get; } = address;
|
||||
}
|
||||
|
||||
/// <summary>A conflict, as a row.</summary>
|
||||
internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
||||
{
|
||||
@@ -320,10 +355,10 @@ internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
||||
/// </remarks>
|
||||
internal enum VaultSection
|
||||
{
|
||||
/// <summary>The hosts to connect to, and the column's opening state.</summary>
|
||||
Hosts,
|
||||
/// <summary>Every kind at once, which is where the screen opens.</summary>
|
||||
All,
|
||||
|
||||
/// <summary>The SSH keys those hosts authenticate with.</summary>
|
||||
/// <summary>The SSH keys hosts authenticate with.</summary>
|
||||
Keys,
|
||||
|
||||
/// <summary>The usernames and passwords they authenticate with instead.</summary>
|
||||
@@ -333,6 +368,60 @@ internal enum VaultSection
|
||||
KnownHosts,
|
||||
}
|
||||
|
||||
/// <summary>What kind of thing a row in the vault table is.</summary>
|
||||
internal enum VaultItemKind
|
||||
{
|
||||
/// <summary>An SSH key.</summary>
|
||||
Key,
|
||||
|
||||
/// <summary>A stored username and password.</summary>
|
||||
Credential,
|
||||
|
||||
/// <summary>A pinned host key.</summary>
|
||||
KnownHost,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row of the vault table, whatever kind of item it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The table has one shape and a <c>TYPE</c> column, which is what lets a single category show every kind
|
||||
/// at once — and that is the only reason this projection exists. It is deliberately a view of a typed row
|
||||
/// rather than a replacement for one: selecting here sets the typed selection the editors and the delete
|
||||
/// commands already act on, so nothing downstream had to learn about a second way of naming an item.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here is a secret.</b> <see cref="Detail"/> is what is <em>known about</em> an item — whether a
|
||||
/// key has a passphrase stored, which account a password is for, what a pin's fingerprint is — never the key
|
||||
/// or the password itself. The same rule the three list templates already followed, now in one place where
|
||||
/// it is harder to break by pointing a template at the obvious property.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Kind">Which of the three lists this came from.</param>
|
||||
/// <param name="EntityId">The item, so a selection can be mapped back.</param>
|
||||
/// <param name="Name">The label the user gave it.</param>
|
||||
/// <param name="Type">The one-word kind, for the table's TYPE column.</param>
|
||||
/// <param name="Detail">What is known about it.</param>
|
||||
/// <param name="Badge">Its sync state, or empty.</param>
|
||||
/// <param name="HasUnsyncedChanges">
|
||||
/// Whether this machine has a change to this item that the server has not accepted. Carried separately from
|
||||
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
|
||||
/// by comparing it against the literal "not synced" would break the day that wording improves.
|
||||
/// </param>
|
||||
internal sealed record VaultItemRowViewModel(
|
||||
VaultItemKind Kind,
|
||||
Guid EntityId,
|
||||
string Name,
|
||||
string Type,
|
||||
string Detail,
|
||||
string Badge,
|
||||
bool HasUnsyncedChanges)
|
||||
{
|
||||
/// <summary>Whether this row has anything to say about its sync state.</summary>
|
||||
internal bool HasBadge => Badge.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
|
||||
/// </summary>
|
||||
@@ -395,8 +484,36 @@ internal sealed partial class VaultViewModel(
|
||||
internal VaultSession Session => session;
|
||||
|
||||
/// <summary>The hosts to show, unpushed local state included.</summary>
|
||||
/// <remarks>
|
||||
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
|
||||
/// host key list checks itself against, so a filter applied here would change what the application can
|
||||
/// do rather than what it shows. <see cref="HostGroups"/> is the filtered view.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||
|
||||
/// <summary>The hosts the sidebar is showing: the filter applied, nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// A second collection rather than a filtered view over the first, because the sidebar's list has to be
|
||||
/// one <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
|
||||
/// terminal gives it back, and neither of those survives being split across several lists.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// What the sidebar's one group heading says.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
|
||||
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
|
||||
/// presented as structure. One heading, because one vault is reachable: the server denies access to
|
||||
/// every vault that is not this user's own. See <c>docs/design-import-gaps.md</c>.
|
||||
/// </remarks>
|
||||
internal string HostsHeading => VaultName.ToUpperInvariant();
|
||||
|
||||
/// <summary>Whether the host list under the heading is folded away.</summary>
|
||||
[ObservableProperty]
|
||||
private bool areHostsExpanded = true;
|
||||
|
||||
/// <summary>The SSH keys to show, unpushed local state included.</summary>
|
||||
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
|
||||
|
||||
@@ -424,12 +541,49 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selectedKnownHost;
|
||||
|
||||
/// <summary>
|
||||
/// What the sidebar's filter box holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matched against the label, the address and the notes, case-insensitively, because those are the three
|
||||
/// things a person remembers a machine by. It narrows <see cref="HostGroups"/> only: the selection, the
|
||||
/// connect path and everything else read <see cref="Hosts"/>, so filtering can never make a host
|
||||
/// unusable — only unlisted.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string hostFilter = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private int pendingChanges;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the last synchronisation attempt failed to reach the server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Holding a connection object is not the same as being able to reach anything, and this is the
|
||||
/// difference. <c>IVaultServer</c> is obtained once at sign-in and never dropped, so a laptop whose lid
|
||||
/// has been shut all afternoon still has one — and the background pass swallows its socket errors on
|
||||
/// purpose, which means nothing else would ever notice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It exists because the titlebar makes a claim now. A green dot saying SYNCED over a machine that has
|
||||
/// not reached the server since lunch is exactly the sort of thing this project writes down instead of
|
||||
/// implying — and an empty outbox does not rule it out, because an empty outbox on an unreachable
|
||||
/// machine is the ordinary state of a laptop nobody has changed anything on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// False until proven otherwise rather than the reverse. The auto-sync loop runs a pass the moment the
|
||||
/// vault opens, so the honest answer arrives within a moment of unlocking, and starting pessimistic
|
||||
/// would flash UNREACHABLE at every launch by somebody who is not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool lastSyncFailed;
|
||||
|
||||
[ObservableProperty]
|
||||
private int unreadableItems;
|
||||
|
||||
@@ -448,18 +602,123 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private VaultSection section;
|
||||
|
||||
/// <summary>Whether the hosts section is the one showing.</summary>
|
||||
internal bool ShowsHosts => Section is VaultSection.Hosts;
|
||||
/// <summary>Whether every kind is showing at once.</summary>
|
||||
internal bool ShowsAll => Section is VaultSection.All;
|
||||
|
||||
/// <inheritdoc cref="ShowsHosts" />
|
||||
/// <inheritdoc cref="ShowsAll" />
|
||||
internal bool ShowsKeys => Section is VaultSection.Keys;
|
||||
|
||||
/// <inheritdoc cref="ShowsHosts" />
|
||||
/// <inheritdoc cref="ShowsAll" />
|
||||
internal bool ShowsCredentials => Section is VaultSection.Credentials;
|
||||
|
||||
/// <inheritdoc cref="ShowsHosts" />
|
||||
/// <inheritdoc cref="ShowsAll" />
|
||||
internal bool ShowsKnownHosts => Section is VaultSection.KnownHosts;
|
||||
|
||||
/// <summary>
|
||||
/// The rows the vault table is showing, for whichever category is selected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rebuilt whenever the category changes or the lists reload, from the typed lists rather than from
|
||||
/// storage — so it costs one pass over what is already decrypted in memory and can never disagree with
|
||||
/// the lists the editors act on.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The selected row of the vault table.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Setting it sets the matching typed selection, which is what every editor and every delete command
|
||||
/// reads. The typed selections stay the state; this is the way the table names one of them.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private VaultItemRowViewModel? selectedVaultItem;
|
||||
|
||||
/// <summary>What the vault screen's header calls the category showing.</summary>
|
||||
internal string SectionTitle => Section switch
|
||||
{
|
||||
VaultSection.Keys => "SSH KEYS",
|
||||
VaultSection.Credentials => "PASSWORDS",
|
||||
VaultSection.KnownHosts => "HOST KEYS",
|
||||
_ => "ALL ITEMS",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// What the header says about the category, under its name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The design says "18 items · 12 shared". Sharing does not exist — every item in this vault is this
|
||||
/// user's — so the second half is what this build can actually count instead: how many of the ones on
|
||||
/// screen this machine has not managed to push yet.
|
||||
/// <para>
|
||||
/// Counted over the rows showing rather than over the whole outbox, which is what this said first and
|
||||
/// was wrong in a way a screenshot made obvious: "7 items · 12 not synced" under a list of seven reads
|
||||
/// as twelve of those seven. The outbox counts hosts too, and hosts are a different screen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string SectionSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = VaultItems.Count == 1 ? "1 item" : $"{VaultItems.Count} items";
|
||||
var waiting = VaultItems.Count(row => row.HasUnsyncedChanges);
|
||||
|
||||
return waiting == 0 ? items : $"{items} · {waiting} not synced";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Everything in the vault except the hosts, which have their own screen.</summary>
|
||||
internal int TotalItemCount => Keys.Count + Credentials.Count + KnownHostPins.Count;
|
||||
|
||||
internal bool HasVaultItems => VaultItems.Count > 0;
|
||||
|
||||
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
||||
|
||||
/// <summary>Whether the selected row is one with an editor behind it.</summary>
|
||||
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
||||
VaultItemKind.Key or VaultItemKind.Credential;
|
||||
|
||||
/// <summary>Whether the selected row is a pinned host key, which is edited by being withdrawn.</summary>
|
||||
internal bool SelectedItemIsPin => SelectedVaultItem?.Kind is VaultItemKind.KnownHost;
|
||||
|
||||
/// <summary>What the detail pane calls the block under the chips.</summary>
|
||||
internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch
|
||||
{
|
||||
VaultItemKind.KnownHost => "FINGERPRINT",
|
||||
VaultItemKind.Credential => "ACCOUNT",
|
||||
_ => "WHAT IS STORED",
|
||||
};
|
||||
|
||||
internal bool HasUnreadableItems => UnreadableItems > 0;
|
||||
|
||||
/// <remarks>
|
||||
/// A sentence rather than a number, because the number alone reads as a count of something you have
|
||||
/// rather than of something you cannot open — and what to do about it is not guessable.
|
||||
/// </remarks>
|
||||
internal string UnreadableSummary => UnreadableItems == 1
|
||||
? "1 item will not decrypt"
|
||||
: $"{UnreadableItems} items will not decrypt";
|
||||
|
||||
/// <summary>What an empty category says instead of showing an empty grid.</summary>
|
||||
internal string EmptySectionMessage => Section switch
|
||||
{
|
||||
VaultSection.Keys =>
|
||||
"No SSH keys yet. Paste one in and bind a host to it, and that host stops asking for a password.",
|
||||
VaultSection.Credentials =>
|
||||
"No stored passwords yet. Add one to stop typing the same password into every connection.",
|
||||
VaultSection.KnownHosts =>
|
||||
"No host keys approved yet. One appears here the first time you accept a host's fingerprint.",
|
||||
_ => "Nothing in the vault but your hosts. Add an SSH key or a password to stop typing one.",
|
||||
};
|
||||
|
||||
/// <summary>Whether the category showing is one that can have something added to it.</summary>
|
||||
/// <remarks>
|
||||
/// Pins are the exception and always have been: one appears because somebody approved a fingerprint at
|
||||
/// the moment of connecting, which is the only place it can be checked against what the operator
|
||||
/// published. A form for typing one in would be a form for pasting whatever a man in the middle offered.
|
||||
/// </remarks>
|
||||
internal bool CanAddToSection => Section is not VaultSection.KnownHosts;
|
||||
|
||||
// ---- The editor ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -621,13 +880,20 @@ internal sealed partial class VaultViewModel(
|
||||
/// Raised once a terminal session is open and its renderer has it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An event rather than a property because handing the terminal the keyboard is something that
|
||||
/// happens, not something that is true: connecting a second host while one is already open has to
|
||||
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
|
||||
/// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
|
||||
/// controls directly.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It carries the session, because the shell opens a tab for it and the shell is where tabs live. The
|
||||
/// vault is the only thing that knows what this session is <em>of</em> — a host's name is a decrypted
|
||||
/// item — so the naming happens here and the tab list happens there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal event EventHandler? SessionOpened;
|
||||
internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;
|
||||
|
||||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||||
|
||||
@@ -695,6 +961,9 @@ internal sealed partial class VaultViewModel(
|
||||
UnreadableItems = unreadable;
|
||||
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After all four lists, because the table is a projection of three of them.
|
||||
RebuildVaultItems();
|
||||
|
||||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -718,9 +987,59 @@ internal sealed partial class VaultViewModel(
|
||||
// under the user.
|
||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
|
||||
|
||||
RebuildVisibleHosts();
|
||||
|
||||
return listing.Unreadable;
|
||||
}
|
||||
|
||||
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
|
||||
/// <remarks>
|
||||
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
|
||||
/// keeps this method from undoing its own caller. <c>ListBox.SelectedItem</c> is two-way bound to
|
||||
/// <see cref="SelectedHost"/>, so <c>VisibleHosts.Clear()</c> is a <c>Reset</c> the list reacts to by
|
||||
/// nulling its selection, and the binding writes that null straight back — silently, and before this
|
||||
/// method's own refill has a chance to matter. <see cref="ReloadHostsAsync"/> restores the selection and
|
||||
/// calls this immediately after, which used to mean every reload undid what it had just restored, and
|
||||
/// every keystroke in the filter box did the same.
|
||||
/// </remarks>
|
||||
private void RebuildVisibleHosts()
|
||||
{
|
||||
var selected = SelectedHost;
|
||||
|
||||
VisibleHosts.Clear();
|
||||
|
||||
foreach (var host in Hosts.Where(Matches))
|
||||
{
|
||||
VisibleHosts.Add(host);
|
||||
}
|
||||
|
||||
// Restored when it still matches, and explicitly cleared when it does not — rather than left alone
|
||||
// and trusted to whatever a live SelectedItem binding happens to do about it. A filter that hides
|
||||
// the selected host has to mean nothing is selected: Connect, Edit and Delete all read this
|
||||
// property directly, and a host that is not on screen is not one any of them should act on.
|
||||
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
|
||||
/// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
|
||||
/// wrote down about a machine is often the only place its purpose is recorded.
|
||||
/// </remarks>
|
||||
private bool Matches(HostRowViewModel row)
|
||||
{
|
||||
var filter = HostFilter.Trim();
|
||||
|
||||
if (filter.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Contains(row.Label) || Contains(row.Address) || Contains(row.Host.Notes);
|
||||
|
||||
bool Contains(string? value) =>
|
||||
value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
/// <returns>How many keys would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
||||
@@ -825,6 +1144,7 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
Status = "Offline. Changes are queued and will be sent after you sign in.";
|
||||
return;
|
||||
}
|
||||
@@ -899,10 +1219,13 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Swallowed on purpose, and this is the one place in the view model where that is right: a
|
||||
// laptop that has been closed all afternoon would otherwise replace whatever the user was
|
||||
// reading with a socket error once a minute. The failure is not hidden — the account bar
|
||||
// already shows when there is no connection, and pressing Sync reports the real reason.
|
||||
// The message is swallowed on purpose, and this is the one place in the view model where that
|
||||
// is right: a laptop that has been closed all afternoon would otherwise replace whatever the
|
||||
// user was reading with a socket error once a minute. Pressing Sync still reports the reason.
|
||||
//
|
||||
// The *fact* is not swallowed, and that is the half that used to be missing. Recording it is
|
||||
// what lets the titlebar stop claiming to be up to date with a server it cannot reach.
|
||||
LastSyncFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -922,6 +1245,8 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
LastSyncFailed = false;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
|
||||
@@ -980,7 +1305,7 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
if (AnEditorIsInTheWay())
|
||||
if (AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -997,12 +1322,11 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
{
|
||||
if (AnEditorIsInTheWay())
|
||||
if (AHostEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Section = VaultSection.Hosts;
|
||||
editingEntityId = null;
|
||||
EditorLabel = string.Empty;
|
||||
EditorHostname = string.Empty;
|
||||
@@ -1019,7 +1343,7 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void EditSelectedHost()
|
||||
{
|
||||
if (SelectedHost is not { } row || AnEditorIsInTheWay())
|
||||
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1032,7 +1356,6 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
Section = VaultSection.Hosts;
|
||||
editingEntityId = row.EntityId;
|
||||
EditorLabel = row.Host.Label;
|
||||
EditorHostname = row.Host.Hostname;
|
||||
@@ -1045,6 +1368,64 @@ internal sealed partial class VaultViewModel(
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens whichever editor the selected row belongs to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One button over three kinds, because the table is one table. It delegates rather than duplicating:
|
||||
/// each kind's own command already knows how to refuse a read-only item and how to load an editor
|
||||
/// without a second decryption, and a merged copy of that would be a second place to get it wrong.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void EditSelectedItem()
|
||||
{
|
||||
switch (SelectedVaultItem?.Kind)
|
||||
{
|
||||
case VaultItemKind.Key:
|
||||
EditSelectedKeyCommand.Execute(null);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Credential:
|
||||
EditSelectedCredentialCommand.Execute(null);
|
||||
break;
|
||||
|
||||
default:
|
||||
// A pin has no editor. Its button is Forget, and it is elsewhere on the pane.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes whatever the selected row is.</summary>
|
||||
/// <remarks>
|
||||
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
||||
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
||||
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteSelectedItemAsync()
|
||||
{
|
||||
switch (SelectedVaultItem?.Kind)
|
||||
{
|
||||
// Null rather than a token, and deliberately: a [RelayCommand] over a method whose only
|
||||
// parameter is a CancellationToken generates ExecuteAsync(object? parameter) that ignores the
|
||||
// argument and supplies a token from its own source. Passing one would read as plumbing.
|
||||
case VaultItemKind.Key:
|
||||
await DeleteKeyCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Credential:
|
||||
await DeleteCredentialCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Folds the host list away, or brings it back.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
|
||||
|
||||
/// <summary>Abandons the editor.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelEdit()
|
||||
@@ -1129,7 +1510,7 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void NewKey()
|
||||
{
|
||||
if (AnEditorIsInTheWay())
|
||||
if (AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1149,7 +1530,7 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void EditSelectedKey()
|
||||
{
|
||||
if (SelectedKey is not { } row || AnEditorIsInTheWay())
|
||||
if (SelectedKey is not { } row || AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1254,7 +1635,7 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void NewCredential()
|
||||
{
|
||||
if (AnEditorIsInTheWay())
|
||||
if (AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1274,7 +1655,7 @@ internal sealed partial class VaultViewModel(
|
||||
[RelayCommand]
|
||||
private void EditSelectedCredential()
|
||||
{
|
||||
if (SelectedCredential is not { } row || AnEditorIsInTheWay())
|
||||
if (SelectedCredential is not { } row || AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1627,7 +2008,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Username,
|
||||
authentication.Credential);
|
||||
|
||||
await workspace
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
@@ -1637,7 +2018,18 @@ internal sealed partial class VaultViewModel(
|
||||
// the document, which does nothing while the window's keyboard focus is still on the
|
||||
// Connect button — so without this the first keystrokes of the session go to the shell's
|
||||
// UI instead of the remote shell.
|
||||
SessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
//
|
||||
// The address is built from what was actually dialled rather than from the host's own fields,
|
||||
// because a bound credential can supply the username — so a host saved with no username of its
|
||||
// own still has one here, and it is the one the remote saw.
|
||||
SessionOpened?.Invoke(
|
||||
this,
|
||||
new TerminalSessionEventArgs(
|
||||
sessionId,
|
||||
row.Label,
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{authentication.Username}@{row.Host.Hostname}:{row.Host.Port}")));
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
@@ -1901,49 +2293,61 @@ internal sealed partial class VaultViewModel(
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether an open editor has to be dealt with before the column does anything else.
|
||||
/// Whether the host editor has to be dealt with before the sidebar starts another one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scoped to the host editor alone, and that scoping is the point: the host editor lives in
|
||||
/// <c>HostSidebar</c>, on the Hosts screen, and nothing on the Vault screen shares its column or its
|
||||
/// visibility with it. A vault-screen editor being open says nothing about whether it is safe to start
|
||||
/// editing a host — the two cannot even be looked at at the same time — so this no longer asks about
|
||||
/// them. See <see cref="AVaultEditorIsInTheWay"/> for the reasoning this once shared with them, and why
|
||||
/// splitting it was necessary rather than cosmetic: the earlier single check refused every host action
|
||||
/// while a key editor sat open on a screen the sidebar was not showing, with a status message naming an
|
||||
/// editor the user could not see and no way to reach it without abandoning what they had just started
|
||||
/// on the Hosts screen.
|
||||
/// </remarks>
|
||||
private bool AHostEditorIsInTheWay()
|
||||
{
|
||||
if (IsEditing)
|
||||
{
|
||||
Status = "Finish or cancel the host you are editing first.";
|
||||
}
|
||||
|
||||
return IsEditing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a vault-screen editor has to be dealt with before the rail or another editor opens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One editor open at a time, and <b>the reason has changed</b>. It used to be a layout constraint:
|
||||
/// both editors sat in the same 340-pixel column as <c>Auto</c> rows and their desired heights together
|
||||
/// exceeded it, so opening both pushed the lower one's Save and Cancel past the bottom edge. Sections
|
||||
/// dissolved that — the two editors are now in different sections and only one section is ever laid out,
|
||||
/// so two open editors no longer clip anything. That is measured, not assumed:
|
||||
/// <c>BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut</c> is the same test that used to prove the
|
||||
/// opposite.
|
||||
/// One editor open at a time on this screen, and the reason is the key editor: it holds a pasted
|
||||
/// private key in a bound string for as long as it is open, and only <c>CancelKeyEdit</c> lets go of
|
||||
/// it. Letting the rail move the category, or another editor open, with that editor still holding
|
||||
/// material would leave a private key in a form nobody can see, with nothing on screen to say it is
|
||||
/// there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rule stays for a better reason. The key editor holds a pasted private key in a bound string for
|
||||
/// as long as it is open, and only <c>CancelKeyEdit</c> lets go of it. Letting the column move on with
|
||||
/// that editor open would leave key material in a form nobody can see, with nothing on screen to say it
|
||||
/// is there — so what was a workaround for a sizing problem is now a rule about not hiding a private key
|
||||
/// from the person holding it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused rather than resolved by closing the other editor, because closing it would silently discard
|
||||
/// Refused rather than resolved by closing the open editor, because closing it would silently discard
|
||||
/// what was typed there — and in the key editor that is a pasted private key the user may have nowhere
|
||||
/// else. One sentence and one click is the cheaper of the two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One check rather than the pair this replaced. Each of those asked about the <i>other</i> editor,
|
||||
/// which only made sense while the two lists shared a column; the question a selector asks is whether
|
||||
/// anything is open at all, and every caller wants that same answer.
|
||||
/// Does not ask about <see cref="IsEditing"/>. The host editor is a different screen's business now —
|
||||
/// see <see cref="AHostEditorIsInTheWay"/> — and asking about it here is what used to leave three
|
||||
/// quarters of this screen inert with a status line pointing at an editor the user was not looking at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool AnEditorIsInTheWay()
|
||||
private bool AVaultEditorIsInTheWay()
|
||||
{
|
||||
// Names the editor that is actually open, because "finish what you are editing" is useless advice
|
||||
// in a column that shows one section: the thing to go back to may not be on screen.
|
||||
Status = (IsEditing, IsEditingKey, IsEditingCredential) switch
|
||||
Status = (IsEditingKey, IsEditingCredential) switch
|
||||
{
|
||||
(_, true, _) => "Finish or cancel the SSH key you are editing first.",
|
||||
(_, _, true) => "Finish or cancel the credential you are editing first.",
|
||||
(true, _, _) => "Finish or cancel the host you are editing first.",
|
||||
(true, _) => "Finish or cancel the SSH key you are editing first.",
|
||||
(_, true) => "Finish or cancel the credential you are editing first.",
|
||||
_ => Status,
|
||||
};
|
||||
|
||||
return IsEditing || IsEditingKey || IsEditingCredential;
|
||||
return IsEditingKey || IsEditingCredential;
|
||||
}
|
||||
|
||||
private void ClearKeyEditor()
|
||||
@@ -2057,6 +2461,119 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the
|
||||
/// hosts already in memory, with no decryption and nothing on disk behind it.
|
||||
/// </remarks>
|
||||
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
|
||||
|
||||
/// <summary>
|
||||
/// Refills the vault table from the typed lists.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins.
|
||||
/// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved
|
||||
/// them would put a pin nobody created between two things somebody did.
|
||||
/// </remarks>
|
||||
private void RebuildVaultItems()
|
||||
{
|
||||
var selectedId = SelectedVaultItem?.EntityId;
|
||||
|
||||
VaultItems.Clear();
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Keys)
|
||||
{
|
||||
foreach (var key in Keys)
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.Key,
|
||||
key.EntityId,
|
||||
key.Label,
|
||||
"SSH KEY",
|
||||
key.Description,
|
||||
key.Badge,
|
||||
key.HasUnsyncedChanges));
|
||||
}
|
||||
}
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Credentials)
|
||||
{
|
||||
foreach (var credential in Credentials)
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.Credential,
|
||||
credential.EntityId,
|
||||
credential.Label,
|
||||
"PASSWORD",
|
||||
credential.Description,
|
||||
credential.Badge,
|
||||
credential.HasUnsyncedChanges));
|
||||
}
|
||||
}
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.KnownHosts)
|
||||
{
|
||||
foreach (var pin in KnownHostPins)
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.KnownHost,
|
||||
pin.EntityId,
|
||||
pin.Label,
|
||||
"HOST KEY",
|
||||
pin.Fingerprint,
|
||||
pin.Badge,
|
||||
pin.HasUnsyncedChanges));
|
||||
}
|
||||
}
|
||||
|
||||
// The selection survives a reload, as every other list's does, and for the same reason: a background
|
||||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||||
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(SectionSummary));
|
||||
OnPropertyChanged(nameof(HasVaultItems));
|
||||
OnPropertyChanged(nameof(TotalItemCount));
|
||||
OnPropertyChanged(nameof(EmptySectionMessage));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Mapped onto the typed selection rather than mirrored into it, and only for the kind selected: leaving
|
||||
/// the other two alone means switching category and back does not clear what an editor was pointing at.
|
||||
/// </remarks>
|
||||
partial void OnSelectedVaultItemChanged(VaultItemRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSelectedVaultItem));
|
||||
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
||||
OnPropertyChanged(nameof(SelectedItemIsPin));
|
||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||
|
||||
switch (value?.Kind)
|
||||
{
|
||||
case VaultItemKind.Key:
|
||||
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Credential:
|
||||
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||||
break;
|
||||
|
||||
case VaultItemKind.KnownHost:
|
||||
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
|
||||
|
||||
partial void OnUnreadableItemsChanged(int value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasUnreadableItems));
|
||||
OnPropertyChanged(nameof(UnreadableSummary));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both, on every change. A selector that highlights the showing section and a column that shows the
|
||||
/// selected one are the same fact read from two directions, and raising only the one that became true
|
||||
@@ -2064,10 +2581,14 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
partial void OnSectionChanged(VaultSection value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsHosts));
|
||||
OnPropertyChanged(nameof(ShowsAll));
|
||||
OnPropertyChanged(nameof(ShowsKeys));
|
||||
OnPropertyChanged(nameof(ShowsCredentials));
|
||||
OnPropertyChanged(nameof(ShowsKnownHosts));
|
||||
OnPropertyChanged(nameof(SectionTitle));
|
||||
OnPropertyChanged(nameof(CanAddToSection));
|
||||
|
||||
RebuildVaultItems();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.HostSidebar"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
The host list, and the editor for whichever host is open.
|
||||
|
||||
This was the top half of VaultColumn. The design gives hosts their own column beside the terminal and
|
||||
puts everything else in the vault screen, which is the split this control and VaultScreen are — and it
|
||||
is a better split than the one it replaces: the hosts list is the thing you look at while you work, and
|
||||
the keys and passwords behind it are the thing you go and manage.
|
||||
|
||||
Its data context is the VaultViewModel, so every binding here is a property of the vault. The shell
|
||||
hands it over; see MainWindow.
|
||||
|
||||
The editor stays in this column rather than moving into the terminal's half of the window, and that is
|
||||
an occlusion constraint rather than a preference: the terminal's WebView is a native child window that
|
||||
composites above anything Avalonia draws in the same rectangle, so a form laid over there would render
|
||||
underneath it with its buttons unclickable. It is also why this control is measurable at all — no part
|
||||
of it is the WebView, so the layout harness can lay it out headlessly.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Background="{StaticResource Sidebar}">
|
||||
|
||||
<!--
|
||||
The filter. It narrows this list and nothing else — the connect path, the selection and the pinned
|
||||
host key list all read the unfiltered collection — so a filter left in the box can hide a host but
|
||||
can never break one.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" PlaceholderText="filter hosts" FontSize="11"
|
||||
FontFamily="{StaticResource MonoFont}" MinHeight="26" Padding="8,3" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
One heading, for one vault. The chevron folds the list away; the count is the collection's own, so it
|
||||
follows the filter without a second number to keep in step.
|
||||
-->
|
||||
<Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="▾" Foreground="{StaticResource TextFaint}" FontSize="8"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0"
|
||||
IsVisible="{Binding AreHostsExpanded}" />
|
||||
<TextBlock Grid.Column="0" Text="▸" Foreground="{StaticResource TextFaint}" FontSize="8"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0"
|
||||
IsVisible="{Binding !AreHostsExpanded}" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="{Binding HostsHeading}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding VisibleHosts.Count}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the user leaves the terminal.
|
||||
|
||||
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty list has
|
||||
none. Without it the release-the-keyboard path is a measured no-op: it takes Win32 focus off the
|
||||
terminal's child window and then calls Focus() on something that refuses it, leaving the window with
|
||||
nothing focused and the keystrokes going nowhere.
|
||||
-->
|
||||
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
|
||||
IsVisible="{Binding AreHostsExpanded}"
|
||||
ItemsSource="{Binding VisibleHosts}"
|
||||
SelectedItem="{Binding SelectedHost}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5">
|
||||
|
||||
<!-- The accent strip a selected row carries; see the style in App.axaml. -->
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
|
||||
<!--
|
||||
Connected, and nothing more. Green means a terminal is open on this host right now; grey means
|
||||
there is not one. It is deliberately not reachability — nothing here pings anything, and a dot
|
||||
that meant "up" would be a claim this application never checks.
|
||||
-->
|
||||
<Ellipse Grid.Column="1" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||
Margin="8,5,8,0" VerticalAlignment="Top" />
|
||||
|
||||
<StackPanel Grid.Column="2" Spacing="1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
|
||||
FontSize="11.5" FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
Which of the three ways this host authenticates. In the list because only one of them wants
|
||||
the password box filled in, and an empty box on a key-authenticated host is otherwise
|
||||
indistinguishable from one somebody forgot to fill in.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
|
||||
<Border Grid.Row="3" Padding="10" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsEditing}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" ShowButtonSpinner="False" />
|
||||
<TextBox Grid.Column="2" Text="{Binding EditorUsername}" PlaceholderText="username" />
|
||||
</Grid>
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
<!--
|
||||
How this host authenticates: a typed password, one of the vault's keys, or one of its credentials.
|
||||
Part of the host rather than of the connection, so it follows the host to every machine; a host
|
||||
bound to something since deleted keeps a placeholder entry here, so that editing the port cannot
|
||||
quietly turn it back into a typed-password host.
|
||||
|
||||
One control for all three, which is what makes "a key or a credential, never both" impossible to
|
||||
express rather than merely invalid. The qualifier beside each label is not decoration: a key called
|
||||
"deploy" and the deploy account's password are the ordinary case, and bare labels would offer two
|
||||
identical-looking entries that authenticate completely differently.
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="10"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
||||
Content="Connect through the server relay" />
|
||||
<!--
|
||||
Stated at the moment the decision is made, which is the only place it means anything. With
|
||||
relay off the server stores no address at all; with it on the server must be able to resolve
|
||||
the target, or it becomes an authenticated open proxy into the operator's network.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="The relay stores this host's address on the server in plain text. Everything else stays encrypted." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
|
||||
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
|
||||
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
|
||||
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
|
||||
not a field of the host.
|
||||
-->
|
||||
<Button Classes="danger" Content="FORGET HOST KEY" HorizontalAlignment="Left"
|
||||
Command="{Binding ForgetHostKeyCommand}"
|
||||
IsVisible="{Binding CanForgetHostKey}"
|
||||
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding !IsEditing}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteHostCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,34 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The host list beside the terminal, and the editor for whichever host is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault
|
||||
/// rather than of the shell. The shell hands it over; see <see cref="MainWindow"/>.
|
||||
/// </remarks>
|
||||
internal sealed partial class HostSidebar : UserControl
|
||||
{
|
||||
public HostSidebar() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard should land when the terminal hands it back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Exposed as a property rather than left for the window to find by name, because the name is inside
|
||||
/// this control's template and the window cannot see it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It has to be a control that is on screen. <c>Focus()</c> on a collapsed control is measurably a no-op
|
||||
/// and is not replayed when the control is revealed, so handing the keyboard to a folded-away list would
|
||||
/// swallow it: the terminal would let go and nothing would take it. Folding the host list is the one way
|
||||
/// a user can put this control into that state, so that is the case this answer has to cover — the
|
||||
/// filter box is always there, and it is a perfectly good place for a keyboard to arrive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal Avalonia.Input.IInputElement KeyboardTarget =>
|
||||
HostList.IsEffectivelyVisible ? HostList : HostFilter;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<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.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
||||
@@ -7,331 +8,357 @@
|
||||
Title="DodoSSH"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="820"
|
||||
MinHeight="520"
|
||||
Background="#10131a">
|
||||
|
||||
<!-- The shared classes these views use are in App.axaml, so a control laid out on its own keeps them. -->
|
||||
MinWidth="880"
|
||||
MinHeight="560"
|
||||
Background="{StaticResource Canvas}"
|
||||
SystemDecorations="BorderOnly"
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
The terminal's WebView is collapsed whenever the vault is not unlocked, and that is not a style
|
||||
choice. 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 the setup screens over it left them sliced at the WebView's left
|
||||
edge, with their buttons unreachable at the window's default width.
|
||||
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar.
|
||||
|
||||
Collapsing is safe, which the earlier version of this comment denied: NativeControlHost creates the
|
||||
native control when the control is attached to the visual tree, not when it is laid out or shown, and
|
||||
an assigned Source is replayed once the adapter exists. IsVisible=false therefore still starts
|
||||
WebView2, still loads the page and still lets the renderer attach its socket; it only swaps
|
||||
ShowInBounds for HideWithSize. Flipping it back re-pushes the bounds.
|
||||
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.
|
||||
|
||||
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, a screen other than Hosts, 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.
|
||||
|
||||
What the first connection after unlocking actually depends on is the await in
|
||||
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate
|
||||
is that await, never this control's visibility.
|
||||
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate is
|
||||
that await, never this control's visibility.
|
||||
|
||||
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.
|
||||
-->
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" ColumnDefinitions="340,*">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- Account bar -->
|
||||
<Border Grid.Row="0" Grid.ColumnSpan="2" Padding="12,8" Background="#171b24"
|
||||
IsVisible="{Binding IsUnlocked}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Vault.VaultName}" Foreground="#e6e9f0" FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding AccountName}" Classes="hint" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Vault.Status}" Classes="hint" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="520" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="offline" Foreground="#c8a55a" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !IsOnline}" />
|
||||
<Button Content="Sign in" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}" />
|
||||
<!--
|
||||
Offered only on a machine that can keep a device key and has not already registered one, so it
|
||||
disappears once used and never appears where it could not work. Its own button rather than a
|
||||
setting, because it is a one-time decision with a consent dialog attached — and because the
|
||||
honest place to ask "may this machine unlock itself?" is right after somebody proved they can.
|
||||
-->
|
||||
<Button Content="Use Windows Hello here" Command="{Binding RegisterDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanRegisterDevice}"
|
||||
ToolTip.Tip="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
<!--
|
||||
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one:
|
||||
a machine with no TPM and a machine that is already registered are both "cannot register", and
|
||||
only the second has anything to take back.
|
||||
-->
|
||||
<Button Content="Stop unlocking here" Command="{Binding ForgetDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanForgetDevice}"
|
||||
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
|
||||
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
|
||||
<!--
|
||||
The tooltip carries the policy to the point of action, because the button's name implies
|
||||
the opposite of what it does to a running shell.
|
||||
-->
|
||||
<Button Content="Lock" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Closes the vault and forgets its keys. Open shells keep running and reappear when you unlock." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<views:TitleBar Grid.Row="0" />
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- The unlocked application. -->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
|
||||
<!-- ============ HOSTS + TERMINAL ============ -->
|
||||
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" />
|
||||
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
|
||||
<!--
|
||||
Connecting. A password box only for a host that asks to be — a host bound to a stored
|
||||
credential or a key wants nothing typed here — and a sentence in its place when it does not,
|
||||
because "nothing needs typing" and "something needs typing and the box has not appeared yet"
|
||||
look identical and only one of them is fine.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="12,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Vault and bind this host to it in the host's own editor." />
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
||||
other is a refusal. Presenting a changed key with a "continue" button is how users are
|
||||
taught to click through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode
|
||||
is kept and shown; without this panel it would be last-writer-wins with a longer
|
||||
explanation.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
|
||||
CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
||||
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
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 Grid.Row="3" x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTransfersScreen}"
|
||||
Title="FILE TRANSFER"
|
||||
Milestone="MILESTONE M2"
|
||||
Summary="The design shows a two-pane file browser over SFTP with a transfer queue. None of it is here: an SSH connection in this build opens exactly one interactive shell channel and nothing else, so there is no file transfer to show the state of."
|
||||
Instead="Until this lands, move files the way you would from any terminal — scp or rsync from a shell on this machine, or a shell open on the host itself.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>An SFTP subsystem channel on ISshConnection, which today offers OpenShellAsync and nothing more (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>Remote directory listing — names, sizes, modification times and permission bits (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>A transfer queue with progress, throughput and resume, and somewhere for it to live across a lock (DodoSSH.Client.Ssh, DodoSSH.Client.Session).</x:String>
|
||||
<x:String>Routing a transfer through a bastion, which needs jump-host support the connection layer does not have — the host model already records the chain.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<!--
|
||||
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 IsVaultScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultScreen}">
|
||||
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
|
||||
Title="TEAM"
|
||||
Milestone="MILESTONE M3"
|
||||
Summary="The design shows members, roles, shared vaults and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every vault that is not your own — so there is nobody to list and no shared vault to open."
|
||||
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Vault screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
|
||||
<x:String>Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
|
||||
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
|
||||
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
|
||||
<x:String>Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
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." />
|
||||
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
|
||||
<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 vault 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 vault." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Classes="accent" Content="CREATE MY VAULT" 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 vault 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 vault: 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="16" Foreground="{StaticResource Accent}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
||||
Content="I have written it down somewhere safe" />
|
||||
<Button Classes="accent" Content="CONTINUE" Command="{Binding ConfirmRecoveryCodeCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to
|
||||
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
|
||||
machine that never had it invites the reading that something is broken — and the passphrase
|
||||
box beside it is not a fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The vault column, in its own control. VaultColumn.axaml carries the reasoning about its shape; what
|
||||
matters here is that it is the part of this window a test can lay out, because this window as a
|
||||
whole cannot be shown off the UI thread that owns WebView2.
|
||||
|
||||
Wrapped in a Panel rather than bound directly. The column's data context is the vault, and this
|
||||
element's visibility is the shell's business — put both on one element and IsVisible would resolve
|
||||
against the vault as well, where IsUnlocked does not exist.
|
||||
Last, so it is over everything Avalonia draws. It is over the terminal too, because opening it
|
||||
collapses the terminal — see IsTerminalShowing.
|
||||
-->
|
||||
<Panel Grid.Row="1" Grid.Column="0" IsVisible="{Binding IsUnlocked}">
|
||||
<views:VaultColumn x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
||||
|
||||
<!-- Terminal column -->
|
||||
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
||||
</Panel>
|
||||
|
||||
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!--
|
||||
Typed per connection, and now only for a host that asks to be. A host bound to a stored
|
||||
credential wants nothing typed here; so does one bound to a key. "not stored" rather than "not
|
||||
stored yet", because the vault can hold this now — a host on a typed password is a host nobody
|
||||
has bound a credential to, which is a choice rather than a missing feature.
|
||||
<views:StatusBar Grid.Row="2" />
|
||||
|
||||
The rest of that explanation is a tooltip and not a sentence in this row, which was measured the
|
||||
hard way: at the window's 820px minimum this column gets 480, and a 220px box plus Connect plus
|
||||
any sentence does not fit. The row shipped clipped for as long as it has had a hint in it. This
|
||||
is also the one strip of the window no test can see — MainWindow cannot be laid out headlessly at
|
||||
all, because WebView2 refuses the headless dispatcher's MTA thread. Extracting this header into
|
||||
its own control, as the vault column was extracted for exactly this reason, is what would make
|
||||
it measurable.
|
||||
-->
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="220" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Passwords and bind this host to it in the host's own editor." />
|
||||
<!--
|
||||
Hidden rather than disabled, unlike most of this window. A disabled password box invites the
|
||||
reading that a password is wanted and unavailable; the honest statement for a host that carries
|
||||
its own secret is that nothing needs typing, and an absent box says that better than a
|
||||
greyed-out one. Which secret it carries is in the sentence, because a stored password can be
|
||||
wrong and re-typed here and a key cannot.
|
||||
-->
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint" FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
||||
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
|
||||
to click through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding Vault.HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="#e8dcb0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
Foreground="#f4ecd0" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Trust and connect" Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="#d59aa1" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is
|
||||
kept and shown; without this panel it would be last-writer-wins with a longer explanation.
|
||||
-->
|
||||
<Border Padding="10,8" Background="#1b2432" IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="#bcd2ea" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="#141b26" CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="#dfe6f0" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Text="{Binding Detail}" Classes="hint" FontSize="11"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Content="Dismiss all" Command="{Binding Vault.AcknowledgeAllConflictsCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
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.
|
||||
|
||||
IsVisible is load-bearing rather than cosmetic — see the note on the root Panel. Without it the
|
||||
native child window paints over the setup and unlock screens and swallows their input.
|
||||
|
||||
It must stay IsVisible on this control specifically, and 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.
|
||||
|
||||
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 Grid.Row="2" x:Name="Terminal"
|
||||
IsVisible="{Binding IsUnlocked, FallbackValue=False}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
Setup and unlock. Last in the Panel, so it is above the app content in Avalonia's z-order — which
|
||||
covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered.
|
||||
-->
|
||||
<Border Background="#10131a" 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." />
|
||||
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
|
||||
<Button 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 vault 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 vault." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Content="Create my vault" 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 vault 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 vault: nobody — including whoever runs the server — can recover it for you." />
|
||||
<Border Background="#0c0f15" CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||
FontFamily="ui-monospace,Consolas,monospace"
|
||||
FontSize="16" Foreground="#9ee6b4" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
||||
Content="I have written it down somewhere safe" />
|
||||
<Button Content="Continue" Command="{Binding ConfirmRecoveryCodeCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Unlock" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to
|
||||
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
|
||||
machine that never had it invites the reading that something is broken — and the passphrase
|
||||
box beside it is not a fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Content="Use Windows Hello" Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="#1b2432" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="#bcd2ea"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
</Window>
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// <remarks>
|
||||
/// Keyboard focus across the Avalonia/WebView boundary is handled here rather than in a view model,
|
||||
/// because it is a property of the controls and not of the state. What the view models expose is the
|
||||
/// two facts the policy needs — a session opened, and the vault is no longer unlocked — and both are
|
||||
/// things they already know. See <see cref="NativeKeyboardFocus"/> for why one direction is a plain
|
||||
/// <c>Focus()</c> call and the other is not.
|
||||
/// facts the policy needs — a session opened, the vault is no longer unlocked, the palette is open — and
|
||||
/// all of them are things they already know. See <see cref="NativeKeyboardFocus"/> for why one direction is
|
||||
/// a plain <c>Focus()</c> call and the other is not.
|
||||
/// </remarks>
|
||||
internal sealed partial class MainWindow : Window
|
||||
{
|
||||
@@ -44,13 +44,121 @@ internal sealed partial class MainWindow : Window
|
||||
// posted by the page arrives in Body verbatim.
|
||||
if (string.Equals(e.Body, ReleaseFocusMessage, StringComparison.Ordinal))
|
||||
{
|
||||
// The column decides which of its lists the keyboard belongs to; this window only decides
|
||||
// that the keyboard should leave the terminal.
|
||||
ReleaseKeyboardTo(VaultPane.KeyboardTarget);
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard belongs when the terminal is not holding it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each screen answers for itself, because <c>Focus()</c> on a collapsed control is measurably a no-op
|
||||
/// that is not replayed when the control is revealed — so a fixed target would swallow the keyboard
|
||||
/// whenever its own screen was not the one showing. Only two screens have anything focusable on them;
|
||||
/// the other two are prose, and the window is the fallback there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The window has to be marked <c>Focusable="True"</c> in the markup for that fallback to mean
|
||||
/// anything — a <c>Window</c> is not focusable by default, and <c>Focus()</c> on one that is not
|
||||
/// measurably returns false. Without it, closing the palette on Files, Team or Preferences left the
|
||||
/// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears
|
||||
/// it outright, and the fallback's own <c>Focus()</c> call was failing silently.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IInputElement KeyboardHome => shell?.Screen switch
|
||||
{
|
||||
ShellScreen.Vault => VaultPane.KeyboardTarget,
|
||||
ShellScreen.Hosts => Hosts.KeyboardTarget,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The shortcuts the window owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A tunnelled handler rather than <c>KeyBindings</c>, because three of these four keys have to be
|
||||
/// intercepted before the control under the pointer sees them: Escape and the arrows belong to the
|
||||
/// palette while it is open, and the palette's own text box would otherwise eat them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// None of this reaches the terminal, and it does not need to. Once the WebView's child window holds
|
||||
/// Win32 focus Avalonia sees no key events at all — which is why the terminal has its own way out
|
||||
/// (Ctrl+Shift+F6, handled in the page) and why the shortcuts here can be as ordinary as they like.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected override void OnKeyDown(KeyEventArgs e)
|
||||
{
|
||||
if (shell is not { } viewModel)
|
||||
{
|
||||
base.OnKeyDown(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.K && e.KeyModifiers.HasFlag(KeyModifiers.Control))
|
||||
{
|
||||
viewModel.ToggleSearchCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (viewModel.IsSearching)
|
||||
{
|
||||
HandlePaletteKey(viewModel, e);
|
||||
}
|
||||
|
||||
base.OnKeyDown(e);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The selection is moved here rather than by letting the list take focus, because the list taking
|
||||
/// focus is exactly what would stop the query box receiving the next character typed.
|
||||
/// </remarks>
|
||||
private static void HandlePaletteKey(MainWindowViewModel viewModel, KeyEventArgs e)
|
||||
{
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Escape:
|
||||
viewModel.CloseSearchCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Enter:
|
||||
viewModel.ConnectToSearchResultCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Down:
|
||||
Move(viewModel, 1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
case Key.Up:
|
||||
Move(viewModel, -1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>Clamped rather than wrapped: a list that jumps from the last row to the first loses people.</remarks>
|
||||
private static void Move(MainWindowViewModel viewModel, int delta)
|
||||
{
|
||||
if (viewModel.SearchResults.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var current = viewModel.SelectedSearchResult is { } selected
|
||||
? viewModel.SearchResults.IndexOf(selected)
|
||||
: -1;
|
||||
|
||||
viewModel.SelectedSearchResult =
|
||||
viewModel.SearchResults[Math.Clamp(current + delta, 0, viewModel.SearchResults.Count - 1)];
|
||||
}
|
||||
|
||||
private void Attach(MainWindowViewModel? viewModel)
|
||||
{
|
||||
if (shell is { } previous)
|
||||
@@ -80,30 +188,61 @@ internal sealed partial class MainWindow : Window
|
||||
/// <remarks>
|
||||
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes
|
||||
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
|
||||
/// a session can only be opened from an unlocked vault, and the vault being unlocked is what
|
||||
/// reveals the control. Focus() on a collapsed control is measurably a no-op and is not replayed
|
||||
/// when it is revealed.
|
||||
/// a session can only be opened from the hosts screen of an unlocked vault, and that is exactly the
|
||||
/// state in which the terminal is showing. Focus() on a collapsed control is measurably a no-op and is
|
||||
/// not replayed when it is revealed.
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
|
||||
|
||||
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (!string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal)
|
||||
|| shell is not { } viewModel)
|
||||
if (shell is not { } viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
|
||||
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
|
||||
{
|
||||
ReleaseKeyboardTo(UnlockPassphrase);
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
|
||||
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(UnlockPassphrase);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
return;
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
// The palette is a text box somebody is expected to start typing into immediately, so opening it
|
||||
// has to move the caret there — including out of the terminal, which needs the Win32 half as well.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
|
||||
{
|
||||
ReleaseKeyboardTo(viewModel.IsSearching ? Palette.QueryBox : KeyboardHome);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switching screens moves the keyboard to whatever the new screen offers, for the same reason:
|
||||
// leaving it on a control that has just been collapsed leaves the window with nothing focused.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.Screen), StringComparison.Ordinal)
|
||||
&& viewModel.IsUnlocked)
|
||||
{
|
||||
KeyboardHome.Focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click
|
||||
// is what took the WebView's Win32 focus away in the first place. term.focus() in the page only
|
||||
// ever reaches document.activeElement, which does nothing for a page that no longer holds the
|
||||
// native focus, so without this the pane looks selected and every keystroke goes to the button
|
||||
// instead of the shell until the user clicks inside the terminal by hand.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.SelectedTab), StringComparison.Ordinal)
|
||||
&& viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
|
||||
{
|
||||
Terminal.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.NavRail"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Five destinations down the left edge.
|
||||
|
||||
Two of them — TRANSFERS and TEAM — reach screens that say they are not built. They are in the rail
|
||||
anyway rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screens
|
||||
behind these two say plainly what is missing, and a rail that quietly had three entries would make file
|
||||
transfer and sharing look like a change of product rather than the next two milestones. Both are
|
||||
recorded in docs/design-import-gaps.md.
|
||||
|
||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
||||
anything. Buttons carry no state and cannot disagree with the screen that is showing.
|
||||
-->
|
||||
|
||||
<Border Width="54" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,8,0,0">
|
||||
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
||||
ToolTip.Tip="Your hosts, and the terminals open on them" />
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
|
||||
ToolTip.Tip="File transfer over SSH. Not built yet — see the screen for what is missing." />
|
||||
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}"
|
||||
ToolTip.Tip="SSH keys, stored passwords, and the host keys you have approved" />
|
||||
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Team}"
|
||||
ToolTip.Tip="Shared vaults and the people in them. Not built yet — see the screen for what is missing." />
|
||||
</StackPanel>
|
||||
|
||||
<Button DockPanel.Dock="Bottom" Classes="flat nav" Content="PREFS"
|
||||
Classes.active="{Binding IsPreferencesScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
||||
ToolTip.Tip="Preferences, and this machine's device key" />
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The five destinations down the left edge of the unlocked window.</summary>
|
||||
internal sealed partial class NavRail : UserControl
|
||||
{
|
||||
public NavRail() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="DodoSSH.Client.App.Views.NotBuiltScreen">
|
||||
|
||||
<!--
|
||||
The screen behind a nav-rail entry whose feature does not exist.
|
||||
|
||||
One control for both of them, taking its words as properties, because the two say the same kind of thing
|
||||
and a second copy of this layout would be a second place for the tone to drift. The tone is the point:
|
||||
this project's README states its limits rather than implying them, and the failure mode this screen
|
||||
exists to prevent is a plausible-looking file browser full of invented files.
|
||||
|
||||
Deliberately not a disabled nav entry. A greyed-out destination says "broken" or "not for you"; a screen
|
||||
that names the milestone, lists what is missing and says what to do instead says what is actually true.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="560" Margin="28,26" HorizontalAlignment="Left">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="mono" Name="TitleText" FontSize="16" FontWeight="SemiBold" LetterSpacing="0.5"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<Border Classes="chip warn">
|
||||
<TextBlock Name="MilestoneText" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Name="SummaryText" Classes="hint" FontSize="12" Margin="0,10,0,0" />
|
||||
|
||||
<TextBlock Classes="label" Text="WHAT IS MISSING" Margin="0,24,0,8" />
|
||||
<ItemsControl Name="MissingList">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid ColumnDefinitions="Auto,*" Margin="0,0,0,7">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="·" Foreground="{StaticResource TextFaint}"
|
||||
Margin="0,0,8,0" />
|
||||
<TextBlock Grid.Column="1" Classes="hint" FontSize="11" Text="{Binding}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="4" Padding="12" Margin="0,18,0,0">
|
||||
<TextBlock Name="InsteadText" Classes="hint" FontSize="11" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" Margin="0,14,0,0"
|
||||
Text="Everything this design asked for and this build does not have is written down in docs/design-import-gaps.md, with which project each piece would land in." />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,90 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The screen behind a nav-rail entry whose feature does not exist yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Styled properties set from XAML rather than a view model, because there is no state here — a screen
|
||||
/// saying what is absent has nothing to observe, and a view model per absent feature would be two classes
|
||||
/// whose only job is to hold four strings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Missing"/> takes the list rather than the markup listing it inline, so the two instances of
|
||||
/// this control differ only in their content and the layout is written once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class NotBuiltScreen : UserControl
|
||||
{
|
||||
/// <summary>What the feature is called, in the design's own words.</summary>
|
||||
public static readonly StyledProperty<string> TitleProperty =
|
||||
AvaloniaProperty.Register<NotBuiltScreen, string>(nameof(Title), string.Empty);
|
||||
|
||||
/// <summary>Which milestone it belongs to, so the chip is a fact and not an apology.</summary>
|
||||
public static readonly StyledProperty<string> MilestoneProperty =
|
||||
AvaloniaProperty.Register<NotBuiltScreen, string>(nameof(Milestone), string.Empty);
|
||||
|
||||
/// <summary>One paragraph on what the design shows and why none of it is here.</summary>
|
||||
public static readonly StyledProperty<string> SummaryProperty =
|
||||
AvaloniaProperty.Register<NotBuiltScreen, string>(nameof(Summary), string.Empty);
|
||||
|
||||
/// <summary>The specific pieces, each naming the layer it would land in.</summary>
|
||||
public static readonly StyledProperty<IEnumerable<string>?> MissingProperty =
|
||||
AvaloniaProperty.Register<NotBuiltScreen, IEnumerable<string>?>(nameof(Missing));
|
||||
|
||||
/// <summary>What somebody wanting this today should do instead.</summary>
|
||||
public static readonly StyledProperty<string> InsteadProperty =
|
||||
AvaloniaProperty.Register<NotBuiltScreen, string>(nameof(Instead), string.Empty);
|
||||
|
||||
public NotBuiltScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Bound in code rather than in the markup, because the markup's own DataContext is the shell and
|
||||
// these five are properties of this control. Self-bindings in XAML would need a relative source on
|
||||
// every one of them, which is five chances to point one at the wrong object.
|
||||
TitleText.Bind(TextBlock.TextProperty, this.GetObservable(TitleProperty));
|
||||
MilestoneText.Bind(TextBlock.TextProperty, this.GetObservable(MilestoneProperty));
|
||||
SummaryText.Bind(TextBlock.TextProperty, this.GetObservable(SummaryProperty));
|
||||
InsteadText.Bind(TextBlock.TextProperty, this.GetObservable(InsteadProperty));
|
||||
MissingList.Bind(ItemsControl.ItemsSourceProperty, this.GetObservable(MissingProperty));
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="TitleProperty" />
|
||||
public string Title
|
||||
{
|
||||
get => GetValue(TitleProperty);
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="MilestoneProperty" />
|
||||
public string Milestone
|
||||
{
|
||||
get => GetValue(MilestoneProperty);
|
||||
set => SetValue(MilestoneProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="SummaryProperty" />
|
||||
public string Summary
|
||||
{
|
||||
get => GetValue(SummaryProperty);
|
||||
set => SetValue(SummaryProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="MissingProperty" />
|
||||
public IEnumerable<string>? Missing
|
||||
{
|
||||
get => GetValue(MissingProperty);
|
||||
set => SetValue(MissingProperty, value);
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="InsteadProperty" />
|
||||
public string Instead
|
||||
{
|
||||
get => GetValue(InsteadProperty);
|
||||
set => SetValue(InsteadProperty, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Preferences.
|
||||
|
||||
The design's rail has six sections and its TERMINAL panel has six settings. None of the six settings can
|
||||
be saved: there is no preferences table in the local cache, no preference item type in the vault, and no
|
||||
channel from the host to the renderer that carries a terminal option — the data plane's frames are
|
||||
output, session-opened, session-closed and session-activated, and nothing else. A font-size stepper
|
||||
that reset on every launch would be worse than not having one.
|
||||
|
||||
So this screen ships what is real, which is not nothing: this machine's device key is a genuine
|
||||
preference with a genuine effect, and it is the one thing on the design's SECURITY panel that exists.
|
||||
The two commands behind it were already in the shell; they were merely homeless, wedged into the old
|
||||
account bar because there was nowhere else to put them.
|
||||
|
||||
Everything else is listed as absent rather than omitted, because a preferences screen that is silent
|
||||
about the settings it has not got reads as a product with six preferences.
|
||||
-->
|
||||
|
||||
<ScrollViewer>
|
||||
<StackPanel MaxWidth="620" Margin="28,26" HorizontalAlignment="Left">
|
||||
|
||||
<TextBlock Classes="mono" Text="THIS MACHINE" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
|
||||
Command="{Binding RegisterDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanRegisterDevice}" />
|
||||
<!--
|
||||
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one: a
|
||||
machine with no TPM and a machine that is already registered are both "cannot register", and only
|
||||
the second has anything to take back.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="danger" Content="STOP UNLOCKING HERE"
|
||||
Command="{Binding ForgetDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanForgetDevice}"
|
||||
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
|
||||
</Grid>
|
||||
|
||||
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
|
||||
<TextBlock Classes="hint" FontSize="10" Margin="0,8,0,0"
|
||||
Text="This machine has nowhere to keep a device key, so the vault will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="VAULT" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Lock the vault" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Closes the vault and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the vault, not this machine's access to your hosts." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open, so nothing depends on this being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}"
|
||||
ToolTip.Tip="Opens your browser. Syncing needs a connection; everything else works without one." />
|
||||
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||
Text="These are on the design and have nothing behind them. They are listed rather than left out, so that what this screen does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
|
||||
|
||||
<ItemsControl Margin="0,12,0,0">
|
||||
<ItemsControl.Styles>
|
||||
<Style Selector="TextBlock.gap">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="Margin" Value="0,0,0,7" />
|
||||
</Style>
|
||||
</ItemsControl.Styles>
|
||||
<TextBlock Classes="gap"
|
||||
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the vault." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Per-use approval before a key signs — keys are handed to the SSH stack whole at connect time, so there is no per-signature moment to interrupt." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="SSO and team policy — the server has no team endpoints, so there is no policy for this screen to show." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Keyboard shortcuts — the window binds one chord, and the terminal keeps the rest for the remote." />
|
||||
</ItemsControl>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Preferences: what this build can actually change, and a list of what it cannot.</summary>
|
||||
internal sealed partial class PreferencesScreen : UserControl
|
||||
{
|
||||
public PreferencesScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.QuickConnect"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Ctrl+K: type a few characters of a host's name and press Enter to connect to it.
|
||||
|
||||
The design's box promises "search hosts · run command". Only the first half is here, because there is no
|
||||
snippet or saved-command item type in the vault for the second half to run — see
|
||||
docs/design-import-gaps.md.
|
||||
|
||||
It connects through the vault's own ConnectCommand rather than opening a session itself, so it inherits
|
||||
every refusal that path already makes: a key or credential that has been deleted on another machine, a
|
||||
host with no username, a host key that has changed. A second connect path would be a second place for
|
||||
those to be forgotten.
|
||||
|
||||
While this is open the shell collapses the terminal's WebView, and that is not a visual nicety — the
|
||||
WebView is a native child window that composites above anything Avalonia draws in the same rectangle, so
|
||||
an overlay across the middle of this window would be painted underneath it and take no clicks.
|
||||
-->
|
||||
|
||||
<Border Background="#CC0A0C0B">
|
||||
<Border Width="520" VerticalAlignment="Top" Margin="0,90,0,0"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6">
|
||||
<StackPanel>
|
||||
|
||||
<Border Padding="12,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||
PlaceholderText="search hosts" Margin="8,0,0,0"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="13"
|
||||
Background="Transparent" BorderThickness="0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Arrow keys move the selection and Enter takes it; the window's key handler owns both, because a
|
||||
ListBox that took focus would take the arrow keys away from the box being typed into.
|
||||
-->
|
||||
<ListBox x:Name="Results" MaxHeight="280" Focusable="False"
|
||||
ItemsSource="{Binding SearchResults}"
|
||||
SelectedItem="{Binding SelectedSearchResult}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,8,12,8">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<StackPanel Grid.Column="1" Spacing="1" Margin="12,0,0,0">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Authentication}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="14,16"
|
||||
Text="No host matches that."
|
||||
IsVisible="{Binding !HasSearchResults}" />
|
||||
|
||||
<Border Padding="12,7" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<TextBlock Classes="mono" FontSize="9" Foreground="{StaticResource TextFaint}"
|
||||
Text="↑ ↓ to choose · ENTER to connect · ESC to close" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The Ctrl+K host search, overlaid on the window.</summary>
|
||||
/// <remarks>
|
||||
/// The keys it responds to are handled by <see cref="MainWindow"/> rather than here, because two of them —
|
||||
/// Ctrl+K to open and Escape to close — have to work when this control does not exist yet or has just
|
||||
/// stopped existing. Handling the arrows in the same place keeps the whole chord set in one method.
|
||||
/// </remarks>
|
||||
internal sealed partial class QuickConnect : UserControl
|
||||
{
|
||||
public QuickConnect() => InitializeComponent();
|
||||
|
||||
/// <summary>The box, so the window can put the caret in it the moment the palette opens.</summary>
|
||||
internal TextBox QueryBox => Query;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.StatusBar"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The strip along the bottom.
|
||||
|
||||
The design puts five things here: the active host and the bastion it is reached through, the open port
|
||||
forwards, the SFTP transfer count, an auto-lock countdown, and the last sync time. Four of those five
|
||||
have nothing behind them — there is no jump-host routing, no port forwarding, no file transfer and no
|
||||
idle auto-lock — so what is left is the active session, the vault's last message, and the shortcut hint.
|
||||
|
||||
The last message is the load-bearing one, and it is why this bar is worth having at all: the vault's
|
||||
status line used to be a cramped label in the account bar, and everything the application has to say
|
||||
about a save, a sync, a refusal or a conflict goes through it.
|
||||
-->
|
||||
|
||||
<Border Height="24" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="12,0">
|
||||
|
||||
<!-- What is connected, and where. Absent rather than empty when nothing is. -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="5" VerticalAlignment="Center"
|
||||
Margin="0,0,14,0" IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding SelectedTab.IsLive}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Everything the vault has to say. Trimmed rather than wrapped, because this bar is one line high and
|
||||
a message that grew it would move the whole window's contents up.
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Vault.Status}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding SyncLabel}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="14,0,0,0"
|
||||
IsVisible="{Binding IsUnlocked}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="CTRL K SEARCH" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="14,0,0,0"
|
||||
IsVisible="{Binding IsUnlocked}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The strip along the bottom of the window.</summary>
|
||||
internal sealed partial class StatusBar : UserControl
|
||||
{
|
||||
public StatusBar() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TerminalTabs"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The tab strip above the terminal.
|
||||
|
||||
Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to
|
||||
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
|
||||
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
|
||||
one is the only thing in this application that deliberately ends a session.
|
||||
|
||||
Three of the design's header controls are absent: SPLIT, FORWARDS and SNIPPETS. Splits would need a
|
||||
second pane geometry the renderer does not have, port forwarding does not exist in the SSH layer, and
|
||||
there is no snippet item type in the vault. Three disabled buttons would teach nobody anything; see
|
||||
docs/design-import-gaps.md.
|
||||
|
||||
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
|
||||
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
|
||||
that state.
|
||||
-->
|
||||
|
||||
<Border Height="34" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<Button Grid.Column="0" Classes="flat tab"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
Classes.active="{Binding IsSelected}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green while the shell behind this tab is running, grey once it has ended. The pane
|
||||
keeps its scrollback either way, which is usually why somebody is still looking at a
|
||||
tab whose dot has gone out.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="1" Classes="flat close" Width="20"
|
||||
VerticalAlignment="Stretch"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Closes this terminal and ends its shell.">
|
||||
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
Nothing open, and this is where that is said. The strip stays rather than collapsing — a row of
|
||||
chrome that appears and disappears moves the terminal up and down by 34 pixels every time the last
|
||||
tab closes — and it is also the only place near the terminal that can carry a sentence at all: the
|
||||
rectangle below is a native child window, and anything Avalonia draws in it is drawn underneath.
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5"
|
||||
Text="no terminals open · choose a host and press Connect, or Ctrl+K"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding !HasTabs}" />
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="280"
|
||||
IsVisible="{Binding HasTabs}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The tab strip above the terminal.</summary>
|
||||
internal sealed partial class TerminalTabs : UserControl
|
||||
{
|
||||
public TerminalTabs() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TitleBar"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The window's own titlebar, because the design draws one.
|
||||
|
||||
The window asks Windows for no chrome at all, so everything a titlebar does has to be here: dragging,
|
||||
the double-click to maximise, and three buttons. That is a real cost, and the reason it is worth paying
|
||||
is that a 38-pixel grey system bar above a near-black application is the one part of the window that
|
||||
would look borrowed.
|
||||
|
||||
It sits above the terminal rather than over it, which matters more than it looks: the terminal is a
|
||||
native child window that composites above anything Avalonia draws in the same rectangle, so a titlebar
|
||||
overlapping it would be painted underneath and its close button would not be clickable.
|
||||
-->
|
||||
|
||||
<Border Height="38" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="12,0,10,0">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Border Width="20" Height="20" BorderBrush="{StaticResource Accent}" BorderThickness="1">
|
||||
<TextBlock Classes="mono" Text=">_" FontSize="9" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Accent}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock Classes="mono" Text="DodoSSH" FontSize="12" FontWeight="SemiBold"
|
||||
LetterSpacing="0.5" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
The design puts an organisation here — "dodotech / platform". There are no organisations: the
|
||||
server has team tables and no endpoint that reads them, so the only name this application can
|
||||
truthfully print is the one on the vault it has open. The account is beside it because a machine
|
||||
can be enrolled to one account at a time and knowing which is the point of the chip.
|
||||
-->
|
||||
<Border Classes="chip" IsVisible="{Binding IsUnlocked}">
|
||||
<TextBlock Text="{Binding Vault.VaultName}" />
|
||||
</Border>
|
||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="220" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The quick-connect box. It searches hosts and nothing else — the design's box also promises "run
|
||||
command", and there is no snippet or saved-command item type for it to run. Clicking it is the same
|
||||
as Ctrl+K, which is what the window binds; the design says ⌘K, and this is a Windows build.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="flat" MaxWidth="420" Height="24" Margin="16,0"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding ToggleSearchCommand}" IsEnabled="{Binding IsUnlocked}">
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text=">" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="search hosts" FontSize="10.5" Margin="8,0"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2" Classes="chip" Padding="5,1">
|
||||
<TextBlock Text="CTRL K" FontSize="9" Foreground="{StaticResource TextFaint}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
|
||||
<!--
|
||||
The design's dot is always green and always says SYNCED. This one says what is true: green only
|
||||
while a connection is held and this machine's outbox is empty, and the word changes to the number
|
||||
of changes still waiting, or to OFFLINE. A permanently green light is the same thing as no light.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsUnlocked}"
|
||||
ToolTip.Tip="Green means a connection is held and nothing this machine changed is still waiting to be sent. It does not mean a colleague's change has arrived — that is pulled on a timer.">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding SyncLabel}" FontSize="8.5" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Width="1" Height="16" Background="{StaticResource Border}"
|
||||
IsVisible="{Binding IsUnlocked}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="2">
|
||||
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
|
||||
ToolTip.Tip="Minimise">
|
||||
<TextBlock Text="–" FontSize="12" Foreground="{StaticResource TextDim}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
|
||||
ToolTip.Tip="Maximise">
|
||||
<TextBlock Text="▢" FontSize="10" Foreground="{StaticResource TextDim}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
|
||||
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
|
||||
<TextBlock Text="✕" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,68 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The window's own titlebar, drawn because the design draws one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything here is a window operation, which is why it is code-behind and not a command on a view model:
|
||||
/// dragging, maximising and closing are properties of the <see cref="Window"/> this control happens to be
|
||||
/// inside, and a view model that knew about them would be a view model that could not be tested without one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The window is found by walking up rather than injected, so this control drops into any window — including
|
||||
/// the bare one the layout harness hosts it in, where <see cref="Host"/> is simply a different window and
|
||||
/// every handler still has something to act on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TitleBar : UserControl
|
||||
{
|
||||
public TitleBar() => InitializeComponent();
|
||||
|
||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
/// <remarks>
|
||||
/// Left button only, and only on a press that has not already been handled by something inside the bar —
|
||||
/// otherwise dragging would start from the close button and swallow the click that was meant to close
|
||||
/// the window.
|
||||
/// </remarks>
|
||||
private void OnDrag(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Host?.BeginMoveDrag(e);
|
||||
}
|
||||
|
||||
private void OnMinimise(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Host is { } window)
|
||||
{
|
||||
window.WindowState = WindowState.Minimized;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
|
||||
/// the one people reach for without thinking about it.
|
||||
/// </remarks>
|
||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Host is not { } window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
window.WindowState = window.WindowState == WindowState.Maximized
|
||||
? WindowState.Normal
|
||||
: WindowState.Maximized;
|
||||
}
|
||||
|
||||
private void OnClose(object? sender, RoutedEventArgs e) => Host?.Close();
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultColumn"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
The vault column: a selector at the top, and one kind of item below it.
|
||||
|
||||
One kind at a time rather than every list stacked. Stacking is what this column did with two of them and
|
||||
it was already at its limit — the key list needed a MaxHeight and had to hide itself whenever its editor
|
||||
opened, both to keep the host list above it from pushing the buttons off the bottom edge. Neither of those
|
||||
workarounds is here any more, because a section owns the whole column. Credentials are the third kind and
|
||||
would not have fitted at all.
|
||||
|
||||
Its own control rather than part of MainWindow, for two reasons that arrived together. It is the part of
|
||||
this window with a height budget tight enough to break, and it is the only part that can be laid out by a
|
||||
test, because MainWindow cannot be shown headlessly at all: the terminal's WebView2 adapter initialises on
|
||||
attach and refuses a non-STA thread, which a headless dispatcher is. See DodoSSH.Client.App.Layout.Tests,
|
||||
which measures one section at a time because that is all a user can see at once.
|
||||
|
||||
Buttons and a command rather than a TabControl, a TabStrip or a ListBox, and not for looks. All three hold
|
||||
the selection themselves, so a click moves the highlight before the view model can refuse it — and this
|
||||
column does refuse, while an editor is open. A selector lit on a section the column is not showing is
|
||||
worse than the refusal it would be hiding. Buttons carry no state and cannot disagree with the vault.
|
||||
|
||||
It also keeps the keyboard working. MainWindow releases focus to VaultColumn.KeyboardTarget, and Focus()
|
||||
on a collapsed control is measurably a no-op that is not replayed when the control is revealed — so the
|
||||
target has to be a control that is on screen right now. A tabbed version would put the unselected list
|
||||
outside the visual tree entirely; here it is one section switch away and the column knows which.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
The selector's two states. The brushes go on the presenter rather than the button because that is where
|
||||
the Fluent theme puts its own: a Background set on the button is a TemplateBinding the theme's
|
||||
:pointerover rule overrides, so the accent would survive until the pointer arrived and then vanish.
|
||||
-->
|
||||
<Style Selector="StackPanel#SectionSelector > Button /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="#9aa4b6" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="StackPanel#SectionSelector > Button.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="#e6e9f0" />
|
||||
<Setter Property="BorderBrush" Value="#5b7cfa" />
|
||||
</Style>
|
||||
<!-- Hover on the inactive one, so the row reads as clickable before anything is clicked. -->
|
||||
<Style Selector="StackPanel#SectionSelector > Button:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="#1c2230" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Background="#131722">
|
||||
|
||||
<!--
|
||||
Adding a kind is an enum member, one button here, and one section grid below. Deliberately not a
|
||||
generated list: each section's contents are different markup, so a loop would buy nothing and cost the
|
||||
compiled bindings.
|
||||
-->
|
||||
<Border Grid.Row="0" Background="#10141d" BorderBrush="#1c2230" BorderThickness="0,0,0,1">
|
||||
<StackPanel x:Name="SectionSelector" Orientation="Horizontal">
|
||||
<Button Content="Hosts" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
|
||||
Classes.active="{Binding ShowsHosts}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Hosts}" />
|
||||
<Button Content="SSH keys" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
|
||||
Classes.active="{Binding ShowsKeys}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Keys}" />
|
||||
<Button Content="Passwords" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
|
||||
Classes.active="{Binding ShowsCredentials}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Credentials}" />
|
||||
<Button Content="Host keys" Padding="12,7" CornerRadius="0" BorderThickness="0,0,0,2"
|
||||
Classes.active="{Binding ShowsKnownHosts}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.KnownHosts}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The two sections share this row and take turns being visible. Same row rather than two rows with one
|
||||
collapsed, so neither can reserve height from the other by accident.
|
||||
|
||||
The list keeps the flexible row in both, so it is what grows with the window and what shrinks when an
|
||||
editor opens; the editor takes what it needs. The list stays visible while its editor is open, which the
|
||||
key list did not manage before — and that is what keeps KeyboardTarget honest, since the control it
|
||||
returns has to be one a Focus() call can actually reach.
|
||||
-->
|
||||
|
||||
<!-- Hosts -->
|
||||
<Grid Grid.Row="1" RowDefinitions="*,Auto,Auto" IsVisible="{Binding ShowsHosts}">
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the user leaves the terminal.
|
||||
|
||||
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty list has
|
||||
none. Without it MainWindow's release-the-keyboard path was a measured no-op: it took Win32 focus off
|
||||
the terminal's child window and then called Focus() on something that refuses it, leaving the window
|
||||
with nothing focused and the keystrokes going nowhere. Exactly the state the second half of
|
||||
ReleaseKeyboardTo says it exists to prevent.
|
||||
-->
|
||||
<ListBox Grid.Row="0" x:Name="HostList" Margin="6" Focusable="True"
|
||||
ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
Background="Transparent">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="2,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
|
||||
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
|
||||
FontFamily="ui-monospace,Consolas,monospace" />
|
||||
<!--
|
||||
Which of the two ways this host authenticates. In the list because the password box in the
|
||||
terminal column is only relevant to one of them, and an empty box on a key-authenticated
|
||||
host is otherwise indistinguishable from one somebody forgot to fill in.
|
||||
-->
|
||||
<TextBlock Text="{Binding Authentication}" Classes="hint" FontSize="11" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
|
||||
<Border Grid.Row="1" Padding="10" Background="#171b24" IsVisible="{Binding IsEditing}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
|
||||
<NumericUpDown Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" />
|
||||
<TextBox Text="{Binding EditorUsername}" PlaceholderText="username" />
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="60" TextWrapping="Wrap" />
|
||||
<!--
|
||||
How this host authenticates: a typed password, one of the vault's keys, or one of its credentials.
|
||||
Part of the host rather than of the connection, so it follows the host to every machine; a host
|
||||
bound to something since deleted keeps a placeholder entry here, so that editing the port cannot
|
||||
quietly turn it back into a typed-password host.
|
||||
|
||||
One control for all three, which is what makes "a key or a credential, never both" impossible to
|
||||
express rather than merely invalid. The qualifier beside each label is not decoration: a key called
|
||||
"deploy" and the deploy account's password are the ordinary case, and bare labels would offer two
|
||||
identical-looking entries that authenticate completely differently.
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
||||
Content="Allow connecting through the server relay" />
|
||||
<!--
|
||||
Stated at the moment the decision is made, which is the only place it means anything. With
|
||||
relay off the server stores no address at all; with it on the server must be able to resolve
|
||||
the target, or it becomes an authenticated open proxy into the operator's network.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Enabling the relay stores this host's address on the server in plain text. Everything else about the host stays encrypted." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Save" Command="{Binding SaveHostCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding CancelEditCommand}" />
|
||||
<!--
|
||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
|
||||
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
|
||||
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
|
||||
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
|
||||
not a field of the host.
|
||||
-->
|
||||
<Button Content="Forget host key" Command="{Binding ForgetHostKeyCommand}"
|
||||
IsVisible="{Binding CanForgetHostKey}"
|
||||
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
|
||||
IsVisible="{Binding !IsEditing}">
|
||||
<Button Content="Add" Command="{Binding NewHostCommand}" />
|
||||
<Button Content="Edit" Command="{Binding EditSelectedHostCommand}" />
|
||||
<Button Content="Delete" Command="{Binding DeleteHostCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- SSH keys -->
|
||||
<Grid Grid.Row="1" RowDefinitions="*,Auto,Auto" IsVisible="{Binding ShowsKeys}">
|
||||
|
||||
<!-- The other place the keyboard can land, and focusable for the same reason; see KeyboardTarget. -->
|
||||
<ListBox Grid.Row="0" x:Name="KeyList" Margin="6" Focusable="True"
|
||||
ItemsSource="{Binding Keys}"
|
||||
SelectedItem="{Binding SelectedKey}"
|
||||
Background="Transparent">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SshKeyRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="2,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
|
||||
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
What is known about the key, never the key. Binding the material here would put a private
|
||||
key into a list item's visual tree, where a tooltip or a screen reader could read it out.
|
||||
-->
|
||||
<TextBlock Text="{Binding Description}" Classes="hint" FontSize="11" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Border Grid.Row="1" Padding="10" Background="#171b24"
|
||||
IsVisible="{Binding IsEditingKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
|
||||
<!--
|
||||
Not a password box. The armour has to be visible to be pasted and checked — a masked
|
||||
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this actually
|
||||
prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
|
||||
-->
|
||||
<TextBox Text="{Binding KeyEditorPrivateKey}"
|
||||
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
|
||||
FontFamily="ui-monospace,Consolas,monospace" FontSize="11" />
|
||||
<TextBox Text="{Binding KeyEditorPassphrase}"
|
||||
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
|
||||
<TextBox Text="{Binding KeyEditorPublicKey}"
|
||||
PlaceholderText="public half (optional)" FontSize="11" />
|
||||
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Save" Command="{Binding SaveKeyCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding CancelKeyEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8,4,8,8"
|
||||
IsVisible="{Binding !IsEditingKey}">
|
||||
<Button Content="Add key" Command="{Binding NewKeyCommand}" />
|
||||
<Button Content="Edit" Command="{Binding EditSelectedKeyCommand}" />
|
||||
<Button Content="Delete" Command="{Binding DeleteKeyCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- Stored passwords -->
|
||||
<Grid Grid.Row="1" RowDefinitions="*,Auto,Auto" IsVisible="{Binding ShowsCredentials}">
|
||||
|
||||
<!-- The third place the keyboard can land; see KeyboardTarget. -->
|
||||
<ListBox Grid.Row="0" x:Name="CredentialList" Margin="6" Focusable="True"
|
||||
ItemsSource="{Binding Credentials}"
|
||||
SelectedItem="{Binding SelectedCredential}"
|
||||
Background="Transparent">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CredentialRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="2,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
|
||||
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
The account, never the password. The same rule as the key list, and the same reason: a
|
||||
password bound into a list item's visual tree is one a tooltip or a screen reader can read out.
|
||||
-->
|
||||
<TextBlock Text="{Binding Description}" Classes="hint" FontSize="11" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Border Grid.Row="1" Padding="10" Background="#171b24"
|
||||
IsVisible="{Binding IsEditingCredential}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBox Text="{Binding CredentialEditorLabel}" PlaceholderText="name" />
|
||||
<!--
|
||||
Optional, and the reason a credential is worth being its own item rather than two more fields on a
|
||||
host: one account on twenty machines is described once and rotated once. Left blank, each host
|
||||
supplies its own username and only the password is shared.
|
||||
-->
|
||||
<TextBox Text="{Binding CredentialEditorUsername}"
|
||||
PlaceholderText="username (blank: use each host's own)" />
|
||||
<!--
|
||||
Masked, unlike the private key box, and the difference is not inconsistency. A key's armour has to
|
||||
be visible to be checked for truncation after a paste; a password is short, usually typed, and
|
||||
shoulder-surfing is the likelier problem.
|
||||
-->
|
||||
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
|
||||
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to this in the host's own editor, under how it authenticates." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Save" Command="{Binding SaveCredentialCommand}" />
|
||||
<Button Content="Cancel" Command="{Binding CancelCredentialEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8,4,8,8"
|
||||
IsVisible="{Binding !IsEditingCredential}">
|
||||
<Button Content="Add password" Command="{Binding NewCredentialCommand}" />
|
||||
<Button Content="Edit" Command="{Binding EditSelectedCredentialCommand}" />
|
||||
<Button Content="Delete" Command="{Binding DeleteCredentialCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
Pinned host keys.
|
||||
|
||||
No editor, and no Add — the only section without either. A pin is not something anybody writes: it
|
||||
appears when somebody approves a fingerprint at the moment of connecting, which is the one place a
|
||||
person can actually check it against what the operator published. A form for typing one in would be a
|
||||
form for pasting whatever a man in the middle just offered.
|
||||
|
||||
So this section exists to show and to withdraw, which is exactly what was missing: pins outlive the
|
||||
hosts they were approved for, and nothing surfaced them.
|
||||
-->
|
||||
<Grid Grid.Row="1" RowDefinitions="*,Auto" IsVisible="{Binding ShowsKnownHosts}">
|
||||
|
||||
<ListBox Grid.Row="0" x:Name="KnownHostList" Margin="6" Focusable="True"
|
||||
ItemsSource="{Binding KnownHostPins}"
|
||||
SelectedItem="{Binding SelectedKnownHost}"
|
||||
Background="Transparent">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="2,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
|
||||
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
The fingerprint in full, wrapped rather than trimmed. The only thing anybody does with one is
|
||||
compare it against a fingerprint an operator published, and half of one cannot be compared —
|
||||
it can only be glanced at, which is the habit pinning exists to replace. Nothing here is
|
||||
secret: a host key fingerprint is published on purpose.
|
||||
-->
|
||||
<TextBlock Text="{Binding Fingerprint}" Classes="hint" FontSize="11"
|
||||
FontFamily="ui-monospace,Consolas,monospace" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="1" Spacing="6" Margin="8,4,8,8">
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
|
||||
<Button Content="Forget this host key" Command="{Binding ForgetPinCommand}"
|
||||
HorizontalAlignment="Left"
|
||||
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,48 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The vault's left-hand column: the lists of items, and the editor for whichever one is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault
|
||||
/// rather than of the shell. The shell hands it over; see <see cref="MainWindow"/>.
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultColumn : UserControl
|
||||
{
|
||||
public VaultColumn() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard should land when the terminal hands it back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Exposed as a property rather than left for the window to find by name, because the name is now inside
|
||||
/// this control's template and the window cannot see it. Which is the better arrangement anyway: the
|
||||
/// column shows one list at a time, so "the list the keyboard belongs to" is a question only the column
|
||||
/// can answer, and answering it here means the window never has to know how many lists there are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It has to be the list that is on screen, not a fixed one. <c>Focus()</c> on a collapsed control is
|
||||
/// measurably a no-op and is not replayed when the control is revealed, so returning the host list while
|
||||
/// the keys section is showing would swallow the keyboard: the terminal would let go and nothing would
|
||||
/// take it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Read from the view model rather than from the controls' own <c>IsVisible</c>, because that is the
|
||||
/// direction the truth flows — the section is the state and the visibility is a binding to it. Asking the
|
||||
/// controls would answer the same question one indirection later, and would answer it wrongly for the
|
||||
/// moment between a section change and the next layout pass.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => DataContext switch
|
||||
{
|
||||
VaultViewModel { ShowsKeys: true } => KeyList,
|
||||
VaultViewModel { ShowsCredentials: true } => CredentialList,
|
||||
VaultViewModel { ShowsKnownHosts: true } => KnownHostList,
|
||||
_ => HostList,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
Everything in the vault that is not a host: the keys, the stored passwords, and the host keys this user
|
||||
has approved.
|
||||
|
||||
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
|
||||
shape for every kind, which is what makes the ALL category possible and is why the row projection
|
||||
exists; see VaultItemRowViewModel.
|
||||
|
||||
Two of the design's five categories are not here. IDENTITIES and CERTIFICATES have no item type behind
|
||||
them — the vault holds exactly four kinds and two of those are hosts and pins — so listing them would be
|
||||
two headings that could never have anything under them. HOST KEYS is the other way round: a real,
|
||||
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md.
|
||||
|
||||
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The
|
||||
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its
|
||||
access service, so a rail with three entries would be showing two vaults nothing can open.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="176,*,244">
|
||||
|
||||
<!-- Categories and scopes -->
|
||||
<Border Grid.Column="0" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,12">
|
||||
|
||||
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" />
|
||||
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.All}"
|
||||
Classes.active="{Binding ShowsAll}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||
<TextBlock Grid.Column="1" Text="ALL" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding TotalItemCount}"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Keys}"
|
||||
Classes.active="{Binding ShowsKeys}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||
<TextBlock Grid.Column="1" Text="SSH KEYS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Keys.Count}"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.Credentials}"
|
||||
Classes.active="{Binding ShowsCredentials}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||
<TextBlock Grid.Column="1" Text="PASSWORDS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding Credentials.Count}"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.KnownHosts}"
|
||||
Classes.active="{Binding ShowsKnownHosts}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||
<TextBlock Grid.Column="1" Text="HOST KEYS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding KnownHostPins.Count}"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="14,10" />
|
||||
|
||||
<TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" />
|
||||
|
||||
<!--
|
||||
One entry per vault this session opened. Not a selector: every list on this screen reads the
|
||||
active vault, and a rail that let you click a vault you cannot switch to would be a control that
|
||||
does nothing. It is here because knowing which vault you are looking at is worth a line, and
|
||||
because this is where a second one appears when shared vaults arrive.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
|
||||
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
|
||||
Text="One vault, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
|
||||
|
||||
<!--
|
||||
Items that would not decrypt. Shown here rather than only in the status line because this is the
|
||||
screen the number is about, and because a non-zero count after a rekey is the signal that new
|
||||
grants are needed rather than a transient.
|
||||
-->
|
||||
<Border Classes="chip warn" Margin="14,12,14,0" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding HasUnreadableItems}">
|
||||
<TextBlock Text="{Binding UnreadableSummary}" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- The table -->
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SectionTitle}" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The design's columns are NAME / TYPE / FINGERPRINT / SCOPE / ACCESS / LAST. Three of those six have
|
||||
nothing behind them: there is one scope, no roles, and no item carries a last-used or modified time —
|
||||
VaultItem is (id, secret, version, three sync flags) and nothing else. What replaces them is the one
|
||||
thing this build does know and the design had no column for: whether a change is still sitting in
|
||||
this machine's outbox.
|
||||
-->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,1.3*,74,*,88" Margin="0,6,14,6"
|
||||
IsVisible="{Binding HasVaultItems}">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="DETAIL" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="ItemList" Focusable="True"
|
||||
ItemsSource="{Binding VaultItems}"
|
||||
SelectedItem="{Binding SelectedVaultItem}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||
<Grid ColumnDefinitions="2,1.3*,74,*,88" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="9"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Detail}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="4" Classes="chip warn" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
The empty state says which category is empty and what to do about it, rather than showing an empty
|
||||
grid that reads as a list still loading.
|
||||
-->
|
||||
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptySectionMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="320"
|
||||
IsVisible="{Binding !HasVaultItems}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- The detail pane, and the editors -->
|
||||
<Border Grid.Column="2" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16">
|
||||
|
||||
<!-- Nothing selected. -->
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Choose something on the left to see what is known about it."
|
||||
IsVisible="{Binding !HasSelectedVaultItem}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelectedVaultItem}">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Name}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip">
|
||||
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
|
||||
</Border>
|
||||
<Border Classes="chip accent">
|
||||
<TextBlock Text="{Binding HostsHeading}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="label" Text="{Binding SelectedDetailHeading}" Margin="0,12,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedVaultItem.Detail}"
|
||||
FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
What the design puts here — who added it, when, who it is shared with, and a TEST CONNECT
|
||||
button — has nothing behind it. Items carry no author, no timestamps and no sharing, and
|
||||
nothing can exercise a credential without a host to exercise it against. Rather than five
|
||||
empty rows, this says what is missing in one line.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
|
||||
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||
IsVisible="{Binding SelectedItemIsEditable}">
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
||||
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
||||
the one place it can be checked against what the operator published. What it does have is a
|
||||
way out, because a changed host key is refused outright and a rebuilt server would otherwise
|
||||
be unreachable for ever.
|
||||
-->
|
||||
<StackPanel Spacing="6" Margin="0,14,0,0" IsVisible="{Binding SelectedItemIsPin}">
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
|
||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" HorizontalAlignment="Left"
|
||||
Command="{Binding ForgetPinCommand}"
|
||||
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The key editor. -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingKey}">
|
||||
<TextBlock Classes="label" Text="SSH KEY" Margin="0,0,0,4" />
|
||||
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
|
||||
<!--
|
||||
Not a password box. The armour has to be visible to be pasted and checked — a masked
|
||||
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this actually
|
||||
prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
|
||||
-->
|
||||
<TextBox Text="{Binding KeyEditorPrivateKey}"
|
||||
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="10.5" />
|
||||
<TextBox Text="{Binding KeyEditorPassphrase}"
|
||||
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
|
||||
<TextBox Text="{Binding KeyEditorPublicKey}" PlaceholderText="public half (optional)" />
|
||||
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The password editor. -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingCredential}">
|
||||
<TextBlock Classes="label" Text="PASSWORD" Margin="0,0,0,4" />
|
||||
<TextBox Text="{Binding CredentialEditorLabel}" PlaceholderText="name" />
|
||||
<!--
|
||||
Optional, and the reason a credential is worth being its own item rather than two more fields on
|
||||
a host: one account on twenty machines is described once and rotated once. Left blank, each host
|
||||
supplies its own username and only the password is shared.
|
||||
-->
|
||||
<TextBox Text="{Binding CredentialEditorUsername}"
|
||||
PlaceholderText="username (blank: use each host's own)" />
|
||||
<!--
|
||||
Masked, unlike the private key box, and the difference is not inconsistency. A key's armour has
|
||||
to be visible to be checked for truncation after a paste; a password is short, usually typed,
|
||||
and shoulder-surfing is the likelier problem.
|
||||
-->
|
||||
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
|
||||
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to this in the host's own editor, under how it authenticates." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCredentialCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCredentialEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The vault screen: everything in the vault that is not a host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>VaultViewModel</c>, the same object the host sidebar binds to. Two views over
|
||||
/// one vault rather than two view models, because the two show different projections of the same four lists
|
||||
/// and a second view model would have to keep a copy of them in step.
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultScreen : UserControl
|
||||
{
|
||||
public VaultScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// One list, unlike the host sidebar, because the categories are a projection of the same table rather
|
||||
/// than four separate ones — so there is only ever one control here that could take it.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => ItemList;
|
||||
}
|
||||
@@ -20,6 +20,8 @@
|
||||
const SERVER_OUTPUT = 1;
|
||||
const SERVER_SESSION_OPENED = 2;
|
||||
const SERVER_SESSION_CLOSED = 3;
|
||||
const SERVER_SESSION_ACTIVATED = 4;
|
||||
const SERVER_SESSION_REMOVED = 5;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
@@ -229,6 +231,50 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_ACTIVATED: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
// Ignored for a pane that does not exist. The host sends this when a tab is selected, and a tab
|
||||
// whose session ended still has its pane — but one the host knows about and this page has not
|
||||
// created yet cannot be shown, and inventing an empty terminal for it would be worse than waiting
|
||||
// for the SessionOpened frame that is already on its way.
|
||||
if (!session) {
|
||||
break;
|
||||
}
|
||||
|
||||
activate(sessionId);
|
||||
|
||||
// Refitted on activation, not only on resize. A hidden pane has no layout, so every resize while
|
||||
// it was hidden was skipped by the guard in resize() — meaning it comes back holding whatever
|
||||
// geometry it had when it was last visible, and the remote pty is still sized to match.
|
||||
resize(session, sessionId);
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_REMOVED: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
The tab is gone, so the pane goes with it — and this is the only place that is true. A shell that
|
||||
ended on its own keeps its pane, because the last thing the remote said is usually why it ended;
|
||||
a tab the user closed has nothing left to read.
|
||||
|
||||
term.dispose() is what actually matters. It releases the WebGL context, and a browser hands out
|
||||
about sixteen of those: without this, a day of opening and closing terminals ends with panes that
|
||||
cannot get a renderer, and nothing outside this page would ever say why.
|
||||
*/
|
||||
session.term.dispose();
|
||||
session.pane.remove();
|
||||
sessions.delete(sessionId);
|
||||
|
||||
setStatus('');
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_CLOSED: {
|
||||
const session = sessions.get(sessionId);
|
||||
const reason = new TextDecoder().decode(payload);
|
||||
|
||||
Reference in New Issue
Block a user