Files
DodoSSH/src/DodoSSH.Client.App/Views/TransfersScreen.axaml
T
jaap-jan cfc2e3ae0c
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
Notice when a file session stops, and give the file browser the four things it was short of
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

◆ WHAT IS VERIFIED.

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

What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
2026-08-24 11:48:23 +02:00

1300 lines
83 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.TransfersScreen"
x:DataType="vm:TransfersViewModel">
<!--
File transfer: this machine on the left, the host on the right, and the queue underneath.
Two things about this screen are worth knowing before reading the markup. It has its own CONNECT
button, because SSH.NET cannot open an SFTP subsystem on a transport that is already carrying a shell —
so browsing a host's files is a second authenticated connection rather than a second channel, and
pretending otherwise would hide a second login from the person whose audit log it appears in. And the
panes are symmetrical apart from one column: PERMS is remote-only, because a POSIX mode is not a fact
about a file on the machine this client is developed on.
── v3 ────────────────────────────────────────────────────────────────────────────────────────────────
◆ THE CONNECT BAR IS GONE, and everything it held is inside the right-hand pane now.
It was a 44-pixel strip across the top of the screen holding a heading, a picker, a password box and a
button — chrome spanning both panes to configure one of them, and drawn at full width whether or not
anything was ever going to be connected. Underneath it sat a pane that was empty for exactly the same
reason, saying so in a sentence nobody had to be told twice.
So the pane says it instead. Disconnected, the right-hand half is an invitation where the listing would
be: what this screen is for, what pressing the button will cost, and the button. Connected, the
invitation is replaced by the listing and the connection states itself in a strip above it, beside the
control that closes it — which is the arrangement the two facts were always about. The heading the bar
carried is not reprinted anywhere: the tab in the strip says SFTP or S3, and it says it whether or not
this screen is showing.
What the design has and this does not: `sftp over bastion-eu`, which needs jump hosts the connection
layer has not got. See docs/design-import-gaps.md.
-->
<!--
══ 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>
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
A directory used to be marked by colour alone — this application shipped no icon set when that rule
was written. It ships one now: v5b embeds Material Icons as IconFont, already spent on the nav rail,
the drawer and the session shell's own sidebar, and per SFTP.dc.html a file row draws a 14px glyph of
its own — folder for a directory, and the closest classic Material Icons has to the design's own
Material-Symbols-only "draft": insert_drive_file, a plain document rather than a page with a folded
corner. Recorded as a deviation rather than silently swapped, because "draft" is simply not a glyph
this font contains.
So NAME's colour stopped being the directory's own mark — the glyph is now — and become what the
design says instead: a folder is white and medium weight, a file is TextDim and regular. Blue is not
spent here any more; Info keeps meaning "a distinct scope" everywhere else it already did, and this
screen no longer borrows it.
A second colour still comes from the mode, and it still overrides the first rather than competing with
it: an executable is Live green in NAME — "live, yours, something that runs" — regardless of whether
the row beside it is asking for the folder colours or the file ones. PERMS keeps its own, separate
amber for a mode anyone may write to, on the same reasoning as before: NAME says what a row is, PERMS
says what is notable about how it is set, and the two never compete for one TextBlock.
-->
<Style Selector="TextBlock.entry">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontWeight" Value="Medium" />
</Style>
<!--
Declared after .dir on purpose — Avalonia has no specificity, and an executable that is also somehow
the target row would need this rule to win. It never is in practice; see SftpEntry, which reads the
execute bit off a file's own mode and nothing else's.
-->
<Style Selector="TextBlock.entry.exec">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<!--
The glyph beside NAME. One quiet colour regardless of folder or file — the design's own renderVals
gives both the same default iconFg — because the glyph itself already says which one a row is; the
colour underneath it is furniture, on the same TextFaint-family step TextBlock.fieldglyph already uses
for a mark meant to be skipped rather than read.
-->
<Style Selector="TextBlock.entryicon">
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<!--
SIZE and MODIFIED, both quieter than the design's own literal values ask for — rgb(93,95,116) and
rgb(110,112,137) sit a step below every text colour this palette already names. Per
design-notes/v5b-fidelity-notes.md's own instruction for values this close, the nearest existing steps
are reused rather than two new keys added for a difference nobody would see: TextGhost for SIZE, which
the design draws a shade darker than MODIFIED, and TextFaint — one step lighter — for MODIFIED, which
keeps the two in the same order the design puts them in even though neither is the design's exact hex.
-->
<Style Selector="TextBlock.entrysize">
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="TextBlock.entrydate">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
-->
<Style Selector="TextBlock.perms">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
Warn rather than WarnText, which is the muted amber a warning card writes its sentences in. At 9.5px
against TextFaint that one is a shade, not a signal, and a marker nobody notices is the same as no
marker at all.
-->
<Style Selector="TextBlock.perms.loose">
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<!--
── v5b: the TRANSFERS strip's own progress track ────────────────────────────────────────────────────
5px, 3px radius, Chip's own fill under an AccentGradient indicator — the design's own slim bar. A real
0% is what a queued transfer draws with this: the indicator's width is nothing, which reads exactly as
the design's own queued sample row does (a bar the same colour as its own track). No indeterminate
animation is drawn for it — a queued transfer really is at zero, so zero is the honest thing to show,
not a state this screen has to invent furniture for.
-->
<Style Selector="ProgressBar.transferbar">
<Setter Property="Height" Value="5" />
<Setter Property="MinHeight" Value="5" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="Foreground" Value="{StaticResource AccentGradient}" />
</Style>
<!--
The strip's own right-aligned status word. Quiet by default — a stopped transfer's "stopped" gets no
rule of its own and falls through to this — and three states get their own colour: in motion, kept, and
refused. See TransferRowViewModel.StatusWord for what word each state actually prints.
-->
<Style Selector="TextBlock.transferstatus">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.transferstatus.running">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.transferstatus.done">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.transferstatus.failed">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="*,Auto">
<!-- ============ The two panes ============ -->
<Grid Grid.Row="0" ColumnDefinitions="*,64,*">
<!-- ==== This machine ==== -->
<!--
AllowDrop on the pane rather than on the list, because an empty directory lays its ListBox out at
zero height behind the empty-state sentence — a handler on the list would have nothing to hit.
This side takes remote rows only; see TransfersScreen.axaml.cs.
-->
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
Per SFTP.dc.html: a 10px tracked-out label beside the current path in mono 13.5, and the pane's
affordances restyled to quiet glyphs rather than dropped. "THIS MACHINE" becomes the design's own
"LOCAL" — nothing in this file's own comments ever argued for that particular wording over the
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
recorded where it is drawn.
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
thing (go to the parent directory), and the pair reading differently is the design's own choice
faithfully carried over rather than a functional difference invented to justify it.
-->
<!--
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
TextTrimming only acts when measure hands the block a finite width, and a horizontal StackPanel
never does — it measures every child at infinity, takes the full answer, and an Auto grid column
passes that on. With the path in a StackPanel beside the label, a directory deep enough — the
remote pane meets one on any real host, this pane on any machine whose profile path is long —
made the header wider than the pane and pushed the icon buttons past the window's own edge. The
layout suite caught it at the 472-pixel session budget, on the one machine whose home directory
was long enough to arm it. Star column: bounded width, working ellipsis, buttons that stay.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="LOCAL"
VerticalAlignment="Center" Margin="0,0,12,0" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
HorizontalAlignment="Left"
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding LocalPath}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
<!--
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
rather than the pane's own quiet icon buttons, because a drive is named rather than a single
glyph — "C:" has no honest Material Icons equivalent.
-->
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<Button Classes="panechip" Content="{Binding Name}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
CommandParameter="{Binding Path}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="paneicon" Command="{Binding LocalUpCommand}"
ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D8;" />
</Button>
<Button Classes="paneicon" Command="{Binding RefreshLocalCommand}"
ToolTip.Tip="Refresh">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D5;" />
</Button>
</StackPanel>
</Grid>
</Border>
<ItemsControl Grid.Row="1" ItemsSource="{Binding LocalTrail}" Margin="12,6,12,4">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<StackPanel Orientation="Horizontal">
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
Foreground="{StaticResource TextDim}" />
</Button>
<TextBlock Classes="mono" Text="›" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
v5b: 20 for the glyph column rather than the design's bare icon with no gutter, because a Material
glyph centred with no reserved width drifts as the font's own advance width varies between folder
and insert_drive_file. 60/56 for SIZE/MODIFIED are narrower than the design's literal 80/78 — see
the row template below for why the whole column set was chosen against 204 pixels rather than the
design's own 1920-pixel canvas.
-->
<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="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
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>
<!--
h38, radius 7, Track for hover and selected — see ListBox.filerows in App.axaml — and no rowmark:
the design has nothing to distinguish "selected" from "hovered" beyond which one is currently true,
and :selected alone already carries that once the pointer moves on.
-->
<ListBox Grid.Row="3" x:Name="LocalList" Classes="filerows" ItemsSource="{Binding LocalEntries}"
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<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>
<!-- 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="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="3" Classes="hint" FontSize="12" Margin="24" MaxWidth="260"
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" />
<!--
The drop highlight, over the whole pane and last so it is on top.
IsHitTestVisible="False" is not optional. An overlay that takes part in hit testing swallows the
DragOver events underneath it the moment it appears — so the pointer leaves, the highlight never
clears, and the drop lands nowhere.
-->
<Border Grid.Row="0" Grid.RowSpan="4" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsLocalDropTarget}" />
</Grid>
<!-- ==== The two directions ==== -->
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0">
<StackPanel VerticalAlignment="Center" Spacing="10" Margin="6">
<!--
Pointing at the pane the file is going to, which is the only reading that survives the panes
being side by side: the left-hand pane is this machine, so an upload points right.
-->
<Button Classes="ghost" Content="→" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding UploadCommand}" IsEnabled="{Binding CanUpload}"
ToolTip.Tip="Upload the selected file to the directory showing on the host" />
<Button Classes="ghost" Content="←" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding DownloadCommand}" IsEnabled="{Binding CanDownload}"
ToolTip.Tip="Download the selected file into the folder showing on this machine" />
</StackPanel>
</Border>
<!-- ==== The host ==== -->
<!--
Seven rows. Two of them are furniture over a listing rather than part of it: the connection's own
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,Auto,*"
DragDrop.AllowDrop="True">
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
HOST/BUCKET keep their own words rather than adopting the design's generic "REMOTE": the comment
this replaced already carried a documented honesty reason — the pair is the difference between a
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
the style moves to the design's 10px tracked label, not the wording.
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
-->
<!--
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
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">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
Margin="0,0,8,0">
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" />
</StackPanel>
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
Text="{Binding RemotePathDraft}" IsEnabled="{Binding IsConnected}"
PlaceholderText="path"
ToolTip.Tip="Type a path and press Enter. A relative path is taken from here; ~ is the account's home directory.">
<!--
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}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" />
</Button>
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D5;" />
</Button>
</StackPanel>
</Grid>
</Border>
<!--
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
prints the very same address beside this screen — in the sidebar's own session block since v5c-4
retired the header row that printed it above — see MainWindowViewModel.SessionAddress,
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
170-pixel chip this row used to carry would have taken most of that on its own.
The status line still is not here, for the reason the comment this replaced gave: there is no room
for a sentence beside anything else this row holds. It is at the foot of the screen instead — see
the queue's own strip.
-->
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConnected}">
<Button Classes="headerghost" HorizontalAlignment="Right" Content="DISCONNECT"
Command="{Binding DisconnectCommand}" />
</Border>
<!--
◆ WHAT CAN BE DONE TO THE ROW THAT IS CHOSEN. Only while something is connected.
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">
<!--
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;" />
</Button>
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE675;" />
</Button>
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE872;" />
</Button>
</StackPanel>
</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
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
application deletes is a tombstone against a copy the server still has, and a file on somebody's
host is bytes with nothing behind them.
A WrapPanel for the two buttons rather than a horizontal StackPanel — v5b's own change, made once
the session shell's sidebar could take this pane down to 204 pixels: "DELETE ON THE HOST" and
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.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConfirmingRemoteDeletion}">
<StackPanel Spacing="7">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Question}" />
<!--
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>
<Button Classes="danger" Content="DELETE ON THE HOST" Margin="0,0,8,4"
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelDeleteRemoteCommand}" />
</WrapPanel>
</StackPanel>
</Border>
<!--
◆ 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>
<!--
◆ 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
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.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<StackPanel Orientation="Horizontal">
<TextBlock Classes="mono" Text="/" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding Path}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
Foreground="{StaticResource TextDim}" />
</Button>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Making a directory sits here, beside the path it would be made in, rather than with the queue's
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
"that name is already taken" is a dead end.
v5b narrows the box from 140 to 90: at the session shell's own 204-pixel width once QUICK ACCESS
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
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"
IsVisible="{Binding IsConnected}">
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="directory" />
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
</StackPanel>
</Grid>
<!--
v5b: PERMS keeps its own column — a real fact about a file on the host, dropped nowhere — but at
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.
-->
<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="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
<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>
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<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>
<!--
◆ 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="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<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>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
A directory with nothing in it. It no longer has to say anything about not being connected: that
state is the invitation below, which covers this pane whole.
-->
<TextBlock Grid.Row="6" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
<!--
============ THE INVITATION, which is what this screen opens on ============
Everything the connect bar used to hold, in the half of the screen it was always about. It covers
the pane from below the header to the bottom, opaquely: the breadcrumb, the column headings and
the empty listing are all still laid out underneath, and all three are furniture for a listing
that does not exist yet.
Two steps, not one. The first is an invitation with a button — a name for what this screen does, a
sentence about what it costs, and one thing to press — and the picker is the second. A combo box
sitting open in the middle of an empty pane would be a form with no question above it; the reason
the pane is empty is the question, and that is what the first step says.
◆ The two steps also keep this inside its budget, which is the pane's height less whatever the
queue has taken — 268 pixels with three transfers on it. The sentence and the button go away when
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
reaches the floor. Anything added here has to hold that; the layout suite measures both.
-->
<!--
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 ShowsRemoteInvitation}">
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
HorizontalAlignment="Center" VerticalAlignment="Center">
<!--
The mark the hosts screen puts on a group, at the size an empty state can carry one. Still a
hand-picked glyph rather than one of the Material Icons codepoints the file rows now use — this
invitation predates the app's own IconFont and is outside wave C's restyle, which touched the
pane headers, the rows and the TRANSFERS strip and not this card — and ▤ already means "a place
things are kept" without borrowing from a font built for a different visual language.
-->
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1">
<TextBlock Text="▤" FontSize="18" Foreground="{StaticResource TextDim}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
Text="Connect to a host" IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
Text="Open a bucket" IsVisible="{Binding ShowsBucketChoice}" />
<!--
A different heading rather than the same one over an empty picker, which is what this screen
used to do: SELECT BUCKET opened a combo box with nothing in it and no sentence anywhere saying
where a bucket comes from. From here that is indistinguishable from an application that cannot
add one — and this is the screen somebody looking for S3 arrives at.
-->
<TextBlock Classes="heading" FontSize="16" TextAlignment="Center"
Text="No buckets yet" IsVisible="{Binding ShowsNoBuckets}" />
<!--
The sentence, and the button under it. Both go away once the picker is up — by then they have
been read or they have not — which is why they are one collapsing group rather than two
elements each answering the same question.
The sentence is the one the empty pane used to carry, and it is kept rather than dropped
because it is the one thing about this screen that surprises people: browsing files is a
second login, and it shows up in the host's auth log as one. The bucket's counterpart says
the opposite thing for the opposite reason — there is no session to open at all, so nothing
is dialled until a listing is asked for.
-->
<StackPanel Spacing="10" IsVisible="{Binding !IsChoosingRemote}">
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
IsVisible="{Binding ShowsHostPicker}"
Text="Browsing a host's files opens its own SFTP connection, so the host records a second login — it is not the channel a terminal uses." />
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
IsVisible="{Binding ShowsBucketChoice}"
Text="A bucket has nothing to connect to: the keys and the endpoint are tested by the first listing, not by opening a session." />
<!--
Where a bucket comes from, said on the screen that needs one. It names the keychain rather
than only offering a button, because the button is a shortcut and the sentence is what makes
the bucket findable again next week without it.
-->
<TextBlock Classes="hint" FontSize="12" TextAlignment="Center"
IsVisible="{Binding ShowsNoBuckets}"
Text="A bucket is an item in your keychain — the bucket name, its keys and its endpoint, encrypted like everything else there. Add one and it appears here." />
<Button Classes="accent" HorizontalAlignment="Center" Content="SELECT HOST"
IsVisible="{Binding ShowsHostPicker}"
Command="{Binding BeginChoosingRemoteCommand}" />
<Button Classes="accent" HorizontalAlignment="Center" Content="SELECT BUCKET"
IsVisible="{Binding ShowsBucketChoice}"
Command="{Binding BeginChoosingRemoteCommand}" />
<Button Classes="accent" HorizontalAlignment="Center" Content="+ BUCKET"
IsVisible="{Binding ShowsNoBuckets}"
Command="{Binding AddBucketCommand}"
ToolTip.Tip="Goes to the keychain with the bucket editor open." />
</StackPanel>
<!-- ==== The picker, once it has been asked for ==== -->
<StackPanel Spacing="8" IsVisible="{Binding IsChoosingRemote}">
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
IsVisible="{Binding ShowsHostPicker}"
PlaceholderText="choose a host">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Gated on there being one, not merely on this being the S3 screen. An empty picker is the
thing that made this screen look like it could not add a bucket, and the invitation above
now sends somebody to the keychain instead of opening this.
-->
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Buckets}"
SelectedItem="{Binding SelectedBucket}"
IsVisible="{Binding ShowsBucketChoice}"
PlaceholderText="choose a bucket">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="10"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a
different box holding a different value. This connection authenticates separately, so a
password typed to open a terminal was never offered here.
-->
<TextBox PasswordChar="•" Text="{Binding TypedPassword}" PlaceholderText="password"
IsVisible="{Binding SelectedHostAsksForAPassword}" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center">
<Button Classes="accent" Content="{Binding ConnectLabel}"
Command="{Binding ConnectCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding CancelChoosingRemoteCommand}" />
</StackPanel>
</StackPanel>
<!--
The status line, in the state where the connected strip above is not on screen. Wrapped rather
than trimmed, unlike its counterpart up there: what lands here is a refusal with a reason in
it — a server's own sentence about why a connection did not open — and an ellipsis through the
middle of that is a message that has been shown without being said.
-->
<TextBlock Classes="hint" FontSize="11.5" TextAlignment="Center" Text="{Binding Status}" />
</StackPanel>
</Border>
<!--
Two highlights rather than one, because refusing is worth showing. Something dragged over a
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
window that has stopped answering, and the answer arriving after the drop is the answer arriving
too late. See the local pane for why neither may hit-test.
-->
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
Foreground="{StaticResource Danger}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Grid>
<!--
============ The TRANSFERS strip ============
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
DeepChrome and a top border, per SFTP.dc.html, in place of the plain Sidebar fill this used to carry —
the same surface the session shell's own header and status bar are painted in, which is what fuses the
strip into that frame rather than leaving it read as another pane. Two Borders share this row and
exactly one is ever visible: the design's own header — TRANSFERS plus a count chip — with the queue
underneath while there is one, and a single quiet line otherwise. That is the "collapse" wave C's own
brief asks for: not zero height, because the honest sentence that used to live inside the full strip
still has somewhere to be read, but far short of the full header-plus-rows shape.
The design's own aggregate throughput readout — "8.4 MB/s" beside an upward arrow — is not drawn.
TransferRowViewModel.Progress computes a rate per transfer, off TransferSnapshot.BytesPerSecond, and
nothing anywhere in this queue sums those into one number for the whole strip; inventing one here would
be exactly the fabricated fact this project's honesty rule forbids. Recorded as a deviation rather than
silently dropped.
-->
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" MaxHeight="196" IsVisible="{Binding HasTransfers}">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16,12">
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="TRANSFERS" VerticalAlignment="Center" />
<!--
h18, radius 5, Chip fill with no border — the design's own count chip, distinct from Border.chip's
usual bordered-and-unfilled shape elsewhere in this application. ActiveTransfersLabel is
"N active", off TransfersViewModel.ActiveTransfers: queued counts as active there for the reason
its own remark gives, which matches what this chip is naming.
-->
<Border Grid.Column="1" Height="18" CornerRadius="5" Background="{StaticResource Chip}"
Padding="8,0" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="10.5" FontWeight="Medium"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding ActiveTransfersLabel}" />
</Border>
</Grid>
<!--
◆ THE STATUS LINE, in the state where the remote pane has no room for one — unchanged in meaning
from the strip this replaced, only in paint. Right-aligned beside the queue's own "one at a time"
policy sentence, which stays for the reason it always did: the queue refuses to overwrite, and
nothing else on this screen says so.
-->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,8,0,10">
<TextBlock Grid.Column="0" Classes="mono" FontSize="10.5" Foreground="{StaticResource TextGhost}"
Text="one at a time · nothing lands at its final name until it is complete" />
<TextBlock Grid.Column="1" Classes="hint" FontSize="11" Margin="16,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Transfers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<!--
Per SFTP.dc.html: a 320-pixel ellipsized "source → destination" label, a slim progress track
and a right-aligned status word — see TransferRowViewModel.Label/StatusWord. STOP, RETRY/
RESUME and DISCARD are the app's own, kept per wave C's brief rather than dropped for having
no equivalent in the design's static mock: icon-only now, restyled to the pane headers' own
quiet-glyph idiom, with a tooltip carrying the word the button used to print.
-->
<Grid ColumnDefinitions="320,*,64,Auto" Margin="0,5" ColumnSpacing="14">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
<ProgressBar Grid.Column="1" Classes="transferbar" Minimum="0" Maximum="100"
Value="{Binding Percent}" VerticalAlignment="Center"
ToolTip.Tip="{Binding Progress}" />
<TextBlock Grid.Column="2" Classes="mono transferstatus"
Classes.running="{Binding IsInProgress}" Classes.done="{Binding IsDone}"
Classes.failed="{Binding HasFailed}" FontSize="12" FontWeight="Medium"
Text="{Binding StatusWord}" TextAlignment="Right" VerticalAlignment="Center" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding IsRunning}"
ToolTip.Tip="Stop this transfer"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE5CD;" />
</Button>
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding CanRetry}"
ToolTip.Tip="{Binding RetryLabel}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE5D5;" />
</Button>
<Button Classes="paneicon danger" Width="22" Height="22" IsVisible="{Binding IsFinished}"
ToolTip.Tip="Discard this row"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
</Grid>
</Border>
<!--
The collapsed shape: no header, no count chip, no rows — just the honest sentence the full strip
carried before anything was ever queued, kept per wave C's own brief rather than dropped along with the
rest of the strip's furniture.
-->
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="16,12" IsVisible="{Binding !HasTransfers}">
<TextBlock Classes="hint" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
</Border>
<!--
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
they arrive here on their own because this is a separate connection — a host trusted for a shell is
trusted for this too, but a host nobody has connected to at all is met here first.
-->
<Border Grid.Row="0" Grid.RowSpan="2" Background="{StaticResource Canvas}"
IsVisible="{Binding HasPendingHostKey}">
<Border Classes="card">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="Check this host's fingerprint" />
<TextBlock Classes="hint"
Text="This host has not been seen before. Compare the fingerprint with what the operator published. Trusting it here also trusts it for terminals, and on your other machines." />
<SelectableTextBlock Classes="mono" FontSize="12" Foreground="{StaticResource Text}"
TextWrapping="Wrap" Text="{Binding PendingHostKey.Fingerprint}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT" Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Border>
<Border Grid.Row="0" Grid.RowSpan="2" Background="{StaticResource Canvas}"
IsVisible="{Binding HasHostKeyMismatch}">
<Border Classes="card" BorderBrush="{StaticResource DangerSoft}">
<StackPanel Spacing="12">
<TextBlock Classes="heading" Text="The host key has changed" Foreground="{StaticResource Danger}" />
<TextBlock Classes="hint" Text="{Binding HostKeyMismatch}" />
<TextBlock Classes="hint"
Text="Nothing was connected, and there is no way to continue from here. If the server was legitimately rebuilt, edit the host on the Hosts screen and choose Forget host key." />
<Button Classes="ghost" Content="CLOSE" Command="{Binding RejectHostKeyCommand}"
HorizontalAlignment="Left" />
</StackPanel>
</Border>
</Border>
</Grid>
</UserControl>