Notice when a file session stops, and give the file browser the four things it was short of
ci / build and test (push) Successful in 3m22s
ci / android head (push) Successful in 3m33s
ci / desktop nightly (push) Successful in 1m26s
ci / api image (push) Successful in 24s

The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.

◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.

IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.

IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.

  - THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
    having. SftpEntry is a record of what the server said rather than a handle,
    so the pane can go on showing where the user was — which is what RECONNECT
    is offering to put them back into. That needed a new ShowsRemoteContents on
    both heads: every "is there anything here" binding read IsConnected, which
    was right while the only way to stop being connected was to press a button
    that empties the pane, and wrong the moment a session could be lost.

  - RECONNECT re-dials through the ordinary connect path — same host key gate,
    same refusals, same log entry — and then walks back to the directory the
    pane was showing. The path is read off RemotePath rather than captured,
    because the pane already keeps it up to date and a copy is a second thing to
    get wrong. A host wanting a typed password is asked for one rather than
    dialled with an empty box.

  - SessionEndedBar is now one control on both surfaces, with the shell
    resolving which it is drawn over: a terminal has two endings to tell apart
    and a keychain entry to check, a file session has one ending and already
    captured what it would dial. The phone's Files screen has its own copy,
    bound to TransfersViewModel directly, because its DataContext is that rather
    than the shell.

◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.

It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.

Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.

Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.

◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.

The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.

With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.

Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.

A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.

◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.

A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.

The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.

Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.

The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.

◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.

The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.

DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.

A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.

Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.

◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.

The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.

And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.

◆ WHAT IS VERIFIED.

901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.

What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
This commit is contained in:
jaap-jan committed 2026-08-24 11:48:23 +02:00
1 parent e2733e5cbc
commit cfc2e3ae0c
19 files changed
+3896 -240

No files matched your search

@@ -319,20 +319,25 @@
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<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="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<!-- The remote pane's own tick, on this side and for the same reasons. -->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnLocalRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
@@ -495,10 +500,50 @@
<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}" />
<!--
The chosen row's name, or the count once anything is ticked. The count wins because it is the
one the buttons beside it are actually about: with three rows gathered up, a name would be
describing whichever of them happens to be highlighted while DELETE takes all three.
-->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="ghost" Content="ALL" Command="{Binding ToggleAllRemoteCommand}"
ToolTip.Tip="Tick every row, or untick them all" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
Text="{Binding SelectedRemoteName}"
IsVisible="{Binding !HasCheckedRemote}" />
<TextBlock Classes="mono" FontSize="11.5" VerticalAlignment="Center"
Foreground="{StaticResource AccentText}"
Text="{Binding CheckedRemoteLabel}"
IsVisible="{Binding HasCheckedRemote}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
<!--
◆ FOUR NOW, and this is the one that was missing: the PERMS column has been a true fact
about a file with nothing to be done about it since it arrived, so the commonest next act
after using this screen was opening a terminal to chmod what had just been uploaded. Dark on
a bucket rather than live-and-refused — an object has an access policy rather than nine bits;
see CanChangeRemotePermissions.
A lock rather than a key glyph: a key is what this application's keychain uses, and one here
would read as "which credential" over a row that has nothing to do with one.
-->
<!--
◆ EDIT, and it is first because it is the only one of the five that changes nothing on the
host by pressing it: it fetches a copy and opens it. Dark on a head with no way to open a
file or watch one — see CanEditRemote, which is what the phone fails rather than this
button being absent there for a reason nobody can see.
-->
<Button Classes="paneicon" Command="{Binding EditRemoteCommand}"
IsEnabled="{Binding CanEditRemote}"
ToolTip.Tip="Open in this machine's editor, and send each save back">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon" Command="{Binding ChangeRemotePermissionsCommand}"
IsEnabled="{Binding CanChangeRemotePermissions}"
ToolTip.Tip="Permissions on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE897;" />
</Button>
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE9AD;" />
@@ -515,6 +560,80 @@
</Grid>
</Border>
<!--
◆ WHAT AN EDIT SESSION SAYS FOR ITSELF, and it is a strip rather than a question: nothing is
waiting on an answer, a file is simply open somewhere else and each save is going back. It shares
the questions' Grid row because it is exclusive with them — opening any of the three needs a
selection, and while one is up ShowsRemoteItemActions has taken the buttons away.
The log is newest-first and bounded. Watching means saving happens more than once, and each one is
a round trip somebody may want to see happened; an editor that autosaves would otherwise grow this
without limit. See TransfersViewModel.RemoteEditLog.
The conflict card inside it is the one state where a save is not written back. Three answers,
because those are the three that exist: send it anyway, throw away what was typed and take the
host's copy, or stop. There is deliberately no merge — this client has no way to do one, and a
button that pretended otherwise would be the fabrication this project's honesty rule forbids.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsEditingRemote}">
<!--
◆ THE WHOLE STRIP SCROLLS, not just the log inside it, and that is what makes this safe at any
width. It is the tallest thing that can be in this Grid row — a heading, a path, a log, a
conflict card and a button — and unlike the three questions beside it, it is not modal: the
listing underneath stays live, because a file being open in an editor is no reason to stop
being able to browse. So it cannot be allowed to take the pane's height, and bounding the
container rather than each part means nothing added inside it later can undo that.
134 is measured. Inside the session shell this pane is 204x377, and ScreenLayoutTests draws
this in its worst case — the conflict card open with a session's worth of saves behind it.
-->
<ScrollViewer MaxHeight="134" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding RemoteEdit.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding RemoteEdit.RemotePath}" />
<!-- Bounded inside a bounded strip: this keeps a long session's log from pushing the conflict
card and STOP EDITING out of view within the scroller. -->
<ScrollViewer MaxHeight="48" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding RemoteEditLog}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="mono" FontSize="11" Margin="0,1"
Foreground="{StaticResource TextGhost}" TextTrimming="CharacterEllipsis"
Text="{Binding}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Border Padding="10,8" CornerRadius="8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
IsVisible="{Binding HasRemoteEditConflict}">
<StackPanel Spacing="6">
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding RemoteEditConflict}" />
<WrapPanel>
<Button Classes="ghost" Content="UPLOAD ANYWAY" Margin="0,0,8,4"
Command="{Binding ForceSaveEditCommand}" />
<Button Classes="ghost" Content="RELOAD" Margin="0,0,0,4"
Command="{Binding ReloadEditCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<Button Classes="ghost" Content="STOP EDITING" HorizontalAlignment="Left"
Command="{Binding StopEditingCommand}" />
</StackPanel>
</ScrollViewer>
</Border>
<!--
The question DELETE asks. It names the full path rather than the file, because a name is the half
that does not identify anything — and that is now the whole of how the question says which row it
@@ -537,9 +656,28 @@
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.FullPath}" />
<!--
Every path, one per line, and never a count on its own: agreeing to "3 items" is agreeing to a
number. See RemoteDeletionRequest.Listing.
◆ 32, AND THE NUMBER IS MEASURED RATHER THAN CHOSEN. A ticked set has no ceiling, so this list
has to scroll, and the budget it scrolls inside is small: within the session shell this pane is
204 wide, where one remote path wraps to two lines, and the card also carries a heading, the
consequence and two buttons that wrap onto two rows at that width. 120 put the buttons outside
the window; 72 left CANCEL three pixels under it; 56 and 40 pushed the listing's own squeezed
row past the bottom — each caught by ScreenLayoutTests, which is why the five-path test exists
beside the single-path one rather than instead of it. 32 is one wrapped path, which is exactly
what the single-path question has always shown.
What that buys is a scrollbar rather than a taller card, and the heading carries the count so
the number is never the thing being scrolled for.
-->
<ScrollViewer MaxHeight="32" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource Danger}"
Text="{Binding PendingRemoteDeletion.Listing}" />
</ScrollViewer>
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<WrapPanel>
@@ -603,6 +741,90 @@
</StackPanel>
</Border>
<!--
◆ THE MODE, as nine boxes and the number they add up to.
In the same Grid row as the other two questions and never at the same time as either — each one's
own opening clears the others, which is what makes one row enough for all three.
Boxes rather than a field to type 755 into, and that is the design rather than a preference: the
mode is already drawn as -rwxr-xr-x in the column this form was opened from, so a who-can-do-what
grid is the same representation the reader is already holding. An octal field would ask them to
translate something they can see into something they cannot check. The octal is printed underneath
as it changes, so the two readings check each other rather than one replacing the other.
The EXEC column is headed from the request rather than fixed, because on a directory that bit is
permission to enter it — the commonest misreading of a POSIX mode there is, and one word fixes it.
See RemotePermissionsRequest.ExecuteLabel.
APPLY is dead until something has actually changed. A round trip that sets a mode to the mode it
already has is not wrong, only pointless, and a live button over an untouched form invites it.
Not painted in the deletion card's red. Changing a mode is undone by changing it back; the red is
spent on the one thing here that nothing walks back, and spending it twice would make it stop
meaning anything — the same argument the rename form above makes for itself.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsChangingRemotePermissions}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemotePermissions.Question}" />
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="{Binding PendingRemotePermissions.FullPath}" />
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,Auto,Auto,Auto" Margin="0,2">
<TextBlock Grid.Row="0" Grid.Column="1" Classes="hint" FontSize="10.5" Text="read"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="2" Classes="hint" FontSize="10.5" Text="write"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="3" Classes="hint" FontSize="10.5"
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="11.5" Text="owner"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="11.5" Text="group"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="11.5" Text="others"
VerticalAlignment="Center" Margin="0,0,10,0" />
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
</Grid>
<SelectableTextBlock Classes="mono" FontSize="12.5"
Foreground="{StaticResource AccentText}"
Text="{Binding PendingRemotePermissions.Preview}" />
<WrapPanel>
<Button Classes="accent" Content="APPLY" Margin="0,0,8,4"
Command="{Binding ConfirmRemotePermissionsCommand}"
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelRemotePermissionsCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<!--
Away while a question is up, for the reason the item row above states: the trail is where the pane
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
@@ -681,24 +903,42 @@
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Grid Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="24" />
<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="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
<!--
◆ THE TICK IS NOT THE LIST'S SELECTION, and the two answer different questions. The
selection is "the row I am looking at", which is what rename, move and permissions act on
— each takes a form naming one path and is one-at-a-time by construction. This is "the
rows I have gathered up", which is what DOWNLOAD and DELETE act on, and it survives the
highlight moving.
It is drawn rather than left to ctrl-click for a reason beyond taste: the phone head shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a tap target
there whatever this pane does. One shape on both heads, and a state somebody can see.
Click is handled rather than bound one-way, because the count above the list is a property
of the collection rather than of any row — see TransfersViewModel.RemoteChecksChanged.
-->
<CheckBox Grid.Column="0" MinWidth="0" VerticalAlignment="Center"
IsChecked="{Binding IsChecked}"
Click="OnRemoteRowChecked" />
<TextBlock Grid.Column="1" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="2" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
<TextBlock Grid.Column="5" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</Grid>
@@ -732,8 +972,14 @@
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.
-->
<!--
ShowsRemoteInvitation rather than !IsConnected, which are no longer the same question. Pressing
DISCONNECT empties this pane, so an invitation over it covers nothing; a connection lost
underneath somebody leaves the listing exactly where it was, and covering that would hide the one
thing the strip below is offering to put them back into. See TransfersViewModel.ShowsRemoteContents.
-->
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
IsVisible="{Binding !IsConnected}">
IsVisible="{Binding ShowsRemoteInvitation}">
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
HorizontalAlignment="Center" VerticalAlignment="Center">