Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f93a172b | ||
|
|
882f01c515 |
No files matched your search
@@ -427,6 +427,82 @@ surface. Use the ← button, or drag the file to the local pane first.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 2b — The path box, and what can be done to a remote row
|
||||||
|
|
||||||
|
The rules are automated — `SftpPath.Resolve` in `RemotePathTests`, the three questions and the automatic
|
||||||
|
refresh in `ShellFlowTests`, both new shapes measured in `ScreenLayoutTests`. What is left here is what
|
||||||
|
those cannot reach: a real server's own refusals, and a soft keyboard.
|
||||||
|
|
||||||
|
Connect the SFTP page to a host first.
|
||||||
|
|
||||||
|
### 2.20 Typing a path
|
||||||
|
|
||||||
|
Click into the path in the remote pane's header, type `/var/log`, press Enter. Then type `..`, Enter. Then
|
||||||
|
`~`, Enter.
|
||||||
|
|
||||||
|
**Pass:** each one lists, the breadcrumb underneath follows, and the box ends up showing the absolute path
|
||||||
|
that was actually reached rather than what was typed.
|
||||||
|
|
||||||
|
### 2.21 Escape puts the path back
|
||||||
|
|
||||||
|
Type nonsense into the box without pressing Enter, then press Escape.
|
||||||
|
|
||||||
|
**Pass:** the box goes back to the directory the pane is showing.
|
||||||
|
|
||||||
|
### 2.22 A path the server refuses
|
||||||
|
|
||||||
|
Type a path that does not exist, and one that exists but is not readable by this account.
|
||||||
|
|
||||||
|
**Pass:** the pane stays where it was, the status line at the foot of the screen carries the server's own
|
||||||
|
message, and the box still holds what was typed so it can be corrected.
|
||||||
|
|
||||||
|
### 2.23 Rename
|
||||||
|
|
||||||
|
Select a file, press the rename button, change the name, press RENAME.
|
||||||
|
|
||||||
|
**Pass:** the listing comes back with the new name and the row still selected. Then try a name that another
|
||||||
|
file in the directory already has: **the server refuses it and says so, and nothing is overwritten** — this
|
||||||
|
is the one that matters, because the whole queue's "nothing lands at its final name until it is complete"
|
||||||
|
promise rests on the same server behaviour.
|
||||||
|
|
||||||
|
### 2.24 Move
|
||||||
|
|
||||||
|
Select a file, press the move button, type a directory — try an absolute one and a relative one — and press
|
||||||
|
MOVE.
|
||||||
|
|
||||||
|
**Pass:** the file leaves the listing and is in the directory that was typed.
|
||||||
|
|
||||||
|
Then try one across a filesystem boundary on the same host — `/tmp` to a path on a separate mount is the
|
||||||
|
usual pair. **Pass:** the server refuses with its own message about a cross-device link. This cannot be
|
||||||
|
faked in a test and is the refusal somebody will eventually meet.
|
||||||
|
|
||||||
|
### 2.25 The listing refreshes itself after an upload
|
||||||
|
|
||||||
|
Upload a file into the directory showing.
|
||||||
|
|
||||||
|
**Pass:** it appears in the remote listing without REFRESH being pressed. Then upload into a *different*
|
||||||
|
directory: nothing on screen changes. Then download a file into the folder the local pane is showing, and
|
||||||
|
that one appears on the left the same way.
|
||||||
|
|
||||||
|
### 2.26 A transfer that lands while a question is open
|
||||||
|
|
||||||
|
Start an upload into the directory showing, and while it runs select a row and press DELETE so the question
|
||||||
|
is up when the transfer finishes.
|
||||||
|
|
||||||
|
**Pass:** the listing does not move while the question is on screen, and refreshes the moment the question
|
||||||
|
is answered or cancelled.
|
||||||
|
|
||||||
|
**Failure means:** the row the question was asked about is not necessarily the row that gets acted on.
|
||||||
|
|
||||||
|
### 2.27 The phone's GO button
|
||||||
|
|
||||||
|
On Android, connect on the Files screen, type a path into the box above the breadcrumb and press GO.
|
||||||
|
|
||||||
|
**Pass:** it lists. The keyboard's own return key may or may not commit — that is the IME's choice, which is
|
||||||
|
why GO exists — but GO always must.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Phase 3 — Host groups and snippets
|
## Phase 3 — Host groups and snippets
|
||||||
|
|
||||||
Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
|
Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
the refusal has no continue button here either.
|
the refusal has no continue button here either.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
<Grid RowDefinitions="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">
|
||||||
@@ -174,8 +174,31 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ ◆ typing where to go ============ -->
|
||||||
|
<!--
|
||||||
|
The breadcrumb below reaches only what is under the directory already listed, so crossing a host's
|
||||||
|
filesystem was a tap and a round trip a level and a path somebody already knew could not be entered at
|
||||||
|
all. This is the way in for one that is: absolute, relative to the directory showing, or starting at
|
||||||
|
~. See SftpPath.Resolve.
|
||||||
|
|
||||||
|
GO is a real button rather than only the keyboard's own action key, and that is a phone decision: a
|
||||||
|
software keyboard's return key is whatever the IME decided to draw, this application does not set the
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6" IsVisible="{Binding IsConnected}">
|
||||||
|
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
|
||||||
|
PlaceholderText="/path/to/somewhere">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Padding="16,0" Content="GO"
|
||||||
|
Command="{Binding GoToTypedRemotePathCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- ============ where we are ============ -->
|
<!-- ============ where we are ============ -->
|
||||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
IsVisible="{Binding IsConnected}" 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"
|
||||||
@@ -215,7 +238,7 @@
|
|||||||
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
|
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
|
||||||
"this host has nothing pinned".
|
"this host has nothing pinned".
|
||||||
-->
|
-->
|
||||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||||
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||||
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
@@ -240,7 +263,7 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- ============ the listing ============ -->
|
<!-- ============ the listing ============ -->
|
||||||
<Panel Grid.Row="5">
|
<Panel Grid.Row="6">
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||||
@@ -306,7 +329,7 @@
|
|||||||
|
|
||||||
<!-- ============ 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="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
<Border Grid.Row="7" 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">
|
||||||
|
|
||||||
@@ -328,6 +351,36 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Renaming and moving, in the place the deletion question already occupies and never at the same
|
||||||
|
time as it. One form for both — see RemoteMoveRequest for why the two questions share a shape and
|
||||||
|
differ only in their words — and no danger colours on it, because a rename is undone by renaming
|
||||||
|
back and the deletion above is the one thing here that nothing can walk back.
|
||||||
|
-->
|
||||||
|
<Border IsVisible="{Binding IsMovingRemote}" 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 PendingRemoteMove.Question}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||||
|
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
|
||||||
|
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||||
|
Content="{Binding PendingRemoteMove.ConfirmLabel}"
|
||||||
|
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelMoveRemoteCommand}" />
|
||||||
|
</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
|
||||||
@@ -386,20 +439,32 @@
|
|||||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||||
a file is selected and something is connected — and is reused rather than restated here.
|
a file is selected and something is connected — and is reused rather than restated here.
|
||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||||
IsEnabled="{Binding CanDownload}" />
|
IsEnabled="{Binding CanDownload}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
<!--
|
||||||
|
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
|
||||||
|
from it. Both open a form in place of these buttons — see the card above — because a phone has
|
||||||
|
nowhere to put a text field except where something else already was.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
|
||||||
|
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||||
|
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<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="DELETE"
|
||||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
||||||
Command="{Binding DisconnectCommand}" />
|
Command="{Binding DisconnectCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||||
|
|
||||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -828,6 +828,41 @@
|
|||||||
<Setter Property="BorderThickness" Value="1.5" />
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE FILE PANE'S PATH BOX, which used to be a TextBlock.
|
||||||
|
|
||||||
|
The SFTP pane's header printed where it was and gave no way to say where to go: reaching a directory
|
||||||
|
the breadcrumb could not see meant walking to a common ancestor and back down, one round trip a level,
|
||||||
|
and a path somebody already knew could not be typed at all. It is a real TextBox now, and this style
|
||||||
|
is the whole of what keeps it looking like the label it replaced — no fill and no border until it is
|
||||||
|
pointed at, the same mono weight and colour the TextBlock carried, and the same height, so a header
|
||||||
|
that gained an input did not gain a control that shouts about being one.
|
||||||
|
|
||||||
|
Declared after the base TextBox rules and after .address for the reason this file keeps restating:
|
||||||
|
Avalonia has no specificity, so an exception written before the rules it overrides does nothing.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBox.pathbox">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="13.5" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
<Setter Property="MinHeight" Value="26" />
|
||||||
|
<Setter Property="Padding" Value="6,2" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.pathbox /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.pathbox:pointerover /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.pathbox:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||||
|
|||||||
@@ -33,6 +33,43 @@
|
|||||||
layer has not got. See docs/design-import-gaps.md.
|
layer has not got. See docs/design-import-gaps.md.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
══ THE TWO LISTINGS' COLUMNS ═════════════════════════════════════════════════════════════════════════
|
||||||
|
◆ They were fixed pixel widths, and fixed was wrong at both ends of the range this screen actually gets.
|
||||||
|
|
||||||
|
The numbers — 20/*/60/56 here and 20/*/56/52/48 across the arrow column — were chosen against the 204
|
||||||
|
pixels the session shell's own QUICK ACCESS sidebar leaves a pane at the window's minimum. Measured,
|
||||||
|
they were wrong in both directions at once:
|
||||||
|
|
||||||
|
· MODIFIED prints "2026-08-21 11:36", which needs 116 pixels, and never had more than 48. So the
|
||||||
|
column was truncated at every width this screen has ever been drawn at — a maximised window showed
|
||||||
|
"2026-08" beside a NAME column with four hundred empty pixels in it. PERMS needs 60 for a
|
||||||
|
ten-character mode and had 48; SIZE needs 58 for "999.9 GB" and had 56.
|
||||||
|
· At 204 the remote pane's NAME column measured **zero pixels wide**. Twenty for the glyph plus
|
||||||
|
56+52+48 for the numbers is 176 of the 180 a row has, so the file names — the one column a file
|
||||||
|
browser exists to show — were not drawn at all.
|
||||||
|
|
||||||
|
Star columns with a MaxWidth fix both, because the cap is what makes "share the width" mean the right
|
||||||
|
thing at each end. Wide, the three numeric columns stop growing at what their own text needs and NAME
|
||||||
|
keeps every remaining pixel. Narrow, nothing has a MinWidth to overflow with, so all four give ground
|
||||||
|
together and NAME — weighted heaviest — keeps enough to read a name by.
|
||||||
|
|
||||||
|
MODIFIED carries the extra weight (1.6) so that it reaches its cap first: it is the widest content and
|
||||||
|
the column somebody is most often actually reading. The local pane's NAME is 3* against the remote's 2*,
|
||||||
|
which is the remote's PERMS weight folded into it — that is what makes SIZE and MODIFIED come out the
|
||||||
|
same width on both sides of the arrow column, so the same amount of a timestamp is visible in each. See
|
||||||
|
Timestamps.Format for why both panes write one the same way in the first place; a shared format read at
|
||||||
|
two different widths would have made that argument for nothing.
|
||||||
|
|
||||||
|
◆ FOUR COPIES OF THIS, and they have to stay in step: a heading row and a row template per pane. Avalonia
|
||||||
|
cannot share one ColumnDefinitions instance between two Grids — each mutates its own computed sizes —
|
||||||
|
and SharedSizeGroup only applies to Auto columns. What catches a copy that drifts is measurement rather
|
||||||
|
than discipline: ScreenLayoutTests holds every cell against the width its own text needs
|
||||||
|
(TheListingColumnsShowTheirContentInFullWhenThereIsRoom), the headings against each other
|
||||||
|
(TheListingHeadingsDoNotRunIntoEachOther), and NAME against zero at the narrowest budget
|
||||||
|
(TheNameColumnSurvivesTheNarrowestBudget).
|
||||||
|
-->
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<!--
|
<!--
|
||||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -259,10 +296,18 @@
|
|||||||
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
||||||
design's own 1920-pixel canvas.
|
design's own 1920-pixel canvas.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
|
<Grid Grid.Row="2" Margin="0,2,10,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="20" />
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||||
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -274,13 +319,19 @@
|
|||||||
SelectedItem="{Binding SelectedLocalEntry}">
|
SelectedItem="{Binding SelectedLocalEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||||
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
|
<Grid Height="38" Margin="14,0,10,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="20" />
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
<TextBlock Grid.Column="1" 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="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
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="3" 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>
|
||||||
@@ -327,10 +378,12 @@
|
|||||||
|
|
||||||
<!-- ==== The host ==== -->
|
<!-- ==== The host ==== -->
|
||||||
<!--
|
<!--
|
||||||
Six rows rather than five: the connection's own strip is the new one, and it is inside this pane
|
Seven rows. Two of them are furniture over a listing rather than part of it: the connection's own
|
||||||
rather than above both because it is about this pane and nothing else.
|
strip, and the row of things that can be done to whichever row is chosen. Both are inside this pane
|
||||||
|
rather than above both panes because both are about this one and nothing else. The two questions —
|
||||||
|
deleting, and renaming or moving — share a single row, since only one of the two is ever on screen.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*"
|
||||||
DragDrop.AllowDrop="True">
|
DragDrop.AllowDrop="True">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -344,25 +397,48 @@
|
|||||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||||
-->
|
-->
|
||||||
<!--
|
<!--
|
||||||
The path is alone in the star column for the reason the local pane's header remark spells out:
|
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||||
TextTrimming needs the finite width only a star column gives it, and this is the pane where the
|
|
||||||
long path is not even unusual — it is any host with a deep directory tree.
|
It was a TextBlock printing where the pane was. A breadcrumb only reaches what is under the
|
||||||
|
directory already listed, so crossing a host's filesystem was a walk to the common ancestor and
|
||||||
|
back down — a click and a round trip a level — and a path somebody already knew could not be
|
||||||
|
entered at all. Enter goes; Escape puts back what the pane is really showing. See
|
||||||
|
TransfersViewModel.RemotePathDraft for why the box holds its own value rather than RemotePath
|
||||||
|
itself, and SftpPath.Resolve for what an entry that is not absolute means.
|
||||||
|
|
||||||
|
Still alone in the star column, for the reason the local pane's header remark spells out: this box
|
||||||
|
has to be bounded, and an Auto column beside a StackPanel would measure it at its content's width
|
||||||
|
and push the buttons past the pane's edge.
|
||||||
|
|
||||||
|
DELETE has left this row. It acts on the selected row rather than on the pane, and it is one of
|
||||||
|
three that do now — see the item row below, which is where the three of them are together and
|
||||||
|
where there is room for three rather than for one.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
|
||||||
Margin="0,0,12,0">
|
Margin="0,0,8,0">
|
||||||
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding ShowsHostPicker}" />
|
IsVisible="{Binding ShowsHostPicker}" />
|
||||||
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding ShowsBucketPicker}" />
|
IsVisible="{Binding ShowsBucketPicker}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
|
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
Text="{Binding RemotePathDraft}" IsEnabled="{Binding IsConnected}"
|
||||||
HorizontalAlignment="Left"
|
PlaceholderText="path"
|
||||||
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
ToolTip.Tip="Type a path and press Enter. A relative path is taken from here; ~ is the account's home directory.">
|
||||||
ToolTip.Tip="{Binding RemotePath}" />
|
<!--
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
|
One word, because the only time this placeholder shows is on a pane with nothing connected,
|
||||||
|
where the box is disabled and the invitation below is doing the explaining. "/path/to/
|
||||||
|
somewhere" needs 146 pixels and had 91 at the session shell's narrowest, so it drew as a
|
||||||
|
path cut off mid-segment. What the box is for is in the tooltip, where there is room for it.
|
||||||
|
-->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding RevertTypedRemotePathCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
@@ -371,10 +447,6 @@
|
|||||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
|
|
||||||
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
|
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -402,9 +474,53 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
|
◆ WHAT CAN BE DONE TO THE ROW THAT IS CHOSEN. Only while something is connected.
|
||||||
still on screen and still selected while it is being answered — and it names the full path rather
|
|
||||||
than the file, because a name is the half that does not identify anything.
|
Three buttons in one place, which is the point. DELETE used to sit alone in the pane header beside
|
||||||
|
UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and
|
||||||
|
there was no rename and no move at all, so a file that had landed under the wrong name could only
|
||||||
|
be put right by deleting it and sending it again. These three act on the selection, so they belong
|
||||||
|
together and away from the two that act on the pane.
|
||||||
|
|
||||||
|
The name beside them is the row they are about, ellipsized. It says which row rather than which
|
||||||
|
path: the listing directly underneath has that row highlighted, so a full path here would be
|
||||||
|
repeating what the selection is already showing. The forms the buttons open do print the path,
|
||||||
|
because that is the moment something is committed rather than merely looked at.
|
||||||
|
|
||||||
|
The row goes while one of the three questions is up rather than sitting there greyed out, and so
|
||||||
|
does the breadcrumb below it — see ShowsRemoteItemActions. The form that opens is the answer to
|
||||||
|
what these buttons asked, and inside the session shell this pane is 204 pixels wide: the height
|
||||||
|
goes to the question or to the buttons that raised it, not to both.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
|
||||||
|
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="11.5" VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding SelectedRemoteName}" />
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||||
|
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||||
|
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
||||||
|
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
|
||||||
|
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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
|
||||||
|
is about: at the session shell's own 204-pixel width there is no height left for a listing under
|
||||||
|
it, so "the row is still on screen and still selected" is no longer true and the path in the card
|
||||||
|
is what carries the identity. See the rename form below, which is measured in the same shape.
|
||||||
|
|
||||||
This is the strongest warning on any of these screens, and deliberately: everything else this
|
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||||
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||||||
@@ -415,7 +531,7 @@
|
|||||||
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
|
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
|
||||||
instead of one of them going off the edge, which the WrapPanel achieves for free.
|
instead of one of them going off the edge, which the WrapPanel achieves for free.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
|
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||||
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
||||||
<StackPanel Spacing="7">
|
<StackPanel Spacing="7">
|
||||||
@@ -435,7 +551,65 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
<!--
|
||||||
|
◆ RENAME AND MOVE, in the row the deletion question uses and never at the same time as it.
|
||||||
|
|
||||||
|
One form for both, because they differ only in what the box starts out holding and in the words
|
||||||
|
around it — see RemoteMoveRequest, where both sets are chosen. It shares this Grid row with
|
||||||
|
the deletion card above: the three questions are mutually exclusive by construction, each one's
|
||||||
|
own opening clearing the others, so a row of its own would be a row that is empty whenever this
|
||||||
|
one is not.
|
||||||
|
|
||||||
|
It is not painted in the deletion card's danger colours, and that is deliberate rather than an
|
||||||
|
oversight. A rename is undone by renaming back; the deletion beside it is the one thing this
|
||||||
|
application does that nothing can walk back, and spending the same red on both would make the red
|
||||||
|
stop meaning anything.
|
||||||
|
|
||||||
|
Enter commits from inside the box, which is where the hands already are once an answer has been
|
||||||
|
typed.
|
||||||
|
|
||||||
|
◆ IT TAKES THE WHOLE PANE at the session shell's own 204-pixel width, and that is measured rather
|
||||||
|
than hoped: the listing's star row is squeezed to nothing while either question is open, exactly
|
||||||
|
as the deletion card above already squeezed it. There is no room for a form and a listing in 336
|
||||||
|
pixels, and the form carries the full path precisely so the row underneath does not have to be
|
||||||
|
visible for the question to be about something identifiable. That is why the item row and the
|
||||||
|
breadcrumb step aside instead — the height they free is what keeps the form itself un-clipped,
|
||||||
|
which is what ScreenLayoutTests measures.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||||
|
IsVisible="{Binding IsMovingRemote}">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemoteMove.Question}" />
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding PendingRemoteMove.FullPath}" />
|
||||||
|
<TextBox Text="{Binding RemoteMoveTarget}"
|
||||||
|
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding CancelMoveRemoteCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemoteMove.Note}" />
|
||||||
|
<WrapPanel>
|
||||||
|
<Button Classes="accent" Content="{Binding PendingRemoteMove.ConfirmLabel}" Margin="0,0,8,4"
|
||||||
|
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||||
|
Command="{Binding CancelMoveRemoteCommand}" />
|
||||||
|
</WrapPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
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 doing.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" Margin="12,6,12,4"
|
||||||
|
IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
@@ -467,10 +641,16 @@
|
|||||||
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
|
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
|
||||||
enough for a real directory name to be typed and read back, which is what this box is for; a
|
enough for a real directory name to be typed and read back, which is what this box is for; a
|
||||||
name that outgrows it scrolls inside the box the way every TextBox already does.
|
name that outgrows it scrolls inside the box the way every TextBox already does.
|
||||||
|
|
||||||
|
◆ The placeholder says "directory" rather than "new directory", which is what it said until it
|
||||||
|
was measured: the longer text needs 101 pixels inside a box that has 74, so it drew as "new
|
||||||
|
directo" — a placeholder cut off mid-word, which reads as a rendering fault rather than as a
|
||||||
|
hint. MKDIR is right beside it and carries the "new" half already. The path box in the header
|
||||||
|
above lost the same argument and for the same reason; see its own PlaceholderText.
|
||||||
-->
|
-->
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||||
IsVisible="{Binding IsConnected}">
|
IsVisible="{Binding IsConnected}">
|
||||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="directory" />
|
||||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -480,25 +660,42 @@
|
|||||||
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
||||||
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
|
<Grid Grid.Row="5" Margin="0,2,10,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="20" />
|
||||||
|
<ColumnDefinition Width="2*" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||||
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" Margin="6,0,0,0"
|
||||||
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
|
<Grid Height="38" Margin="14,0,10,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="20" />
|
||||||
|
<ColumnDefinition Width="2*" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
<TextBlock Grid.Column="1" 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="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
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="3" 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="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||||
@@ -513,7 +710,7 @@
|
|||||||
A directory with nothing in it. It no longer has to say anything about not being connected: that
|
A directory with nothing in it. It no longer has to say anything about not being connected: that
|
||||||
state is the invitation below, which covers this pane whole.
|
state is the invitation below, which covers this pane whole.
|
||||||
-->
|
-->
|
||||||
<TextBlock Grid.Row="5" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
<TextBlock Grid.Row="6" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||||||
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
|
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
|
||||||
|
|
||||||
@@ -535,7 +732,7 @@
|
|||||||
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.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="1" Grid.RowSpan="5" Background="{StaticResource Canvas}"
|
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
|
||||||
IsVisible="{Binding !IsConnected}">
|
IsVisible="{Binding !IsConnected}">
|
||||||
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
@@ -679,11 +876,11 @@
|
|||||||
window that has stopped answering, and the answer arriving after the drop is the answer arriving
|
window that has stopped answering, and the answer arriving after the drop is the answer arriving
|
||||||
too late. See the local pane for why neither may hit-test.
|
too late. See the local pane for why neither may hit-test.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
|
||||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||||||
|
|
||||||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
|
||||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
||||||
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
|
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
|
||||||
|
|||||||
@@ -306,6 +306,83 @@ internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool
|
|||||||
+ "touched, and there is no undo.";
|
+ "touched, and there is no undo.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Which of the two things a rename actually is, from where the user is standing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One SFTP operation underneath — <c>ISftpSession.RenameAsync</c> is the whole of both — and two separate
|
||||||
|
/// questions on the screen, because the answer a person types is a different kind of thing each time. A
|
||||||
|
/// rename takes a name and must not take a path; a move takes a directory and must not take a name. Folding
|
||||||
|
/// them into one box asking for a destination path would be fewer controls and a worse question: the two
|
||||||
|
/// mistakes it invites — typing a directory where a name goes, or a name where a directory goes — are
|
||||||
|
/// exactly the ones this split makes impossible to make.
|
||||||
|
/// </remarks>
|
||||||
|
internal enum RemoteMoveKind
|
||||||
|
{
|
||||||
|
/// <summary>Same directory, different name.</summary>
|
||||||
|
Rename,
|
||||||
|
|
||||||
|
/// <summary>Same name, different directory.</summary>
|
||||||
|
Move,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Something on the host that is being given a new name or a new home, and has not been committed yet.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The shape <see cref="RemoteDeletionRequest"/> already established, and here for the same reason: it
|
||||||
|
/// carries the full path rather than the name, so nothing that happens between opening the form and
|
||||||
|
/// pressing its button can point the operation at a different file. The selection can move underneath it —
|
||||||
|
/// a refresh nulls it — and the request stays about what was asked about.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It asks rather than warns.</b> Renaming and moving on the host are not deletions: what they change is
|
||||||
|
/// where the bytes are reached from, and both are undone by doing them again the other way. So this form
|
||||||
|
/// carries a field and a verb instead of <see cref="RemoteDeletionRequest.Consequence"/>'s "there is no
|
||||||
|
/// undo" — the one warning on this screen that is earned, and worth not spending twice.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Name">What the row is called now.</param>
|
||||||
|
/// <param name="FullPath">Where it is now, which is the end of the operation nothing may re-point.</param>
|
||||||
|
/// <param name="IsDirectory">Whether it is a directory, which changes what moving it can be asked to do.</param>
|
||||||
|
/// <param name="Kind">Which question is being asked.</param>
|
||||||
|
internal sealed record RemoteMoveRequest(string Name, string FullPath, bool IsDirectory, RemoteMoveKind Kind)
|
||||||
|
{
|
||||||
|
/// <summary>Whether this is the rename question rather than the move one.</summary>
|
||||||
|
internal bool IsRename => Kind is RemoteMoveKind.Rename;
|
||||||
|
|
||||||
|
/// <summary>The heading, which names the thing rather than only the verb.</summary>
|
||||||
|
internal string Question => IsRename
|
||||||
|
? $"Rename '{Name}' on the host"
|
||||||
|
: $"Move '{Name}' to another directory";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What an empty box shows, which is the shape of the answer rather than an example of one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The placeholder is the whole of what says which of the two kinds of answer the box wants — there is
|
||||||
|
/// no label above it. A field label would be a second line saying what this line already says, and the
|
||||||
|
/// pane this form opens in is 204 pixels wide inside the session shell: every line it does not need is
|
||||||
|
/// a line of the listing underneath that survives.
|
||||||
|
/// </remarks>
|
||||||
|
internal string FieldHint => IsRename ? "new name" : "/path/to/directory";
|
||||||
|
|
||||||
|
/// <summary>What the button that commits it says.</summary>
|
||||||
|
internal string ConfirmLabel => IsRename ? "RENAME" : "MOVE";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What it does, in the one sentence the form has room for.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves state the refusal rather than the action, because the action is already in the heading
|
||||||
|
/// and the refusal is the part nobody expects: SFTP's rename does not replace, so a name already taken
|
||||||
|
/// is a failure rather than a silent overwrite — which is the same promise the transfer queue makes and
|
||||||
|
/// rests on the same server behaviour.
|
||||||
|
/// </remarks>
|
||||||
|
internal string Note => IsRename
|
||||||
|
? "A name already taken is refused, not overwritten."
|
||||||
|
: "Relative to the directory showing. A name already taken is refused, not overwritten.";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -382,6 +459,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
private bool disposed;
|
private bool disposed;
|
||||||
|
|
||||||
|
/// <summary>Whether the remote listing on screen is known to be out of date.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set by <see cref="NoteTransferLanded"/> and cleared by any listing of the directory it is about.
|
||||||
|
/// Read back by <see cref="RemoteListingIsStale"/>, which exists so that the deferral this drives can be
|
||||||
|
/// asserted on in the state where it defers — the moment a transfer lands over an open question is not
|
||||||
|
/// one a test can otherwise see the inside of.
|
||||||
|
/// </remarks>
|
||||||
|
private bool remoteListingIsStale;
|
||||||
|
|
||||||
/// <param name="sftp">Opens SFTP sessions.</param>
|
/// <param name="sftp">Opens SFTP sessions.</param>
|
||||||
/// <param name="clock">Time source, for transfer rates and timestamps.</param>
|
/// <param name="clock">Time source, for transfer rates and timestamps.</param>
|
||||||
/// <param name="post">
|
/// <param name="post">
|
||||||
@@ -706,6 +792,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
|
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What is in the pane's path box, which is not where the pane is.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A draft beside <see cref="RemotePath"/> rather than the path itself made editable, and the two facts
|
||||||
|
/// really are different: <see cref="RemotePath"/> is the directory that was listed, and this is what
|
||||||
|
/// somebody is halfway through typing. Binding the box straight to the path would navigate on every
|
||||||
|
/// keystroke — <c>/</c>, then <c>/v</c>, then <c>/va</c> — which is a round trip and a failed listing
|
||||||
|
/// per character.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It is written back from <see cref="ListRemoteAsync"/>, so every other way of moving — a breadcrumb,
|
||||||
|
/// UP, a pin, connecting — puts the real path back in the box. What a failed listing leaves is what was
|
||||||
|
/// typed, on purpose: the path that did not resolve is the one worth having in front of you to correct.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string remotePathDraft = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private RemoteEntryRowViewModel? selectedRemoteEntry;
|
private RemoteEntryRowViewModel? selectedRemoteEntry;
|
||||||
|
|
||||||
@@ -721,13 +827,75 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
||||||
|
|
||||||
|
/// <summary>The rename or move being filled in, or null when none is.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private RemoteMoveRequest? pendingRemoteMove;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What has been typed into whichever of the two forms is open — a name, or a directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One box for both, because only one of the two forms is ever open: they replace each other, and a
|
||||||
|
/// second field would be a second thing to clear at every place this one is. Seeded from the row when
|
||||||
|
/// the form opens — the current name for a rename, the current directory for a move — so that both
|
||||||
|
/// questions start from the answer that changes nothing, and what somebody edits is the part they came
|
||||||
|
/// to change.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string remoteMoveTarget = string.Empty;
|
||||||
|
|
||||||
|
internal bool IsMovingRemote => PendingRemoteMove is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the pane is waiting on an answer about the selected row.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The three item buttons and the automatic refresh all read this rather than the two flags separately.
|
||||||
|
/// Deleting, renaming and moving are one at a time by construction — each form's own opening clears the
|
||||||
|
/// other — and what this names is the state where none of them may be started and nothing may pull the
|
||||||
|
/// listing out from under the question. See <see cref="SettleRemoteListingAsync"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool IsAskingAboutRemoteEntry => IsConfirmingRemoteDeletion || IsMovingRemote;
|
||||||
|
|
||||||
/// <summary>Whether the pane's DELETE is live.</summary>
|
/// <summary>Whether the pane's DELETE is live.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
|
/// Off while any of the three questions is up, so a second press cannot arm a second one behind the
|
||||||
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
|
/// form — and disabled rather than hidden, because these buttons sit in a row and a gap where one was
|
||||||
/// move UP and REFRESH out from under the pointer.
|
/// would move the other two out from under the pointer.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
|
internal bool CanDeleteRemote => IsConnected && !IsAskingAboutRemoteEntry;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="CanDeleteRemote" />
|
||||||
|
internal bool CanRenameRemote => CanDeleteRemote;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="CanDeleteRemote" />
|
||||||
|
internal bool CanMoveRemote => CanDeleteRemote;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the pane shows the row of things that can be done to the chosen row.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Gone while one of the three questions is up, rather than merely disabled, and the phone head does
|
||||||
|
/// the same with its own buttons for the same reason: the form is the answer to the question those
|
||||||
|
/// buttons ask, and a pane 204 pixels wide inside the session shell has to spend the height on one or
|
||||||
|
/// the other. What is not on screen is not a control somebody can press by mistake either.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool ShowsRemoteItemActions => IsConnected && !IsAskingAboutRemoteEntry;
|
||||||
|
|
||||||
|
/// <summary>Whether a transfer has landed in the directory showing and it has not been re-read yet.</summary>
|
||||||
|
/// <remarks>See <see cref="SettleRemoteListingAsync"/> for when that is allowed to be true for a while.</remarks>
|
||||||
|
internal bool RemoteListingIsStale => remoteListingIsStale;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the item row says it is about — the chosen row's name, or the sentence that says to choose one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A name rather than a full path, unlike the questions the buttons open: this line sits directly above
|
||||||
|
/// the listing with the row it names highlighted in it, so the disambiguation a confirmation needs is
|
||||||
|
/// already on screen. The forms carry the path, because by then the answer is being committed rather
|
||||||
|
/// than merely looked at.
|
||||||
|
/// </remarks>
|
||||||
|
internal string SelectedRemoteName => SelectedRemoteEntry?.Name ?? "Choose a row to act on it";
|
||||||
|
|
||||||
// ---- The local pane ----
|
// ---- The local pane ----
|
||||||
|
|
||||||
@@ -1199,6 +1367,58 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Goes wherever the path box says.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The way out of the breadcrumb's own limit: it can only offer what is under the directory already
|
||||||
|
/// listed, so crossing the filesystem meant walking down to the root and back up again, a round trip a
|
||||||
|
/// level. What is typed may be absolute, relative to the directory showing, or start at <c>~</c> — see
|
||||||
|
/// <see cref="SftpPath.Resolve"/>, which is where all three become one absolute path.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A blank box is not a request to go to the root. It puts the current path back and stays where it is:
|
||||||
|
/// somebody who cleared the box and pressed Enter has not asked for anything, and <c>/</c> is a long way
|
||||||
|
/// from where they were.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Nothing is validated here beyond that. Whether the path exists, is a directory and may be read are
|
||||||
|
/// three questions only the server can answer, and it answers all three at once by refusing the listing
|
||||||
|
/// — which lands in the status line with the server's own words in it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task GoToTypedRemotePathAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (session is not { } open)
|
||||||
|
{
|
||||||
|
Status = "Connect to a host first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemotePathDraft.Trim().Length == 0)
|
||||||
|
{
|
||||||
|
RemotePathDraft = RemotePath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var from = RemotePath.Length > 0 ? RemotePath : open.HomeDirectory;
|
||||||
|
|
||||||
|
await NavigateRemoteAsync(
|
||||||
|
SftpPath.Resolve(from, RemotePathDraft, open.HomeDirectory), cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts the real path back in the box, for the Escape key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Half-typed text left in a box that says where you are is a box that is lying about where you are, and
|
||||||
|
/// the pane underneath it goes on showing the directory it always was. Escape is the way out of that
|
||||||
|
/// without having to remember what was in the box before.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private void RevertTypedRemotePath() => RemotePathDraft = RemotePath;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
|
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1631,6 +1851,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The other question goes, so the two forms cannot be on screen at once over one row.
|
||||||
|
PendingRemoteMove = null;
|
||||||
|
|
||||||
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,8 +1889,173 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Thinks better of it.</summary>
|
/// <summary>Thinks better of it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Settles the listing on the way out. A transfer that landed while the question was up left the pane
|
||||||
|
/// out of date and was not allowed to refresh it — see <see cref="SettleRemoteListingAsync"/> — so
|
||||||
|
/// dismissing the question is the moment that refresh becomes safe.
|
||||||
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
private async Task CancelDeleteRemoteAsync()
|
||||||
|
{
|
||||||
|
PendingRemoteDeletion = null;
|
||||||
|
|
||||||
|
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens the rename form on the chosen row.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void RenameRemote() => AskAboutMoving(RemoteMoveKind.Rename);
|
||||||
|
|
||||||
|
/// <summary>Opens the move form on the chosen row.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void MoveRemote() => AskAboutMoving(RemoteMoveKind.Move);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Both forms, because they differ only in what the box starts out holding and what the labels say —
|
||||||
|
/// see <see cref="RemoteMoveRequest"/>. The rename starts from the current name and the move from the
|
||||||
|
/// current directory, so each opens showing the answer that changes nothing and what gets edited is the
|
||||||
|
/// half somebody actually came to change.
|
||||||
|
/// </remarks>
|
||||||
|
private void AskAboutMoving(RemoteMoveKind kind)
|
||||||
|
{
|
||||||
|
if (SelectedRemoteEntry is not { } row)
|
||||||
|
{
|
||||||
|
Status = kind is RemoteMoveKind.Rename
|
||||||
|
? "Choose something on the host to rename."
|
||||||
|
: "Choose something on the host to move.";
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other question goes, so the two forms cannot be on screen at once over one row.
|
||||||
|
PendingRemoteDeletion = null;
|
||||||
|
|
||||||
|
PendingRemoteMove = new RemoteMoveRequest(row.Name, row.FullPath, !row.IsFile, kind);
|
||||||
|
RemoteMoveTarget = kind is RemoteMoveKind.Rename
|
||||||
|
? row.Name
|
||||||
|
: SftpPath.Parent(row.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Carries out whichever of the two was filled in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One SFTP rename underneath both, which is what the protocol offers and the whole of what a move is
|
||||||
|
/// on one host. It follows that neither works across hosts and neither works across a filesystem
|
||||||
|
/// boundary on the same host: the server refuses with <c>EXDEV</c>, and that refusal arrives in the
|
||||||
|
/// status line as the server's own sentence rather than being pre-empted by a guess here about which of
|
||||||
|
/// its mounts a path is on.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It acts on the path the form named rather than on the selection, for the reason
|
||||||
|
/// <see cref="ConfirmDeleteRemoteAsync"/> does: nothing between opening the form and pressing its button
|
||||||
|
/// may re-point it at a different file.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ConfirmMoveRemoteAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (PendingRemoteMove is not { } request)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryResolveMoveDestination(request, out var destination, out var refusal))
|
||||||
|
{
|
||||||
|
// The form stays open. What was typed is what has to be corrected, and taking the form away
|
||||||
|
// would take the box holding it as well.
|
||||||
|
Status = refusal;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingRemoteMove = null;
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
request.IsRename ? $"Renaming {request.Name}…" : $"Moving {request.Name}…",
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
await RequireSession()
|
||||||
|
.RenameAsync(request.FullPath, destination, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = request.IsRename
|
||||||
|
? $"Renamed {request.Name} to {SftpPath.Name(destination)}."
|
||||||
|
: $"Moved {request.Name} to {SftpPath.Parent(destination)}.";
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Thinks better of it.</summary>
|
||||||
|
/// <inheritdoc cref="CancelDeleteRemoteAsync" path="/remarks" />
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CancelMoveRemoteAsync()
|
||||||
|
{
|
||||||
|
PendingRemoteMove = null;
|
||||||
|
RemoteMoveTarget = string.Empty;
|
||||||
|
|
||||||
|
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Works out where the typed answer actually points, or why it points nowhere.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The three refusals here are the ones a server's own error message would not explain. A separator in
|
||||||
|
/// a name is a move wearing a rename's clothing and the server would simply say "no such file" about a
|
||||||
|
/// directory nobody meant to name; a destination equal to the source is a request that succeeds while
|
||||||
|
/// doing nothing; and a directory moved inside itself is <c>EINVAL</c>, which is true and says nothing.
|
||||||
|
/// Everything else — a path that is not there, a name already taken, a mount that cannot be crossed —
|
||||||
|
/// is left to the server, which knows and this does not.
|
||||||
|
/// </remarks>
|
||||||
|
private bool TryResolveMoveDestination(
|
||||||
|
RemoteMoveRequest request, out string destination, out string refusal)
|
||||||
|
{
|
||||||
|
destination = string.Empty;
|
||||||
|
var typed = RemoteMoveTarget.Trim();
|
||||||
|
|
||||||
|
if (typed.Length == 0)
|
||||||
|
{
|
||||||
|
refusal = request.IsRename
|
||||||
|
? "Type a new name for it."
|
||||||
|
: "Type the directory to move it into.";
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsRename)
|
||||||
|
{
|
||||||
|
if (typed.Contains('/', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
refusal = "A name cannot contain '/'. Use MOVE to put it in another directory.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
destination = SftpPath.Combine(SftpPath.Parent(request.FullPath), typed);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var into = SftpPath.Resolve(RemotePath, typed, session?.HomeDirectory);
|
||||||
|
destination = SftpPath.Combine(into, request.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(destination, request.FullPath, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
refusal = $"{request.Name} is already there.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsDirectory
|
||||||
|
&& destination.StartsWith(request.FullPath + "/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
refusal = "A directory cannot be moved inside itself.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
refusal = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
@@ -1713,13 +2101,31 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
|
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The selection survives if the row it was on does.</b> Every listing replaces every row object, so
|
||||||
|
/// keeping the object would keep a selection pointing at something no longer in the list; it is re-found
|
||||||
|
/// by path instead. This matters because a listing is no longer only something somebody asked for — a
|
||||||
|
/// finished upload refreshes this pane on its own, and a refresh that cleared the selection would take
|
||||||
|
/// the row out from under whoever was about to press RENAME on it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The path box is written back here rather than by each caller, so that every way of moving — a crumb,
|
||||||
|
/// UP, a pin, connecting, a rename that lands — leaves the box saying where the pane actually is.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
|
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
|
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
|
||||||
|
var wasSelected = SelectedRemoteEntry?.FullPath;
|
||||||
|
|
||||||
RemotePath = path;
|
RemotePath = path;
|
||||||
|
RemotePathDraft = path;
|
||||||
SelectedRemoteEntry = null;
|
SelectedRemoteEntry = null;
|
||||||
|
|
||||||
|
// Whatever made it stale, this is a fresh read of the very directory that was stale.
|
||||||
|
remoteListingIsStale = false;
|
||||||
|
|
||||||
RemoteEntries.Clear();
|
RemoteEntries.Clear();
|
||||||
|
|
||||||
foreach (var entry in entries)
|
foreach (var entry in entries)
|
||||||
@@ -1734,10 +2140,99 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
RemoteTrail.Add(new CrumbViewModel(name, crumb));
|
RemoteTrail.Add(new CrumbViewModel(name, crumb));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SelectedRemoteEntry = wasSelected is null
|
||||||
|
? null
|
||||||
|
: RemoteEntries.FirstOrDefault(
|
||||||
|
row => string.Equals(row.FullPath, wasSelected, StringComparison.Ordinal));
|
||||||
|
|
||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-reads the remote directory because something landed in it, if now is a moment to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>An upload that finished is a listing that is wrong.</b> The pane went on showing the directory as
|
||||||
|
/// it was before the file arrived, and the only way to see the file that had just been sent was to press
|
||||||
|
/// REFRESH — which is the application asking somebody to tell it about something it did itself.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It waits rather than interrupting.</b> Three states are not the moment: another operation is
|
||||||
|
/// running, which would have two listings racing for the same collection; one of the three questions is
|
||||||
|
/// on screen, and pulling the listing out from under a question about a row is how the row somebody
|
||||||
|
/// answered about stops being the row that was acted on; or nothing is connected. In all three the
|
||||||
|
/// staleness is remembered and settled later — at the end of the operation, or when the question is
|
||||||
|
/// dismissed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It says nothing.</b> Nobody asked for this listing, so it neither announces itself nor reports a
|
||||||
|
/// failure: the status line at that moment is carrying the word about the transfer that just finished,
|
||||||
|
/// which is the news. A refresh that failed leaves the pane as it was and the staleness set, so the next
|
||||||
|
/// settling tries again.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal async Task SettleRemoteListingAsync()
|
||||||
|
{
|
||||||
|
if (!remoteListingIsStale || !IsConnected || IsBusy || IsAskingAboutRemoteEntry
|
||||||
|
|| RemotePath.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var said = Status;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ListRemoteAsync(RemotePath, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = said;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
remoteListingIsStale = true;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Notices that a finished transfer changed a directory that is on screen.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both panes, because both go stale the same way and for the same reason — an upload lands on the host
|
||||||
|
/// and a download lands on this machine, and whichever pane is showing that directory is now showing it
|
||||||
|
/// as it was a moment ago. Only the directory actually on screen: a transfer into somewhere else changed
|
||||||
|
/// nothing anybody is looking at, and re-listing for it would be a round trip per completed transfer in
|
||||||
|
/// a queue that may be moving a hundred files elsewhere.
|
||||||
|
/// </remarks>
|
||||||
|
internal void NoteTransferLanded(TransferSnapshot transfer)
|
||||||
|
{
|
||||||
|
if (transfer.Direction is TransferDirection.Upload)
|
||||||
|
{
|
||||||
|
if (string.Equals(SftpPath.Parent(transfer.RemotePath), RemotePath, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
remoteListingIsStale = true;
|
||||||
|
|
||||||
|
_ = SettleRemoteListingAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The local pane is a filesystem call rather than a round trip, so there is nothing to defer: it is
|
||||||
|
// re-read where it stands. Compared case-insensitively because this end is Windows as often as not.
|
||||||
|
if (string.Equals(
|
||||||
|
Path.GetDirectoryName(transfer.LocalPath), LocalPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
NavigateLocal(LocalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
|
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
|
||||||
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
|
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
|
||||||
@@ -1748,6 +2243,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var entries = LocalDirectory.List(path);
|
var entries = LocalDirectory.List(path);
|
||||||
|
var wasSelected = SelectedLocalEntry?.FullPath;
|
||||||
|
|
||||||
LocalPath = Path.GetFullPath(path);
|
LocalPath = Path.GetFullPath(path);
|
||||||
SelectedLocalEntry = null;
|
SelectedLocalEntry = null;
|
||||||
@@ -1761,6 +2257,13 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
RebuildLocalTrail();
|
RebuildLocalTrail();
|
||||||
|
|
||||||
|
// Re-found by path rather than kept, for the reason ListRemoteAsync gives: a finished download
|
||||||
|
// re-reads this pane on its own, and a refresh that cleared the selection would take the row
|
||||||
|
// out from under whoever was about to send it somewhere.
|
||||||
|
SelectedLocalEntry = wasSelected is null
|
||||||
|
? null
|
||||||
|
: LocalEntries.FirstOrDefault(
|
||||||
|
row => string.Equals(row.FullPath, wasSelected, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
@@ -1877,10 +2380,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
ConnectedPinnedPaths.Clear();
|
ConnectedPinnedPaths.Clear();
|
||||||
OnPropertyChanged(nameof(HasConnectedPins));
|
OnPropertyChanged(nameof(HasConnectedPins));
|
||||||
RemotePath = string.Empty;
|
RemotePath = string.Empty;
|
||||||
|
RemotePathDraft = string.Empty;
|
||||||
RemoteEntries.Clear();
|
RemoteEntries.Clear();
|
||||||
RemoteTrail.Clear();
|
RemoteTrail.Clear();
|
||||||
SelectedRemoteEntry = null;
|
SelectedRemoteEntry = null;
|
||||||
|
|
||||||
|
// Both questions go with the session they were about, and the box either of them was filled in
|
||||||
|
// from with them. A form left open over a pane that is no longer connected to anything would
|
||||||
|
// commit against RequireSession's own refusal.
|
||||||
|
PendingRemoteDeletion = null;
|
||||||
|
PendingRemoteMove = null;
|
||||||
|
RemoteMoveTarget = string.Empty;
|
||||||
|
remoteListingIsStale = false;
|
||||||
|
|
||||||
// Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
|
// Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
|
||||||
// already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
|
// already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
|
||||||
// covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
|
// covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
|
||||||
@@ -1911,6 +2423,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
{
|
{
|
||||||
ReleaseStaged(e.Transfer.LocalPath);
|
ReleaseStaged(e.Transfer.LocalPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whichever pane the bytes landed in is showing that directory as it was before they did.
|
||||||
|
NoteTransferLanded(e.Transfer);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||||
@@ -1961,6 +2476,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
{
|
{
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one place every command on this screen passes through on its way out, which makes it the one
|
||||||
|
// place a listing that went stale while it was running can safely be settled.
|
||||||
|
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||||
@@ -2002,7 +2521,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanDownload));
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
OnPropertyChanged(nameof(CanUpload));
|
OnPropertyChanged(nameof(CanUpload));
|
||||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
RaiseItemActionState();
|
||||||
|
|
||||||
// Both ways, and the disconnecting half is the one worth stating: closing a session puts the pane
|
// Both ways, and the disconnecting half is the one worth stating: closing a session puts the pane
|
||||||
// back to its invitation rather than to the form, so what the pane shows after a disconnect is the
|
// back to its invitation rather than to the form, so what the pane shows after a disconnect is the
|
||||||
@@ -2019,14 +2538,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanDownload));
|
OnPropertyChanged(nameof(CanDownload));
|
||||||
|
OnPropertyChanged(nameof(SelectedRemoteName));
|
||||||
|
|
||||||
PendingRemoteDeletion = null;
|
PendingRemoteDeletion = null;
|
||||||
|
PendingRemoteMove = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
||||||
|
RaiseItemActionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPendingRemoteMoveChanged(RemoteMoveRequest? value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsMovingRemote));
|
||||||
|
RaiseItemActionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Raises the flags the three item buttons and their two forms are drawn from.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Together, because they are one fact — is there a question on screen, and is anything connected —
|
||||||
|
/// read five ways, and a caller that raised some of them would leave a button live beside a form that
|
||||||
|
/// says it is not.
|
||||||
|
/// </remarks>
|
||||||
|
private void RaiseItemActionState()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsAskingAboutRemoteEntry));
|
||||||
|
OnPropertyChanged(nameof(ShowsRemoteItemActions));
|
||||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||||
|
OnPropertyChanged(nameof(CanRenameRemote));
|
||||||
|
OnPropertyChanged(nameof(CanMoveRemote));
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||||
|
|||||||
@@ -234,6 +234,93 @@ public static class SftpPath
|
|||||||
|
|
||||||
return trail;
|
return trail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns something a person typed into an absolute path.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The file browser's path box is the reason this exists. A breadcrumb can only offer the path it is
|
||||||
|
/// already standing on, so reaching <c>/var/log/nginx</c> from a home directory is a click and a round
|
||||||
|
/// trip per level — and somebody who already knows where they are going types it instead. What they
|
||||||
|
/// type is not always absolute: <c>..</c>, <c>logs</c> and <c>~/.ssh</c> are all ordinary answers to
|
||||||
|
/// "where to", and a box that took only a leading slash would refuse three of the four shapes every
|
||||||
|
/// shell accepts.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Resolved here rather than left to the server.</b> An SFTP server does resolve a relative path —
|
||||||
|
/// against the session's own working directory, which is not necessarily the directory this pane is
|
||||||
|
/// showing — so handing one over would navigate somewhere the breadcrumb above it did not predict. What
|
||||||
|
/// this returns is absolute, which is the only kind every other member here takes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>..</c> is collapsed textually, which is one thing a shell's own <c>cd</c> does not do: through a
|
||||||
|
/// symbolic link, <c>..</c> leads to the link's parent here and to the target's parent on the server.
|
||||||
|
/// The difference shows only on a path that walks into a link and back out of it, and the alternative is
|
||||||
|
/// a <c>realpath</c> round trip before anything is listed at all.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="current">The directory a relative path is taken to be relative to.</param>
|
||||||
|
/// <param name="typed">What was typed. Blank answers <paramref name="current"/>.</param>
|
||||||
|
/// <param name="home">
|
||||||
|
/// What <c>~</c> means, or null in a session that has not said. A leading <c>~</c> with no home to
|
||||||
|
/// expand stays an ordinary segment rather than being guessed at — a directory really can be called
|
||||||
|
/// that, and inventing a home directory to point at would be worse than listing the one that is there.
|
||||||
|
/// </param>
|
||||||
|
public static string Resolve(string current, string typed, string? home = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(current);
|
||||||
|
ArgumentNullException.ThrowIfNull(typed);
|
||||||
|
|
||||||
|
var wanted = typed.Trim();
|
||||||
|
|
||||||
|
if (wanted.Length == 0)
|
||||||
|
{
|
||||||
|
return Normalize(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (home is { Length: > 0 }
|
||||||
|
&& (string.Equals(wanted, "~", StringComparison.Ordinal)
|
||||||
|
|| wanted.StartsWith("~/", StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
wanted = wanted.Length == 1 ? home : Combine(home, wanted[2..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Normalize(IsAbsolute(wanted) ? wanted : Combine(current, wanted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Collapses <c>.</c>, <c>..</c> and repeated separators, anchored at the root.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>..</c> above the root is the root, as it is in every shell: <c>/../..</c> is <c>/</c> rather than
|
||||||
|
/// a failure, because there is nowhere else for it to be.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Normalize(string path)
|
||||||
|
{
|
||||||
|
var walked = new List<string>();
|
||||||
|
|
||||||
|
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
switch (segment)
|
||||||
|
{
|
||||||
|
case ".":
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "..":
|
||||||
|
if (walked.Count > 0)
|
||||||
|
{
|
||||||
|
walked.RemoveAt(walked.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
walked.Add(segment);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return walked.Count == 0 ? Root : Root + string.Join('/', walked);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Headless;
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
@@ -1311,10 +1312,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
||||||
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
||||||
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
||||||
|
/// <para>
|
||||||
|
/// Connected, because that is the only state this question can be asked in — and it is the state that
|
||||||
|
/// asks it at the narrowest budget, since <c>IsConnected</c> is what pulls QUICK ACCESS's own sidebar
|
||||||
|
/// in beside this screen and takes the pane down to 204 pixels. Measured disconnected until the row of
|
||||||
|
/// item actions arrived beside DELETE, which is a shape this card has to share the pane's height with.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
||||||
{
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/srv/releases/site/backups/nightly";
|
||||||
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||||
"2026-07-30",
|
"2026-07-30",
|
||||||
"/srv/releases/site/backups/nightly/2026-07-30",
|
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||||
@@ -1323,6 +1332,112 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The rename form, which takes a row out of the remote pane's column the way the deletion question
|
||||||
|
/// does but is a taller shape: a heading, the path, a label, a box and a sentence, over a listing that
|
||||||
|
/// is still showing. Measured connected, so it is asked at the 204-pixel width the session shell's own
|
||||||
|
/// sidebar leaves this pane.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransfersScreenFitsWithARenameInQuestion()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/srv/releases/site/shared";
|
||||||
|
transfers.PendingRemoteMove = new RemoteMoveRequest(
|
||||||
|
"site-backup-2026-07-30.sql.gz",
|
||||||
|
"/srv/releases/site/shared/site-backup-2026-07-30.sql.gz",
|
||||||
|
IsDirectory: false,
|
||||||
|
RemoteMoveKind.Rename);
|
||||||
|
transfers.RemoteMoveTarget = "site-backup-2026-07-30.sql.gz";
|
||||||
|
|
||||||
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a rename being filled in"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The move form is the same shape with longer words in it — see <c>RemoteMoveRequest.Note</c>, whose
|
||||||
|
/// move half is two sentences where the rename half is one — and a directory, which is the kind whose
|
||||||
|
/// destination is most likely to be a deep path typed in full.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheTransfersScreenFitsWithAMoveInQuestion()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/srv/releases/site/shared";
|
||||||
|
transfers.PendingRemoteMove = new RemoteMoveRequest(
|
||||||
|
"uploads",
|
||||||
|
"/srv/releases/site/shared/uploads",
|
||||||
|
IsDirectory: true,
|
||||||
|
RemoteMoveKind.Move);
|
||||||
|
transfers.RemoteMoveTarget = "/srv/releases/site/shared/archive/2026/production";
|
||||||
|
|
||||||
|
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a move being filled in"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The pane header's path is a box that can be typed into, and this is what says so: the regression it
|
||||||
|
/// guards against is the one this replaced, a <c>TextBlock</c> that printed where the pane was and gave
|
||||||
|
/// no way to say where to go. It is bound to <see cref="TransfersViewModel.RemotePathDraft"/> rather
|
||||||
|
/// than to <c>RemotePath</c>, which is the difference between a box somebody can finish typing into and
|
||||||
|
/// one that navigates on every keystroke.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The three item buttons are asserted in the same pass because they and the box are the same change:
|
||||||
|
/// DELETE left the header to join RENAME and MOVE below it, and the room the box has is exactly the
|
||||||
|
/// room those two buttons no longer take up there.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheRemotePanesPathIsTypedAndItsRowActionsAreAllThere()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/srv/releases/site/shared/uploads/production/2026/08/nightly";
|
||||||
|
transfers.RemotePathDraft = transfers.RemotePath;
|
||||||
|
|
||||||
|
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
|
||||||
|
3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
|
||||||
|
|
||||||
|
transfers.SelectedRemoteEntry = transfers.RemoteEntries[0];
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var box = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBox>()
|
||||||
|
.Single(text => text.Classes.Contains("pathbox"));
|
||||||
|
|
||||||
|
box.Text.ShouldBe(transfers.RemotePath);
|
||||||
|
box.IsEffectivelyEnabled.ShouldBeTrue("nothing may type into a disconnected pane");
|
||||||
|
box.IsReadOnly.ShouldBeFalse();
|
||||||
|
|
||||||
|
var tips = screen.GetVisualDescendants()
|
||||||
|
.OfType<Button>()
|
||||||
|
.Select(button => ToolTip.GetTip(button) as string)
|
||||||
|
.Where(tip => tip is not null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
tips.ShouldContain("Rename on the host");
|
||||||
|
tips.ShouldContain("Move to another directory");
|
||||||
|
tips.ShouldContain("Delete on the host");
|
||||||
|
|
||||||
|
LayoutHarness.Unreachable(window).ShouldBeEmpty("with a row chosen and its actions showing");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
|
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2057,6 +2172,262 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
|
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
|
||||||
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
|
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ---- ◆ The listings' columns ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every cell shows what it holds, at a width this screen actually gets.</b> The columns were fixed
|
||||||
|
/// pixel widths until they were measured against their own content: MODIFIED prints a sixteen-character
|
||||||
|
/// timestamp needing 116 pixels and had 52, so it was truncated at every width the application has ever
|
||||||
|
/// been drawn at — on a maximised window it read "2026-08" beside a NAME column with four hundred empty
|
||||||
|
/// pixels in it. PERMS and SIZE were short by the same kind of margin.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asserted by measuring each cell's own text unconstrained and holding the rendered width against it,
|
||||||
|
/// rather than against numbers written down here: a column that fits is a column at least as wide as
|
||||||
|
/// what it has to draw, and any other formulation is this test agreeing with the markup about an
|
||||||
|
/// arithmetic they both got from the same place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheListingColumnsShowTheirContentInFullWhenThereIsRoom()
|
||||||
|
{
|
||||||
|
SeedBothListings();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var text in new[] { "2.1 KB", "2026-08-21 11:36", "-rw-r--r--", "2026-07-04 09:00" })
|
||||||
|
{
|
||||||
|
var cell = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.First(block => string.Equals(block.Text, text, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
cell.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
Unconstrained(cell) - 0.5, $"'{text}' has to fit the column it is drawn in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The headings are right-aligned in their own columns, so two of them whose columns are exactly the
|
||||||
|
/// width of their text run together: SIZE ended on the pixel MODIFIED began at, and the row read
|
||||||
|
/// "SIZEMODIFIED". This holds the gap that fixes it — a left margin on each of the three, which does not
|
||||||
|
/// move a right-aligned label but does stop the column before it reaching this one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheListingHeadingsDoNotRunIntoEachOther()
|
||||||
|
{
|
||||||
|
SeedBothListings();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Per pane, not across both. Each has its own NAME at its own left edge, and a list of
|
||||||
|
// all seven sorted by position pairs the local pane's last heading with the remote
|
||||||
|
// pane's first — two labels in different panes, which no amount of margin separates.
|
||||||
|
var rows = screen.GetVisualDescendants()
|
||||||
|
.OfType<Grid>()
|
||||||
|
.Where(grid => grid.Children.OfType<TextBlock>().Any(
|
||||||
|
block => block.Classes.Contains("label")
|
||||||
|
&& string.Equals(block.Text, "NAME", StringComparison.Ordinal)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
rows.Count.ShouldBe(2, "one heading row per pane");
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var headings = row.Children
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Where(block => block.Classes.Contains("label"))
|
||||||
|
.Select(block => (block.Text, Left: block.Bounds.X, Right: block.Bounds.Right))
|
||||||
|
.OrderBy(heading => heading.Left)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var (before, after) in headings.Zip(headings.Skip(1)))
|
||||||
|
{
|
||||||
|
after.Left.ShouldBeGreaterThan(
|
||||||
|
before.Right, $"'{before.Text}' must not touch '{after.Text}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The other end of the same fault. At the 204 pixels the session shell's own sidebar leaves a pane, the
|
||||||
|
/// fixed columns took 176 of a row's 180 and the remote pane's NAME cell measured <b>zero</b> — a file
|
||||||
|
/// browser drawing sizes, timestamps and modes for rows whose names were not on screen at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No column carries a MinWidth, which is what keeps this from being a fight the row can lose: at any
|
||||||
|
/// width all four give ground together, and NAME — weighted heaviest — is the last to run out. What is
|
||||||
|
/// asserted is only that it has not run out, because how much of a name fits in 204 pixels is a question
|
||||||
|
/// with no good answer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheNameColumnSurvivesTheNarrowestBudget()
|
||||||
|
{
|
||||||
|
SeedBothListings();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { ".bash_history", ".azure-devops" })
|
||||||
|
{
|
||||||
|
var cell = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.First(block => string.Equals(block.Text, name, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
cell.Bounds.Width.ShouldBeGreaterThan(20, $"'{name}' must be readable at all");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A placeholder cut off mid-word reads as a rendering fault rather than as a hint — MKDIR's box drew
|
||||||
|
/// "new directo" and the pane header's drew a path ending mid-segment. Both are boxes whose width is
|
||||||
|
/// fixed by the pane's own narrowest budget, so the text is what had to give.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePaneBoxesPlaceholdersFitTheBoxesTheyAreIn()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var box in screen.GetVisualDescendants().OfType<TextBox>())
|
||||||
|
{
|
||||||
|
// The picker's own password box is in the collapsed half of the invitation, so it
|
||||||
|
// lays out at zero width and has no room for anything. Only what is on screen.
|
||||||
|
if (box.Watermark is not { Length: > 0 } mark || !box.IsEffectivelyVisible)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var room = box.Bounds.Width - box.Padding.Left - box.Padding.Right;
|
||||||
|
|
||||||
|
room.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
Measured(mark, box.FontSize, box.FontFamily),
|
||||||
|
$"the placeholder '{mark}' has to fit its own box");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts one row of each pane's own shape in, with content of a real length.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The local pane is cleared first for the reason <see cref="TheRowTemplateShowsSizeAndDateColumns"/>
|
||||||
|
/// gives: it lists this fixture's real home directory, and a virtualizing ListBox realises only the rows
|
||||||
|
/// that fit — a row appended after those would never be built, and the assertion would find nothing to
|
||||||
|
/// have missed.
|
||||||
|
/// </remarks>
|
||||||
|
private void SeedBothListings()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/root";
|
||||||
|
|
||||||
|
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
".bash_history",
|
||||||
|
"/root/.bash_history",
|
||||||
|
SftpEntryKind.File,
|
||||||
|
2_150,
|
||||||
|
new DateTimeOffset(2026, 8, 21, 11, 36, 0, TimeSpan.Zero),
|
||||||
|
"-rw-r--r--")));
|
||||||
|
|
||||||
|
transfers.LocalEntries.Clear();
|
||||||
|
transfers.LocalEntries.Add(new LocalEntryRowViewModel(new LocalEntry(
|
||||||
|
".azure-devops",
|
||||||
|
Path.Combine(Path.GetTempPath(), ".azure-devops"),
|
||||||
|
IsDirectory: true,
|
||||||
|
Length: 0,
|
||||||
|
new DateTimeOffset(2026, 7, 4, 9, 0, 0, TimeSpan.Zero))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a session-shell screen gets on a maximised window, which is where the columns were reported
|
||||||
|
/// wrong.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A 1843-pixel window less the shell's own 26-pixel padding a side, its 1-pixel border a side, and
|
||||||
|
/// QUICK ACCESS's 300 — the arithmetic <see cref="LayoutHarness.SessionScreenWidth"/> does, at a width
|
||||||
|
/// that constant cannot express because it is pinned to the window's <em>minimum</em>. Both belong in
|
||||||
|
/// this file: the minimum is what everything else here measures against, and this is the width at which
|
||||||
|
/// the numeric columns reach their caps and the question "does the content fit" has a yes to give.
|
||||||
|
/// Below it they share the width instead, which is what
|
||||||
|
/// <see cref="TheNameColumnSurvivesTheNarrowestBudget"/> holds the other end of.
|
||||||
|
/// </remarks>
|
||||||
|
private const double MaximisedSessionScreenWidth = 1843 - (2 * 26) - (2 * 1) - 300;
|
||||||
|
|
||||||
|
/// <summary>What a rendered block would want if nothing bounded it.</summary>
|
||||||
|
private static double Unconstrained(TextBlock block) =>
|
||||||
|
Measured(block.Text ?? string.Empty, block.FontSize, block.FontFamily);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Unconstrained" />
|
||||||
|
private static double Measured(string text, double fontSize, Avalonia.Media.FontFamily family)
|
||||||
|
{
|
||||||
|
var block = new TextBlock { Text = text, FontSize = fontSize, FontFamily = family };
|
||||||
|
|
||||||
|
block.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||||
|
|
||||||
|
return block.DesiredSize.Width;
|
||||||
|
}
|
||||||
|
|
||||||
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
return new FakeSshConnection(request);
|
return new FakeSshConnection(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The file-transfer session this factory last handed out, or null when it never has.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Kept so a test can read back what the screen actually asked the host to do — which path was renamed,
|
||||||
|
/// which was deleted, which directories were listed. The screen reports all three in its own status
|
||||||
|
/// line, and a suite that asserted only on that would be checking a sentence this code wrote about
|
||||||
|
/// itself rather than the operation it claims to have performed.
|
||||||
|
/// </remarks>
|
||||||
|
internal FakeSftpSession? OpenedSftp { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISftpSession> OpenSftpAsync(
|
public Task<ISftpSession> OpenSftpAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
@@ -75,9 +84,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
{
|
{
|
||||||
SftpRequests.Add(request);
|
SftpRequests.Add(request);
|
||||||
|
|
||||||
return Failure is { } failure
|
if (Failure is { } failure)
|
||||||
? Task.FromException<ISftpSession>(failure)
|
{
|
||||||
: Task.FromResult<ISftpSession>(new FakeSftpSession(request));
|
return Task.FromException<ISftpSession>(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenedSftp = new FakeSftpSession(request);
|
||||||
|
|
||||||
|
return Task.FromResult<ISftpSession>(OpenedSftp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +117,26 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string HomeDirectory => $"/home/{request.Username}";
|
public string HomeDirectory => $"/home/{request.Username}";
|
||||||
|
|
||||||
|
/// <summary>Every directory this session has been asked to list, in order.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The evidence that a refresh happened at all. A listing whose contents never change — this one always
|
||||||
|
/// answers the same single row — leaves no other trace of having been re-read, which is exactly the
|
||||||
|
/// thing the automatic refresh after a transfer has to be proved to do.
|
||||||
|
/// </remarks>
|
||||||
|
internal List<string> Listed { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Every rename this session has been asked for, as it was asked.</summary>
|
||||||
|
internal List<(string From, string To)> Renamed { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Every path this session has been asked to delete.</summary>
|
||||||
|
internal List<string> Deleted { get; } = [];
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||||
Task.FromResult<IReadOnlyList<SftpEntry>>(
|
{
|
||||||
|
Listed.Add(path);
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||||
[
|
[
|
||||||
new SftpEntry(
|
new SftpEntry(
|
||||||
"notes.txt",
|
"notes.txt",
|
||||||
@@ -115,6 +146,7 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
DateTimeOffset.UnixEpoch,
|
DateTimeOffset.UnixEpoch,
|
||||||
"-rw-r--r--"),
|
"-rw-r--r--"),
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||||
@@ -133,11 +165,20 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
|||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task DeleteAsync(string path, CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Deleted.Add(path);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||||
Task.CompletedTask;
|
{
|
||||||
|
Renamed.Add((fromPath, toPath));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using DodoSSH.Client.Shell.ViewModels;
|
|||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
using DodoSSH.Client.Storage;
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Client.Sync;
|
using DodoSSH.Client.Sync;
|
||||||
|
using DodoSSH.Client.Transfer;
|
||||||
using DodoSSH.Client.Terminal;
|
using DodoSSH.Client.Terminal;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
@@ -8549,6 +8550,233 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
raised.ShouldBeGreaterThan(0);
|
raised.ShouldBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ◆ The path box, and the three things that can be done to a row ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The way out of the breadcrumb's own reach. It can only offer what is under the directory already
|
||||||
|
/// listed, so before this box existed a path on the other side of the filesystem cost a walk to the
|
||||||
|
/// common ancestor and back down — a round trip a level — and one somebody already knew could not be
|
||||||
|
/// entered at all.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TypingAnAbsolutePathOnTheTransfersScreen_ListsIt()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.RemotePathDraft = "/etc/nginx";
|
||||||
|
|
||||||
|
await shell.Transfers.GoToTypedRemotePathCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.RemotePath.ShouldBe("/etc/nginx");
|
||||||
|
sftp.Listed[^1].ShouldBe("/etc/nginx");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Relative to the pane rather than to the session's own working directory, which is the distinction
|
||||||
|
/// <see cref="SftpPath.Resolve"/> exists for: handing the server a relative path would resolve it
|
||||||
|
/// somewhere the breadcrumb above the box did not predict.
|
||||||
|
/// </remarks>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("logs", "/home/deploy/logs")]
|
||||||
|
[InlineData("../shared", "/home/shared")]
|
||||||
|
[InlineData("~/.ssh", "/home/deploy/.ssh")]
|
||||||
|
public async Task TypingARelativePathOnTheTransfersScreen_TakesItFromWhereThePaneIs(
|
||||||
|
string typed, string expected)
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.RemotePath.ShouldBe("/home/deploy", "the fake opens on the account's home");
|
||||||
|
shell.Transfers.RemotePathDraft = typed;
|
||||||
|
|
||||||
|
await shell.Transfers.GoToTypedRemotePathCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.RemotePath.ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Somebody who cleared the box and pressed Enter has not asked for anything, and the root is a long way
|
||||||
|
/// from where they were standing.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClearingThePathBox_PutsThePathBackRatherThanGoingToTheRoot()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.RemotePathDraft = " ";
|
||||||
|
|
||||||
|
await shell.Transfers.GoToTypedRemotePathCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.RemotePath.ShouldBe("/home/deploy");
|
||||||
|
shell.Transfers.RemotePathDraft.ShouldBe("/home/deploy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Renaming asks first and then acts on the path the question named, not on the selection — the promise
|
||||||
|
/// <c>ConfirmDeleteRemoteAsync</c> already makes, kept here for the same reason: nothing between opening
|
||||||
|
/// the form and pressing its button may re-point it at another file.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task RenamingARemoteEntry_KeepsItInPlaceUnderTheNewName()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.RenameRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsMovingRemote.ShouldBeTrue();
|
||||||
|
shell.Transfers.RemoteMoveTarget.ShouldBe("notes.txt", "it opens on the name that changes nothing");
|
||||||
|
|
||||||
|
shell.Transfers.RemoteMoveTarget = "notes.md";
|
||||||
|
|
||||||
|
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
sftp.Renamed.ShouldHaveSingleItem().ShouldBe(("/home/deploy/notes.txt", "/home/deploy/notes.md"));
|
||||||
|
shell.Transfers.IsMovingRemote.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A separator in a name is a move wearing a rename's clothing, and the server would answer it with "no
|
||||||
|
/// such file" about a directory nobody meant to name. The form stays open, because what has to be
|
||||||
|
/// corrected is what was typed into it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task RenamingToANameWithASeparatorInIt_IsRefusedAndTheFormStaysOpen()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.RenameRemoteCommand.Execute(null);
|
||||||
|
shell.Transfers.RemoteMoveTarget = "archive/notes.txt";
|
||||||
|
|
||||||
|
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
sftp.Renamed.ShouldBeEmpty();
|
||||||
|
shell.Transfers.IsMovingRemote.ShouldBeTrue();
|
||||||
|
shell.Transfers.Status.ShouldContain("cannot contain");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A move keeps the name and changes the directory, which is the half of the pair a rename cannot do.
|
||||||
|
/// The destination is resolved the way the path box's is, so <c>../shared</c> means what it would mean
|
||||||
|
/// in a shell rather than being handed to the server as typed.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task MovingARemoteEntry_KeepsItsNameAndTakesTheTypedDirectory()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.MoveRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.Transfers.RemoteMoveTarget.ShouldBe("/home/deploy", "it opens on the directory it is in");
|
||||||
|
|
||||||
|
shell.Transfers.RemoteMoveTarget = "../shared";
|
||||||
|
|
||||||
|
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
sftp.Renamed.ShouldHaveSingleItem().ShouldBe(("/home/deploy/notes.txt", "/home/shared/notes.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The destination that changes nothing. SFTP would answer it with success and a file that had not
|
||||||
|
/// moved, which reads as a move that silently did not happen.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task MovingARemoteEntryIntoTheDirectoryItIsAlreadyIn_IsRefused()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.MoveRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
sftp.Renamed.ShouldBeEmpty();
|
||||||
|
shell.Transfers.Status.ShouldContain("already there");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The three questions are one at a time by construction — each one's own opening clears the others — so
|
||||||
|
/// that no row can have two forms open over it and no button can arm a second question behind the first.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task OpeningTheRenameForm_TakesAwayADeletionThatWasBeingAskedAbout()
|
||||||
|
{
|
||||||
|
await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.DeleteRemoteCommand.Execute(null);
|
||||||
|
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.Transfers.RenameRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeFalse();
|
||||||
|
shell.Transfers.IsMovingRemote.ShouldBeTrue();
|
||||||
|
shell.Transfers.CanDeleteRemote.ShouldBeFalse("a question is on screen");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The pane used to go on showing the directory as it was before the file arrived, so the only way to
|
||||||
|
/// see something this application had just sent was to press REFRESH — the application asking to be
|
||||||
|
/// told about what it had done itself.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUploadThatLandsInTheDirectoryShowing_ReReadsItWithoutBeingAsked()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var before = sftp.Listed.Count;
|
||||||
|
|
||||||
|
shell.Transfers.NoteTransferLanded(Landed("/home/deploy/build.tar.gz"));
|
||||||
|
|
||||||
|
sftp.Listed.Count.ShouldBe(before + 1);
|
||||||
|
sftp.Listed[^1].ShouldBe("/home/deploy");
|
||||||
|
shell.Transfers.RemoteListingIsStale.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Only the directory on screen. A queue moving a hundred files into somewhere nobody is looking at
|
||||||
|
/// would otherwise be a round trip per file for a listing that changed nothing anybody can see.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUploadThatLandsSomewhereElse_ChangesNothingOnScreen()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
var before = sftp.Listed.Count;
|
||||||
|
|
||||||
|
shell.Transfers.NoteTransferLanded(Landed("/srv/releases/build.tar.gz"));
|
||||||
|
|
||||||
|
sftp.Listed.Count.ShouldBe(before);
|
||||||
|
shell.Transfers.RemoteListingIsStale.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The refresh waits rather than interrupting. Re-listing under an open question is how the row somebody
|
||||||
|
/// answered about stops being the row that gets acted on — so the staleness is remembered and settled
|
||||||
|
/// when the question goes.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUploadThatLandsWhileAQuestionIsOpen_WaitsForItToBeAnswered()
|
||||||
|
{
|
||||||
|
var sftp = await ConnectedTransfersAsync();
|
||||||
|
|
||||||
|
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||||
|
shell.Transfers.DeleteRemoteCommand.Execute(null);
|
||||||
|
|
||||||
|
var before = sftp.Listed.Count;
|
||||||
|
|
||||||
|
shell.Transfers.NoteTransferLanded(Landed("/home/deploy/build.tar.gz"));
|
||||||
|
|
||||||
|
sftp.Listed.Count.ShouldBe(before, "nothing may pull the listing out from under an open question");
|
||||||
|
shell.Transfers.RemoteListingIsStale.ShouldBeTrue();
|
||||||
|
|
||||||
|
await shell.Transfers.CancelDeleteRemoteCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
sftp.Listed.Count.ShouldBe(before + 1);
|
||||||
|
shell.Transfers.RemoteListingIsStale.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A bucket is an <c>IRemoteFileStore</c> with no <c>HostSecret</c> underneath it, so there is no
|
/// A bucket is an <c>IRemoteFileStore</c> with no <c>HostSecret</c> underneath it, so there is no
|
||||||
/// <c>PinnedPaths</c> to read at all — see <see cref="TransfersViewModel.OpenBucketAsync"/>'s own remark.
|
/// <c>PinnedPaths</c> to read at all — see <see cref="TransfersViewModel.OpenBucketAsync"/>'s own remark.
|
||||||
@@ -9154,4 +9382,39 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
return vault;
|
return vault;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
|
||||||
|
private async Task<FakeSftpSession> ConnectedTransfersAsync()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return ssh.OpenedSftp.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An upload that has just finished, as the queue would report it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Built here rather than run through <c>FileTransferQueue</c>, because what is asserted is what the
|
||||||
|
/// screen does when a transfer lands and not that a transfer can land: the queue's own path from
|
||||||
|
/// enqueued to completed is covered against a fuller fake in <c>DodoSSH.Client.Transfer.Tests</c>, and
|
||||||
|
/// running one here would put a thread-pool hand-off and this fixture's own dispatcher-less posting
|
||||||
|
/// between the event and the assertion.
|
||||||
|
/// </remarks>
|
||||||
|
private static TransferSnapshot Landed(string remotePath) => new(
|
||||||
|
Guid.CreateVersion7(),
|
||||||
|
TransferDirection.Upload,
|
||||||
|
SftpPath.Name(remotePath),
|
||||||
|
Path.Combine(Path.GetTempPath(), SftpPath.Name(remotePath)),
|
||||||
|
remotePath,
|
||||||
|
128,
|
||||||
|
128,
|
||||||
|
TransferState.Completed,
|
||||||
|
BytesPerSecond: 0,
|
||||||
|
Failure: null);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
|
using DodoSSH.Client.Transfer;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Tests;
|
namespace DodoSSH.Client.App.Tests;
|
||||||
@@ -281,6 +282,71 @@ public sealed class TransferQueueingTests : IDisposable
|
|||||||
delivered.ShouldBe(0);
|
delivered.ShouldBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The local half of the automatic refresh. A download that finished left this pane showing the folder
|
||||||
|
/// as it was before the file arrived, so the file this application had just fetched was not in the
|
||||||
|
/// listing it was fetched into — and the way to see it was to press REFRESH.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Synchronous, unlike its remote counterpart, and that is the whole reason it is nothing more than a
|
||||||
|
/// re-read where it stands: a local listing is a filesystem call, so there is no round trip to defer and
|
||||||
|
/// no busy state to wait out. See <c>TransfersViewModel.SettleRemoteListingAsync</c> for the other side.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ADownloadThatLandsInTheFolderShowing_ReReadsItWithoutBeingAsked()
|
||||||
|
{
|
||||||
|
Connected();
|
||||||
|
|
||||||
|
transfers.RefreshLocalCommand.Execute(null);
|
||||||
|
transfers.LocalEntries.ShouldBeEmpty();
|
||||||
|
|
||||||
|
var landed = File("fetched.bin");
|
||||||
|
|
||||||
|
transfers.NoteTransferLanded(new TransferSnapshot(
|
||||||
|
Guid.CreateVersion7(),
|
||||||
|
TransferDirection.Download,
|
||||||
|
"fetched.bin",
|
||||||
|
landed,
|
||||||
|
"/srv/app/fetched.bin",
|
||||||
|
128,
|
||||||
|
128,
|
||||||
|
TransferState.Completed,
|
||||||
|
BytesPerSecond: 0,
|
||||||
|
Failure: null));
|
||||||
|
|
||||||
|
transfers.LocalEntries.Select(row => row.Name).ShouldBe(["fetched.bin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Only the folder on screen, for the reason its remote counterpart gives: a queue writing into
|
||||||
|
/// somewhere nobody is looking at would otherwise re-read a directory per completed transfer.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ADownloadThatLandsSomewhereElse_ChangesNothingOnScreen()
|
||||||
|
{
|
||||||
|
Connected();
|
||||||
|
|
||||||
|
transfers.RefreshLocalCommand.Execute(null);
|
||||||
|
|
||||||
|
File("elsewhere.bin");
|
||||||
|
|
||||||
|
transfers.NoteTransferLanded(new TransferSnapshot(
|
||||||
|
Guid.CreateVersion7(),
|
||||||
|
TransferDirection.Download,
|
||||||
|
"elsewhere.bin",
|
||||||
|
Path.Combine(Path.GetTempPath(), "somewhere-else", "elsewhere.bin"),
|
||||||
|
"/srv/app/elsewhere.bin",
|
||||||
|
128,
|
||||||
|
128,
|
||||||
|
TransferState.Completed,
|
||||||
|
BytesPerSecond: 0,
|
||||||
|
Failure: null));
|
||||||
|
|
||||||
|
transfers.LocalEntries.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
||||||
|
|||||||
@@ -58,6 +58,58 @@ public sealed class RemotePathTests
|
|||||||
SftpPath.Trail("/").ShouldBeEmpty();
|
SftpPath.Trail("/").ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The four shapes the file browser's path box has to take, because they are the four a shell takes and
|
||||||
|
/// somebody typing a path into a box is typing what they would type into a shell.
|
||||||
|
/// </remarks>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/var/log", "/etc/nginx", "/etc/nginx")]
|
||||||
|
[InlineData("/var/log", "nginx", "/var/log/nginx")]
|
||||||
|
[InlineData("/var/log", "../lib", "/var/lib")]
|
||||||
|
[InlineData("/var/log", "./nginx/", "/var/log/nginx")]
|
||||||
|
public void Resolve_TakesAbsoluteRelativeAndDotted(string current, string typed, string expected)
|
||||||
|
{
|
||||||
|
SftpPath.Resolve(current, typed).ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("~", "/home/dodo")]
|
||||||
|
[InlineData("~/.ssh", "/home/dodo/.ssh")]
|
||||||
|
public void Resolve_ExpandsHomeWhenTheSessionHasSaidWhatItIs(string typed, string expected)
|
||||||
|
{
|
||||||
|
SftpPath.Resolve("/var/log", typed, "/home/dodo").ShouldBe(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A directory really can be called <c>~</c>, and a session that has not said where home is has nothing
|
||||||
|
/// to expand it to. Guessing would send somebody to a path nobody typed.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_LeavesHomeAloneWhenNothingHasSaidWhereItIs()
|
||||||
|
{
|
||||||
|
SftpPath.Resolve("/var/log", "~/keys").ShouldBe("/var/log/~/keys");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A blank box is not a request to go to the root, which is where "resolve nothing against the current
|
||||||
|
/// directory" would otherwise land somebody who cleared the box and pressed Enter.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_AnswersTheCurrentDirectoryForNothingTyped()
|
||||||
|
{
|
||||||
|
SftpPath.Resolve("/var/log", " ").ShouldBe("/var/log");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Above the root is the root, as it is in every shell. The alternative is a path with no leading
|
||||||
|
/// separator, which is not a remote path at all.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_StopsClimbingAtTheRoot()
|
||||||
|
{
|
||||||
|
SftpPath.Resolve("/var", "../../../..").ShouldBe("/");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PosixMode_RendersTheKindCharacterAndThreeTriples()
|
public void PosixMode_RendersTheKindCharacterAndThreeTriples()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user