Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -36,8 +36,10 @@
|
||||
</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.
|
||||
One heading, which names the vault while there is one and says ALL VAULTS once a team's is readable
|
||||
too — a heading that went on naming the personal vault over a list containing a team's hosts would be
|
||||
a quiet lie, so the rows carry the vault name instead. 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">
|
||||
@@ -65,10 +67,37 @@
|
||||
-->
|
||||
<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">
|
||||
ItemsSource="{Binding SidebarRows}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}">
|
||||
|
||||
<!--
|
||||
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
|
||||
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
|
||||
group. A vault with no groups produces no heading rows at all, so this is the list it always was.
|
||||
|
||||
The heading is a row rather than a container, which means the control will happily select it. That is
|
||||
turned back into the previous host selection in the view model — see SelectedSidebarRow — because
|
||||
CONNECT, EDIT and DELETE all act on a host and a highlighted heading is not one.
|
||||
-->
|
||||
<ListBox.DataTemplates>
|
||||
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Button Classes="flat grouphead" Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Chevron}" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="8" VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate 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. -->
|
||||
@@ -102,11 +131,20 @@
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
<!--
|
||||
Which vault this host is in, and only when there is more than one to be in. It decides
|
||||
who else can see the host and where an edit goes back to, so on a list that spans
|
||||
several vaults it is not decoration.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding HasVaultBadge}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
|
||||
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
|
||||
@@ -148,6 +186,20 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!--
|
||||
Which group this host is filed under. Inside the encrypted payload like everything else here, so
|
||||
the server learns nothing about how the estate is organised — and a group the vault no longer has
|
||||
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||
SelectedItem="{Binding EditorSelectedGroup}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
||||
Content="Connect through the server relay" />
|
||||
<!--
|
||||
@@ -193,7 +245,7 @@
|
||||
-->
|
||||
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsConfirmingDeletion}">
|
||||
IsVisible="{Binding IsConfirmingHostDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.HostsScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The hosts screen: the list of machines, and what this application has to say about the one that is
|
||||
selected.
|
||||
|
||||
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
|
||||
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
|
||||
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
|
||||
promised: an overview.
|
||||
|
||||
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
|
||||
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
|
||||
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
|
||||
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
|
||||
has to reproduce by hand.
|
||||
|
||||
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
|
||||
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
|
||||
two cannot be put on one element.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
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="0" 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 Keychain 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="1">
|
||||
|
||||
<!--
|
||||
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.
|
||||
|
||||
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
|
||||
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
|
||||
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
|
||||
for as long as it did because no test could lay this markup out; that is the other half of why this
|
||||
file exists.
|
||||
-->
|
||||
<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" />
|
||||
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
|
||||
<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>
|
||||
</ScrollViewer>
|
||||
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
||||
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The overview proper: what is known about the host the list has selected.
|
||||
|
||||
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
|
||||
terminal until this screen stopped hosting one, and filling it with something that needed new state
|
||||
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
|
||||
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
|
||||
list of forty.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
|
||||
<Panel Margin="24">
|
||||
|
||||
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Classes="chip" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
|
||||
<TextBlock Text="CONNECTED" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
|
||||
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
MaxWidth="440" TextWrapping="Wrap"
|
||||
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
|
||||
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
|
||||
|
||||
</Panel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
Groups: making them, renaming them, and taking them away.
|
||||
|
||||
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
|
||||
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
|
||||
in the host's own editor, on the left, for the same reason its key and its password are.
|
||||
|
||||
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
|
||||
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
|
||||
happen.
|
||||
-->
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
|
||||
third of this column to say what a row of chips says in one line.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding Vault.HasGroups}">
|
||||
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
|
||||
Background="Transparent" MaxHeight="72">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
|
||||
<StackPanel Margin="2,4" Spacing="1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
|
||||
FontSize="11.5" />
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
|
||||
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
|
||||
FontSize="11" MinHeight="26" Padding="8,3" />
|
||||
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
|
||||
Command="{Binding Vault.SaveGroupCommand}" />
|
||||
<Button Classes="ghost" Content="RENAME SELECTED" Command="{Binding Vault.EditGroupCommand}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
|
||||
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
|
||||
two panels share one pending deletion, and the sidebar checks the same way.
|
||||
-->
|
||||
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
|
||||
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
|
||||
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,27 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The hosts screen: the host list, and an overview of the one that is selected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the shell rather than the vault, unlike <see cref="HostSidebar"/> and
|
||||
/// <see cref="VaultScreen"/>. The sidebar is handed the vault from inside the markup; everything else here
|
||||
/// reaches it through <c>Vault.*</c>. That split is not tidiness — this element's visibility is the shell's
|
||||
/// business and the sidebar's bindings are the vault's, and an element carrying both resolves the first
|
||||
/// against the second, where it does not exist.
|
||||
/// </remarks>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
public HostsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// Forwarded to the sidebar, which answers for itself: the host list can be folded away, and
|
||||
/// <c>Focus()</c> on a collapsed control is measurably a no-op that is not replayed when the control is
|
||||
/// revealed. Nothing in the right column can take the keyboard — it is a heading and three sentences.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => Sidebar.KeyboardTarget;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.ImportScreen"
|
||||
x:DataType="vm:ImportViewModel">
|
||||
|
||||
<!--
|
||||
Importing ~/.ssh/config.
|
||||
|
||||
A preview and then a button, rather than one action, and that is the whole design. This reads a file
|
||||
the application did not write, out of the user's home directory, and a real ssh_config often holds
|
||||
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
|
||||
says what each entry means; importing is a separate press on a set somebody has looked at.
|
||||
|
||||
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
|
||||
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
|
||||
something almost nobody is looking at.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="11" Margin="14,12,14,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
What could not be honoured, above the list rather than beside it. Every one of these is a way the
|
||||
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
|
||||
comparing the two needs to be told before they conclude the parser lost something.
|
||||
-->
|
||||
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="10"
|
||||
TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<StackPanel Margin="14,0">
|
||||
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="9.5" Margin="34,0,0,8"
|
||||
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
IsVisible="{Binding HasWarnings}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<StackPanel Spacing="8">
|
||||
<!--
|
||||
Said before the button, not after. A key path is recorded and the key itself is not read: that is
|
||||
the difference between a bookmark that connects and one that asks for a password, and somebody
|
||||
who is not told will conclude the import was broken.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain. Nothing here reaches into ~/.ssh for private key material." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Importing hosts from <c>~/.ssh/config</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportScreen : UserControl
|
||||
{
|
||||
public ImportScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// The count on the import button is derived from the ticks, and a CheckBox bound with
|
||||
// {Binding IsSelected} tells its own row and nothing else. Rather than have every row hold a
|
||||
// reference back to the screen, the screen listens for the event they all bubble.
|
||||
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
|
||||
}
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
internal IInputElement KeyboardTarget => this;
|
||||
|
||||
private void OnTickChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ImportViewModel import)
|
||||
{
|
||||
import.NoteSelectionChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.KnownHostsScreen"
|
||||
x:DataType="vm:KnownHostsViewModel">
|
||||
|
||||
<!--
|
||||
The host keys this keychain has approved.
|
||||
|
||||
These were a category on the keychain screen, alongside SSH keys and passwords, and they do not belong
|
||||
there: the other two are things a person creates and edits, and a pin is a decision recorded at the
|
||||
moment of connecting. Nobody goes looking for one in a list of credentials. They are also the only items
|
||||
with a workflow of their own — compare a fingerprint against what the operator published — and that
|
||||
workflow needs a filter and a column layout the shared table could not give them.
|
||||
|
||||
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
|
||||
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="*,244">
|
||||
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
Matches fingerprints as well as host names, which is the point of it. What somebody does with
|
||||
this screen is check whether a published SHA256:… is the one they approved, and searching only
|
||||
by name would answer a different question.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
|
||||
IsVisible="{Binding HasVisiblePins}">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
|
||||
ItemsSource="{Binding VisiblePins}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
||||
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="9"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<!--
|
||||
Never trimmed, and this column is why the table is laid out the way it is. The only thing
|
||||
anybody does with a fingerprint is compare it character by character against one an operator
|
||||
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
|
||||
mechanism exists to replace.
|
||||
-->
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="340"
|
||||
IsVisible="{Binding !HasVisiblePins}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="6">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Choose a pinned key to see it in full, and to withdraw it."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<Border Classes="chip warn" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !Selected.IsDialledByAHost}">
|
||||
<TextBlock Text="no host uses this" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
|
||||
FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
<!--
|
||||
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
|
||||
the item's own version 7 id — which records when the pin was created and knows nothing about
|
||||
it being re-approved since. Presenting that as "last used" would be inventing a fact.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,12,0,0"
|
||||
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
|
||||
|
||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding ForgetSelectedCommand}"
|
||||
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The host keys this keychain has approved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>KnownHostsViewModel</c>, which is a screen-scoped wrapper over the vault rather
|
||||
/// than an owner of anything: the pins, the reload and the withdrawal all still belong to
|
||||
/// <c>VaultViewModel</c>. See that class for why.
|
||||
/// </remarks>
|
||||
internal sealed partial class KnownHostsScreen : UserControl
|
||||
{
|
||||
public KnownHostsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// The filter box rather than the list, unlike the keychain screen. This screen is reached to answer a
|
||||
/// question — is this fingerprint one of mine — and the first thing anybody does is type part of it.
|
||||
/// The box is also always there, where the list is empty on a fresh keychain, and <c>Focus()</c> on a
|
||||
/// collapsed control is a no-op that is not replayed.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => PinFilter;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.LogsScreen"
|
||||
x:DataType="vm:LogsViewModel">
|
||||
|
||||
<!--
|
||||
What has been connected to, and what has been changed.
|
||||
|
||||
Two logs behind one screen, chosen by two buttons rather than by a selector's selection — the same idiom
|
||||
the keychain screen's categories use, and for the same reason: a selection binding moves before a command
|
||||
can refuse it.
|
||||
|
||||
Both are ordinary synced keychain items, encrypted like everything else. The server holds them and cannot
|
||||
read a single field; what it does learn is that rows exist and when they were written, which ADR 0001
|
||||
records as the metadata this design cannot hide.
|
||||
|
||||
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
|
||||
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
|
||||
connection closes, so a live session is deliberately not in the vault yet.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,14,0" />
|
||||
|
||||
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
|
||||
Classes.active="{Binding ShowsConnections}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Connections}" />
|
||||
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
|
||||
Classes.active="{Binding ShowsActivity}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Activity}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Connections ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasConnections}">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
|
||||
ItemsSource="{Binding Connections}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="11" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
Only when there is something to say. A connection that opened and closed says nothing
|
||||
here; one that was refused says so, and that is the row worth finding in a long list.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
|
||||
<TextBlock Text="{Binding Outcome}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasConnections}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ Keychain changes ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsActivity}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasActivity}">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
The names of the fields that changed, and never what they changed to. A log that recorded
|
||||
an old password would be a plaintext credential store with a vault drawn around it.
|
||||
-->
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasChangedFields}" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasActivity}" />
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// What has been connected to, and what has been changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>LogsViewModel</c>, a screen-scoped wrapper over the open session. Both logs are
|
||||
/// ordinary synced keychain items; nothing about them is local.
|
||||
/// </remarks>
|
||||
internal sealed partial class LogsScreen : UserControl
|
||||
{
|
||||
public LogsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// Whichever list is on screen, because this screen has no filter box and a collapsed control cannot
|
||||
/// take focus — <c>Focus()</c> on one is a no-op that nothing replays when it is revealed. The lists are
|
||||
/// focusable explicitly for the same reason the host list is: Avalonia leaves focus to the items, and an
|
||||
/// empty list has none.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget =>
|
||||
DataContext is LogsViewModel { ShowsActivity: true } ? ActivityList : ConnectionList;
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar.
|
||||
The shell window: a titlebar it draws itself, a nav rail, a tab strip, one surface at a time, and a
|
||||
status bar.
|
||||
|
||||
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
|
||||
@@ -28,6 +29,13 @@
|
||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||
chrome worth having.
|
||||
|
||||
TWO SURFACES, ONE RECTANGLE.
|
||||
|
||||
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
|
||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
||||
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
|
||||
enum rather than two flags, so there is no way to write the state where both are showing.
|
||||
|
||||
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
||||
|
||||
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
|
||||
@@ -36,15 +44,16 @@
|
||||
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
|
||||
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
|
||||
quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
||||
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
|
||||
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
|
||||
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
|
||||
|
||||
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.
|
||||
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
|
||||
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
|
||||
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
|
||||
only chooses between the pages.
|
||||
|
||||
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
|
||||
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
||||
@@ -64,167 +73,111 @@
|
||||
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
<!--
|
||||
The rail is full height and the strip is not, so the strip spans exactly the area it navigates.
|
||||
The other arrangement — strip above rail — would put a row of tabs over a column of destinations
|
||||
they have nothing to do with.
|
||||
-->
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||
|
||||
<!-- ============ HOSTS + TERMINAL ============ -->
|
||||
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" />
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
<!-- ============ THE PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
|
||||
<!--
|
||||
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.
|
||||
Bound directly rather than wrapped, unlike the two below it: this screen's data context is
|
||||
the shell's, so IsHostsScreen resolves. It hands the vault to the sidebar from inside its
|
||||
own markup.
|
||||
-->
|
||||
<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>
|
||||
<views:HostsScreen x:Name="HostsPane" IsVisible="{Binding IsHostsScreen}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
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.
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and
|
||||
putting both on one element resolves IsVisible against that view model, where
|
||||
IsTransfersScreen does not exist.
|
||||
-->
|
||||
<NativeWebView Grid.Row="3" x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
<!-- ============ KEYCHAIN ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this
|
||||
element's visibility is the shell's business and its data context is the vault, and put both
|
||||
on one element and IsVisible resolves against the vault as well, where IsVaultScreen does
|
||||
not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultScreen}">
|
||||
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ HOST KEYS ============ -->
|
||||
<!--
|
||||
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
||||
view model, where IsKnownHostsScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
||||
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
||||
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsLogsScreen}">
|
||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<!--
|
||||
Wrapped, for the reason the vault and transfers screens are: the visibility is the shell's
|
||||
business and the data context is the teams view model, and both on one element would resolve
|
||||
IsTeamScreen against a type that does not have it.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTeamScreen}">
|
||||
<views:TeamsScreen DataContext="{Binding Teams}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
<!-- ============ IMPORT ============ -->
|
||||
<!--
|
||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
||||
the others whose data context is their own view model.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportScreen}">
|
||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
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.
|
||||
|
||||
A sibling of the page area rather than a child of any screen, which is the structural half of
|
||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
||||
|
||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
||||
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
||||
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
||||
does.
|
||||
-->
|
||||
<NativeWebView x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and putting
|
||||
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
|
||||
exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ 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>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -262,12 +215,12 @@
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
|
||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Classes="accent" Content="CREATE MY VAULT" Command="{Binding EnrollCommand}"
|
||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
@@ -275,14 +228,14 @@
|
||||
|
||||
<!--
|
||||
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.
|
||||
together with the passphrase means the keychain is unrecoverable — there is no server-side reset
|
||||
by design.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
@@ -66,14 +67,56 @@ internal sealed partial class MainWindow : Window
|
||||
/// 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>
|
||||
/// <para>
|
||||
/// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> still
|
||||
/// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
|
||||
/// keyboard to a host list nobody can see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IInputElement KeyboardHome => shell?.Screen switch
|
||||
private IInputElement KeyboardHome => shell switch
|
||||
{
|
||||
ShellScreen.Vault => VaultPane.KeyboardTarget,
|
||||
ShellScreen.Hosts => Hosts.KeyboardTarget,
|
||||
{ IsTerminalShowing: true } => Terminal,
|
||||
{ Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the terminal to take the keyboard, once layout has run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Posted, not called.</b> Every path that reaches here has revealed the WebView in this same turn —
|
||||
/// a session opened from another screen, a tab clicked while a page was showing, the palette closing
|
||||
/// back onto a terminal. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so
|
||||
/// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
|
||||
/// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
|
||||
/// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
|
||||
/// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
|
||||
/// into a collapsed WebView would leave the window with nothing focused at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void FocusTerminalWhenLaidOut() =>
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (shell is { IsTerminalShowing: true })
|
||||
{
|
||||
Terminal.Focus();
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard belongs once the vault is no longer open.
|
||||
/// </summary>
|
||||
@@ -160,14 +203,19 @@ 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 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.
|
||||
/// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> call is
|
||||
/// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
|
||||
/// reliably is at this instant. A session can now be opened from any screen, so this event routinely
|
||||
/// arrives in the same turn that revealed the WebView. Hence the post; see
|
||||
/// <see cref="FocusTerminalWhenLaidOut"/>.
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||
/// point at which "does this one return early" stops being obvious to a reader.
|
||||
/// </remarks>
|
||||
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (shell is not { } viewModel)
|
||||
@@ -175,54 +223,117 @@ internal sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
case nameof(MainWindowViewModel.IsUnlocked):
|
||||
OnVaultOpenedOrClosed(viewModel);
|
||||
break;
|
||||
|
||||
// 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(ClosedVaultKeyboardHome);
|
||||
}
|
||||
case nameof(MainWindowViewModel.IsSearching):
|
||||
OnPaletteToggled(viewModel);
|
||||
break;
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
// One arm for both, deliberately. They mean the same thing to this handler — what the window is
|
||||
// showing may have changed — and answering them separately would make the order of two
|
||||
// PropertyChanged raises decide the outcome. Connecting from the palette moves both.
|
||||
case nameof(MainWindowViewModel.Surface):
|
||||
case nameof(MainWindowViewModel.Screen):
|
||||
OnShowingSomethingElse(viewModel);
|
||||
break;
|
||||
|
||||
case nameof(MainWindowViewModel.SelectedTab):
|
||||
OnSelectedTabChanged(viewModel);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
|
||||
{
|
||||
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(ClosedVaultKeyboardHome);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||
/// which is a moment this handler is measurably ahead of: it runs from the view model's
|
||||
/// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
|
||||
/// that is still collapsed is a no-op that is not replayed when it is revealed.
|
||||
/// </remarks>
|
||||
private void OnPaletteToggled(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.IsSearching)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is
|
||||
// expected to start typing into immediately — but the palette does that for itself when it becomes
|
||||
// visible, which is a moment this handler is measurably ahead of: it runs from the view model's
|
||||
// PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
|
||||
// still collapsed is a no-op that is not replayed when it is revealed.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
|
||||
// Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
|
||||
// focus rather than the immediate one.
|
||||
if (viewModel.IsTerminalShowing)
|
||||
{
|
||||
if (!viewModel.IsSearching)
|
||||
{
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
else
|
||||
{
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
|
||||
/// are both one click away at all times.
|
||||
/// <para>
|
||||
/// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
|
||||
/// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> The native
|
||||
/// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
|
||||
/// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
|
||||
/// was rare; it is the hot path now. See <c>docs/platform-flags.md</c>, and
|
||||
/// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnShowingSomethingElse(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (!viewModel.IsUnlocked)
|
||||
{
|
||||
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)
|
||||
if (viewModel.IsTerminalShowing)
|
||||
{
|
||||
KeyboardHome.Focus();
|
||||
return;
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
|
||||
// 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)
|
||||
else
|
||||
{
|
||||
Terminal.Focus();
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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. <c>term.focus()</c> in the page only
|
||||
/// ever reaches <c>document.activeElement</c>, 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.
|
||||
/// </remarks>
|
||||
private void OnSelectedTabChanged(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
|
||||
{
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Five destinations down the left edge.
|
||||
Six destinations down the left edge.
|
||||
|
||||
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
|
||||
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
|
||||
@@ -16,6 +16,12 @@
|
||||
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.
|
||||
|
||||
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
|
||||
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
|
||||
back — and a rail entry lit while a terminal filled the window would be pointing at a screen that is not
|
||||
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
|
||||
mark, in the strip, and two "you are here" marks is one too many.
|
||||
-->
|
||||
|
||||
<Border Width="54" Background="{StaticResource Chrome}"
|
||||
@@ -23,26 +29,44 @@
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,8,0,0">
|
||||
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsScreen}"
|
||||
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsShowing}"
|
||||
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}"
|
||||
ToolTip.Tip="Your hosts, and what is known about the one you have selected" />
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
|
||||
ToolTip.Tip="Move files to and from a host over SFTP" />
|
||||
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
|
||||
<Button Classes="flat nav" Content="KEYS" Classes.active="{Binding IsVaultShowing}"
|
||||
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}"
|
||||
ToolTip.Tip="Your keychain: SSH keys, stored passwords, and the host keys you have approved" />
|
||||
<!--
|
||||
PINS, not HOST KEYS. The rail is 54 pixels wide at mono FontSize 9, which is five characters —
|
||||
and "pins" is what this codebase calls them everywhere else anyway.
|
||||
-->
|
||||
<Button Classes="flat nav" Content="PINS" Classes.active="{Binding IsKnownHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one" />
|
||||
<!-- SNIPS, for the same five-character reason as PINS above. -->
|
||||
<Button Classes="flat nav" Content="SNIPS" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal" />
|
||||
<!-- LOGS, four characters, so it needs no abbreviating at all. -->
|
||||
<Button Classes="flat nav" Content="LOGS" Classes.active="{Binding IsLogsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
||||
ToolTip.Tip="What has been connected to, and what has been changed in this keychain" />
|
||||
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamShowing}"
|
||||
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." />
|
||||
ToolTip.Tip="Shared keychains 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}"
|
||||
Classes.active="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
||||
ToolTip.Tip="Preferences, and this machine's device key" />
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<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." />
|
||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
|
||||
Command="{Binding RegisterDeviceCommand}"
|
||||
@@ -54,20 +54,20 @@
|
||||
|
||||
<!-- 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."
|
||||
Text="This machine has nowhere to keep a device key, so the keychain 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"
|
||||
<TextBlock Classes="mono" Text="KEYCHAIN" 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"
|
||||
<TextBlock Text="Lock the keychain" 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." />
|
||||
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
|
||||
</Grid>
|
||||
@@ -77,7 +77,7 @@
|
||||
<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 — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
@@ -87,6 +87,18 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Import}" />
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
|
||||
@@ -100,7 +112,7 @@
|
||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
||||
@@ -137,7 +149,7 @@
|
||||
<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." />
|
||||
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the keychain." />
|
||||
<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"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." />
|
||||
Text="This deletes this machine's copy of the keychain — the profile, the cached hosts, keys and passwords, and this machine's device key. Your keychain is on the server and is not touched: signing in again brings it all back." />
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SnippetsScreen"
|
||||
x:DataType="vm:SnippetsViewModel">
|
||||
|
||||
<!--
|
||||
Commands somebody has saved, and how to get one into a terminal.
|
||||
|
||||
The list and the writing belong to the vault, as every other item kind's do; this screen is the filter,
|
||||
the editor and the insert over the top. See SnippetsViewModel.
|
||||
|
||||
The two buttons at the bottom right are the whole safety design, and their wording is load-bearing.
|
||||
A terminal is one input stream with no notion of being at a prompt — the remote may be inside vi, or at
|
||||
a sudo password prompt with echo off — so this application cannot say "run this command", only "type
|
||||
this into whatever is there". RUN appears solely for a snippet whose own flag says it runs, which makes
|
||||
that a decision taken once while writing it rather than a button beside every one of them.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="*,300">
|
||||
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
The command is searched as well as the name: half of what anybody remembers about a saved
|
||||
command is a word that was inside it.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="SnippetFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="SnippetList" Focusable="True"
|
||||
ItemsSource="{Binding Visible}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
The flag, where the decision is made. A snippet that presses Enter for you is not the
|
||||
same kind of thing as one that does not, and the list is where somebody chooses between
|
||||
them.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
|
||||
<TextBlock Text="runs immediately" FontSize="8.5" />
|
||||
</Border>
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
||||
text reads as a single command, which is the thing being decided about on this row.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="360"
|
||||
IsVisible="{Binding !HasVisible}" />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,8" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="+ NEW SNIPPET" Command="{Binding NewCommand}" />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="8">
|
||||
|
||||
<!-- ============ The editor ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<!--
|
||||
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
|
||||
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
|
||||
waiting for one that never comes.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
|
||||
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="11" />
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
|
||||
Content="Press Enter after inserting this" />
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ The selected snippet ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Choose a snippet to see it in full and put it into a terminal."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
|
||||
FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
The button names the tab it will type into. This screen is not the terminal — the strip
|
||||
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
|
||||
about to receive a command, at the moment that is worst to be wrong about.
|
||||
-->
|
||||
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
|
||||
|
||||
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
|
||||
Command="{Binding RunCommand}"
|
||||
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,10,0,0"
|
||||
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The commands this keychain has saved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>SnippetsViewModel</c>, a screen-scoped wrapper over the vault rather than an
|
||||
/// owner of anything: the list, the storage and the push all still belong to <c>VaultViewModel</c>.
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsScreen : UserControl
|
||||
{
|
||||
public SnippetsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// The filter box rather than the list, for the reason the pins screen gives: the box is there on a
|
||||
/// keychain with nothing saved yet, where the list is empty and <c>Focus()</c> on it would be a no-op
|
||||
/// nothing replays.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => SnippetFilter;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TeamsScreen"
|
||||
x:DataType="vm:TeamsViewModel">
|
||||
|
||||
<!--
|
||||
Teams.
|
||||
|
||||
The screen is built around one fact that every other product in this category hides: adding somebody to
|
||||
a team and giving them a vault key are two different acts, and only the first is something a server can
|
||||
do. The second needs a machine that holds the key, because this server never does. So the members table
|
||||
and the vaults table are side by side, an addition says out loud that it granted nothing readable yet,
|
||||
and SHARE KEY is its own button rather than a checkbox on the member row.
|
||||
|
||||
What the design asked for and is still not here: pending invitations (there is no outbound mail path and
|
||||
no invitation token), two-factor state and last-active (the server records neither), and avatars (no
|
||||
picture is stored anywhere). None of them is drawn with invented data.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<!-- ============ The team list ============ -->
|
||||
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW"
|
||||
Command="{Binding NewTeamCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding Detail}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" Margin="14,12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasTeams}"
|
||||
Text="No teams yet. A team is what makes a vault shareable: its vaults can be opened by every member you wrap a key to." />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- The create form, in place rather than in a modal: this window has no idiom for one. -->
|
||||
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingTeam}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
||||
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Members and vaults ============ -->
|
||||
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
|
||||
<StackPanel Margin="14,14" Spacing="18">
|
||||
|
||||
<!-- Members -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="MEMBERS" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="240">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding Email}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="hint" FontSize="10" VerticalAlignment="Center"
|
||||
Text="{Binding KeyState}" TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
|
||||
Margin="0,0,6,0" />
|
||||
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER"
|
||||
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
|
||||
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Removes the selected member and withdraws every vault key they hold from this team. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding CanAdministerSelected}"
|
||||
Text="Adding somebody lets the server serve them this team's vaults. It does not let them read one: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<!-- Vaults -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
|
||||
Command="{Binding CreateVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
|
||||
</Grid>
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="200">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasSelection}"
|
||||
Text="Select a team to see its vaults." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Wraps the selected vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
|
||||
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" FontSize="11" Margin="20" TextWrapping="Wrap"
|
||||
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
|
||||
Text="Create a team on the left, or wait to be added to one. A team owns vaults; a vault's key is what makes its contents readable, and that key is handed out by people rather than by the server." />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Teams: who is in one, what they may do, and which vaults they hold a key to.</summary>
|
||||
internal sealed partial class TeamsScreen : UserControl
|
||||
{
|
||||
public TeamsScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -5,17 +5,20 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The tab strip above the terminal.
|
||||
The tab strip, above every screen.
|
||||
|
||||
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.
|
||||
It spans the whole window rather than the hosts screen, which is what the strip is for: a connection you
|
||||
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
|
||||
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
|
||||
|
||||
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
|
||||
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
|
||||
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
|
||||
@@ -24,10 +27,15 @@
|
||||
|
||||
<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">
|
||||
<!--
|
||||
Everything in one scrolling row: the tabs, then the button that opens another, then the sentence for
|
||||
when there are none. The strip stays rather than collapsing — a row of chrome that appears and
|
||||
disappears would move every screen up and down by 34 pixels each time the last tab closed.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -36,57 +44,85 @@
|
||||
</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>
|
||||
<!--
|
||||
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
|
||||
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
|
||||
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
|
||||
for the tab to its right.
|
||||
|
||||
<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>
|
||||
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
|
||||
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
|
||||
event handled — so a left press on the cross does not also select the tab. It deliberately
|
||||
does not handle any other button, which is exactly what lets a middle press bubble out of
|
||||
the cross and reach the handler below.
|
||||
-->
|
||||
<Button Classes="flat tab"
|
||||
Classes.active="{Binding IsSelected}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
PointerPressed="OnTabPointerPressed"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<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" />
|
||||
|
||||
<!--
|
||||
Always drawn, never on hover only. The strip has no other close affordance, and one
|
||||
that appears when the pointer is already over the tab cannot be found by somebody
|
||||
looking for it.
|
||||
-->
|
||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
|
||||
<TextBlock Text="✕" FontSize="9" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</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}" />
|
||||
<!--
|
||||
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
|
||||
honestly, and there is one way to start a connection rather than two that have to agree.
|
||||
|
||||
<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}" />
|
||||
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
|
||||
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
|
||||
dropping into it composites above a native child window depends on whether Avalonia gives it its
|
||||
own platform window. docs/platform-flags.md records what this project already paid for treating a
|
||||
rendering claim as settled without a screenshot. The palette has no such question — opening it
|
||||
collapses the terminal outright.
|
||||
-->
|
||||
<Button Classes="flat tab plus" Width="30"
|
||||
Command="{Binding ToggleSearchCommand}"
|
||||
ToolTip.Tip="Open a connection · Ctrl+K">
|
||||
<TextBlock Text="+" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
<!--
|
||||
Nothing open, and this is where that is said. 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 Classes="mono" FontSize="9.5"
|
||||
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding !HasTabs}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -1,9 +1,56 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The tab strip above the terminal.</summary>
|
||||
/// <summary>The tab strip, above every screen.</summary>
|
||||
internal sealed partial class TerminalTabs : UserControl
|
||||
{
|
||||
public TerminalTabs() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Closes a tab on a middle click.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
|
||||
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
|
||||
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
|
||||
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
|
||||
/// and for every press during a middle drag. The question here is which button caused this press, and
|
||||
/// that is the one thing only <c>PointerUpdateKind</c> answers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On press rather than on release, which is what every browser and every terminal does. Matching a
|
||||
/// release to its press would need capture tracking, to buy the ability to change your mind about a
|
||||
/// middle click — a gesture nobody makes by accident and nobody aborts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|
||||
|| DataContext is not MainWindowViewModel shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
|
||||
is not PointerUpdateKind.MiddleButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
|
||||
e.Handled = true;
|
||||
|
||||
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
|
||||
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
|
||||
// it. Its failures are the workspace's to report, not this strip's.
|
||||
shell.CloseTabCommand.Execute(tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,15 +65,32 @@
|
||||
|
||||
<!-- ============ The host, and the connection ============ -->
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="Auto,Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
|
||||
<!--
|
||||
Which sort of remote. Two buttons rather than one picker holding hosts and buckets together, and
|
||||
the reason is that the two are not interchangeable: a host brings a password box, a host key
|
||||
prompt and a mismatch refusal with it, and a bucket has no equivalent of any of them. One picker
|
||||
would mean half this bar appearing and disappearing with the selection.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2" Margin="0,0,8,0"
|
||||
IsVisible="{Binding !IsConnected}">
|
||||
<Button Classes="flat cat" Content="HOST" Classes.active="{Binding ShowsHostPicker}"
|
||||
Command="{Binding ShowRemoteCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}" />
|
||||
<Button Classes="flat cat" Content="BUCKET" Classes.active="{Binding ShowsBucketPicker}"
|
||||
Command="{Binding ShowRemoteCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}" />
|
||||
</StackPanel>
|
||||
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
IsVisible="{Binding ShowsHostPicker}"
|
||||
PlaceholderText="choose a host">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
@@ -87,26 +104,43 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Buckets}"
|
||||
SelectedItem="{Binding SelectedBucket}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
IsVisible="{Binding ShowsBucketPicker}"
|
||||
PlaceholderText="choose a bucket">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
|
||||
holding a different value. This connection authenticates separately, so a password typed to open a
|
||||
terminal was never offered here.
|
||||
-->
|
||||
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
<TextBox Grid.Column="3" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
Text="{Binding TypedPassword}" PlaceholderText="password"
|
||||
IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
IsEnabled="{Binding !IsConnected}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
|
||||
<Button Grid.Column="4" Classes="accent" Content="{Binding ConnectLabel}" Margin="6,0,0,0"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsVisible="{Binding !IsConnected}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
<Button Grid.Column="4" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding DisconnectCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
|
||||
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
|
||||
<Border Grid.Column="5" Classes="chip accent" Margin="8,0,0,0"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Text="{Binding ConnectedTo}" />
|
||||
</Border>
|
||||
@@ -122,7 +156,12 @@
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
|
||||
|
||||
<!-- ==== This machine ==== -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
<!--
|
||||
AllowDrop on the pane rather than on the list, because an empty directory lays its ListBox out at
|
||||
zero height behind the empty-state sentence — a handler on the list would have nothing to hit.
|
||||
This side takes remote rows only; see TransfersScreen.axaml.cs.
|
||||
-->
|
||||
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -204,6 +243,17 @@
|
||||
Text="Nothing in this folder. Use the trail above to go somewhere else."
|
||||
IsVisible="{Binding !HasLocalEntries}" />
|
||||
|
||||
<!--
|
||||
The drop highlight, over the whole pane and last so it is on top.
|
||||
|
||||
IsHitTestVisible="False" is not optional. An overlay that takes part in hit testing swallows the
|
||||
DragOver events underneath it the moment it appears — so the pointer leaves, the highlight never
|
||||
clears, and the drop lands nowhere.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="4" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsLocalDropTarget}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ==== The two directions ==== -->
|
||||
@@ -226,7 +276,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,*"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -345,6 +396,24 @@
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two highlights rather than one, because refusing is worth showing. Something dragged over a
|
||||
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
|
||||
window that has stopped answering, and the answer arriving after the drop is the answer arriving
|
||||
too late. See the local pane for why neither may hit-test.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
||||
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="11"
|
||||
Foreground="{StaticResource Danger}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
@@ -8,12 +11,42 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// The two-pane file browser and the transfer queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
|
||||
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
|
||||
/// <c>MainWindowViewModel.LockAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything about drag and drop is in this file and nothing about it is policy.</b> The handlers pull
|
||||
/// paths or rows out of a drop and hand them to <c>QueueUploads</c>/<c>QueueDownloads</c>; what may be
|
||||
/// queued, what is skipped and what is said about it all live in the view model, where they can be tested
|
||||
/// without a window. Nothing headless can synthesise a real platform drag, so the wiring below is verified
|
||||
/// by hand — see <c>docs/manual-checks.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersScreen : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// How remote rows travel while being dragged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An in-process format, so the rows themselves cross rather than a list of path strings that would
|
||||
/// have to be looked up again on the other side. It also cannot be confused with a drop from the
|
||||
/// operating system: a file dragged out of the file manager arrives as <c>DataFormat.File</c> and never
|
||||
/// as this, so "did this come from our own remote pane" needs no guessing.
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<RemoteDragPayload> RemoteEntries =
|
||||
DataFormat.CreateInProcessFormat<RemoteDragPayload>("dodossh/remote-entries");
|
||||
|
||||
/// <summary>How far the pointer moves before a press becomes a drag.</summary>
|
||||
/// <remarks>
|
||||
/// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
|
||||
/// </remarks>
|
||||
private const double DragThreshold = 4;
|
||||
|
||||
private PointerPressedEventArgs? pressed;
|
||||
private Point pressedAt;
|
||||
|
||||
public TransfersScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -23,11 +56,32 @@ internal sealed partial class TransfersScreen : UserControl
|
||||
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
|
||||
LocalList.DoubleTapped += OnLocalActivated;
|
||||
RemoteList.DoubleTapped += OnRemoteActivated;
|
||||
|
||||
// On the pane rather than on the list. A directory with nothing in it lays its ListBox out at zero
|
||||
// height behind the empty-state sentence, and a drop handler on the list would have nothing to hit.
|
||||
LocalPane.AddHandler(DragDrop.DragOverEvent, OnLocalDragOver);
|
||||
LocalPane.AddHandler(DragDrop.DragLeaveEvent, OnLocalDragLeave);
|
||||
LocalPane.AddHandler(DragDrop.DropEvent, OnLocalDrop);
|
||||
|
||||
RemotePane.AddHandler(DragDrop.DragOverEvent, OnRemoteDragOver);
|
||||
RemotePane.AddHandler(DragDrop.DragLeaveEvent, OnRemoteDragLeave);
|
||||
RemotePane.AddHandler(DragDrop.DropEvent, OnRemoteDrop);
|
||||
|
||||
// Tunnelling, so noting where a press started does not take the press away from the ListBox — a row
|
||||
// still selects, and the drag only begins once the pointer has moved far enough.
|
||||
foreach (var list in new Control[] { LocalList, RemoteList })
|
||||
{
|
||||
list.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
list.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
|
||||
list.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
|
||||
}
|
||||
}
|
||||
|
||||
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
|
||||
|
||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.OpenLocalCommand.Execute(null);
|
||||
}
|
||||
@@ -40,9 +94,211 @@ internal sealed partial class TransfersScreen : UserControl
|
||||
/// </remarks>
|
||||
private void OnRemoteActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Starting a drag ----
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed)
|
||||
{
|
||||
pressed = e;
|
||||
pressedAt = e.GetPosition(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => pressed = null;
|
||||
|
||||
/// <remarks>
|
||||
/// The drag starts here rather than on the press, because a press is also how a row is selected.
|
||||
/// <c>DoDragDropAsync</c> wants the original <c>PointerPressedEventArgs</c>, so it is held from the
|
||||
/// press until either the pointer moves far enough or the button comes back up.
|
||||
/// </remarks>
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (pressed is not { } origin || Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
pressed = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var moved = e.GetPosition(this) - pressedAt;
|
||||
|
||||
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pressed = null;
|
||||
|
||||
if (ReferenceEquals(sender, RemoteList))
|
||||
{
|
||||
StartRemoteDrag(origin, transfers);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = StartLocalDragAsync(origin, transfers);
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartRemoteDrag(PointerPressedEventArgs origin, TransfersViewModel transfers)
|
||||
{
|
||||
if (transfers.SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var transfer = new DataTransfer();
|
||||
transfer.Add(DataTransferItem.Create(RemoteEntries, new RemoteDragPayload([row])));
|
||||
|
||||
_ = DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Local files travel as the platform's own file format rather than as an in-process one, which is what
|
||||
/// makes a single drag work both onto the remote pane and out into the file manager. It needs a real
|
||||
/// <see cref="IStorageItem"/>, hence the asynchronous lookup — and hence a fire-and-forget call, because
|
||||
/// nothing on a pointer-moved path can await.
|
||||
/// </remarks>
|
||||
private async Task StartLocalDragAsync(PointerPressedEventArgs origin, TransfersViewModel transfers)
|
||||
{
|
||||
if (transfers.SelectedLocalEntry is not { IsFile: true } row
|
||||
|| TopLevel.GetTopLevel(this) is not { } top)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var file = await top.StorageProvider.TryGetFileFromPathAsync(row.FullPath).ConfigureAwait(true);
|
||||
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var transfer = new DataTransfer();
|
||||
transfer.Add(DataTransferItem.CreateFile(file));
|
||||
|
||||
await DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- Accepting a drop ----
|
||||
|
||||
/// <remarks>
|
||||
/// The local pane takes remote rows and nothing else. A file dragged from the file manager onto it
|
||||
/// would be a copy from this machine to this machine, which is not what this screen is for.
|
||||
/// </remarks>
|
||||
private void OnLocalDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var accepted = e.DataTransfer.Contains(RemoteEntries);
|
||||
|
||||
e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsLocalDropTarget = accepted;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLocalDragLeave(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsLocalDropTarget = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLocalDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
transfers.IsLocalDropTarget = false;
|
||||
e.Handled = true;
|
||||
|
||||
if (e.DataTransfer.TryGetValue(RemoteEntries) is { } payload)
|
||||
{
|
||||
transfers.QueueDownloads(payload.Rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The remote pane takes files: from the file manager, and from the local pane, which offers the same
|
||||
/// platform format. A drop while disconnected is refused visibly rather than accepted and then
|
||||
/// explained, because a red pane under the pointer is the answer arriving before the drop rather than
|
||||
/// after it.
|
||||
/// </remarks>
|
||||
private void OnRemoteDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var files = e.DataTransfer.Contains(DataFormat.File);
|
||||
var connected = Transfers is { IsConnected: true };
|
||||
|
||||
e.DragEffects = files && connected ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsRemoteDropTarget = files && connected;
|
||||
transfers.IsRemoteDropRefused = files && !connected;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnRemoteDragLeave(object? sender, DragEventArgs e) => ClearRemoteHighlight();
|
||||
|
||||
private void OnRemoteDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClearRemoteHighlight();
|
||||
e.Handled = true;
|
||||
|
||||
if (e.DataTransfer.TryGetFiles() is not { } files)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// TryGetLocalPath, because the queue reads bytes off a real path. A storage item that is not a
|
||||
// local file — one from a cloud provider's virtual folder — has none, and dropping it is a thing
|
||||
// this screen declines rather than a thing it half does.
|
||||
var paths = files
|
||||
.Select(file => file.TryGetLocalPath())
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
transfers.QueueUploads(paths);
|
||||
}
|
||||
|
||||
private void ClearRemoteHighlight()
|
||||
{
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsRemoteDropTarget = false;
|
||||
transfers.IsRemoteDropRefused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The remote rows carried by one drag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A record wrapping the list rather than the list itself, because <c>DataFormat.CreateInProcessFormat</c>
|
||||
/// keys on the type and a bare <c>IReadOnlyList<T></c> is too general a key to be sure of.
|
||||
/// </remarks>
|
||||
internal sealed record RemoteDragPayload(IReadOnlyList<RemoteEntryRowViewModel> Rows);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Classes="heading" Text="Unlock your keychain" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
|
||||
<!--
|
||||
@@ -33,7 +33,7 @@
|
||||
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•">
|
||||
PlaceholderText="keychain passphrase" PasswordChar="•">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
@@ -52,7 +52,7 @@
|
||||
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." />
|
||||
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||
@@ -73,7 +73,7 @@
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
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." />
|
||||
Text="Locking closes the keychain, 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 keychain, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." />
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the keychain is on the server and comes back." />
|
||||
<Button Classes="ghost" Content="RESET THIS MACHINE"
|
||||
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -2,25 +2,30 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
xmlns:ssh="using:DodoSSH.Client.Ssh"
|
||||
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.
|
||||
The keychain: the SSH keys and the stored passwords. Things a person creates and edits.
|
||||
|
||||
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.
|
||||
HOST KEYS was a fourth category here and is now a screen of its own; see KnownHostsScreen. It never fit:
|
||||
the two categories left are things somebody made on purpose, and a pin is a decision recorded at the
|
||||
moment of connecting — nobody goes looking for one in a list of credentials. It also has a workflow the
|
||||
shared table could not serve, which is comparing an untruncated fingerprint against a published one.
|
||||
|
||||
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.
|
||||
Two of the design's five categories are still not here. IDENTITIES and CERTIFICATES have no item type
|
||||
behind them, so listing them would be two headings that could never have anything under them. Recorded
|
||||
in docs/design-import-gaps.md.
|
||||
|
||||
The SCOPES rail below the categories is the keychain list. Since M3 it genuinely has more than one entry
|
||||
when somebody is in a team — but it is still not a selector, because every table on this screen already
|
||||
spans every keychain this session holds a key for and each row names its own. What it carries instead is
|
||||
the one keychain question with an answer: where a new item is filed.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="176,*,244">
|
||||
@@ -31,7 +36,7 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,12">
|
||||
|
||||
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" />
|
||||
<TextBlock Classes="label" Text="KEYCHAIN" Margin="14,0,14,8" />
|
||||
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.All}"
|
||||
@@ -66,13 +71,18 @@
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a bucket
|
||||
is something somebody creates, edits and keeps a secret for, which is what the other two
|
||||
categories are. A pin is a decision recorded at connect time and is not.
|
||||
-->
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.KnownHosts}"
|
||||
Classes.active="{Binding ShowsKnownHosts}">
|
||||
CommandParameter="{x:Static vm:VaultSection.Buckets}"
|
||||
Classes.active="{Binding ShowsBuckets}">
|
||||
<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}"
|
||||
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding ObjectStores.Count}"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
@@ -82,18 +92,35 @@
|
||||
<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.
|
||||
Still not a selector. Every list on this screen now spans every vault this session holds a key
|
||||
for, and each row names its own vault — so there is nothing to switch to. What the picker below
|
||||
chooses is where a *new* item is filed, which is a different question and the only one that has
|
||||
an answer worth asking for.
|
||||
-->
|
||||
<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." />
|
||||
|
||||
<!--
|
||||
Hidden at one vault, which is where most people stay. A control offering a single option is a
|
||||
question with no answer.
|
||||
-->
|
||||
<StackPanel Margin="14,10,14,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
|
||||
<TextBlock Classes="label" Text="NEW ITEMS GO TO" />
|
||||
<ComboBox ItemsSource="{Binding TargetVaults}"
|
||||
SelectedItem="{Binding SelectedTargetVault}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="11" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="An item filed into a team's vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Items that would not decrypt. Shown here rather than only in the status line because this is the
|
||||
@@ -121,10 +148,18 @@
|
||||
<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">
|
||||
<!--
|
||||
Always offered. Every category left on this screen is one things can be added to — the one
|
||||
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
|
||||
either. See KnownHostsScreen.
|
||||
-->
|
||||
<Button Classes="ghost" Content="GENERATE KEY" Command="{Binding NewGeneratedKeyCommand}"
|
||||
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
|
||||
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
ToolTip.Tip="Pastes in a key you already have." />
|
||||
<Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
|
||||
<Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
|
||||
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -218,7 +253,7 @@
|
||||
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." />
|
||||
Text="Keychain 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 ShowsItemActions}">
|
||||
@@ -226,6 +261,17 @@
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The public half only, and there is no button for the other one. Installing a key means pasting
|
||||
this line into a host's authorized_keys; a private key on the clipboard is a private key in
|
||||
every application on the machine.
|
||||
-->
|
||||
<Button Classes="ghost" Content="COPY PUBLIC KEY" Margin="0,6,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding SelectedItemIsKey}"
|
||||
Command="{Binding CopyPublicKeyCommand}"
|
||||
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
|
||||
|
||||
<!--
|
||||
The question DELETE asks, in the place those two buttons were. Here rather than over the
|
||||
screen, because this pane is where the item being deleted is described: the name, the kind and
|
||||
@@ -238,19 +284,50 @@
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm, a
|
||||
comment, and a button. What it produces lands in the editor below, unsaved — so there is still
|
||||
exactly one thing on this screen that writes a key, and it is still SAVE.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
|
||||
<TextBlock Classes="label" Text="NEW SSH KEY" Margin="0,0,0,4" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
Buttons and a command rather than a selector bound to the algorithm, which is the same
|
||||
choice the category rail makes and for the same reason: a selector moves its own highlight
|
||||
before anything can refuse, so it can end up showing a choice nobody made.
|
||||
-->
|
||||
<Button Classes="flat choice" Content="ED25519"
|
||||
Classes.active="{Binding GeneratesEd25519}"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}"
|
||||
ToolTip.Tip="What every current OpenSSH prefers. Small, fast, and generated instantly." />
|
||||
<Button Classes="flat choice" Content="RSA 4096"
|
||||
Classes.active="{Binding GeneratesRsa}"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}"
|
||||
ToolTip.Tip="For servers too old to accept Ed25519. Larger, and a few seconds to generate." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
|
||||
|
||||
<!--
|
||||
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.
|
||||
Said plainly rather than left to be discovered. Writing an encrypted openssh-key-v1 file needs
|
||||
bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
|
||||
already makes: a passphrase protects a key file on a disk, and this key is never on one.
|
||||
-->
|
||||
<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." />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Classes="accent" Content="GENERATE" Command="{Binding GenerateKeyCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGenerateKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -273,7 +350,7 @@
|
||||
<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." />
|
||||
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 keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
|
||||
@@ -307,6 +384,43 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The bucket editor. -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
|
||||
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
|
||||
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
|
||||
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
|
||||
<!--
|
||||
Masked, like a password and for the same reason: a secret access key is one. The access key id
|
||||
beside it is an identifier and is shown, which is also why the two are separate boxes.
|
||||
-->
|
||||
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
|
||||
PasswordChar="•" />
|
||||
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
|
||||
<!--
|
||||
Blank means Amazon, and then the region resolves the host. Anything else is a full URL, which
|
||||
is what makes this work against a self-hosted service.
|
||||
-->
|
||||
<TextBox Text="{Binding BucketEditorEndpoint}"
|
||||
PlaceholderText="endpoint (blank: Amazon S3)" />
|
||||
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}"
|
||||
Content="Address the bucket as a path" />
|
||||
<!--
|
||||
Said where the decision is made. Getting this wrong produces a DNS failure whose message
|
||||
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
|
||||
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelObjectStoreEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
Reference in New Issue
Block a user