Public Access
Notice when a file session stops, and give the file browser the four things it was short of
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.
◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.
IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.
IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.
- THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
having. SftpEntry is a record of what the server said rather than a handle,
so the pane can go on showing where the user was — which is what RECONNECT
is offering to put them back into. That needed a new ShowsRemoteContents on
both heads: every "is there anything here" binding read IsConnected, which
was right while the only way to stop being connected was to press a button
that empties the pane, and wrong the moment a session could be lost.
- RECONNECT re-dials through the ordinary connect path — same host key gate,
same refusals, same log entry — and then walks back to the directory the
pane was showing. The path is read off RemotePath rather than captured,
because the pane already keeps it up to date and a copy is a second thing to
get wrong. A host wanting a typed password is asked for one rather than
dialled with an empty box.
- SessionEndedBar is now one control on both surfaces, with the shell
resolving which it is drawn over: a terminal has two endings to tell apart
and a keychain entry to check, a file session has one ending and already
captured what it would dial. The phone's Files screen has its own copy,
bound to TransfersViewModel directly, because its DataContext is that rather
than the shell.
◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.
It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.
Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.
Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.
◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.
The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.
With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.
Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.
A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.
◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.
A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.
The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.
Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.
The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.
◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.
The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.
DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.
A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.
Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.
◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.
The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.
And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.
◆ WHAT IS VERIFIED.
901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.
What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
This commit is contained in:
1 parent
e2733e5cbc
commit
cfc2e3ae0c
19 files changed
+3886
-230
No files matched your search
@@ -48,7 +48,12 @@
|
|||||||
the refusal has no continue button here either.
|
the refusal has no continue button here either.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto">
|
<!--
|
||||||
|
Nine rows since the disconnect strip arrived; it is row 7, between the listing and the action bar, so
|
||||||
|
that what it says is next to the buttons it replaces the point of. Auto and empty except while the
|
||||||
|
session has been lost, so an ordinary browse pays nothing for it.
|
||||||
|
-->
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!-- ============ header ============ -->
|
<!-- ============ header ============ -->
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||||
@@ -78,7 +83,13 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ============ opening one ============ -->
|
<!-- ============ opening one ============ -->
|
||||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
|
<!--
|
||||||
|
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
|
||||||
|
connection could be lost rather than only closed: a session that went away leaves its listing on
|
||||||
|
screen, and this card springing back over it would hide the one thing the strip below is offering to
|
||||||
|
put the user back into. See TransfersViewModel.ShowsRemoteContents.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding ShowsRemoteInvitation}">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
|
|
||||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
||||||
@@ -186,7 +197,8 @@
|
|||||||
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
|
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
|
||||||
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
|
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6" IsVisible="{Binding IsConnected}">
|
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6"
|
||||||
|
IsVisible="{Binding ShowsRemoteContents}">
|
||||||
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
|
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
|
||||||
PlaceholderText="/path/to/somewhere">
|
PlaceholderText="/path/to/somewhere">
|
||||||
<TextBox.KeyBindings>
|
<TextBox.KeyBindings>
|
||||||
@@ -197,9 +209,24 @@
|
|||||||
Command="{Binding GoToTypedRemotePathCommand}" />
|
Command="{Binding GoToTypedRemotePathCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ How many are gathered up, and the one gesture that gathers or releases them all. Only while
|
||||||
|
something is — an always-present row would spend 30 pixels of a listing on a count of zero.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" Margin="14,0,14,6" VerticalAlignment="Bottom"
|
||||||
|
IsVisible="{Binding HasCheckedRemote}">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" MinHeight="36" Padding="12,0"
|
||||||
|
Command="{Binding ToggleAllRemoteCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="ALL / NONE" />
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="12" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center" Foreground="{StaticResource AccentText}"
|
||||||
|
Text="{Binding CheckedRemoteLabel}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- ============ where we are ============ -->
|
<!-- ============ where we are ============ -->
|
||||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||||
Command="{Binding RemoteUpCommand}">
|
Command="{Binding RemoteUpCommand}">
|
||||||
@@ -265,7 +292,7 @@
|
|||||||
<!-- ============ the listing ============ -->
|
<!-- ============ the listing ============ -->
|
||||||
<Panel Grid.Row="6">
|
<Panel Grid.Row="6">
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
|
||||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -282,7 +309,7 @@
|
|||||||
selection navigate would send a single desktop click walking into a directory.
|
selection navigate would send a single desktop click walking into a directory.
|
||||||
-->
|
-->
|
||||||
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
||||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
|
Background="Transparent" BorderThickness="0" IsVisible="{Binding ShowsRemoteContents}"
|
||||||
Tapped="OnRemoteEntryTapped">
|
Tapped="OnRemoteEntryTapped">
|
||||||
<ListBox.Styles>
|
<ListBox.Styles>
|
||||||
<Style Selector="ListBoxItem">
|
<Style Selector="ListBoxItem">
|
||||||
@@ -299,15 +326,27 @@
|
|||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
<Border MinHeight="52" Padding="10,7">
|
<Border MinHeight="52" Padding="10,7">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
<!--
|
||||||
|
◆ The tick, which on this head is the only multi-selection there can be: a phone has no
|
||||||
|
ctrl-click and no shift-click. It is not the list's selection — that stays "the row I am
|
||||||
|
looking at", which RENAME, MOVE and PERMS act on one at a time — and this is "the rows I
|
||||||
|
have gathered up", which SAVE and DELETE take. See RemoteEntryRowViewModel.IsChecked.
|
||||||
|
|
||||||
|
Its own 40-pixel column so a thumb aimed at it does not open the directory beside it: the
|
||||||
|
row's own tap navigates, which is this head's one behaviour the desktop does not share.
|
||||||
|
-->
|
||||||
|
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||||
@@ -318,7 +357,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
|
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -327,9 +366,50 @@
|
|||||||
</ListBox>
|
</ListBox>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ what the screen says once the session has stopped ============ -->
|
||||||
|
<!--
|
||||||
|
The phone's half of the desktop's SessionEndedBar, and it binds this screen's own view model rather
|
||||||
|
than the shell's — this control's DataContext is TransfersViewModel, where the desktop's strip sits in
|
||||||
|
MainWindow beside a status bar and can ask which surface it is on. The facts are the same two:
|
||||||
|
TransfersViewModel.LostConnection and the reconnect that goes back to the directory showing.
|
||||||
|
|
||||||
|
A phone needs it more than a desk does, and for the reason its terminal does: a mobile link drops on
|
||||||
|
its own and the screen goes off, so this session is discovered later with a listing on it and no
|
||||||
|
account of why nothing works. The listing stays deliberately — see ShowsRemoteContents.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="7" IsVisible="{Binding IsConnectionLost}" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Danger}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
|
<Ellipse Classes="dot" Classes.dropped="True" Width="7" Height="7" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="DISCONNECTED" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two lines maximum, for the reason the terminal's strip caps its own: a transport error can hand
|
||||||
|
over whatever the operating system called it, and a strip that grew to fit one would eat the
|
||||||
|
listing above it on a screen that has none to spare.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||||
|
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding LostConnection}" />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding ReconnectCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding DisconnectCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ============ what to do with the chosen entry ============ -->
|
<!-- ============ what to do with the chosen entry ============ -->
|
||||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||||
<Border Grid.Row="7" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
<Border Grid.Row="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
@@ -381,6 +461,75 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The mode, as nine boxes and the number they add up to — in the place the other two questions
|
||||||
|
already occupy and never at the same time as either. See RemotePermissionsRequest for why it is
|
||||||
|
boxes rather than a field to type 755 into, and TransfersScreen.axaml for the desktop's own copy
|
||||||
|
of the same form.
|
||||||
|
|
||||||
|
Compact rather than a second layout: the three columns are the mode's own three triples, and at
|
||||||
|
360dp they are exactly what fits. The octal is printed underneath as it changes, which is what
|
||||||
|
lets somebody who came here to type chmod check the boxes they just ticked.
|
||||||
|
-->
|
||||||
|
<Border IsVisible="{Binding IsChangingRemotePermissions}" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||||
|
Padding="13,11">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemotePermissions.Question}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemotePermissions.FullPath}" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,44,44,44" Margin="0,2">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Classes="detail" Text="read"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="2" Classes="detail" Text="write"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="3" Classes="detail"
|
||||||
|
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="12" Text="owner"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="12" Text="group"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="12" Text="others"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource AccentText}"
|
||||||
|
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
|
||||||
|
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||||
|
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
||||||
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
||||||
@@ -457,11 +606,23 @@
|
|||||||
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
|
||||||
|
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
|
||||||
|
access policy rather than nine bits — see CanChangeRemotePermissions.
|
||||||
|
-->
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
|
||||||
|
Command="{Binding ChangeRemotePermissionsCommand}"
|
||||||
|
IsEnabled="{Binding CanChangeRemotePermissions}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
|
||||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
</Grid>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
|
||||||
Command="{Binding DisconnectCommand}" />
|
Command="{Binding DisconnectCommand}" />
|
||||||
|
<Panel Grid.Column="2" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ internal sealed partial class FilesScreen : UserControl
|
|||||||
/// read its size the same gesture as fetching it.
|
/// read its size the same gesture as fetching it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The desktop's own handler, for the reason given there: the count is a property of the collection
|
||||||
|
/// rather than of any row, and each listing replaces every row it has. See
|
||||||
|
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
|
||||||
|
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
|
||||||
|
|
||||||
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
|
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
|
||||||
|
|||||||
@@ -121,7 +121,35 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
|
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
|
||||||
var updates = UpdateChannels.ForThisMachine();
|
var updates = UpdateChannels.ForThisMachine();
|
||||||
|
|
||||||
var viewModel = new MainWindowViewModel(
|
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
|
||||||
|
browser, updates);
|
||||||
|
|
||||||
|
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||||
|
|
||||||
|
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||||
|
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||||
|
// discarding the task here is safe rather than merely convenient.
|
||||||
|
_ = viewModel.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
WireShutdown(desktop, viewModel, workspace, caches);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
|
||||||
|
/// method it came out of is at the limit the analyzers set.
|
||||||
|
/// </remarks>
|
||||||
|
private static MainWindowViewModel BuildShell(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
ClientPaths paths,
|
||||||
|
ClientCacheFactory caches,
|
||||||
|
TerminalWorkspace workspace,
|
||||||
|
VaultKnownHostStore knownHosts,
|
||||||
|
IDeviceKeyStore deviceKeys,
|
||||||
|
SshNetConnectionFactory connections,
|
||||||
|
SystemBrowserLauncher browser,
|
||||||
|
IUpdateChannel updates) =>
|
||||||
|
new(
|
||||||
paths,
|
paths,
|
||||||
caches,
|
caches,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -141,16 +169,73 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
|
||||||
copyToClipboard: ClipboardWriter(desktop),
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
updates: updates);
|
updates: updates,
|
||||||
|
openInEditor: OpenInDefaultEditorAsync,
|
||||||
|
watchFile: WatchFile);
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
/// <summary>
|
||||||
|
/// Hands a local file to whatever this machine opens that kind of file with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
|
||||||
|
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
|
||||||
|
/// Services binding depending on which desktop this is, and watching a file is a platform object with
|
||||||
|
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
|
||||||
|
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
|
||||||
|
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
|
||||||
|
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||||
|
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
|
||||||
|
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static Task OpenInDefaultEditorAsync(string path)
|
||||||
|
{
|
||||||
|
using var opened = System.Diagnostics.Process.Start(
|
||||||
|
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
|
||||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
return Task.CompletedTask;
|
||||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
}
|
||||||
// discarding the task here is safe rather than merely convenient.
|
|
||||||
_ = viewModel.StartAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
WireShutdown(desktop, viewModel, workspace, caches);
|
/// <summary>
|
||||||
|
/// Watches one file for saves, until the handle is disposed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
|
||||||
|
/// A great many editors do not write in place: they write a temporary file beside the original and
|
||||||
|
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
|
||||||
|
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
|
||||||
|
/// work with the editors people actually use.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
|
||||||
|
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
|
||||||
|
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
|
||||||
|
/// server for the sake of saving it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static FileSystemWatcher WatchFile(string path, Action onChanged)
|
||||||
|
{
|
||||||
|
var watcher = new FileSystemWatcher(
|
||||||
|
Path.GetDirectoryName(path)!, Path.GetFileName(path))
|
||||||
|
{
|
||||||
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
|
||||||
|
};
|
||||||
|
|
||||||
|
watcher.Changed += (_, _) => onChanged();
|
||||||
|
watcher.Created += (_, _) => onChanged();
|
||||||
|
watcher.Renamed += (_, _) => onChanged();
|
||||||
|
|
||||||
|
watcher.EnableRaisingEvents = true;
|
||||||
|
|
||||||
|
return watcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
|
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
|
||||||
|
|||||||
@@ -156,9 +156,19 @@
|
|||||||
either: TransfersScreen draws that same string itself, both in its own empty state and
|
either: TransfersScreen draws that same string itself, both in its own empty state and
|
||||||
beside the remote pane's DISCONNECT once something is open.
|
beside the remote pane's DISCONNECT once something is open.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
<!--
|
||||||
|
Three rows since the disconnect strip arrived, and it is the same control the terminal
|
||||||
|
surface draws — see SessionEndedBar.axaml, which resolves which surface it is about
|
||||||
|
through the shell rather than through a binding of its own. Auto and empty except while
|
||||||
|
the file session has been lost, so an ordinary browse pays nothing for it.
|
||||||
|
|
||||||
|
Note the DataContext: this row is the shell's rather than the transfers screen's, which
|
||||||
|
is why the strip sits here and not inside TransfersScreen.axaml.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Column="0" RowDefinitions="*,Auto,Auto">
|
||||||
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
|
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
|
||||||
<views:SessionStatusBar Grid.Row="1" />
|
<views:SessionEndedBar Grid.Row="1" />
|
||||||
|
<views:SessionStatusBar Grid.Row="2" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<!--
|
<!--
|
||||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
||||||
|
|||||||
@@ -5,17 +5,27 @@
|
|||||||
x:DataType="vm:MainWindowViewModel">
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
── What a terminal says once its shell has stopped ──────────────────────────────────────────────────
|
── What a session says once it has stopped ──────────────────────────────────────────────────────────
|
||||||
A strip between the pane and the status bar, shown only while the selected tab has a session that has
|
A strip between the pane and the status bar, shown only while the session in front of the user has
|
||||||
ended. It names which of the two endings it was, prints the sentence that came with it, and offers the
|
ended. It names which ending it was, prints the sentence that came with it, and offers the one thing
|
||||||
one thing worth offering: dialling the machine again.
|
worth offering: dialling the machine again.
|
||||||
|
|
||||||
◆ IT IS A ROW OF ITS OWN RATHER THAN SOMETHING DRAWN OVER THE PANE, and that is not a style choice.
|
◆ ONE CONTROL ON BOTH SURFACES, and the shell is what resolves which it is drawn over. A terminal has
|
||||||
The renderer is a native child view that composites above everything Avalonia draws, so a banner
|
two endings to tell apart and a keychain entry to check before it can offer a redial; a file session has
|
||||||
overlapping its rectangle would either be invisible or would have to collapse the terminal — and
|
one ending and already captured what it would dial. None of that is a decision a view should be making,
|
||||||
collapsing it is exactly wrong here, because the scrollback is what the user is reading. A dead shell's
|
so every binding here is a MainWindowViewModel property that has already asked which surface is showing
|
||||||
last words are usually why it died. So this takes its own Auto row in the grid the WebView's Panel sits
|
— see SessionEndedText, CanReconnectSession and SessionEndedDismissLabel. The second button is even
|
||||||
in, and the pane simply gets 34 fewer pixels while it is up. See MainWindow.axaml.
|
labelled from the shell, because CLOSE TAB and DISCONNECT are noticeably different acts and one vague
|
||||||
|
word covering both would be the wrong kind of reuse.
|
||||||
|
|
||||||
|
◆ IT IS A ROW OF ITS OWN RATHER THAN SOMETHING DRAWN OVER THE PANE, and on the terminal surface that is
|
||||||
|
not a style choice. The renderer is a native child view that composites above everything Avalonia draws,
|
||||||
|
so a banner overlapping its rectangle would either be invisible or would have to collapse the terminal —
|
||||||
|
and collapsing it is exactly wrong here, because the scrollback is what the user is reading. A dead
|
||||||
|
shell's last words are usually why it died. So this takes its own Auto row and the pane simply gets 34
|
||||||
|
fewer pixels while it is up. The file surface has no such constraint and takes the same row anyway,
|
||||||
|
because a listing left on screen is worth the same as a scrollback: it is where the user was, and it is
|
||||||
|
what makes RECONNECT mean something. See MainWindow.axaml for both placements.
|
||||||
|
|
||||||
◆ THE SENTENCE IS HERE AS WELL AS IN THE PANE ON PURPOSE. terminal.js writes the same string into the
|
◆ THE SENTENCE IS HERE AS WELL AS IN THE PANE ON PURPOSE. terminal.js writes the same string into the
|
||||||
scrollback as a grey rule, which is right and is not enough: a machine that printed a page of shutdown
|
scrollback as a grey rule, which is right and is not enough: a machine that printed a page of shutdown
|
||||||
@@ -33,12 +43,13 @@
|
|||||||
RECONNECT is disabled rather than hidden when there is nothing to dial — a tab typed in by hand, or one
|
RECONNECT is disabled rather than hidden when there is nothing to dial — a tab typed in by hand, or one
|
||||||
whose host has left the keychain — because the answer to "why can I not reconnect this" is better given
|
whose host has left the keychain — because the answer to "why can I not reconnect this" is better given
|
||||||
by a control that is visibly refusing than by one that is not there. See
|
by a control that is visibly refusing than by one that is not there. See
|
||||||
MainWindowViewModel.CanReconnectSelectedTab, which is what draws that line, and ReconnectTabAsync for
|
MainWindowViewModel.CanReconnectSession, which is what draws that line on each surface, and
|
||||||
what the button actually does: the vault's own connect path, into this same tab.
|
ReconnectSessionAsync for what the button actually does: each surface's own connect path, back into the
|
||||||
|
thing that stopped. On the file side that includes walking back to the directory the pane was showing,
|
||||||
|
which is what makes it a reconnect rather than a connect.
|
||||||
|
|
||||||
CLOSE TAB is the same command the tab's cross runs. It is here because this is the moment the other
|
The second button is the moment the other reasonable answer is "I have read it, take it away" — and
|
||||||
reasonable answer is "I have read it, take it away", and hunting for a 16-pixel cross in the strip above
|
hunting for a 16-pixel cross in the strip above is not the shape of that answer.
|
||||||
is not the shape of that answer.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
@@ -82,13 +93,11 @@
|
|||||||
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||||
<Button Classes="ghost" Content="RECONNECT"
|
<Button Classes="ghost" Content="RECONNECT"
|
||||||
Command="{Binding ReconnectTabCommand}"
|
Command="{Binding ReconnectSessionCommand}"
|
||||||
CommandParameter="{Binding SelectedTab}"
|
IsEnabled="{Binding CanReconnectSession, FallbackValue=False}"
|
||||||
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
ToolTip.Tip="Opens this connection again, in place." />
|
||||||
ToolTip.Tip="Opens a new shell on this machine, in this tab. The scrollback above it goes." />
|
<Button Classes="ghost" Content="{Binding SessionEndedDismissLabel}"
|
||||||
<Button Classes="ghost" Content="CLOSE TAB"
|
Command="{Binding DismissEndedSessionCommand}" />
|
||||||
Command="{Binding CloseTabCommand}"
|
|
||||||
CommandParameter="{Binding SelectedTab}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ using Avalonia.Controls;
|
|||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>What the terminal surface says once the selected tab's shell has stopped.</summary>
|
/// <summary>What the session shell says once the session in front of the user has stopped.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// No code of its own: the word, the sentence and both buttons are the shell's. In its own file for the
|
/// No code of its own: the word, the sentence and both buttons are the shell's, including which surface
|
||||||
/// reason every other card here is — nothing inside <c>MainWindow</c> can be laid out by a test, because
|
/// they are about — see the markup. In its own file for the reason every other card here is: nothing inside
|
||||||
/// WebView2's adapter refuses the headless session's thread. See the markup.
|
/// <c>MainWindow</c> can be laid out by a test, because WebView2's adapter refuses the headless session's
|
||||||
|
/// thread.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class SessionEndedBar : UserControl
|
internal sealed partial class SessionEndedBar : UserControl
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -319,20 +319,25 @@
|
|||||||
SelectedItem="{Binding SelectedLocalEntry}">
|
SelectedItem="{Binding SelectedLocalEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||||
<Grid Height="38" Margin="14,0,10,0">
|
<Grid Height="38" Margin="6,0,10,0">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="24" />
|
||||||
<ColumnDefinition Width="20" />
|
<ColumnDefinition Width="20" />
|
||||||
<ColumnDefinition Width="3*" />
|
<ColumnDefinition Width="3*" />
|
||||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
<!-- The remote pane's own tick, on this side and for the same reasons. -->
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding IsChecked}"
|
||||||
|
Click="OnLocalRowChecked" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||||
Text="{Binding Name}" FontSize="13.5"
|
Text="{Binding Name}" FontSize="13.5"
|
||||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -495,10 +500,50 @@
|
|||||||
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
|
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
|
||||||
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
|
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="11.5" VerticalAlignment="Center"
|
<!--
|
||||||
|
The chosen row's name, or the count once anything is ticked. The count wins because it is the
|
||||||
|
one the buttons beside it are actually about: with three rows gathered up, a name would be
|
||||||
|
describing whichever of them happens to be highlighted while DELETE takes all three.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<Button Classes="ghost" Content="ALL" Command="{Binding ToggleAllRemoteCommand}"
|
||||||
|
ToolTip.Tip="Tick every row, or untick them all" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
|
||||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||||
Text="{Binding SelectedRemoteName}" />
|
Text="{Binding SelectedRemoteName}"
|
||||||
|
IsVisible="{Binding !HasCheckedRemote}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource AccentText}"
|
||||||
|
Text="{Binding CheckedRemoteLabel}"
|
||||||
|
IsVisible="{Binding HasCheckedRemote}" />
|
||||||
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||||
|
<!--
|
||||||
|
◆ FOUR NOW, and this is the one that was missing: the PERMS column has been a true fact
|
||||||
|
about a file with nothing to be done about it since it arrived, so the commonest next act
|
||||||
|
after using this screen was opening a terminal to chmod what had just been uploaded. Dark on
|
||||||
|
a bucket rather than live-and-refused — an object has an access policy rather than nine bits;
|
||||||
|
see CanChangeRemotePermissions.
|
||||||
|
|
||||||
|
A lock rather than a key glyph: a key is what this application's keychain uses, and one here
|
||||||
|
would read as "which credential" over a row that has nothing to do with one.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
◆ EDIT, and it is first because it is the only one of the five that changes nothing on the
|
||||||
|
host by pressing it: it fetches a copy and opens it. Dark on a head with no way to open a
|
||||||
|
file or watch one — see CanEditRemote, which is what the phone fails rather than this
|
||||||
|
button being absent there for a reason nobody can see.
|
||||||
|
-->
|
||||||
|
<Button Classes="paneicon" Command="{Binding EditRemoteCommand}"
|
||||||
|
IsEnabled="{Binding CanEditRemote}"
|
||||||
|
ToolTip.Tip="Open in this machine's editor, and send each save back">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="paneicon" Command="{Binding ChangeRemotePermissionsCommand}"
|
||||||
|
IsEnabled="{Binding CanChangeRemotePermissions}"
|
||||||
|
ToolTip.Tip="Permissions on the host">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
|
</Button>
|
||||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
@@ -515,6 +560,80 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ WHAT AN EDIT SESSION SAYS FOR ITSELF, and it is a strip rather than a question: nothing is
|
||||||
|
waiting on an answer, a file is simply open somewhere else and each save is going back. It shares
|
||||||
|
the questions' Grid row because it is exclusive with them — opening any of the three needs a
|
||||||
|
selection, and while one is up ShowsRemoteItemActions has taken the buttons away.
|
||||||
|
|
||||||
|
The log is newest-first and bounded. Watching means saving happens more than once, and each one is
|
||||||
|
a round trip somebody may want to see happened; an editor that autosaves would otherwise grow this
|
||||||
|
without limit. See TransfersViewModel.RemoteEditLog.
|
||||||
|
|
||||||
|
The conflict card inside it is the one state where a save is not written back. Three answers,
|
||||||
|
because those are the three that exist: send it anyway, throw away what was typed and take the
|
||||||
|
host's copy, or stop. There is deliberately no merge — this client has no way to do one, and a
|
||||||
|
button that pretended otherwise would be the fabrication this project's honesty rule forbids.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||||
|
IsVisible="{Binding IsEditingRemote}">
|
||||||
|
<!--
|
||||||
|
◆ THE WHOLE STRIP SCROLLS, not just the log inside it, and that is what makes this safe at any
|
||||||
|
width. It is the tallest thing that can be in this Grid row — a heading, a path, a log, a
|
||||||
|
conflict card and a button — and unlike the three questions beside it, it is not modal: the
|
||||||
|
listing underneath stays live, because a file being open in an editor is no reason to stop
|
||||||
|
being able to browse. So it cannot be allowed to take the pane's height, and bounding the
|
||||||
|
container rather than each part means nothing added inside it later can undo that.
|
||||||
|
|
||||||
|
134 is measured. Inside the session shell this pane is 204x377, and ScreenLayoutTests draws
|
||||||
|
this in its worst case — the conflict card open with a session's worth of saves behind it.
|
||||||
|
-->
|
||||||
|
<ScrollViewer MaxHeight="134" VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||||
|
Text="{Binding RemoteEdit.Question}" />
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding RemoteEdit.RemotePath}" />
|
||||||
|
|
||||||
|
<!-- Bounded inside a bounded strip: this keeps a long session's log from pushing the conflict
|
||||||
|
card and STOP EDITING out of view within the scroller. -->
|
||||||
|
<ScrollViewer MaxHeight="48" VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsControl ItemsSource="{Binding RemoteEditLog}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<TextBlock Classes="mono" FontSize="11" Margin="0,1"
|
||||||
|
Foreground="{StaticResource TextGhost}" TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Border Padding="10,8" CornerRadius="8" Background="{StaticResource DangerWash}"
|
||||||
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
|
||||||
|
IsVisible="{Binding HasRemoteEditConflict}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding RemoteEditConflict}" />
|
||||||
|
<WrapPanel>
|
||||||
|
<Button Classes="ghost" Content="UPLOAD ANYWAY" Margin="0,0,8,4"
|
||||||
|
Command="{Binding ForceSaveEditCommand}" />
|
||||||
|
<Button Classes="ghost" Content="RELOAD" Margin="0,0,0,4"
|
||||||
|
Command="{Binding ReloadEditCommand}" />
|
||||||
|
</WrapPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Button Classes="ghost" Content="STOP EDITING" HorizontalAlignment="Left"
|
||||||
|
Command="{Binding StopEditingCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The question DELETE asks. It names the full path rather than the file, because a name is the half
|
The question DELETE asks. It names the full path rather than the file, because a name is the half
|
||||||
that does not identify anything — and that is now the whole of how the question says which row it
|
that does not identify anything — and that is now the whole of how the question says which row it
|
||||||
@@ -537,9 +656,28 @@
|
|||||||
<StackPanel Spacing="7">
|
<StackPanel Spacing="7">
|
||||||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||||
Text="{Binding PendingRemoteDeletion.Question}" />
|
Text="{Binding PendingRemoteDeletion.Question}" />
|
||||||
|
<!--
|
||||||
|
Every path, one per line, and never a count on its own: agreeing to "3 items" is agreeing to a
|
||||||
|
number. See RemoteDeletionRequest.Listing.
|
||||||
|
|
||||||
|
◆ 32, AND THE NUMBER IS MEASURED RATHER THAN CHOSEN. A ticked set has no ceiling, so this list
|
||||||
|
has to scroll, and the budget it scrolls inside is small: within the session shell this pane is
|
||||||
|
204 wide, where one remote path wraps to two lines, and the card also carries a heading, the
|
||||||
|
consequence and two buttons that wrap onto two rows at that width. 120 put the buttons outside
|
||||||
|
the window; 72 left CANCEL three pixels under it; 56 and 40 pushed the listing's own squeezed
|
||||||
|
row past the bottom — each caught by ScreenLayoutTests, which is why the five-path test exists
|
||||||
|
beside the single-path one rather than instead of it. 32 is one wrapped path, which is exactly
|
||||||
|
what the single-path question has always shown.
|
||||||
|
|
||||||
|
What that buys is a scrollbar rather than a taller card, and the heading carries the count so
|
||||||
|
the number is never the thing being scrolled for.
|
||||||
|
-->
|
||||||
|
<ScrollViewer MaxHeight="32" VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Disabled">
|
||||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||||
Foreground="{StaticResource Danger}"
|
Foreground="{StaticResource Danger}"
|
||||||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
Text="{Binding PendingRemoteDeletion.Listing}" />
|
||||||
|
</ScrollViewer>
|
||||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||||
<WrapPanel>
|
<WrapPanel>
|
||||||
@@ -603,6 +741,90 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE MODE, as nine boxes and the number they add up to.
|
||||||
|
|
||||||
|
In the same Grid row as the other two questions and never at the same time as either — each one's
|
||||||
|
own opening clears the others, which is what makes one row enough for all three.
|
||||||
|
|
||||||
|
Boxes rather than a field to type 755 into, and that is the design rather than a preference: the
|
||||||
|
mode is already drawn as -rwxr-xr-x in the column this form was opened from, so a who-can-do-what
|
||||||
|
grid is the same representation the reader is already holding. An octal field would ask them to
|
||||||
|
translate something they can see into something they cannot check. The octal is printed underneath
|
||||||
|
as it changes, so the two readings check each other rather than one replacing the other.
|
||||||
|
|
||||||
|
The EXEC column is headed from the request rather than fixed, because on a directory that bit is
|
||||||
|
permission to enter it — the commonest misreading of a POSIX mode there is, and one word fixes it.
|
||||||
|
See RemotePermissionsRequest.ExecuteLabel.
|
||||||
|
|
||||||
|
APPLY is dead until something has actually changed. A round trip that sets a mode to the mode it
|
||||||
|
already has is not wrong, only pointless, and a live button over an untouched form invites it.
|
||||||
|
|
||||||
|
Not painted in the deletion card's red. Changing a mode is undone by changing it back; the red is
|
||||||
|
spent on the one thing here that nothing walks back, and spending it twice would make it stop
|
||||||
|
meaning anything — the same argument the rename form above makes for itself.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||||
|
IsVisible="{Binding IsChangingRemotePermissions}">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemotePermissions.Question}" />
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding PendingRemotePermissions.FullPath}" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,2">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Classes="hint" FontSize="10.5" Text="read"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="2" Classes="hint" FontSize="10.5" Text="write"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="3" Classes="hint" FontSize="10.5"
|
||||||
|
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="11.5" Text="owner"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="11.5" Text="group"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="11.5" Text="others"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource AccentText}"
|
||||||
|
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||||
|
|
||||||
|
<WrapPanel>
|
||||||
|
<Button Classes="accent" Content="APPLY" Margin="0,0,8,4"
|
||||||
|
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||||
|
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||||
|
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||||
|
</WrapPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Away while a question is up, for the reason the item row above states: the trail is where the pane
|
Away while a question is up, for the reason the item row above states: the trail is where the pane
|
||||||
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
|
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
|
||||||
@@ -681,24 +903,42 @@
|
|||||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
<Grid Height="38" Margin="14,0,10,0">
|
<Grid Height="38" Margin="6,0,10,0">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="24" />
|
||||||
<ColumnDefinition Width="20" />
|
<ColumnDefinition Width="20" />
|
||||||
<ColumnDefinition Width="2*" />
|
<ColumnDefinition Width="2*" />
|
||||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
<!--
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
◆ THE TICK IS NOT THE LIST'S SELECTION, and the two answer different questions. The
|
||||||
|
selection is "the row I am looking at", which is what rename, move and permissions act on
|
||||||
|
— each takes a form naming one path and is one-at-a-time by construction. This is "the
|
||||||
|
rows I have gathered up", which is what DOWNLOAD and DELETE act on, and it survives the
|
||||||
|
highlight moving.
|
||||||
|
|
||||||
|
It is drawn rather than left to ctrl-click for a reason beyond taste: the phone head shares
|
||||||
|
this view model and has neither ctrl nor shift, so a multi-selection has to be a tap target
|
||||||
|
there whatever this pane does. One shape on both heads, and a state somebody can see.
|
||||||
|
|
||||||
|
Click is handled rather than bound one-way, because the count above the list is a property
|
||||||
|
of the collection rather than of any row — see TransfersViewModel.RemoteChecksChanged.
|
||||||
|
-->
|
||||||
|
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding IsChecked}"
|
||||||
|
Click="OnRemoteRowChecked" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||||
Classes.exec="{Binding IsExecutable}"
|
Classes.exec="{Binding IsExecutable}"
|
||||||
Text="{Binding Name}" FontSize="13.5"
|
Text="{Binding Name}" FontSize="13.5"
|
||||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
<TextBlock Grid.Column="5" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||||
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
|
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -732,8 +972,14 @@
|
|||||||
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
|
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.
|
reaches the floor. Anything added here has to hold that; the layout suite measures both.
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
ShowsRemoteInvitation rather than !IsConnected, which are no longer the same question. Pressing
|
||||||
|
DISCONNECT empties this pane, so an invitation over it covers nothing; a connection lost
|
||||||
|
underneath somebody leaves the listing exactly where it was, and covering that would hide the one
|
||||||
|
thing the strip below is offering to put them back into. See TransfersViewModel.ShowsRemoteContents.
|
||||||
|
-->
|
||||||
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
|
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
|
||||||
IsVisible="{Binding !IsConnected}">
|
IsVisible="{Binding ShowsRemoteInvitation}">
|
||||||
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ internal sealed partial class TransfersScreen : UserControl
|
|||||||
|
|
||||||
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
|
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells the screen a row's tick moved, so the count and the buttons that read it keep up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The tick itself is two-way bound and needs nothing from here; what does is the count above the list,
|
||||||
|
/// which is a property of the collection rather than of any row. Subscribing per row would be a
|
||||||
|
/// subscription to attach and detach on every navigation, since each listing replaces every row it has.
|
||||||
|
/// See <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) => Transfers?.RemoteChecksChanged();
|
||||||
|
|
||||||
|
/// <inheritdoc cref="OnRemoteRowChecked" />
|
||||||
|
private void OnLocalRowChecked(object? sender, RoutedEventArgs e) => Transfers?.LocalChecksChanged();
|
||||||
|
|
||||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (Transfers is { } transfers)
|
if (Transfers is { } transfers)
|
||||||
|
|||||||
@@ -55,6 +55,24 @@ internal sealed class S3FileStore : IRemoteFileStore
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool IsConnected => Volatile.Read(ref disposed) == 0;
|
public bool IsConnected => Volatile.Read(ref disposed) == 0;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Null forever, for the reason <see cref="IsConnected"/> is always true: there is no connection under
|
||||||
|
/// this to be lost. A request that fails reports itself, and nothing here outlives one.
|
||||||
|
/// </remarks>
|
||||||
|
public string? TransportFailure => null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Never raised; see <see cref="TransportFailure"/>. Declared rather than left off the type because the
|
||||||
|
/// screen holds whichever store it opened as an <c>IRemoteFileStore</c> and subscribes without asking
|
||||||
|
/// which kind it got — an event that is simply never raised is what makes that honest rather than a
|
||||||
|
/// cast and a branch.
|
||||||
|
/// </remarks>
|
||||||
|
#pragma warning disable CS0067 // The event is never used
|
||||||
|
public event EventHandler? ConnectionLost;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory => ObjectKeys.Root;
|
public string HomeDirectory => ObjectKeys.Root;
|
||||||
|
|
||||||
@@ -383,6 +401,32 @@ internal sealed class S3FileStore : IRemoteFileStore
|
|||||||
/// a rename's clothing, and the failure mode is a half-moved directory.
|
/// a rename's clothing, and the failure mode is a half-moved directory.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Refused rather than approximated. An object is governed by an ACL and a bucket policy, which are not
|
||||||
|
/// nine bits and do not map onto them — writing something plausible here would put a mode in a listing
|
||||||
|
/// that no request would ever be decided by.
|
||||||
|
/// </remarks>
|
||||||
|
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) =>
|
||||||
|
throw new SftpPathException(
|
||||||
|
path,
|
||||||
|
"An object in a bucket has an access policy rather than a POSIX mode, so there is nothing here to set.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Refused for the reason above: an object's timestamp is when the service stored it, and there is no
|
||||||
|
/// request that says otherwise. A copy that claimed the source's time would be a date the bucket does
|
||||||
|
/// not agree with.
|
||||||
|
/// </remarks>
|
||||||
|
public Task SetLastWriteTimeAsync(
|
||||||
|
string path,
|
||||||
|
DateTimeOffset lastWriteTimeUtc,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new SftpPathException(
|
||||||
|
path,
|
||||||
|
"An object's timestamp is when the bucket stored it, and cannot be set to anything else.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(fromPath);
|
ArgumentNullException.ThrowIfNull(fromPath);
|
||||||
|
|||||||
@@ -493,6 +493,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="openInEditor">
|
||||||
|
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
|
||||||
|
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
|
||||||
|
/// that uses it — see its own remark for why this is a delegate and what null means.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="watchFile">
|
||||||
|
/// Watches one local file for changes until the handle it returns is disposed, or null where nothing
|
||||||
|
/// can. Forwarded with <paramref name="openInEditor"/> and useless without it.
|
||||||
|
/// </param>
|
||||||
/// <param name="post">
|
/// <param name="post">
|
||||||
/// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
|
/// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
|
||||||
/// dispatcher, which is the answer in every real head.
|
/// dispatcher, which is the answer in every real head.
|
||||||
@@ -520,7 +529,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
string? deviceName = null,
|
string? deviceName = null,
|
||||||
IUpdateChannel? updates = null,
|
IUpdateChannel? updates = null,
|
||||||
Action<Action>? post = null)
|
Action<Action>? post = null,
|
||||||
|
Func<string, Task>? openInEditor = null,
|
||||||
|
Func<string, Action, IDisposable>? watchFile = null)
|
||||||
{
|
{
|
||||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||||
|
|
||||||
@@ -543,7 +554,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the
|
// The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the
|
||||||
// keychain screen, which is a different tab and two clicks away from somebody who has gone to S3
|
// keychain screen, which is a different tab and two clicks away from somebody who has gone to S3
|
||||||
// to add one — so the screen that needs a bucket is given a way to reach the screen that makes one.
|
// to add one — so the screen that needs a bucket is given a way to reach the screen that makes one.
|
||||||
transfers = new TransfersViewModel(sftpSessions, clock, addBucket: ShowNewBucket);
|
transfers = CreateFileBrowser(sftpSessions, clock, openInEditor, watchFile);
|
||||||
|
|
||||||
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
|
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
|
||||||
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
|
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
|
||||||
@@ -612,6 +623,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
|
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the file browser, which is composed once and outlives every lock.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A method rather than four more lines in the constructor, which is already at the length the analyzers
|
||||||
|
/// allow — the same reason <see cref="CreateUpdateScreen"/> is one.
|
||||||
|
/// <para>
|
||||||
|
/// It is handed this shell's own posting delegate rather than left to default to the UI dispatcher,
|
||||||
|
/// which is what it did while nothing it posted was worth asserting on. A connection lost underneath the
|
||||||
|
/// file browser is raised on SSH.NET's message-listener thread and has to cross onto the thread these
|
||||||
|
/// view models are read from — the same crossing connection phases already make — and a suite with no
|
||||||
|
/// window has no way to drain a dispatcher it does not own. Both defaults are the UI dispatcher, so it
|
||||||
|
/// changes nothing in either head.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private TransfersViewModel CreateFileBrowser(
|
||||||
|
ISftpSessionFactory sftpSessions,
|
||||||
|
TimeProvider clock,
|
||||||
|
Func<string, Task>? openInEditor,
|
||||||
|
Func<string, Action, IDisposable>? watchFile) =>
|
||||||
|
new(
|
||||||
|
sftpSessions,
|
||||||
|
clock,
|
||||||
|
post: post,
|
||||||
|
addBucket: ShowNewBucket,
|
||||||
|
openInEditor: openInEditor,
|
||||||
|
watchFile: watchFile);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
|
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3823,13 +3860,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// null while it has not.
|
/// null while it has not.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Terminal-only, because it is a fact about a shell. The SFTP surface reports its own connection
|
/// <para>
|
||||||
/// through <see cref="TransfersViewModel.IsConnected"/> and has no ending to word: a transfer session is
|
/// Both surfaces, and only one word on the file side. A shell has two endings worth telling apart —
|
||||||
/// opened per browse and closed when the screen is left, so there is no state in which one has stopped
|
/// somebody typed <c>exit</c>, or the machine stopped answering — and a file session has one: this
|
||||||
/// while somebody is still looking at it.
|
/// screen closing it is a disconnect nobody needs told about, so the only ending left to word is the one
|
||||||
|
/// that happened on its own.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A bucket never reaches this. It is HTTP per request with no connection to lose, so
|
||||||
|
/// <see cref="TransfersViewModel.IsConnectionLost"/> is false for one however badly a request goes —
|
||||||
|
/// which is honest rather than a gap: a failed request against a bucket is a failed request, and the
|
||||||
|
/// screen already reports it as one.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string? SessionEndedText =>
|
internal string? SessionEndedText => Surface switch
|
||||||
IsTerminalSurface ? SelectedTab?.EndedSummary : null;
|
{
|
||||||
|
ShellSurface.Terminal => SelectedTab?.EndedSummary,
|
||||||
|
_ when IsTransfersShowing => Transfers.IsConnectionLost ? "DISCONNECTED" : null,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Whether the selected tab's session ended because the connection broke.</summary>
|
/// <summary>Whether the selected tab's session ended because the connection broke.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -3837,8 +3886,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// reason this is a second question rather than "did it end". One of the two is somebody having finished
|
/// reason this is a second question rather than "did it end". One of the two is somebody having finished
|
||||||
/// what they were doing and the other is a machine that stopped answering.
|
/// what they were doing and the other is a machine that stopped answering.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool IsSessionDropped =>
|
internal bool IsSessionDropped => Surface switch
|
||||||
IsTerminalSurface && SelectedTab?.EndReason is SshSessionEnd.ConnectionLost;
|
{
|
||||||
|
ShellSurface.Terminal => SelectedTab?.EndReason is SshSessionEnd.ConnectionLost,
|
||||||
|
|
||||||
|
// The file side's one ending is the lost one, so where the terminal has to ask which it was, this
|
||||||
|
// only has to ask whether there was one.
|
||||||
|
_ when IsTransfersShowing => Transfers.IsConnectionLost,
|
||||||
|
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the status bar should say NOT CONNECTED rather than name an ending.
|
/// Whether the status bar should say NOT CONNECTED rather than name an ending.
|
||||||
@@ -3860,8 +3917,79 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// the line explaining itself off the screen, and this is the copy that stays put. See
|
/// the line explaining itself off the screen, and this is the copy that stays put. See
|
||||||
/// <see cref="TerminalTabViewModel.EndedDetail"/> for the case where it is not that string.
|
/// <see cref="TerminalTabViewModel.EndedDetail"/> for the case where it is not that string.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string? SessionEndedDetail =>
|
internal string? SessionEndedDetail => Surface switch
|
||||||
IsTerminalSurface ? SelectedTab?.EndedDetail : null;
|
{
|
||||||
|
ShellSurface.Terminal => SelectedTab?.EndedDetail,
|
||||||
|
_ when IsTransfersShowing => Transfers.LostConnection,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the strip under a stopped session may offer to dial again.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One question resolved by surface, for the reason <see cref="SessionAddress"/> is: the strip is the
|
||||||
|
/// same markup on both, and a view branching on which one it is drawn over would be the shell's own job
|
||||||
|
/// done in XAML. The two halves are genuinely different questions — a terminal needs a keychain entry to
|
||||||
|
/// redial, and a file session already captured what it would dial when it connected.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool CanReconnectSession => Surface switch
|
||||||
|
{
|
||||||
|
ShellSurface.Terminal => CanReconnectSelectedTab,
|
||||||
|
_ when IsTransfersShowing => Transfers.IsConnectionLost,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>What the strip's second button says, which is not the same act on the two surfaces.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A terminal's tab is a thing that can be taken away and a file session's pane is not — there is one of
|
||||||
|
/// it, and what "dismiss" means there is putting the screen back to its invitation. Two labels rather
|
||||||
|
/// than one vague one, because a button that said CLOSE on both would be doing noticeably different
|
||||||
|
/// things.
|
||||||
|
/// </remarks>
|
||||||
|
internal string SessionEndedDismissLabel => IsTerminalSurface ? "CLOSE TAB" : "DISCONNECT";
|
||||||
|
|
||||||
|
/// <summary>Dials again, whichever kind of session stopped.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Each half is the surface's own reconnect rather than a third path: the terminal's goes through the
|
||||||
|
/// vault's connect command into the tab that stopped, and the file side's re-opens on the remote it
|
||||||
|
/// captured and walks back to the directory it was showing.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ReconnectSessionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (IsTerminalSurface)
|
||||||
|
{
|
||||||
|
await ReconnectTabAsync(SelectedTab).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTransfersShowing)
|
||||||
|
{
|
||||||
|
await Transfers.ReconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes the stopped session away, whichever kind it is.</summary>
|
||||||
|
/// <inheritdoc cref="SessionEndedDismissLabel" path="/remarks" />
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task DismissEndedSessionAsync()
|
||||||
|
{
|
||||||
|
if (IsTerminalSurface)
|
||||||
|
{
|
||||||
|
if (SelectedTab is { } tab)
|
||||||
|
{
|
||||||
|
await CloseTabAsync(tab).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTransfersShowing)
|
||||||
|
{
|
||||||
|
await Transfers.DisconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a new shell on the machine a stopped tab was pointed at, in that tab.
|
/// Opens a new shell on the machine a stopped tab was pointed at, in that tab.
|
||||||
@@ -4382,7 +4510,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
internal string? SessionAddress => Surface switch
|
internal string? SessionAddress => Surface switch
|
||||||
{
|
{
|
||||||
ShellSurface.Terminal => SelectedTab?.Address,
|
ShellSurface.Terminal => SelectedTab?.Address,
|
||||||
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
|
|
||||||
|
// Connected, or connected until the link went. A session that has just been lost still has a host
|
||||||
|
// worth naming — it is the one the strip is offering to dial again — and requiring IsConnected here
|
||||||
|
// collapsed the entire left half of the status bar at exactly the moment it had the most to say.
|
||||||
|
_ when IsTransfersShowing =>
|
||||||
|
Transfers.IsConnected || Transfers.IsConnectionLost ? Transfers.ConnectedTo : null,
|
||||||
|
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4520,6 +4654,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(IsSessionDropped));
|
OnPropertyChanged(nameof(IsSessionDropped));
|
||||||
OnPropertyChanged(nameof(IsSessionIdle));
|
OnPropertyChanged(nameof(IsSessionIdle));
|
||||||
OnPropertyChanged(nameof(CanReconnectSelectedTab));
|
OnPropertyChanged(nameof(CanReconnectSelectedTab));
|
||||||
|
OnPropertyChanged(nameof(CanReconnectSession));
|
||||||
|
OnPropertyChanged(nameof(SessionEndedDismissLabel));
|
||||||
OnPropertyChanged(nameof(SessionElapsedText));
|
OnPropertyChanged(nameof(SessionElapsedText));
|
||||||
OnPropertyChanged(nameof(SessionCipher));
|
OnPropertyChanged(nameof(SessionCipher));
|
||||||
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||||
@@ -4543,6 +4679,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||||
or nameof(TransfersViewModel.IsConnected)
|
or nameof(TransfersViewModel.IsConnected)
|
||||||
|
or nameof(TransfersViewModel.IsConnectionLost)
|
||||||
|
or nameof(TransfersViewModel.LostConnection)
|
||||||
or nameof(TransfersViewModel.Remote)
|
or nameof(TransfersViewModel.Remote)
|
||||||
or nameof(TransfersViewModel.ConnectedTo)
|
or nameof(TransfersViewModel.ConnectedTo)
|
||||||
or nameof(TransfersViewModel.ConnectedCipher)
|
or nameof(TransfersViewModel.ConnectedCipher)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -140,6 +140,54 @@ public static class PosixMode
|
|||||||
/// <summary>Whether the others triple carries the write bit.</summary>
|
/// <summary>Whether the others triple carries the write bit.</summary>
|
||||||
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
|
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the nine permission bits back out of a formatted mode, as an octal number.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The inverse of <see cref="Format"/>, and it exists because the editor that changes a mode has to
|
||||||
|
/// start from the one that is there. Anything that is not a mode this type wrote answers zero — the same
|
||||||
|
/// forgiving reading <see cref="At"/> already takes, and for the same reason: this feeds a form, and a
|
||||||
|
/// listing is not worth failing over one unrecognised string.
|
||||||
|
/// </remarks>
|
||||||
|
public static int ToOctal(string mode)
|
||||||
|
{
|
||||||
|
if (mode is not { Length: 10 })
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bits = 0;
|
||||||
|
|
||||||
|
for (var index = 1; index < 10; index++)
|
||||||
|
{
|
||||||
|
bits <<= 1;
|
||||||
|
|
||||||
|
if (mode[index] != '-')
|
||||||
|
{
|
||||||
|
bits |= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Formats nine permission bits the way <c>chmod</c> takes them, e.g. <c>0755</c>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Four digits with a leading zero, which is how every <c>chmod</c> example on earth writes one — and
|
||||||
|
/// the leading zero is the part that says out loud that the three bits this client does not write are
|
||||||
|
/// being sent as clear. See the type's own remark.
|
||||||
|
/// </remarks>
|
||||||
|
public static string FormatOctal(int mode) =>
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"0{(mode >> 6) & 7}{(mode >> 3) & 7}{mode & 7}");
|
||||||
|
|
||||||
|
/// <summary>The nine bits, and nothing above them.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Applied to everything on its way to a server. It is what keeps a caller from setting setuid through
|
||||||
|
/// an interface that does not admit to offering it — see the type's own remark on why those three bits
|
||||||
|
/// are neither rendered nor written.
|
||||||
|
/// </remarks>
|
||||||
|
public static int Sanitise(int mode) => mode & 0b111_111_111;
|
||||||
|
|
||||||
private const int OwnerExecute = 3;
|
private const int OwnerExecute = 3;
|
||||||
private const int GroupExecute = 6;
|
private const int GroupExecute = 6;
|
||||||
private const int OthersWrite = 8;
|
private const int OthersWrite = 8;
|
||||||
@@ -393,6 +441,43 @@ public interface IRemoteFileStore : IAsyncDisposable
|
|||||||
/// <summary>Whether the transport is still up.</summary>
|
/// <summary>Whether the transport is still up.</summary>
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Why the connection under this store went away, or null if it did not.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The same fact, latched the same way and for the same reason, as
|
||||||
|
/// <see cref="ISshShellSession.TransportFailure"/> — read that member for the argument. What differs is
|
||||||
|
/// what it is read <em>for</em>: a shell has a blocked read this releases, and a file browser has no
|
||||||
|
/// loop at all. Here it is the sentence a screen prints beside the word DISCONNECTED, and the thing that
|
||||||
|
/// tells a failed listing about a path apart from a failed listing about a connection.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Null forever on a store with no connection to lose. A bucket is HTTP, request per operation, and has
|
||||||
|
/// nothing that can be up between two of them.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
string? TransportFailure { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised once, when the connection goes away without this client having closed it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Because otherwise nobody finds out until they click.</b> A file browser is idle between
|
||||||
|
/// operations, so the alternative to an event is a screen that goes on saying CONNECTED over a host that
|
||||||
|
/// is gone until somebody happens to ask it for a listing — which is the same dishonesty a terminal
|
||||||
|
/// showed and the same one this is here to remove. <see cref="TransportFailure"/> is already set by the
|
||||||
|
/// time this is raised.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Raised on whichever thread the failure was noticed on, which is SSH.NET's message-listener thread; a
|
||||||
|
/// handler that touches a view model has to marshal. Never raised by a store with no connection to lose,
|
||||||
|
/// and never raised for a session this client disposed itself.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
event EventHandler? ConnectionLost;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where the session starts, which is the account's home directory.
|
/// Where the session starts, which is the account's home directory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -453,6 +538,42 @@ public interface IRemoteFileStore : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>Renames or moves a path within the same host.</summary>
|
/// <summary>Renames or moves a path within the same host.</summary>
|
||||||
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
|
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a path's POSIX mode.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">What to change.</param>
|
||||||
|
/// <param name="mode">
|
||||||
|
/// The nine permission bits, as an octal number reads them — <c>0o755</c> is <c>rwxr-xr-x</c>. Only the
|
||||||
|
/// low nine bits are sent; see <see cref="PosixMode"/> for why setuid, setgid and sticky are not this
|
||||||
|
/// client's to write.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>The one operation on this interface that writes something the listing already shows.</b> The
|
||||||
|
/// <c>PERMS</c> column has been a read-only fact since it arrived, and the commonest thing anybody does
|
||||||
|
/// straight after using this screen is open a terminal to make the file they just uploaded executable.
|
||||||
|
/// <para>
|
||||||
|
/// Refused by a store with no such thing. An object has an ACL rather than a mode, and mapping one onto
|
||||||
|
/// the other would be inventing a fact about a bucket.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="SftpPathException">The path is not there, is not writable, or has no mode to set.</exception>
|
||||||
|
Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a path's last-write time.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What makes an upload land carrying the timestamp its source had, which is the half of "preserve" that
|
||||||
|
/// every platform can answer — a mode is not a fact about a file on Windows and a modification time is.
|
||||||
|
/// Refused by a store that has no way to say when an object was written other than when it was put.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="SftpPathException">The path is not there, or its time cannot be set.</exception>
|
||||||
|
Task SetLastWriteTimeAsync(
|
||||||
|
string path,
|
||||||
|
DateTimeOffset lastWriteTimeUtc,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -11,14 +11,35 @@ namespace DodoSSH.Client.Ssh;
|
|||||||
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
|
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
|
||||||
/// caller can act on.
|
/// caller can act on.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
internal sealed class SshNetSftpSession : ISftpSession
|
||||||
: ISftpSession
|
|
||||||
{
|
{
|
||||||
|
private readonly SftpClient client;
|
||||||
|
|
||||||
|
private string? transportFailure;
|
||||||
|
private int announced;
|
||||||
|
|
||||||
|
internal SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
||||||
|
{
|
||||||
|
this.client = client;
|
||||||
|
|
||||||
|
HostKey = hostKey;
|
||||||
|
HomeDirectory = homeDirectory;
|
||||||
|
Cipher = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||||
|
|
||||||
|
client.ErrorOccurred += OnTransportError;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsConnected => client.IsConnected;
|
public bool IsConnected => client.IsConnected;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
public string? TransportFailure => Volatile.Read(ref transportFailure);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler? ConnectionLost;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public HostKeyPresentation HostKey { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -27,10 +48,36 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
|
|||||||
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
|
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
|
||||||
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
|
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
public string Cipher { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory { get; } = homeDirectory;
|
public string HomeDirectory { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Latches the failure and says so once.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// No blocked read to release, unlike the shell's own handler: every SFTP call is a request and a reply,
|
||||||
|
/// so a connection that has gone is discovered by the next one throwing. What this adds is that somebody
|
||||||
|
/// finds out <em>without</em> making that next call — a file browser sits idle between operations, and
|
||||||
|
/// waiting for a click to reveal a host that went away half an hour ago is exactly the stale CONNECTED
|
||||||
|
/// this exists to remove.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Announced once. SSH.NET can raise several errors as one connection comes apart, and a screen told
|
||||||
|
/// three times would report the second and third against a session it had already replaced.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnTransportError(object? sender, ExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref announced, 1) == 0)
|
||||||
|
{
|
||||||
|
ConnectionLost?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||||
@@ -141,10 +188,61 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>On a thread-pool thread, because SSH.NET offers no asynchronous <c>SETSTAT</c>.</b>
|
||||||
|
/// <c>ChangePermissions</c> is a blocking round trip and every caller here is on the thread the
|
||||||
|
/// interface is drawn from — so calling it directly would freeze the window for a link's latency, which
|
||||||
|
/// on the connections this product exists for is the difference between instant and noticeably not.
|
||||||
|
/// Every other operation on this type has an async form and uses it.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Sanitised here rather than trusted from the caller, so that the promise the interface makes about
|
||||||
|
// the three bits it does not write holds at the last point before the wire.
|
||||||
|
var bits = (short)PosixMode.Sanitise(mode);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Run(() => client.ChangePermissions(path, bits), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsPathFailure(exception))
|
||||||
|
{
|
||||||
|
throw Translate(path, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>See <see cref="SetPermissionsAsync"/>; the same blocking round trip, moved off the caller's thread for the same reason.</remarks>
|
||||||
|
public async Task SetLastWriteTimeAsync(
|
||||||
|
string path,
|
||||||
|
DateTimeOffset lastWriteTimeUtc,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var when = lastWriteTimeUtc.UtcDateTime;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Run(() => client.SetLastWriteTimeUtc(path, when), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsPathFailure(exception))
|
||||||
|
{
|
||||||
|
throw Translate(path, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
|
// Before the client goes, and it also settles the flag: a disposal that trips SSH.NET's own error
|
||||||
|
// path must not reach a screen as a connection this client did not mean to lose.
|
||||||
|
Interlocked.Exchange(ref announced, 1);
|
||||||
|
client.ErrorOccurred -= OnTransportError;
|
||||||
|
|
||||||
client.Dispose();
|
client.Dispose();
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,41 @@ public sealed record TransferSnapshot(
|
|||||||
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
|
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a transfer should carry across with the bytes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Mode">
|
||||||
|
/// The nine POSIX permission bits to set on the destination, or null to leave whatever it lands with.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="LastWriteTimeUtc">
|
||||||
|
/// The timestamp to set on the destination, or null to leave the one the write produced.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Both nullable, because both are facts the source may not have.</b> A local file on Windows has no
|
||||||
|
/// POSIX mode — this codebase already says so, in <c>LocalEntryRowViewModel</c>, and prints an empty
|
||||||
|
/// <c>PERMS</c> column rather than a plausible <c>-rw-r--r--</c>. So the caller passes what it actually
|
||||||
|
/// knows and null for the rest, and nothing here invents a default: a file that arrives with the server's
|
||||||
|
/// umask on it is the honest outcome of an upload from a machine with no mode to send.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Applied after the rename into place, never before.</b> The part file is what the transfer writes and
|
||||||
|
/// it is not the file anybody asked for; setting a mode on it would put the right bits on the wrong name
|
||||||
|
/// and leave the destination with whatever the rename gave it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A failure here does not fail the transfer.</b> The bytes are the transfer and they arrived; a server
|
||||||
|
/// that refuses <c>SETSTAT</c> — some do, and every object store does — must not turn a completed copy into
|
||||||
|
/// a failed one. It is reported through <see cref="TransferSnapshot.Failure"/> on a row that still says
|
||||||
|
/// <see cref="TransferState.Completed"/>, which is the one shape that says both true things at once.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record TransferAttributes(int? Mode, DateTimeOffset? LastWriteTimeUtc)
|
||||||
|
{
|
||||||
|
/// <summary>Whether there is anything here worth a round trip.</summary>
|
||||||
|
public bool IsEmpty => Mode is null && LastWriteTimeUtc is null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A transfer whose state or progress has moved.</summary>
|
/// <summary>A transfer whose state or progress has moved.</summary>
|
||||||
/// <param name="transfer">The transfer, as it now is.</param>
|
/// <param name="transfer">The transfer, as it now is.</param>
|
||||||
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
|
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
|
||||||
@@ -216,18 +251,23 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
/// rather than measured here, because the pane the user dragged from has just read it and asking again
|
/// rather than measured here, because the pane the user dragged from has just read it and asking again
|
||||||
/// would be a round trip to learn something already on screen.
|
/// would be a round trip to learn something already on screen.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="preserve">
|
||||||
|
/// What to carry across with the bytes, or null to carry nothing. See <see cref="TransferAttributes"/>.
|
||||||
|
/// </param>
|
||||||
/// <returns>The transfer's id.</returns>
|
/// <returns>The transfer's id.</returns>
|
||||||
public Guid Enqueue(
|
public Guid Enqueue(
|
||||||
TransferDirection direction,
|
TransferDirection direction,
|
||||||
string localPath,
|
string localPath,
|
||||||
string remotePath,
|
string remotePath,
|
||||||
long length)
|
long length,
|
||||||
|
TransferAttributes? preserve = null)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(localPath);
|
ArgumentException.ThrowIfNullOrEmpty(localPath);
|
||||||
ArgumentException.ThrowIfNullOrEmpty(remotePath);
|
ArgumentException.ThrowIfNullOrEmpty(remotePath);
|
||||||
|
|
||||||
var entry = new Entry
|
var entry = new Entry
|
||||||
{
|
{
|
||||||
|
Preserve = preserve is { IsEmpty: false } wanted ? wanted : null,
|
||||||
Id = Guid.CreateVersion7(),
|
Id = Guid.CreateVersion7(),
|
||||||
Direction = direction,
|
Direction = direction,
|
||||||
LocalPath = localPath,
|
LocalPath = localPath,
|
||||||
@@ -549,6 +589,75 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
|
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
|
||||||
// failure would leave a complete transfer looking like an incomplete one.
|
// failure would leave a complete transfer looking like an incomplete one.
|
||||||
File.Move(part, destination);
|
File.Move(part, destination);
|
||||||
|
|
||||||
|
ApplyLocally(entry, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts the remote file's mode and timestamp on the copy that has just landed here.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The mode is only set where the platform has one.</b> <c>File.SetUnixFileMode</c> throws on
|
||||||
|
/// Windows, where a POSIX mode is not a fact about a file at all — so it is asked for rather than tried,
|
||||||
|
/// and a download onto Windows quietly carries the timestamp alone. That is not a gap being papered
|
||||||
|
/// over: there is nothing on that filesystem for those nine bits to mean.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Best-effort, and its failures are recorded on a row that still says the transfer completed. See
|
||||||
|
/// <see cref="TransferAttributes"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ApplyLocally(Entry entry, string destination)
|
||||||
|
{
|
||||||
|
if (entry.Preserve is not { } preserve)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (preserve.LastWriteTimeUtc is { } when)
|
||||||
|
{
|
||||||
|
File.SetLastWriteTimeUtc(destination, when.UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preserve.Mode is { } mode && !OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
File.SetUnixFileMode(destination, (UnixFileMode)PosixModeBits(mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException
|
||||||
|
or PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
entry.Failure = $"The file arrived, but its permissions could not be set: {exception.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nine permission bits, as <see cref="UnixFileMode"/> numbers them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A translation rather than a cast, and it is load-bearing: <see cref="UnixFileMode"/> is a flags enum
|
||||||
|
/// whose values happen to line up with the octal mode for the low nine bits, which is true today and is
|
||||||
|
/// not something the BCL promises. Writing the nine out means a change there is a compile error rather
|
||||||
|
/// than a file that lands with the wrong bits on it.
|
||||||
|
/// </remarks>
|
||||||
|
private static UnixFileMode PosixModeBits(int mode)
|
||||||
|
{
|
||||||
|
var bits = UnixFileMode.None;
|
||||||
|
|
||||||
|
if ((mode & 0b100_000_000) != 0) { bits |= UnixFileMode.UserRead; }
|
||||||
|
if ((mode & 0b010_000_000) != 0) { bits |= UnixFileMode.UserWrite; }
|
||||||
|
if ((mode & 0b001_000_000) != 0) { bits |= UnixFileMode.UserExecute; }
|
||||||
|
if ((mode & 0b000_100_000) != 0) { bits |= UnixFileMode.GroupRead; }
|
||||||
|
if ((mode & 0b000_010_000) != 0) { bits |= UnixFileMode.GroupWrite; }
|
||||||
|
if ((mode & 0b000_001_000) != 0) { bits |= UnixFileMode.GroupExecute; }
|
||||||
|
if ((mode & 0b000_000_100) != 0) { bits |= UnixFileMode.OtherRead; }
|
||||||
|
if ((mode & 0b000_000_010) != 0) { bits |= UnixFileMode.OtherWrite; }
|
||||||
|
if ((mode & 0b000_000_001) != 0) { bits |= UnixFileMode.OtherExecute; }
|
||||||
|
|
||||||
|
return bits;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ReadIntoPartAsync(
|
private async Task ReadIntoPartAsync(
|
||||||
@@ -602,6 +711,50 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
|
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await ApplyRemotelyAsync(entry, session, destination, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts the source file's mode and timestamp on the copy that has just landed on the host.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>This is the one that makes an uploaded <c>deploy.sh</c> arrive executable.</b> Without it a file
|
||||||
|
/// lands with whatever the server's umask gives it, which is the reason people reach for a terminal
|
||||||
|
/// straight after using this screen.
|
||||||
|
/// <para>
|
||||||
|
/// A refusal is recorded and does not fail the transfer — a store that has no mode to set says so, and
|
||||||
|
/// the bytes are still there. See <see cref="TransferAttributes"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task ApplyRemotelyAsync(
|
||||||
|
Entry entry,
|
||||||
|
IRemoteFileStore session,
|
||||||
|
string destination,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (entry.Preserve is not { } preserve)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (preserve.Mode is { } mode)
|
||||||
|
{
|
||||||
|
await session.SetPermissionsAsync(destination, mode, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preserve.LastWriteTimeUtc is { } when)
|
||||||
|
{
|
||||||
|
await session.SetLastWriteTimeAsync(destination, when, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||||
|
and not OperationCanceledException)
|
||||||
|
{
|
||||||
|
entry.Failure = $"The file arrived, but its permissions could not be set: {exception.Message}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task WriteIntoPartAsync(
|
private async Task WriteIntoPartAsync(
|
||||||
@@ -865,6 +1018,9 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
|
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
|
||||||
public bool Resume { get; set; }
|
public bool Resume { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc cref="TransferAttributes" />
|
||||||
|
public TransferAttributes? Preserve { get; init; }
|
||||||
|
|
||||||
public CancellationTokenSource? Cancellation { get; set; }
|
public CancellationTokenSource? Cancellation { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1325,9 +1325,61 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
transfers.IsConnected = true;
|
transfers.IsConnected = true;
|
||||||
transfers.RemotePath = "/srv/releases/site/backups/nightly";
|
transfers.RemotePath = "/srv/releases/site/backups/nightly";
|
||||||
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||||
|
[
|
||||||
|
new RemoteDeletionTarget(
|
||||||
"2026-07-30",
|
"2026-07-30",
|
||||||
"/srv/releases/site/backups/nightly/2026-07-30",
|
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||||
IsDirectory: true);
|
IsDirectory: true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The edit strip, which shares the questions' Grid row and is the tallest thing that can be in it: a
|
||||||
|
/// heading, a path, a scrolling log of saves, the conflict card and a button. Its log is bounded for the
|
||||||
|
/// same reason the deletion card's path list is — see TransfersScreen.axaml — and this is what says that
|
||||||
|
/// bound holds with the conflict card open underneath it, which is the worst case.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransfersScreenFitsWithAFileBeingEdited()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/etc/nginx/conf.d";
|
||||||
|
transfers.RemoteEdit = new RemoteEditSession(
|
||||||
|
"site.conf",
|
||||||
|
"/etc/nginx/conf.d/site.conf",
|
||||||
|
Path.Combine(Path.GetTempPath(), "dodossh-edits", "abc", "site.conf"),
|
||||||
|
0b110_100_100);
|
||||||
|
|
||||||
|
transfers.RemoteEditConflict = "The server's copy has changed since you opened it.";
|
||||||
|
|
||||||
|
for (var save = 0; save < 6; save++)
|
||||||
|
{
|
||||||
|
transfers.RemoteEditLog.Add($"14:2{save}:07 saved → uploaded (2.4 KB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The same card with a whole ticked set in it, which is the shape that can grow without limit. Its
|
||||||
|
/// path list scrolls inside a bounded height rather than pushing the buttons under it off the pane —
|
||||||
|
/// see TransfersScreen.axaml — and this is what says that bound actually holds. Five is past the point
|
||||||
|
/// where an unbounded list would have overflowed the 204-pixel pane inside the session shell.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransfersScreenFitsWithSeveralDeletionsInQuestion()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/srv/releases/site/backups/nightly";
|
||||||
|
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||||
|
[
|
||||||
|
.. Enumerable.Range(24, 5).Select(day => new RemoteDeletionTarget(
|
||||||
|
$"2026-07-{day}",
|
||||||
|
$"/srv/releases/site/backups/nightly/2026-07-{day}",
|
||||||
|
IsDirectory: true)),
|
||||||
|
]);
|
||||||
|
|
||||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,27 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsConnected { get; private set; } = true;
|
public bool IsConnected { get; private set; } = true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? TransportFailure { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public event EventHandler? ConnectionLost;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends this session the way a rebooted host does: the connection is gone and it says so.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Latched before the announcement, which is the ordering the real session promises — a screen reading
|
||||||
|
/// the reason out of the event would see null if a fake did it the other way round.
|
||||||
|
/// </remarks>
|
||||||
|
internal void Drop(string reason)
|
||||||
|
{
|
||||||
|
TransportFailure = reason;
|
||||||
|
IsConnected = false;
|
||||||
|
|
||||||
|
ConnectionLost?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } =
|
public HostKeyPresentation HostKey { get; } =
|
||||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||||
@@ -139,6 +160,35 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
/// <summary>Every path this session has been asked to delete.</summary>
|
/// <summary>Every path this session has been asked to delete.</summary>
|
||||||
internal List<string> Deleted { get; } = [];
|
internal List<string> Deleted { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Every mode this session has been asked to set, as it was asked.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The evidence the screen's own status line cannot be: that sentence is code describing itself, and
|
||||||
|
/// what matters is which nine bits actually went to the host.
|
||||||
|
/// </remarks>
|
||||||
|
internal List<(string Path, int Mode)> PermissionsSet { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Every timestamp this session has been asked to set.</summary>
|
||||||
|
internal List<(string Path, DateTimeOffset When)> TimesSet { get; } = [];
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
PermissionsSet.Add((path, mode));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetLastWriteTimeAsync(
|
||||||
|
string path,
|
||||||
|
DateTimeOffset lastWriteTimeUtc,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
TimesSet.Add((path, lastWriteTimeUtc));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -156,21 +206,67 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paths this session admits to already having, and what it says about each.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ Empty by default, and that is load-bearing rather than lazy: the transfer queue asks
|
||||||
|
/// <see cref="StatAsync"/> whether a destination is taken and refuses to overwrite one that is, so a
|
||||||
|
/// fake answering yes for everything would make every upload in this suite fail. What needs a real
|
||||||
|
/// answer is editing, which stats the file before each write-back to see whether it moved — so a test
|
||||||
|
/// about that puts the one path it is about in here.
|
||||||
|
/// </remarks>
|
||||||
|
internal Dictionary<string, SftpEntry> Existing { get; } = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>What the last write-back actually sent, so an edit's bytes are observable.</summary>
|
||||||
|
internal byte[]? Written { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||||
Task.FromResult<SftpEntry?>(null);
|
Task.FromResult(Existing.TryGetValue(path, out var entry) ? entry : null);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||||
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
|
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ A write updates <see cref="Existing"/> only for a path already in it, which is the difference
|
||||||
|
/// between modelling a server and breaking every other test in the suite: the transfer queue writes to
|
||||||
|
/// part files and stats them to decide whether there is anything to resume from, so a fake that
|
||||||
|
/// remembered every write would start answering yes to questions it currently answers no to. What is in
|
||||||
|
/// <see cref="Existing"/> is what a test deliberately put there.
|
||||||
|
/// </remarks>
|
||||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||||
Task.FromResult<Stream>(new MemoryStream());
|
Task.FromResult<Stream>(new RecordingStream(bytes =>
|
||||||
|
{
|
||||||
|
Written = bytes;
|
||||||
|
|
||||||
|
if (Existing.TryGetValue(path, out var before))
|
||||||
|
{
|
||||||
|
Existing[path] = before with
|
||||||
|
{
|
||||||
|
Length = bytes.Length,
|
||||||
|
LastWriteTimeUtc = before.LastWriteTimeUtc.AddSeconds(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
/// <summary>Every directory this session was asked to make, in the order it was asked.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The evidence a folder upload actually built the tree before it queued the files. Order matters and is
|
||||||
|
/// asserted on: a parent has to exist before its child is asked for, and shortest-first is what
|
||||||
|
/// guarantees it.
|
||||||
|
/// </remarks>
|
||||||
|
internal List<string> Created { get; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
|
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||||
Task.CompletedTask;
|
{
|
||||||
|
Created.Add(path);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||||
@@ -228,6 +324,28 @@ internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A write stream that hands its contents over when it is closed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A <see cref="MemoryStream"/> cannot be read after disposal, and disposal is exactly when a write is
|
||||||
|
/// finished — so a fake that handed one back could observe a transfer starting and never what it sent.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RecordingStream(Action<byte[]> onClosed) : MemoryStream
|
||||||
|
{
|
||||||
|
private bool handed;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && !handed)
|
||||||
|
{
|
||||||
|
handed = true;
|
||||||
|
onClosed(ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A shell that is open, silent and closes only when it is told to.</summary>
|
/// <summary>A shell that is open, silent and closes only when it is told to.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
|
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <summary>When set, resuming throws — how a revoked or rotated-away token is exercised.</summary>
|
/// <summary>When set, resuming throws — how a revoked or rotated-away token is exercised.</summary>
|
||||||
private Exception? resumeFailure;
|
private Exception? resumeFailure;
|
||||||
|
|
||||||
|
/// <summary>Local paths the fake editor was asked to open, in order.</summary>
|
||||||
|
private readonly List<string> opened = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fires the watcher on the file being edited, as a save would.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Null until something is being watched, and null again once the watch is disposed — which is itself
|
||||||
|
/// what says STOP EDITING actually stopped. A fake rather than a real <c>FileSystemWatcher</c> because
|
||||||
|
/// what is being tested is what happens <em>when</em> a save arrives; waiting on a real one would be
|
||||||
|
/// measuring the operating system's notification latency.
|
||||||
|
/// </remarks>
|
||||||
|
private Action? save;
|
||||||
|
|
||||||
|
/// <summary>Serialises posted actions, which is the half of a dispatcher this suite has to supply.</summary>
|
||||||
|
/// <remarks>See the <c>post</c> argument below for what happens without it.</remarks>
|
||||||
|
private readonly Lock posting = new();
|
||||||
|
|
||||||
private string directory = null!;
|
private string directory = null!;
|
||||||
private ClientPaths paths = null!;
|
private ClientPaths paths = null!;
|
||||||
private ClientCacheFactory caches = null!;
|
private ClientCacheFactory caches = null!;
|
||||||
@@ -149,9 +167,34 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
|
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
|
||||||
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
|
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
|
||||||
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
|
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
|
||||||
// it first. Running the action where it was raised takes the thread out of the question, and
|
// it first. Running the action where it was raised takes the thread out of the question.
|
||||||
// every phase this suite reports is raised on the thread doing the asserting anyway.
|
//
|
||||||
post: action => action());
|
// ◆ UNDER A LOCK, AND THAT IS NOT BELT AND BRACES. What this delegate stands in for is a
|
||||||
|
// dispatcher, whose defining property is that the actions posted to it do not overlap — and the
|
||||||
|
// view models rely on it: TransfersViewModel.OnTransferChanged looks a transfer up by id and
|
||||||
|
// adds a row when it finds none, which two threads running it at once turn into two rows for
|
||||||
|
// one transfer. Every phase the shell reports is raised on the thread doing the asserting, so
|
||||||
|
// this cost nothing while nothing else went through here; the transfer queue announces from its
|
||||||
|
// own pump thread, and it does.
|
||||||
|
post: action =>
|
||||||
|
{
|
||||||
|
lock (posting)
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openInEditor: path =>
|
||||||
|
{
|
||||||
|
opened.Add(path);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
|
||||||
|
watchFile: (_, onChanged) =>
|
||||||
|
{
|
||||||
|
save = onChanged;
|
||||||
|
return new StopWatching(() => save = null);
|
||||||
|
});
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -1126,6 +1169,689 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
|
||||||
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
|
||||||
|
|
||||||
|
// ---- Editing a remote file in place ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ The round trip this replaces is: download, find the file, open it, edit, come back, delete the old
|
||||||
|
/// one because nothing here overwrites, upload, check the mode survived. What it is instead is a copy,
|
||||||
|
/// an editor, and every save going back.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The write-back deletes first.</b> <c>OpenWriteAsync</c> at offset zero writes over the beginning
|
||||||
|
/// and leaves any tail that is longer — its own contract says so — which for a config that got shorter
|
||||||
|
/// means a file ending in the back half of its previous version. Asserted here because nothing about
|
||||||
|
/// the bytes that arrive would show it in a test that only edited a file to the same length.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EditingARemoteFile_OpensACopyAndSendsEachSaveBack()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var row = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.SelectedRemoteEntry = row;
|
||||||
|
|
||||||
|
// The session stats before each write-back to see whether the host's copy moved; this is the fake
|
||||||
|
// agreeing that the file is where the listing said it was.
|
||||||
|
sftpSession.Existing[row.FullPath] = row.Entry;
|
||||||
|
|
||||||
|
shell.Transfers.CanEditRemote.ShouldBeTrue();
|
||||||
|
|
||||||
|
await shell.Transfers.EditRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var editing = shell.Transfers.RemoteEdit.ShouldNotBeNull();
|
||||||
|
|
||||||
|
editing.RemotePath.ShouldBe(row.FullPath);
|
||||||
|
opened.ShouldHaveSingleItem().ShouldBe(editing.LocalCopy);
|
||||||
|
File.ReadAllText(editing.LocalCopy).ShouldBe("hello there\n", "the host's copy was fetched");
|
||||||
|
|
||||||
|
// Somebody edits and saves.
|
||||||
|
await File.WriteAllTextAsync(editing.LocalCopy, "goodbye\n", Token);
|
||||||
|
save.ShouldNotBeNull().Invoke();
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => sftpSession.Written is not null,
|
||||||
|
$"the save should have gone back; log was <{string.Join(" | ", shell.Transfers.RemoteEditLog)}>");
|
||||||
|
|
||||||
|
System.Text.Encoding.UTF8.GetString(sftpSession.Written!).ShouldBe("goodbye\n");
|
||||||
|
|
||||||
|
// Truncated rather than written over, and the mode the listing had put back on afterwards.
|
||||||
|
sftpSession.Deleted.ShouldContain(row.FullPath);
|
||||||
|
sftpSession.PermissionsSet.ShouldHaveSingleItem().Mode.ShouldBe(0b110_100_100);
|
||||||
|
|
||||||
|
shell.Transfers.RemoteEditLog[0].ShouldContain("uploaded");
|
||||||
|
|
||||||
|
// ◆ AND AGAIN, which is the whole point of watching rather than fetching once. The first save moved
|
||||||
|
// the file on the host — the fake tracks that, as a server would — so this second one is a save onto
|
||||||
|
// a file that has changed since the copy was taken, and it must not be reported as somebody else's
|
||||||
|
// change. A version of this that replaced the session record instead of moving its stamp in place
|
||||||
|
// left the watcher holding an instance that was no longer the current one, and every save after the
|
||||||
|
// first quietly did nothing at all.
|
||||||
|
await File.WriteAllTextAsync(editing.LocalCopy, "again!\n", Token);
|
||||||
|
save.ShouldNotBeNull().Invoke();
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => System.Text.Encoding.UTF8.GetString(sftpSession.Written!) == "again!\n",
|
||||||
|
$"the second save should have gone back too; log was "
|
||||||
|
+ $"<{string.Join(" | ", shell.Transfers.RemoteEditLog)}>");
|
||||||
|
|
||||||
|
shell.Transfers.HasRemoteEditConflict.ShouldBeFalse(
|
||||||
|
"the file changing because of this session's own write is not somebody else's change");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ Two people editing one config is how a change gets silently reverted, and a client this thin
|
||||||
|
/// cannot merge. What it can do is notice — a stat before each save, compared against what the server
|
||||||
|
/// said when the copy was taken — and stop, which is the only honest answer available. The three
|
||||||
|
/// buttons on the card are the three things that actually exist: send it anyway, take the host's copy,
|
||||||
|
/// or stop.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ASaveOntoAFileThatMovedOnTheHost_IsRefusedUntilItIsAnswered()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var row = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.SelectedRemoteEntry = row;
|
||||||
|
sftpSession.Existing[row.FullPath] = row.Entry;
|
||||||
|
|
||||||
|
await shell.Transfers.EditRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var editing = shell.Transfers.RemoteEdit.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// Somebody else writes it while it is open.
|
||||||
|
sftpSession.Existing[row.FullPath] = row.Entry with { Length = row.Entry.Length + 40 };
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(editing.LocalCopy, "mine\n", Token);
|
||||||
|
save.ShouldNotBeNull().Invoke();
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => shell.Transfers.HasRemoteEditConflict,
|
||||||
|
"the save should have been stopped rather than written over somebody else's");
|
||||||
|
|
||||||
|
sftpSession.Written.ShouldBeNull("nothing may be written until the question is answered");
|
||||||
|
shell.Transfers.RemoteEditConflict.ShouldContain("changed since you opened it");
|
||||||
|
|
||||||
|
// And UPLOAD ANYWAY is the answer that goes through.
|
||||||
|
await shell.Transfers.ForceSaveEditCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
System.Text.Encoding.UTF8.GetString(sftpSession.Written.ShouldNotBeNull()).ShouldBe("mine\n");
|
||||||
|
shell.Transfers.HasRemoteEditConflict.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Stopping has to actually stop: the watch is disposed and the copy this screen made is removed. A
|
||||||
|
/// watcher left running would go on writing somebody's edits to a host after they had said not to,
|
||||||
|
/// which is the worst failure available to this feature.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task StopEditing_ReleasesTheWatchAndRemovesTheCopy()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var row = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.SelectedRemoteEntry = row;
|
||||||
|
sftpSession.Existing[row.FullPath] = row.Entry;
|
||||||
|
|
||||||
|
await shell.Transfers.EditRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var editing = shell.Transfers.RemoteEdit.ShouldNotBeNull();
|
||||||
|
|
||||||
|
File.Exists(editing.LocalCopy).ShouldBeTrue();
|
||||||
|
|
||||||
|
await shell.Transfers.StopEditingCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsEditingRemote.ShouldBeFalse();
|
||||||
|
shell.Transfers.RemoteEditLog.ShouldBeEmpty();
|
||||||
|
save.ShouldBeNull("the watch was disposed");
|
||||||
|
File.Exists(editing.LocalCopy).ShouldBeFalse("the copy this screen made goes with the session");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Whole folders ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ The tree is turned into files before the queue sees it, and the directories it needs are made on
|
||||||
|
/// the host first. That is what keeps every property the queue already had — each file resumable on its
|
||||||
|
/// own, each written to a part file and renamed into place, none able to overwrite anything — while the
|
||||||
|
/// thing being dragged is a folder.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Shortest-first is asserted rather than assumed.</b> A child directory asked for before its parent
|
||||||
|
/// exists is a <c>mkdir</c> that fails, and the transfers under it then fail one at a time with a
|
||||||
|
/// message about a path rather than about the folder that was dropped.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadingAFolder_MakesItsDirectoriesThenQueuesEveryFileUnderIt()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var root = Path.Combine(directory, "site");
|
||||||
|
var nested = Path.Combine(root, "assets", "img");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(nested);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "index.html"), "<!doctype html>", Token);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(root, "assets", "main.js"), "console.log(1)", Token);
|
||||||
|
await File.WriteAllTextAsync(Path.Combine(nested, "logo.svg"), "<svg/>", Token);
|
||||||
|
|
||||||
|
await shell.Transfers.QueueUploadsAsync([root], Token);
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => shell.Transfers.Transfers.Count == 3,
|
||||||
|
$"every file under the folder should be queued; status was <{shell.Transfers.Status}>");
|
||||||
|
|
||||||
|
// Made before anything was queued, parents first.
|
||||||
|
sftpSession.Created.ShouldBe(
|
||||||
|
[
|
||||||
|
"/home/deploy/site",
|
||||||
|
"/home/deploy/site/assets",
|
||||||
|
"/home/deploy/site/assets/img",
|
||||||
|
],
|
||||||
|
ignoreOrder: false);
|
||||||
|
|
||||||
|
shell.Transfers.Status.ShouldContain("3 files");
|
||||||
|
|
||||||
|
// ◆ And the remote paths are POSIX, whatever this test is running on. A Windows relative path
|
||||||
|
// carried into SftpPath.Combine would have produced one file called `site\assets\main.js` rather
|
||||||
|
// than three directories deep, which is the failure this platform is most likely to ship.
|
||||||
|
var queued = shell.Transfers.Transfers.Select(row => row.Transfer.RemotePath).Order().ToArray();
|
||||||
|
|
||||||
|
queued.ShouldBe(
|
||||||
|
[
|
||||||
|
"/home/deploy/site/assets/img/logo.svg",
|
||||||
|
"/home/deploy/site/assets/main.js",
|
||||||
|
"/home/deploy/site/index.html",
|
||||||
|
],
|
||||||
|
ignoreOrder: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other direction. The walk is a listing per directory, because SFTP has no recursive one — what
|
||||||
|
/// matters here is that the local tree it lands in is made before the transfers are queued into it, and
|
||||||
|
/// that the paths mirror the remote's.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DownloadingAFolder_MakesTheLocalTreeAndQueuesEveryFileUnderIt()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var into = Path.Combine(directory, "landing");
|
||||||
|
Directory.CreateDirectory(into);
|
||||||
|
shell.Transfers.LocalPath = into;
|
||||||
|
|
||||||
|
// The shell's SFTP fake answers every listing with one file, so a directory row is a tree one deep
|
||||||
|
// — which is enough to say the walk descended and the local folder was made.
|
||||||
|
await shell.Transfers.QueueDownloadsAsync(
|
||||||
|
[new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
"logs",
|
||||||
|
"/var/log/app",
|
||||||
|
SftpEntryKind.Directory,
|
||||||
|
0,
|
||||||
|
DateTimeOffset.UnixEpoch,
|
||||||
|
"drwxr-xr-x"))],
|
||||||
|
Token);
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => shell.Transfers.Transfers.Count == 1,
|
||||||
|
$"the file under the folder should be queued; status was <{shell.Transfers.Status}>");
|
||||||
|
|
||||||
|
Directory.Exists(Path.Combine(into, "logs"))
|
||||||
|
.ShouldBeTrue("the folder is made here before anything is queued into it");
|
||||||
|
|
||||||
|
shell.Transfers.Transfers[0].Transfer.LocalPath
|
||||||
|
.ShouldBe(Path.Combine(into, "logs", "notes.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ A link is counted and left alone, in both directions and for the same reason: one pointing at an
|
||||||
|
/// ancestor is a walk with no end, and the first anybody would know of it is a queue filling for ever.
|
||||||
|
/// On the remote side there is a second reason — an <c>lstat</c> listing says a name is a link and not
|
||||||
|
/// what it leads to, so descending would be a round trip per entry to find out.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AFolderWalk_CountsLinksRatherThanFollowingThem()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
await shell.Transfers.QueueDownloadsAsync(
|
||||||
|
[new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
"current",
|
||||||
|
"/srv/releases/current",
|
||||||
|
SftpEntryKind.SymbolicLink,
|
||||||
|
0,
|
||||||
|
DateTimeOffset.UnixEpoch,
|
||||||
|
"lrwxrwxrwx"))],
|
||||||
|
Token);
|
||||||
|
|
||||||
|
shell.Transfers.Transfers.ShouldBeEmpty();
|
||||||
|
shell.Transfers.Status.ShouldContain("1 link was left alone");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Several rows at once ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ The fallback is the half worth guarding. Somebody who clicked a row and pressed DOWNLOAD has
|
||||||
|
/// ticked nothing and means that row — so a multi-select that made the commonest use of this screen
|
||||||
|
/// grow a step would be the usual way one of these makes a tool worse. Both readings are asserted here
|
||||||
|
/// because they are one property with two branches.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ABatchAction_TakesTheTickedRowsOrTheSelectionWhenNothingIsTicked()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var row = shell.Transfers.RemoteEntries[0];
|
||||||
|
|
||||||
|
// Nothing ticked: the selection is the batch.
|
||||||
|
shell.Transfers.SelectedRemoteEntry = row;
|
||||||
|
|
||||||
|
shell.Transfers.HasCheckedRemote.ShouldBeFalse();
|
||||||
|
shell.Transfers.CheckedRemoteLabel.ShouldBeEmpty();
|
||||||
|
shell.Transfers.RemoteBatch.ShouldHaveSingleItem().ShouldBe(row);
|
||||||
|
shell.Transfers.CanDownload.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Ticked: the ticks win, and the selection stops being what the buttons are about.
|
||||||
|
row.IsChecked = true;
|
||||||
|
shell.Transfers.RemoteChecksChanged();
|
||||||
|
|
||||||
|
shell.Transfers.HasCheckedRemote.ShouldBeTrue();
|
||||||
|
shell.Transfers.CheckedRemoteLabel.ShouldBe("1 selected");
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = null;
|
||||||
|
shell.Transfers.RemoteBatch.ShouldHaveSingleItem().ShouldBe(row, "a tick survives the highlight moving");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A ticked set is one act — these files, into that directory — so the boxes are released once it has
|
||||||
|
/// been queued. Leaving them ticked is how the next press sends everything a second time.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The queue is what counts and reports what it skipped, which is why a directory among the ticks does
|
||||||
|
/// not stop the files going: see QueueDownloads and the sentence it builds.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DownloadingATickedSet_QueuesEachFileAndReleasesTheTicks()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
// One listing row is all the shell fake offers, so a second is made by navigating: the rows are
|
||||||
|
// rebuilt per directory and the ticks go with them, which is itself the behaviour being relied on.
|
||||||
|
var first = shell.Transfers.RemoteEntries[0];
|
||||||
|
first.IsChecked = true;
|
||||||
|
shell.Transfers.RemoteChecksChanged();
|
||||||
|
|
||||||
|
shell.Transfers.CanDownload.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.Transfers.DownloadCommand.Execute(null);
|
||||||
|
|
||||||
|
// Waited for rather than read straight away: the queue announces from its own pump thread, so the
|
||||||
|
// row appears a moment after the command returns.
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => shell.Transfers.Transfers.Count == 1,
|
||||||
|
"the ticked file should have become exactly one queue row");
|
||||||
|
|
||||||
|
shell.Transfers.Transfers[0].Name.ShouldBe(first.Name);
|
||||||
|
shell.Transfers.HasCheckedRemote.ShouldBeFalse("a queued set releases its ticks");
|
||||||
|
first.IsChecked.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ Deleting is the one act on this screen that nothing walks back, so a batch of them has to name
|
||||||
|
/// every path rather than a count — agreeing to "3 items" is agreeing to a number. And each goes on its
|
||||||
|
/// own: the host refuses a directory that still has something in it, and in a set of five that must not
|
||||||
|
/// silently cancel the four it would have removed, nor hide which ones it did not.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletingATickedSet_NamesEveryPathAndKeepsGoingPastARefusal()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var row = shell.Transfers.RemoteEntries[0];
|
||||||
|
row.IsChecked = true;
|
||||||
|
shell.Transfers.RemoteChecksChanged();
|
||||||
|
|
||||||
|
shell.Transfers.DeleteRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
var question = shell.Transfers.PendingRemoteDeletion.ShouldNotBeNull();
|
||||||
|
|
||||||
|
question.Count.ShouldBe(1);
|
||||||
|
question.IsSingle.ShouldBeTrue();
|
||||||
|
question.Listing.ShouldBe(row.FullPath, "the path is what makes the question answerable");
|
||||||
|
|
||||||
|
await shell.Transfers.ConfirmDeleteRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
sftpSession.Deleted.ShouldHaveSingleItem().ShouldBe(row.FullPath);
|
||||||
|
shell.Transfers.Status.ShouldBe("Deleted 1 item.");
|
||||||
|
shell.Transfers.HasCheckedRemote.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// One gesture for both directions, because from the user's side "select all" and "select none" are the
|
||||||
|
/// same act — they look at the boxes and want the other state.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SelectAll_TicksEverythingAndThenUnticksIt()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.ToggleAllRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.Transfers.CheckedRemoteCount.ShouldBe(shell.Transfers.RemoteEntries.Count);
|
||||||
|
shell.Transfers.RemoteEntries.ShouldAllBe(row => row.IsChecked);
|
||||||
|
|
||||||
|
shell.Transfers.ToggleAllRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.Transfers.CheckedRemoteCount.ShouldBe(0);
|
||||||
|
shell.Transfers.RemoteEntries.ShouldAllBe(row => !row.IsChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Permissions: the PERMS column stops being read-only ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ The whole point of the form: what reaches the host is the nine bits that were ticked, not the
|
||||||
|
/// sentence the screen writes about itself afterwards. It is asserted as a literal — <c>-rw-r--r--</c>
|
||||||
|
/// with the owner's execute bit added is <c>0744</c>, which is close enough to the <c>0755</c> everybody
|
||||||
|
/// reaches for that a test agreeing with the code's own arithmetic would agree with it being wrong.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The listing is re-read afterwards, and that is not tidiness. A umask, an ACL or a filesystem mounted
|
||||||
|
/// without permissions can all land somewhere other than what was asked for, and the server is the only
|
||||||
|
/// thing that knows which — so the column goes back to being a fact rather than a hope.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangingPermissions_SendsTheBitsThatWereTickedAndRereadsTheListing()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
|
||||||
|
shell.Transfers.ChangeRemotePermissionsCommand.Execute(null);
|
||||||
|
|
||||||
|
var form = shell.Transfers.PendingRemotePermissions.ShouldNotBeNull();
|
||||||
|
|
||||||
|
// The form opens on the mode the listing already had, rather than on a fresh stat or on nothing.
|
||||||
|
form.OwnerRead.ShouldBeTrue();
|
||||||
|
form.OwnerExecute.ShouldBeFalse();
|
||||||
|
form.Preview.ShouldBe("-rw-r--r-- (0644)");
|
||||||
|
form.HasChanges.ShouldBeFalse("nothing has been touched yet");
|
||||||
|
|
||||||
|
form.OwnerExecute = true;
|
||||||
|
|
||||||
|
form.HasChanges.ShouldBeTrue();
|
||||||
|
form.Preview.ShouldBe("-rwxr--r-- (0744)");
|
||||||
|
|
||||||
|
var listedBefore = sftpSession.Listed.Count;
|
||||||
|
|
||||||
|
await shell.Transfers.ConfirmRemotePermissionsCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var sent = sftpSession.PermissionsSet.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
sent.Path.ShouldBe(shell.Transfers.RemoteEntries[0].FullPath);
|
||||||
|
sent.Mode.ShouldBe(0b111_100_100);
|
||||||
|
|
||||||
|
shell.Transfers.PendingRemotePermissions.ShouldBeNull("the form closes on success");
|
||||||
|
sftpSession.Listed.Count.ShouldBeGreaterThan(listedBefore, "the column it changed is re-read");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The three questions this pane can ask are one at a time by construction, and each one's own opening
|
||||||
|
/// is what clears the others. Worth a test because the guard is three assignments in three methods
|
||||||
|
/// rather than one piece of shared state, so a fourth question added later has three places to remember.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpeningThePermissionsForm_TakesAwayWhicheverQuestionWasAlreadyUp()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
|
||||||
|
shell.Transfers.DeleteRemoteCommand.Execute(null);
|
||||||
|
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.Transfers.ChangeRemotePermissionsCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeFalse();
|
||||||
|
shell.Transfers.IsChangingRemotePermissions.ShouldBeTrue();
|
||||||
|
shell.Transfers.IsAskingAboutRemoteEntry.ShouldBeTrue();
|
||||||
|
|
||||||
|
// And back the other way, so the clearing is not one-directional.
|
||||||
|
shell.Transfers.RenameRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsChangingRemotePermissions.ShouldBeFalse();
|
||||||
|
shell.Transfers.IsMovingRemote.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An object is governed by an access policy rather than nine bits, so the button is dark on that
|
||||||
|
/// destination rather than live and answered with a refusal from the store. The other three item actions
|
||||||
|
/// stay live, which is the half that says this is a considered difference rather than the whole row
|
||||||
|
/// being switched off.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ABucket_IsNotOfferedPermissions()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
|
||||||
|
|
||||||
|
vault.NewObjectStoreCommand.Execute(null);
|
||||||
|
vault.BucketEditorLabel = "Backups";
|
||||||
|
vault.BucketEditorBucket = "backups";
|
||||||
|
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
|
||||||
|
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
|
||||||
|
vault.BucketEditorRegion = "eu-west-1";
|
||||||
|
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.Remote = RemoteKind.Bucket;
|
||||||
|
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||||
|
|
||||||
|
shell.Transfers.CanChangeRemotePermissions.ShouldBeFalse();
|
||||||
|
shell.Transfers.CanDeleteRemote.ShouldBeTrue("the other three are unaffected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ The reason "keep mode and mtime" is worth having at all: an uploaded <c>deploy.sh</c> that lands
|
||||||
|
/// with the server's umask on it is the thing that makes people open a terminal straight after using
|
||||||
|
/// this screen. What can be carried depends on the platform the file came from — a mode is not a fact
|
||||||
|
/// about a file on Windows — so this asserts the timestamp, which every platform has, and that a mode is
|
||||||
|
/// sent exactly when the local filesystem had one to send.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUpload_CarriesTheSourcesTimestampAndItsModeWhereThereIsOne()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var source = Path.Combine(directory, "deploy.sh");
|
||||||
|
await File.WriteAllTextAsync(source, "#!/bin/sh\necho hello\n", Token);
|
||||||
|
|
||||||
|
var written = new DateTimeOffset(2026, 3, 14, 9, 26, 53, TimeSpan.Zero);
|
||||||
|
File.SetLastWriteTimeUtc(source, written.UtcDateTime);
|
||||||
|
|
||||||
|
shell.Transfers.QueueUploads([source]);
|
||||||
|
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => sftpSession.TimesSet.Count > 0,
|
||||||
|
"the upload should have set the destination's timestamp");
|
||||||
|
|
||||||
|
var stamped = sftpSession.TimesSet.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
stamped.Path.ShouldEndWith("deploy.sh");
|
||||||
|
stamped.When.ShouldBe(written);
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
sftpSession.PermissionsSet.ShouldBeEmpty(
|
||||||
|
"a file on Windows has no POSIX mode, and inventing one would be this client deciding it");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sftpSession.PermissionsSet.ShouldHaveSingleItem().Path.ShouldEndWith("deploy.sh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- A file session that stops, and the way back into the directory it was showing ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ The same defect the terminal had, on the other surface: nothing noticed. IsConnected was set at
|
||||||
|
/// connect and cleared only by the DISCONNECT button, so a host that went away left this screen saying
|
||||||
|
/// CONNECTED over a session that was gone, and every action after it failed with the same server message
|
||||||
|
/// and no explanation of why they all did.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The listing deliberately stays.</b> SftpEntry is a record of what the server said rather than a
|
||||||
|
/// handle on anything, so the pane can go on showing where the user was — which is the whole of what
|
||||||
|
/// makes the reconnect below worth offering rather than merely correct.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AFileSessionWhoseConnectionBreaks_SaysSoAndKeepsWhereItWas()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.ShowFilesCommand.Execute(RemoteKind.Host);
|
||||||
|
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue();
|
||||||
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
|
|
||||||
|
var showing = shell.Transfers.RemotePath;
|
||||||
|
|
||||||
|
sftpSession.Drop("An established connection was aborted by the server.");
|
||||||
|
|
||||||
|
shell.Transfers.IsConnected.ShouldBeFalse();
|
||||||
|
shell.Transfers.IsConnectionLost.ShouldBeTrue();
|
||||||
|
shell.Transfers.LostConnection.ShouldBe("An established connection was aborted by the server.");
|
||||||
|
|
||||||
|
// Where they were, still on screen and still named.
|
||||||
|
shell.Transfers.RemotePath.ShouldBe(showing);
|
||||||
|
shell.Transfers.RemoteEntries.ShouldNotBeEmpty("the listing outlives the session it came from");
|
||||||
|
|
||||||
|
// And the status bar says which of the two states this is, rather than collapsing to nothing.
|
||||||
|
shell.SessionEndedText.ShouldBe("DISCONNECTED");
|
||||||
|
shell.SessionEndedDetail.ShouldBe("An established connection was aborted by the server.");
|
||||||
|
shell.IsSessionDropped.ShouldBeTrue();
|
||||||
|
shell.SessionAddress.ShouldNotBeNull("there is still a host worth naming");
|
||||||
|
shell.CanReconnectSession.ShouldBeTrue();
|
||||||
|
shell.SessionEndedDismissLabel.ShouldBe("DISCONNECT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The half that makes the button worth having. A reconnect that landed in the home directory would
|
||||||
|
/// leave somebody five levels deep in a deployment tree to walk back down it, so the path is captured at
|
||||||
|
/// connect and restored afterwards — and the dial itself goes through the ordinary connect path, so the
|
||||||
|
/// host key gate and the log entry are the ones every other connection gets.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconnectingALostFileSession_DialsAgainAndGoesBackToTheDirectoryItWasShowing()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
// Bound to a key, which is what lets this host be redialled with nobody present. One that wants a
|
||||||
|
// password typed is asked for it again instead, and has a test of its own below.
|
||||||
|
await AddKeyAsync(vault, "deploy");
|
||||||
|
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts);
|
||||||
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||||
|
|
||||||
|
var sftpSession = ssh.OpenedSftp.ShouldNotBeNull();
|
||||||
|
|
||||||
|
shell.ShowFilesCommand.Execute(RemoteKind.Host);
|
||||||
|
|
||||||
|
await shell.Transfers.GoRemoteCommand.ExecuteAsync("/home/dodo/deploy");
|
||||||
|
|
||||||
|
var showing = shell.Transfers.RemotePath;
|
||||||
|
showing.ShouldBe("/home/dodo/deploy");
|
||||||
|
|
||||||
|
sftpSession.Drop("An established connection was aborted by the server.");
|
||||||
|
|
||||||
|
await shell.ReconnectSessionCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||||
|
shell.Transfers.IsConnectionLost.ShouldBeFalse();
|
||||||
|
shell.Transfers.RemotePath.ShouldBe(showing, "a reconnect puts them back where they were");
|
||||||
|
|
||||||
|
shell.SessionEndedText.ShouldBeNull();
|
||||||
|
ssh.SftpRequests.Count.ShouldBe(2, "the ordinary connect path was used, not a second one");
|
||||||
|
|
||||||
|
// A second, separate session — the first one is gone, not resumed.
|
||||||
|
ssh.OpenedSftp.ShouldNotBeNull().ShouldNotBe(sftpSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A host that wants a password typed cannot be redialled on its own: the box is cleared the moment a
|
||||||
|
/// connection succeeds and nothing keeps it, so dialling with an empty one would be an authentication
|
||||||
|
/// failure wearing the word RECONNECT. The picker comes back up with the host already chosen, which is
|
||||||
|
/// as far as this can honestly get by itself — and the pane keeps its listing, so the button is still
|
||||||
|
/// there once the password is in.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ReconnectingAFileSessionOnAPasswordHost_AsksForOneRatherThanDiallingWithout()
|
||||||
|
{
|
||||||
|
var sftpSession = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.ShowFilesCommand.Execute(RemoteKind.Host);
|
||||||
|
|
||||||
|
sftpSession.Drop("An established connection was aborted by the server.");
|
||||||
|
|
||||||
|
await shell.ReconnectSessionCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsChoosingRemote.ShouldBeTrue();
|
||||||
|
shell.Transfers.Status.ShouldContain("asks for a password");
|
||||||
|
|
||||||
|
ssh.SftpRequests.ShouldHaveSingleItem("nothing should have been dialled without the password");
|
||||||
|
shell.Transfers.RemoteEntries.ShouldNotBeEmpty("the listing is still there to come back to");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A bucket has no connection to lose — it is HTTPS per request — so it never reaches the lost state at
|
||||||
|
/// all, and the strip that offers a reconnect stays away. Asserted because the store interface carries
|
||||||
|
/// the two members regardless, and an implementation that raised them would put a DISCONNECTED strip
|
||||||
|
/// over a screen whose next request would have worked perfectly well.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ABucket_NeverReportsALostConnection()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
|
||||||
|
|
||||||
|
vault.NewObjectStoreCommand.Execute(null);
|
||||||
|
vault.BucketEditorLabel = "Backups";
|
||||||
|
vault.BucketEditorBucket = "backups";
|
||||||
|
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
|
||||||
|
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
|
||||||
|
vault.BucketEditorRegion = "eu-west-1";
|
||||||
|
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// Remote is what ConnectAsync branches on; Attach has already auto-selected the host the fixture
|
||||||
|
// left in the picker.
|
||||||
|
shell.Transfers.Remote = RemoteKind.Bucket;
|
||||||
|
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.ConnectedTo.ShouldBe("s3://backups");
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||||
|
shell.Transfers.IsConnectionLost.ShouldBeFalse();
|
||||||
|
|
||||||
|
shell.ShowFilesCommand.Execute(RemoteKind.Bucket);
|
||||||
|
shell.SessionEndedText.ShouldBeNull("a bucket has no connection to report the loss of");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- A session that stops: the status it gets, and the way back ----
|
// ---- A session that stops: the status it gets, and the way back ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -9125,6 +9851,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
public bool IsConnected => true;
|
public bool IsConnected => true;
|
||||||
|
|
||||||
|
/// <remarks>Null, and never raised: a bucket has no connection to lose. See <c>S3FileStore</c>.</remarks>
|
||||||
|
public string? TransportFailure => null;
|
||||||
|
|
||||||
|
#pragma warning disable CS0067 // The event is never used
|
||||||
|
public event EventHandler? ConnectionLost;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
|
||||||
public string HomeDirectory => "/";
|
public string HomeDirectory => "/";
|
||||||
|
|
||||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
||||||
@@ -9133,6 +9866,17 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||||
Task.FromResult<SftpEntry?>(null);
|
Task.FromResult<SftpEntry?>(null);
|
||||||
|
|
||||||
|
/// <remarks>Refused, as S3FileStore refuses it: an object has an access policy rather than a mode.</remarks>
|
||||||
|
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken) =>
|
||||||
|
throw new SftpPathException(path, "An object in a bucket has no POSIX mode.");
|
||||||
|
|
||||||
|
/// <inheritdoc cref="SetPermissionsAsync" />
|
||||||
|
public Task SetLastWriteTimeAsync(
|
||||||
|
string path,
|
||||||
|
DateTimeOffset lastWriteTimeUtc,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new SftpPathException(path, "An object's timestamp is when the bucket stored it.");
|
||||||
|
|
||||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||||
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
||||||
|
|
||||||
@@ -9634,6 +10378,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
|
shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What the fake watcher hands back, so disposing it is observable.</summary>
|
||||||
|
private sealed class StopWatching(Action onDisposed) : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() => onDisposed();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
|
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
|
||||||
private async Task<VaultViewModel> ReadyToConnectAsync()
|
private async Task<VaultViewModel> ReadyToConnectAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -91,25 +91,6 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
transfers.Status.ShouldContain("/srv/app");
|
transfers.Status.ShouldContain("/srv/app");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored
|
|
||||||
/// looks exactly like a transfer that failed to start — so it is counted and reported.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public void DroppingAFolderAmongFiles_SkipsItAndSaysSo()
|
|
||||||
{
|
|
||||||
Connected();
|
|
||||||
|
|
||||||
var folder = Path.Combine(directory, "a-folder");
|
|
||||||
Directory.CreateDirectory(folder);
|
|
||||||
|
|
||||||
transfers.QueueUploads([File("one.txt"), folder]);
|
|
||||||
|
|
||||||
Queued().ShouldHaveSingleItem();
|
|
||||||
transfers.Status.ShouldContain("1 file");
|
|
||||||
transfers.Status.ShouldContain("1 folder was skipped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The paths in an operating-system drop come from another process and are not obliged to still be
|
/// The paths in an operating-system drop come from another process and are not obliged to still be
|
||||||
/// right by the time the drop lands.
|
/// right by the time the drop lands.
|
||||||
@@ -125,21 +106,6 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
transfers.Status.ShouldContain("1 item was no longer there");
|
transfers.Status.ShouldContain("1 item was no longer there");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise()
|
|
||||||
{
|
|
||||||
Connected();
|
|
||||||
|
|
||||||
var folder = Path.Combine(directory, "a-folder");
|
|
||||||
Directory.CreateDirectory(folder);
|
|
||||||
|
|
||||||
transfers.QueueUploads([folder]);
|
|
||||||
|
|
||||||
Queued().ShouldBeEmpty();
|
|
||||||
transfers.Status.ShouldContain("Nothing was queued");
|
|
||||||
transfers.Status.ShouldContain("1 folder was skipped");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
|
public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
|
||||||
{
|
{
|
||||||
@@ -152,21 +118,18 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
transfers.Status.ShouldContain("download into");
|
transfers.Status.ShouldContain("download into");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void DroppingARemoteDirectory_SkipsItAndSaysSo()
|
|
||||||
{
|
|
||||||
Connected();
|
|
||||||
|
|
||||||
transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]);
|
|
||||||
|
|
||||||
Queued().ShouldHaveSingleItem();
|
|
||||||
transfers.Status.ShouldContain("1 folder was skipped");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The buttons were the only way to queue anything before drag and drop, and they now go through the
|
/// The buttons were the only way to queue anything before drag and drop, and they now go through the
|
||||||
/// same two methods — so there is one set of rules rather than two that have to agree. This is what
|
/// same two methods — so there is one set of rules rather than two that have to agree. This is what
|
||||||
/// says they still do.
|
/// says they still do.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Files only, since folders stopped being skipped: walking one needs a session to list and to make
|
||||||
|
/// directories on, and this fixture deliberately has none — everything here is about what may be
|
||||||
|
/// queued, which needs no server. The recursive half is exercised in <c>ShellFlowTests</c>, against the
|
||||||
|
/// SFTP fake that a real connect produces.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
|
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
|
||||||
@@ -177,13 +140,7 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
transfers.DownloadCommand.Execute(null);
|
transfers.DownloadCommand.Execute(null);
|
||||||
|
|
||||||
Queued().ShouldHaveSingleItem();
|
Queued().ShouldHaveSingleItem();
|
||||||
|
transfers.Status.ShouldContain("download into");
|
||||||
// And refuses a directory in the same words, rather than with the button's own message.
|
|
||||||
transfers.SelectedRemoteEntry = RemoteDirectory("logs");
|
|
||||||
transfers.DownloadCommand.Execute(null);
|
|
||||||
|
|
||||||
Queued().Count.ShouldBe(1);
|
|
||||||
transfers.Status.ShouldContain("1 folder was skipped");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -229,14 +186,11 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
{
|
{
|
||||||
Connected();
|
Connected();
|
||||||
|
|
||||||
var folder = Path.Combine(directory, "a-folder");
|
transfers.QueueStagedUploads([File("picked.txt"), Path.Combine(directory, "never-existed.txt")]);
|
||||||
Directory.CreateDirectory(folder);
|
|
||||||
|
|
||||||
transfers.QueueStagedUploads([File("picked.txt"), folder]);
|
|
||||||
|
|
||||||
Queued().ShouldHaveSingleItem();
|
Queued().ShouldHaveSingleItem();
|
||||||
transfers.Status.ShouldContain("1 file");
|
transfers.Status.ShouldContain("1 file");
|
||||||
transfers.Status.ShouldContain("1 folder was skipped");
|
transfers.Status.ShouldContain("1 item was no longer there");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -20,6 +20,63 @@ internal sealed class FakeSftpSession : ISftpSession
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsConnected => true;
|
public bool IsConnected => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Null, and the event below is never raised: this fake breaks reads rather than connections — see
|
||||||
|
/// <see cref="FailReadAfter"/> — because what the queue has to get right is a transfer that stops
|
||||||
|
/// halfway, which is the same shape whichever caused it.
|
||||||
|
/// </remarks>
|
||||||
|
public string? TransportFailure => null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
#pragma warning disable CS0067 // The event is never used
|
||||||
|
public event EventHandler? ConnectionLost;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
|
||||||
|
/// <summary>Modes this session was asked to set, in order, so preservation is observable.</summary>
|
||||||
|
public List<(string Path, int Mode)> PermissionsSet { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Timestamps this session was asked to set, in order.</summary>
|
||||||
|
public List<(string Path, DateTimeOffset When)> TimesSet { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Set to make both setters refuse, which is what a server that will not do SETSTAT looks like.</summary>
|
||||||
|
public bool RefuseAttributes { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (RefuseAttributes)
|
||||||
|
{
|
||||||
|
throw new SftpPathException(path, "This server will not set a mode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
PermissionsSet.Add((path, mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetLastWriteTimeAsync(
|
||||||
|
string path,
|
||||||
|
DateTimeOffset lastWriteTimeUtc,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (RefuseAttributes)
|
||||||
|
{
|
||||||
|
throw new SftpPathException(path, "This server will not set a timestamp.");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
TimesSet.Add((path, lastWriteTimeUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
public HostKeyPresentation HostKey { get; } = new("host.internal", 22, "ssh-ed25519", "SHA256:fake");
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user