Merge pull request 'Type a path on the SFTP pane, and put a file right without deleting it' (#15) from claude/dodossh-sftp-improvements-3b6305 into main
ci / build and test (push) Successful in 2m29s
ci / android head (push) Successful in 3m15s
ci / desktop nightly (push) Successful in 48s
ci / api image (push) Successful in 32s

Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
jaap-jan committed 2026-08-21 12:03:50 +00:00
commit 1970e20e56
2 files changed
+350 -13

No files matched your search

@@ -33,6 +33,43 @@
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 ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -259,10 +296,18 @@
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" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
<Grid Grid.Row="2" Margin="0,2,10,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
<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>
<!--
@@ -274,13 +319,19 @@
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="14,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" 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"
HorizontalAlignment="Right" VerticalAlignment="Center" />
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
@@ -374,8 +425,14 @@
</StackPanel>
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
Text="{Binding RemotePathDraft}" IsEnabled="{Binding IsConnected}"
PlaceholderText="/path/to/somewhere"
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}" />
@@ -584,10 +641,16 @@
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="new directory" />
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="directory" />
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
</StackPanel>
</Grid>
@@ -597,25 +660,42 @@
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" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
<Grid Grid.Row="5" Margin="0,2,10,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
<ColumnDefinition Width="*" MaxWidth="66" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
<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 ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
<Grid Height="38" Margin="14,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="*" MaxWidth="66" />
<ColumnDefinition Width="1.6*" MaxWidth="124" />
<ColumnDefinition Width="*" MaxWidth="66" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" 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"
HorizontalAlignment="Right" VerticalAlignment="Center" />
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
@@ -2171,6 +2172,262 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
/// </remarks>
// ---- ◆ The listings' columns ----
/// <remarks>
/// <para>
/// <b>Every cell shows what it holds, at a width this screen actually gets.</b> The columns were fixed
/// pixel widths until they were measured against their own content: MODIFIED prints a sixteen-character
/// timestamp needing 116 pixels and had 52, so it was truncated at every width the application has ever
/// been drawn at — on a maximised window it read "2026-08" beside a NAME column with four hundred empty
/// pixels in it. PERMS and SIZE were short by the same kind of margin.
/// </para>
/// <para>
/// Asserted by measuring each cell's own text unconstrained and holding the rendered width against it,
/// rather than against numbers written down here: a column that fits is a column at least as wide as
/// what it has to draw, and any other formulation is this test agreeing with the markup about an
/// arithmetic they both got from the same place.
/// </para>
/// </remarks>
[Fact]
public async Task TheListingColumnsShowTheirContentInFullWhenThereIsRoom()
{
SeedBothListings();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
try
{
foreach (var text in new[] { "2.1 KB", "2026-08-21 11:36", "-rw-r--r--", "2026-07-04 09:00" })
{
var cell = screen.GetVisualDescendants()
.OfType<TextBlock>()
.First(block => string.Equals(block.Text, text, StringComparison.Ordinal));
cell.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
Unconstrained(cell) - 0.5, $"'{text}' has to fit the column it is drawn in");
}
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The headings are right-aligned in their own columns, so two of them whose columns are exactly the
/// width of their text run together: SIZE ended on the pixel MODIFIED began at, and the row read
/// "SIZEMODIFIED". This holds the gap that fixes it — a left margin on each of the three, which does not
/// move a right-aligned label but does stop the column before it reaching this one.
/// </remarks>
[Fact]
public async Task TheListingHeadingsDoNotRunIntoEachOther()
{
SeedBothListings();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
try
{
// Per pane, not across both. Each has its own NAME at its own left edge, and a list of
// all seven sorted by position pairs the local pane's last heading with the remote
// pane's first — two labels in different panes, which no amount of margin separates.
var rows = screen.GetVisualDescendants()
.OfType<Grid>()
.Where(grid => grid.Children.OfType<TextBlock>().Any(
block => block.Classes.Contains("label")
&& string.Equals(block.Text, "NAME", StringComparison.Ordinal)))
.ToList();
rows.Count.ShouldBe(2, "one heading row per pane");
foreach (var row in rows)
{
var headings = row.Children
.OfType<TextBlock>()
.Where(block => block.Classes.Contains("label"))
.Select(block => (block.Text, Left: block.Bounds.X, Right: block.Bounds.Right))
.OrderBy(heading => heading.Left)
.ToList();
foreach (var (before, after) in headings.Zip(headings.Skip(1)))
{
after.Left.ShouldBeGreaterThan(
before.Right, $"'{before.Text}' must not touch '{after.Text}'");
}
}
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// <para>
/// The other end of the same fault. At the 204 pixels the session shell's own sidebar leaves a pane, the
/// fixed columns took 176 of a row's 180 and the remote pane's NAME cell measured <b>zero</b> — a file
/// browser drawing sizes, timestamps and modes for rows whose names were not on screen at all.
/// </para>
/// <para>
/// No column carries a MinWidth, which is what keeps this from being a fight the row can lose: at any
/// width all four give ground together, and NAME — weighted heaviest — is the last to run out. What is
/// asserted is only that it has not run out, because how much of a name fits in 204 pixels is a question
/// with no good answer.
/// </para>
/// </remarks>
[Fact]
public async Task TheNameColumnSurvivesTheNarrowestBudget()
{
SeedBothListings();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
foreach (var name in new[] { ".bash_history", ".azure-devops" })
{
var cell = screen.GetVisualDescendants()
.OfType<TextBlock>()
.First(block => string.Equals(block.Text, name, StringComparison.Ordinal));
cell.Bounds.Width.ShouldBeGreaterThan(20, $"'{name}' must be readable at all");
}
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// A placeholder cut off mid-word reads as a rendering fault rather than as a hint — MKDIR's box drew
/// "new directo" and the pane header's drew a path ending mid-segment. Both are boxes whose width is
/// fixed by the pane's own narrowest budget, so the text is what had to give.
/// </remarks>
[Fact]
public async Task ThePaneBoxesPlaceholdersFitTheBoxesTheyAreIn()
{
transfers.IsConnected = true;
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
foreach (var box in screen.GetVisualDescendants().OfType<TextBox>())
{
// The picker's own password box is in the collapsed half of the invitation, so it
// lays out at zero width and has no room for anything. Only what is on screen.
if (box.Watermark is not { Length: > 0 } mark || !box.IsEffectivelyVisible)
{
continue;
}
var room = box.Bounds.Width - box.Padding.Left - box.Padding.Right;
room.ShouldBeGreaterThanOrEqualTo(
Measured(mark, box.FontSize, box.FontFamily),
$"the placeholder '{mark}' has to fit its own box");
}
}
finally
{
window.Close();
}
},
Token);
}
/// <summary>Puts one row of each pane's own shape in, with content of a real length.</summary>
/// <remarks>
/// The local pane is cleared first for the reason <see cref="TheRowTemplateShowsSizeAndDateColumns"/>
/// gives: it lists this fixture's real home directory, and a virtualizing ListBox realises only the rows
/// that fit — a row appended after those would never be built, and the assertion would find nothing to
/// have missed.
/// </remarks>
private void SeedBothListings()
{
transfers.IsConnected = true;
transfers.RemotePath = "/root";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
".bash_history",
"/root/.bash_history",
SftpEntryKind.File,
2_150,
new DateTimeOffset(2026, 8, 21, 11, 36, 0, TimeSpan.Zero),
"-rw-r--r--")));
transfers.LocalEntries.Clear();
transfers.LocalEntries.Add(new LocalEntryRowViewModel(new LocalEntry(
".azure-devops",
Path.Combine(Path.GetTempPath(), ".azure-devops"),
IsDirectory: true,
Length: 0,
new DateTimeOffset(2026, 7, 4, 9, 0, 0, TimeSpan.Zero))));
}
/// <summary>
/// What a session-shell screen gets on a maximised window, which is where the columns were reported
/// wrong.
/// </summary>
/// <remarks>
/// A 1843-pixel window less the shell's own 26-pixel padding a side, its 1-pixel border a side, and
/// QUICK ACCESS's 300 — the arithmetic <see cref="LayoutHarness.SessionScreenWidth"/> does, at a width
/// that constant cannot express because it is pinned to the window's <em>minimum</em>. Both belong in
/// this file: the minimum is what everything else here measures against, and this is the width at which
/// the numeric columns reach their caps and the question "does the content fit" has a yes to give.
/// Below it they share the width instead, which is what
/// <see cref="TheNameColumnSurvivesTheNarrowestBudget"/> holds the other end of.
/// </remarks>
private const double MaximisedSessionScreenWidth = 1843 - (2 * 26) - (2 * 1) - 300;
/// <summary>What a rendered block would want if nothing bounded it.</summary>
private static double Unconstrained(TextBlock block) =>
Measured(block.Text ?? string.Empty, block.FontSize, block.FontFamily);
/// <inheritdoc cref="Unconstrained" />
private static double Measured(string text, double fontSize, Avalonia.Media.FontFamily family)
{
var block = new TextBlock { Text = text, FontSize = fontSize, FontFamily = family };
block.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
return block.DesiredSize.Width;
}
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>