Public Access
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.
**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.
So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.
The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.
**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.
The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.
The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.
**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.
The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.
Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.
The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.
Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.
Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
675 lines
40 KiB
XML
675 lines
40 KiB
XML
<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.TransfersScreen"
|
||
x:DataType="vm:TransfersViewModel">
|
||
|
||
<!--
|
||
File transfer: this machine on the left, the host on the right, and the queue underneath.
|
||
|
||
Two things about this screen are worth knowing before reading the markup. It has its own CONNECT
|
||
button, because SSH.NET cannot open an SFTP subsystem on a transport that is already carrying a shell —
|
||
so browsing a host's files is a second authenticated connection rather than a second channel, and
|
||
pretending otherwise would hide a second login from the person whose audit log it appears in. And the
|
||
panes are symmetrical apart from one column: PERMS is remote-only, because a POSIX mode is not a fact
|
||
about a file on the machine this client is developed on.
|
||
|
||
── v3 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||
◆ THE CONNECT BAR IS GONE, and everything it held is inside the right-hand pane now.
|
||
|
||
It was a 44-pixel strip across the top of the screen holding a heading, a picker, a password box and a
|
||
button — chrome spanning both panes to configure one of them, and drawn at full width whether or not
|
||
anything was ever going to be connected. Underneath it sat a pane that was empty for exactly the same
|
||
reason, saying so in a sentence nobody had to be told twice.
|
||
|
||
So the pane says it instead. Disconnected, the right-hand half is an invitation where the listing would
|
||
be: what this screen is for, what pressing the button will cost, and the button. Connected, the
|
||
invitation is replaced by the listing and the connection states itself in a strip above it, beside the
|
||
control that closes it — which is the arrangement the two facts were always about. The heading the bar
|
||
carried is not reprinted anywhere: the tab in the strip says SFTP or S3, and it says it whether or not
|
||
this screen is showing.
|
||
|
||
What the design has and this does not: `sftp over bastion-eu`, which needs jump hosts the connection
|
||
layer has not got. See docs/design-import-gaps.md.
|
||
-->
|
||
|
||
<UserControl.Styles>
|
||
<!--
|
||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||
described as deliberately rare. This is the one place it is spent.
|
||
|
||
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
|
||
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
|
||
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
|
||
characters that actually say so. The two never compete for one TextBlock, which is what lets a
|
||
world-writable executable show both facts instead of one winning an argument.
|
||
|
||
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
|
||
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
|
||
string is the compromise: the eye lands on the column, and the string itself is the detail.
|
||
-->
|
||
<Style Selector="TextBlock.entry">
|
||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||
</Style>
|
||
<!--
|
||
Two different hues, and after v2 that takes saying. These marks encode two independent facts in one
|
||
column, so they have to be told apart at a glance — and they used to be, for free, because the accent
|
||
was green and Info was blue. v2 made the accent blue too, which put #5B8CFF beside #7FB0FF: the same
|
||
hue two steps apart, which is a shade rather than a distinction.
|
||
|
||
So an executable is Live green now. It is the one use of that colour that is not about a session, and
|
||
it earns it on the same grounds — it is a fact about the file rather than something to press, and it
|
||
is the colour this marker already was before the accent moved out from under it.
|
||
-->
|
||
<Style Selector="TextBlock.entry.dir">
|
||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||
</Style>
|
||
<Style Selector="TextBlock.entry.exec">
|
||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||
</Style>
|
||
|
||
<!--
|
||
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
|
||
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
|
||
-->
|
||
<Style Selector="TextBlock.perms">
|
||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||
</Style>
|
||
<!--
|
||
Warn rather than WarnText, which is the muted amber a warning card writes its sentences in. At 9.5px
|
||
against TextFaint that one is a shade, not a signal, and a marker nobody notices is the same as no
|
||
marker at all.
|
||
-->
|
||
<Style Selector="TextBlock.perms.loose">
|
||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||
</Style>
|
||
</UserControl.Styles>
|
||
|
||
<Grid RowDefinitions="*,Auto">
|
||
|
||
<!-- ============ The two panes ============ -->
|
||
<Grid Grid.Row="0" ColumnDefinitions="*,64,*">
|
||
|
||
<!-- ==== This machine ==== -->
|
||
<!--
|
||
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">
|
||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
|
||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||
<!--
|
||
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
||
directory. Without this the pane is stuck on whichever drive the user profile is on.
|
||
-->
|
||
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
|
||
<ItemsControl.ItemsPanel>
|
||
<ItemsPanelTemplate>
|
||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||
</ItemsPanelTemplate>
|
||
</ItemsControl.ItemsPanel>
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||
<Button Classes="ghost" Content="{Binding Name}"
|
||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||
CommandParameter="{Binding Path}" />
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
|
||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
|
||
</StackPanel>
|
||
</Grid>
|
||
</Border>
|
||
|
||
<ItemsControl Grid.Row="1" ItemsSource="{Binding LocalTrail}" Margin="12,6,12,4">
|
||
<ItemsControl.ItemsPanel>
|
||
<ItemsPanelTemplate>
|
||
<WrapPanel Orientation="Horizontal" />
|
||
</ItemsPanelTemplate>
|
||
</ItemsControl.ItemsPanel>
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||
<StackPanel Orientation="Horizontal">
|
||
<Button Classes="flat" Padding="3,1"
|
||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||
CommandParameter="{Binding Path}">
|
||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
|
||
Foreground="{StaticResource TextDim}" />
|
||
</Button>
|
||
<TextBlock Classes="mono" Text="›" FontSize="11"
|
||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||
</StackPanel>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
|
||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
|
||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
|
||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
||
</Grid>
|
||
|
||
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
|
||
SelectedItem="{Binding SelectedLocalEntry}">
|
||
<ListBox.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
|
||
<Border Grid.Column="0" Classes="rowmark" />
|
||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||
Text="{Binding Name}" FontSize="12"
|
||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||
</Grid>
|
||
</DataTemplate>
|
||
</ListBox.ItemTemplate>
|
||
</ListBox>
|
||
|
||
<TextBlock Grid.Row="3" Classes="hint" FontSize="12" Margin="24" MaxWidth="260"
|
||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||
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 ==== -->
|
||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||
BorderBrush="{StaticResource Border}" BorderThickness="1,0">
|
||
<StackPanel VerticalAlignment="Center" Spacing="10" Margin="6">
|
||
<!--
|
||
Pointing at the pane the file is going to, which is the only reading that survives the panes
|
||
being side by side: the left-hand pane is this machine, so an upload points right.
|
||
-->
|
||
<Button Classes="ghost" Content="→" HorizontalAlignment="Stretch"
|
||
HorizontalContentAlignment="Center"
|
||
Command="{Binding UploadCommand}" IsEnabled="{Binding CanUpload}"
|
||
ToolTip.Tip="Upload the selected file to the directory showing on the host" />
|
||
<Button Classes="ghost" Content="←" HorizontalAlignment="Stretch"
|
||
HorizontalContentAlignment="Center"
|
||
Command="{Binding DownloadCommand}" IsEnabled="{Binding CanDownload}"
|
||
ToolTip.Tip="Download the selected file into the folder showing on this machine" />
|
||
</StackPanel>
|
||
</Border>
|
||
|
||
<!-- ==== The host ==== -->
|
||
<!--
|
||
Six rows rather than five: the connection's own strip is the new one, and it is inside this pane
|
||
rather than above both because it is about this pane and nothing else.
|
||
-->
|
||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
||
DragDrop.AllowDrop="True">
|
||
|
||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||
BorderThickness="0,0,0,1">
|
||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||
<!--
|
||
Which kind of remote this pane is for, in the place the local pane names itself. It is the
|
||
only thing left saying so on the screen itself — the bar that used to print SFTP or S3 across
|
||
the top is gone — and the pair is worth keeping apart, because HOST and BUCKET is the
|
||
difference between a directory tree and a flat namespace with inferred folders in it.
|
||
-->
|
||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center"
|
||
IsVisible="{Binding ShowsHostPicker}" />
|
||
<TextBlock Grid.Column="0" Classes="label" Text="BUCKET" VerticalAlignment="Center"
|
||
IsVisible="{Binding ShowsBucketPicker}" />
|
||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
|
||
IsEnabled="{Binding IsConnected}" />
|
||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||
IsEnabled="{Binding IsConnected}" />
|
||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||
IsEnabled="{Binding CanDeleteRemote}" />
|
||
</StackPanel>
|
||
</Grid>
|
||
</Border>
|
||
|
||
<!--
|
||
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
||
|
||
A row of its own rather than three more cells in the header above, and the reason is arithmetic
|
||
rather than taste: this pane is 381 pixels wide at the window's minimum, UP, REFRESH and DELETE
|
||
take most of that, and an account-at-host chip beside a DISCONNECT would have pushed one of them
|
||
off the edge. The layout suite would have caught it — which is the point of stating the number
|
||
here, so the next thing added to either row is measured against it rather than tried.
|
||
|
||
Two things and a gap, and the gap is the point: the status line was tried here and does not fit.
|
||
What is left after a 170-pixel address and a DISCONNECT is about eighty pixels, which turns every
|
||
sentence into its first word and an ellipsis. It is at the foot of the screen instead — see the
|
||
queue's own strip, which has the width for one.
|
||
-->
|
||
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
|
||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||
IsVisible="{Binding IsConnected}">
|
||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||
<Border Grid.Column="0" Classes="chip accent" MaxWidth="170">
|
||
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
||
</Border>
|
||
<Button Grid.Column="2" Classes="ghost" Content="DISCONNECT"
|
||
Command="{Binding DisconnectCommand}" />
|
||
</Grid>
|
||
</Border>
|
||
|
||
<!--
|
||
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
|
||
still on screen and still selected while it is being answered — and it names the full path rather
|
||
than the file, because a name is the half that does not identify anything.
|
||
|
||
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||
host is bytes with nothing behind them.
|
||
-->
|
||
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
|
||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
||
<StackPanel Spacing="7">
|
||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||
Text="{Binding PendingRemoteDeletion.Question}" />
|
||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||
Foreground="{StaticResource Danger}"
|
||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||
<Button Classes="danger" Content="DELETE ON THE HOST"
|
||
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
|
||
</StackPanel>
|
||
</StackPanel>
|
||
</Border>
|
||
|
||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||
<ItemsControl.ItemsPanel>
|
||
<ItemsPanelTemplate>
|
||
<WrapPanel Orientation="Horizontal" />
|
||
</ItemsPanelTemplate>
|
||
</ItemsControl.ItemsPanel>
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||
<StackPanel Orientation="Horizontal">
|
||
<TextBlock Classes="mono" Text="/" FontSize="11"
|
||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||
<Button Classes="flat" Padding="3,1"
|
||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||
CommandParameter="{Binding Path}">
|
||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
|
||
Foreground="{StaticResource TextDim}" />
|
||
</Button>
|
||
</StackPanel>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
|
||
<!--
|
||
Making a directory sits here, beside the path it would be made in, rather than with the queue's
|
||
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
|
||
"that name is already taken" is a dead end.
|
||
-->
|
||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||
IsVisible="{Binding IsConnected}">
|
||
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||
</StackPanel>
|
||
</Grid>
|
||
|
||
<Grid Grid.Row="4" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
|
||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
|
||
</Grid>
|
||
|
||
<ListBox Grid.Row="5" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||
<ListBox.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||
<Border Grid.Column="0" Classes="rowmark" />
|
||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||
Classes.exec="{Binding IsExecutable}"
|
||
Text="{Binding Name}" FontSize="12"
|
||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||
Text="{Binding Permissions}" FontSize="10.5" VerticalAlignment="Center" />
|
||
</Grid>
|
||
</DataTemplate>
|
||
</ListBox.ItemTemplate>
|
||
</ListBox>
|
||
|
||
<!--
|
||
A directory with nothing in it. It no longer has to say anything about not being connected: that
|
||
state is the invitation below, which covers this pane whole.
|
||
-->
|
||
<TextBlock Grid.Row="5" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
|
||
|
||
<!--
|
||
============ THE INVITATION, which is what this screen opens on ============
|
||
|
||
Everything the connect bar used to hold, in the half of the screen it was always about. It covers
|
||
the pane from below the header to the bottom, opaquely: the breadcrumb, the column headings and
|
||
the empty listing are all still laid out underneath, and all three are furniture for a listing
|
||
that does not exist yet.
|
||
|
||
Two steps, not one. The first is an invitation with a button — a name for what this screen does, a
|
||
sentence about what it costs, and one thing to press — and the picker is the second. A combo box
|
||
sitting open in the middle of an empty pane would be a form with no question above it; the reason
|
||
the pane is empty is the question, and that is what the first step says.
|
||
|
||
◆ The two steps also keep this inside its budget, which is the pane's height less whatever the
|
||
queue has taken — 268 pixels with three transfers on it. The sentence and the button go away when
|
||
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
|
||
reaches the floor. Anything added here has to hold that; the layout suite measures both.
|
||
-->
|
||
<Border Grid.Row="1" Grid.RowSpan="5" Background="{StaticResource Canvas}"
|
||
IsVisible="{Binding !IsConnected}">
|
||
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||
|
||
<!--
|
||
The mark the hosts screen puts on a group, at the size an empty state can carry one. This
|
||
application ships no icon set — see the note on colour at the top of this file — so a glyph in
|
||
a rounded square is what an icon is here, and ▤ is already the one that means "a place things
|
||
are kept".
|
||
-->
|
||
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
|
||
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||
BorderThickness="1">
|
||
<TextBlock Text="▤" FontSize="18" Foreground="{StaticResource TextDim}"
|
||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||
</Border>
|
||
|
||
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
|
||
Text="Connect to a host" IsVisible="{Binding ShowsHostPicker}" />
|
||
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
|
||
Text="Open a bucket" IsVisible="{Binding ShowsBucketChoice}" />
|
||
<!--
|
||
A different heading rather than the same one over an empty picker, which is what this screen
|
||
used to do: SELECT BUCKET opened a combo box with nothing in it and no sentence anywhere saying
|
||
where a bucket comes from. From here that is indistinguishable from an application that cannot
|
||
add one — and this is the screen somebody looking for S3 arrives at.
|
||
-->
|
||
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
|
||
Text="No buckets yet" IsVisible="{Binding ShowsNoBuckets}" />
|
||
|
||
<!--
|
||
The sentence, and the button under it. Both go away once the picker is up — by then they have
|
||
been read or they have not — which is why they are one collapsing group rather than two
|
||
elements each answering the same question.
|
||
|
||
The sentence is the one the empty pane used to carry, and it is kept rather than dropped
|
||
because it is the one thing about this screen that surprises people: browsing files is a
|
||
second login, and it shows up in the host's auth log as one. The bucket's counterpart says
|
||
the opposite thing for the opposite reason — there is no session to open at all, so nothing
|
||
is dialled until a listing is asked for.
|
||
-->
|
||
<StackPanel Spacing="10" IsVisible="{Binding !IsChoosingRemote}">
|
||
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
|
||
IsVisible="{Binding ShowsHostPicker}"
|
||
Text="Browsing a host's files opens its own SFTP connection, so the host records a second login — it is not the channel a terminal uses." />
|
||
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
|
||
IsVisible="{Binding ShowsBucketChoice}"
|
||
Text="A bucket has nothing to connect to: the keys and the endpoint are tested by the first listing, not by opening a session." />
|
||
<!--
|
||
Where a bucket comes from, said on the screen that needs one. It names the keychain rather
|
||
than only offering a button, because the button is a shortcut and the sentence is what makes
|
||
the bucket findable again next week without it.
|
||
-->
|
||
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
|
||
IsVisible="{Binding ShowsNoBuckets}"
|
||
Text="A bucket is an item in your keychain — the bucket name, its keys and its endpoint, encrypted like everything else there. Add one and it appears here." />
|
||
<Button Classes="accent" HorizontalAlignment="Center" Content="SELECT HOST"
|
||
IsVisible="{Binding ShowsHostPicker}"
|
||
Command="{Binding BeginChoosingRemoteCommand}" />
|
||
<Button Classes="accent" HorizontalAlignment="Center" Content="SELECT BUCKET"
|
||
IsVisible="{Binding ShowsBucketChoice}"
|
||
Command="{Binding BeginChoosingRemoteCommand}" />
|
||
<Button Classes="accent" HorizontalAlignment="Center" Content="+ BUCKET"
|
||
IsVisible="{Binding ShowsNoBuckets}"
|
||
Command="{Binding AddBucketCommand}"
|
||
ToolTip.Tip="Goes to the keychain with the bucket editor open." />
|
||
</StackPanel>
|
||
|
||
<!-- ==== The picker, once it has been asked for ==== -->
|
||
<StackPanel Spacing="8" IsVisible="{Binding IsChoosingRemote}">
|
||
|
||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Hosts}"
|
||
SelectedItem="{Binding SelectedHost}"
|
||
IsVisible="{Binding ShowsHostPicker}"
|
||
PlaceholderText="choose a host">
|
||
<ComboBox.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||
<StackPanel>
|
||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
|
||
Foreground="{StaticResource Text}" />
|
||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10"
|
||
Foreground="{StaticResource TextFaint}" />
|
||
</StackPanel>
|
||
</DataTemplate>
|
||
</ComboBox.ItemTemplate>
|
||
</ComboBox>
|
||
|
||
<!--
|
||
Gated on there being one, not merely on this being the S3 screen. An empty picker is the
|
||
thing that made this screen look like it could not add a bucket, and the invitation above
|
||
now sends somebody to the keychain instead of opening this.
|
||
-->
|
||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Buckets}"
|
||
SelectedItem="{Binding SelectedBucket}"
|
||
IsVisible="{Binding ShowsBucketChoice}"
|
||
PlaceholderText="choose a bucket">
|
||
<ComboBox.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
|
||
<StackPanel>
|
||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
|
||
Foreground="{StaticResource Text}" />
|
||
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="10"
|
||
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 PasswordChar="•" Text="{Binding TypedPassword}" PlaceholderText="password"
|
||
IsVisible="{Binding SelectedHostAsksForAPassword}" />
|
||
|
||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center">
|
||
<Button Classes="accent" Content="{Binding ConnectLabel}"
|
||
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
|
||
<Button Classes="ghost" Content="CANCEL"
|
||
Command="{Binding CancelChoosingRemoteCommand}" />
|
||
</StackPanel>
|
||
|
||
</StackPanel>
|
||
|
||
<!--
|
||
The status line, in the state where the connected strip above is not on screen. Wrapped rather
|
||
than trimmed, unlike its counterpart up there: what lands here is a refusal with a reason in
|
||
it — a server's own sentence about why a connection did not open — and an ellipsis through the
|
||
middle of that is a message that has been shown without being said.
|
||
-->
|
||
<TextBlock Classes="hint" FontSize="11.5" TextAlignment="Center" Text="{Binding Status}" />
|
||
|
||
</StackPanel>
|
||
</Border>
|
||
|
||
<!--
|
||
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="6" IsHitTestVisible="False"
|
||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||
|
||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
||
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
|
||
Foreground="{StaticResource Danger}"
|
||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||
</Border>
|
||
|
||
</Grid>
|
||
|
||
</Grid>
|
||
|
||
<!-- ============ The queue ============ -->
|
||
<Border Grid.Row="1" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
|
||
BorderThickness="0,1,0,0" MaxHeight="196">
|
||
<Grid RowDefinitions="Auto,*">
|
||
|
||
<Border Grid.Row="0" Padding="12,7">
|
||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
|
||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
|
||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||
Text="one at a time · nothing lands at its final name until it is complete" />
|
||
|
||
<!--
|
||
◆ THE STATUS LINE, in the state where the remote pane has no room for one.
|
||
|
||
It is here rather than beside DISCONNECT because this row spans the window and that one spans
|
||
half of it: what the screen has to say about a session is a sentence, and a sentence needs the
|
||
width. Only while something is open — the other half of the time it is inside the invitation
|
||
in the remote pane, next to the button that provoked it, which is where a refusal has to be.
|
||
|
||
Right-aligned in a free column, so it reads as this row's other end rather than as a third
|
||
clause of the sentence to its left.
|
||
-->
|
||
<TextBlock Grid.Column="2" Classes="hint" FontSize="11.5" Margin="16,0,0,0"
|
||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
|
||
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
||
</Grid>
|
||
</Border>
|
||
|
||
<ScrollViewer Grid.Row="1">
|
||
<StackPanel>
|
||
<TextBlock Classes="hint" FontSize="11.5" Margin="12,4,12,14"
|
||
IsVisible="{Binding !HasTransfers}"
|
||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||
|
||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||
<ItemsControl.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
|
||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="12"
|
||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||
<StackPanel Grid.Column="1" Margin="0,0,8,0">
|
||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11.5"
|
||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="10"
|
||
Foreground="{StaticResource TextFaint}"
|
||
TextTrimming="CharacterEllipsis" />
|
||
</StackPanel>
|
||
|
||
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
|
||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||
Foreground="{StaticResource Accent}"
|
||
Background="{StaticResource Raised}" />
|
||
|
||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="10.5"
|
||
Margin="10,0" VerticalAlignment="Center"
|
||
TextTrimming="CharacterEllipsis"
|
||
Foreground="{StaticResource TextDim}" />
|
||
|
||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
|
||
<Border Classes="chip">
|
||
<TextBlock Text="{Binding StateLabel}" />
|
||
</Border>
|
||
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
|
||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||
CommandParameter="{Binding}" />
|
||
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
|
||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||
CommandParameter="{Binding}" />
|
||
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
|
||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||
CommandParameter="{Binding}" />
|
||
</StackPanel>
|
||
</Grid>
|
||
</DataTemplate>
|
||
</ItemsControl.ItemTemplate>
|
||
</ItemsControl>
|
||
|
||
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
|
||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
|
||
IsVisible="{Binding HasTransfers}" />
|
||
</StackPanel>
|
||
</ScrollViewer>
|
||
|
||
</Grid>
|
||
</Border>
|
||
|
||
<!--
|
||
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
|
||
they arrive here on their own because this is a separate connection — a host trusted for a shell is
|
||
trusted for this too, but a host nobody has connected to at all is met here first.
|
||
-->
|
||
<Border Grid.Row="0" Grid.RowSpan="2" Background="{StaticResource Canvas}"
|
||
IsVisible="{Binding HasPendingHostKey}">
|
||
<Border Classes="card">
|
||
<StackPanel Spacing="12">
|
||
<TextBlock Classes="heading" Text="Check this host's fingerprint" />
|
||
<TextBlock Classes="hint"
|
||
Text="This host has not been seen before. Compare the fingerprint with what the operator published. Trusting it here also trusts it for terminals, and on your other machines." />
|
||
<SelectableTextBlock Classes="mono" FontSize="12" Foreground="{StaticResource Text}"
|
||
TextWrapping="Wrap" Text="{Binding PendingHostKey.Fingerprint}" />
|
||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||
<Button Classes="accent" Content="TRUST AND CONNECT" Command="{Binding TrustHostKeyCommand}" />
|
||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||
</StackPanel>
|
||
</StackPanel>
|
||
</Border>
|
||
</Border>
|
||
|
||
<Border Grid.Row="0" Grid.RowSpan="2" Background="{StaticResource Canvas}"
|
||
IsVisible="{Binding HasHostKeyMismatch}">
|
||
<Border Classes="card" BorderBrush="{StaticResource DangerSoft}">
|
||
<StackPanel Spacing="12">
|
||
<TextBlock Classes="heading" Text="The host key has changed" Foreground="{StaticResource Danger}" />
|
||
<TextBlock Classes="hint" Text="{Binding HostKeyMismatch}" />
|
||
<TextBlock Classes="hint"
|
||
Text="Nothing was connected, and there is no way to continue from here. If the server was legitimately rebuilt, edit the host on the Hosts screen and choose Forget host key." />
|
||
<Button Classes="ghost" Content="CLOSE" Command="{Binding RejectHostKeyCommand}"
|
||
HorizontalAlignment="Left" />
|
||
</StackPanel>
|
||
</Border>
|
||
</Border>
|
||
|
||
</Grid>
|
||
|
||
</UserControl>
|