Public Access
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
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
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.
|
layer has not got. See docs/design-import-gaps.md.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
══ THE TWO LISTINGS' COLUMNS ═════════════════════════════════════════════════════════════════════════
|
||||||
|
◆ They were fixed pixel widths, and fixed was wrong at both ends of the range this screen actually gets.
|
||||||
|
|
||||||
|
The numbers — 20/*/60/56 here and 20/*/56/52/48 across the arrow column — were chosen against the 204
|
||||||
|
pixels the session shell's own QUICK ACCESS sidebar leaves a pane at the window's minimum. Measured,
|
||||||
|
they were wrong in both directions at once:
|
||||||
|
|
||||||
|
· MODIFIED prints "2026-08-21 11:36", which needs 116 pixels, and never had more than 48. So the
|
||||||
|
column was truncated at every width this screen has ever been drawn at — a maximised window showed
|
||||||
|
"2026-08" beside a NAME column with four hundred empty pixels in it. PERMS needs 60 for a
|
||||||
|
ten-character mode and had 48; SIZE needs 58 for "999.9 GB" and had 56.
|
||||||
|
· At 204 the remote pane's NAME column measured **zero pixels wide**. Twenty for the glyph plus
|
||||||
|
56+52+48 for the numbers is 176 of the 180 a row has, so the file names — the one column a file
|
||||||
|
browser exists to show — were not drawn at all.
|
||||||
|
|
||||||
|
Star columns with a MaxWidth fix both, because the cap is what makes "share the width" mean the right
|
||||||
|
thing at each end. Wide, the three numeric columns stop growing at what their own text needs and NAME
|
||||||
|
keeps every remaining pixel. Narrow, nothing has a MinWidth to overflow with, so all four give ground
|
||||||
|
together and NAME — weighted heaviest — keeps enough to read a name by.
|
||||||
|
|
||||||
|
MODIFIED carries the extra weight (1.6) so that it reaches its cap first: it is the widest content and
|
||||||
|
the column somebody is most often actually reading. The local pane's NAME is 3* against the remote's 2*,
|
||||||
|
which is the remote's PERMS weight folded into it — that is what makes SIZE and MODIFIED come out the
|
||||||
|
same width on both sides of the arrow column, so the same amount of a timestamp is visible in each. See
|
||||||
|
Timestamps.Format for why both panes write one the same way in the first place; a shared format read at
|
||||||
|
two different widths would have made that argument for nothing.
|
||||||
|
|
||||||
|
◆ FOUR COPIES OF THIS, and they have to stay in step: a heading row and a row template per pane. Avalonia
|
||||||
|
cannot share one ColumnDefinitions instance between two Grids — each mutates its own computed sizes —
|
||||||
|
and SharedSizeGroup only applies to Auto columns. What catches a copy that drifts is measurement rather
|
||||||
|
than discipline: ScreenLayoutTests holds every cell against the width its own text needs
|
||||||
|
(TheListingColumnsShowTheirContentInFullWhenThereIsRoom), the headings against each other
|
||||||
|
(TheListingHeadingsDoNotRunIntoEachOther), and NAME against zero at the narrowest budget
|
||||||
|
(TheNameColumnSurvivesTheNarrowestBudget).
|
||||||
|
-->
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
<!--
|
<!--
|
||||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -259,10 +296,18 @@
|
|||||||
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
||||||
design's own 1920-pixel canvas.
|
design's own 1920-pixel canvas.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
|
<Grid Grid.Row="2" Margin="0,2,10,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="20" />
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||||
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -274,13 +319,19 @@
|
|||||||
SelectedItem="{Binding SelectedLocalEntry}">
|
SelectedItem="{Binding SelectedLocalEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||||
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
|
<Grid Height="38" Margin="14,0,10,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="20" />
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||||
Text="{Binding Name}" FontSize="13.5"
|
Text="{Binding Name}" FontSize="13.5"
|
||||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -374,8 +425,14 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
|
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
|
||||||
Text="{Binding RemotePathDraft}" IsEnabled="{Binding IsConnected}"
|
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.">
|
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>
|
<TextBox.KeyBindings>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding RevertTypedRemotePathCommand}" />
|
<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
|
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
|
||||||
enough for a real directory name to be typed and read back, which is what this box is for; a
|
enough for a real directory name to be typed and read back, which is what this box is for; a
|
||||||
name that outgrows it scrolls inside the box the way every TextBox already does.
|
name that outgrows it scrolls inside the box the way every TextBox already does.
|
||||||
|
|
||||||
|
◆ The placeholder says "directory" rather than "new directory", which is what it said until it
|
||||||
|
was measured: the longer text needs 101 pixels inside a box that has 74, so it drew as "new
|
||||||
|
directo" — a placeholder cut off mid-word, which reads as a rendering fault rather than as a
|
||||||
|
hint. MKDIR is right beside it and carries the "new" half already. The path box in the header
|
||||||
|
above lost the same argument and for the same reason; see its own PlaceholderText.
|
||||||
-->
|
-->
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||||
IsVisible="{Binding IsConnected}">
|
IsVisible="{Binding IsConnected}">
|
||||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="directory" />
|
||||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -597,25 +660,42 @@
|
|||||||
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
||||||
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="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="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
|
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||||
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" Margin="6,0,0,0"
|
||||||
|
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
|
<Grid Height="38" Margin="14,0,10,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="20" />
|
||||||
|
<ColumnDefinition Width="2*" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||||
|
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||||
Classes.exec="{Binding IsExecutable}"
|
Classes.exec="{Binding IsExecutable}"
|
||||||
Text="{Binding Name}" FontSize="13.5"
|
Text="{Binding Name}" FontSize="13.5"
|
||||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Headless;
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
@@ -2171,6 +2172,262 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
|
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
|
||||||
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
|
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ---- ◆ The listings' columns ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every cell shows what it holds, at a width this screen actually gets.</b> The columns were fixed
|
||||||
|
/// pixel widths until they were measured against their own content: MODIFIED prints a sixteen-character
|
||||||
|
/// timestamp needing 116 pixels and had 52, so it was truncated at every width the application has ever
|
||||||
|
/// been drawn at — on a maximised window it read "2026-08" beside a NAME column with four hundred empty
|
||||||
|
/// pixels in it. PERMS and SIZE were short by the same kind of margin.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asserted by measuring each cell's own text unconstrained and holding the rendered width against it,
|
||||||
|
/// rather than against numbers written down here: a column that fits is a column at least as wide as
|
||||||
|
/// what it has to draw, and any other formulation is this test agreeing with the markup about an
|
||||||
|
/// arithmetic they both got from the same place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheListingColumnsShowTheirContentInFullWhenThereIsRoom()
|
||||||
|
{
|
||||||
|
SeedBothListings();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var text in new[] { "2.1 KB", "2026-08-21 11:36", "-rw-r--r--", "2026-07-04 09:00" })
|
||||||
|
{
|
||||||
|
var cell = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.First(block => string.Equals(block.Text, text, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
cell.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
Unconstrained(cell) - 0.5, $"'{text}' has to fit the column it is drawn in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The headings are right-aligned in their own columns, so two of them whose columns are exactly the
|
||||||
|
/// width of their text run together: SIZE ended on the pixel MODIFIED began at, and the row read
|
||||||
|
/// "SIZEMODIFIED". This holds the gap that fixes it — a left margin on each of the three, which does not
|
||||||
|
/// move a right-aligned label but does stop the column before it reaching this one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheListingHeadingsDoNotRunIntoEachOther()
|
||||||
|
{
|
||||||
|
SeedBothListings();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Per pane, not across both. Each has its own NAME at its own left edge, and a list of
|
||||||
|
// all seven sorted by position pairs the local pane's last heading with the remote
|
||||||
|
// pane's first — two labels in different panes, which no amount of margin separates.
|
||||||
|
var rows = screen.GetVisualDescendants()
|
||||||
|
.OfType<Grid>()
|
||||||
|
.Where(grid => grid.Children.OfType<TextBlock>().Any(
|
||||||
|
block => block.Classes.Contains("label")
|
||||||
|
&& string.Equals(block.Text, "NAME", StringComparison.Ordinal)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
rows.Count.ShouldBe(2, "one heading row per pane");
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
var headings = row.Children
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.Where(block => block.Classes.Contains("label"))
|
||||||
|
.Select(block => (block.Text, Left: block.Bounds.X, Right: block.Bounds.Right))
|
||||||
|
.OrderBy(heading => heading.Left)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var (before, after) in headings.Zip(headings.Skip(1)))
|
||||||
|
{
|
||||||
|
after.Left.ShouldBeGreaterThan(
|
||||||
|
before.Right, $"'{before.Text}' must not touch '{after.Text}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The other end of the same fault. At the 204 pixels the session shell's own sidebar leaves a pane, the
|
||||||
|
/// fixed columns took 176 of a row's 180 and the remote pane's NAME cell measured <b>zero</b> — a file
|
||||||
|
/// browser drawing sizes, timestamps and modes for rows whose names were not on screen at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No column carries a MinWidth, which is what keeps this from being a fight the row can lose: at any
|
||||||
|
/// width all four give ground together, and NAME — weighted heaviest — is the last to run out. What is
|
||||||
|
/// asserted is only that it has not run out, because how much of a name fits in 204 pixels is a question
|
||||||
|
/// with no good answer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheNameColumnSurvivesTheNarrowestBudget()
|
||||||
|
{
|
||||||
|
SeedBothListings();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var name in new[] { ".bash_history", ".azure-devops" })
|
||||||
|
{
|
||||||
|
var cell = screen.GetVisualDescendants()
|
||||||
|
.OfType<TextBlock>()
|
||||||
|
.First(block => string.Equals(block.Text, name, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
cell.Bounds.Width.ShouldBeGreaterThan(20, $"'{name}' must be readable at all");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A placeholder cut off mid-word reads as a rendering fault rather than as a hint — MKDIR's box drew
|
||||||
|
/// "new directo" and the pane header's drew a path ending mid-segment. Both are boxes whose width is
|
||||||
|
/// fixed by the pane's own narrowest budget, so the text is what had to give.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePaneBoxesPlaceholdersFitTheBoxesTheyAreIn()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var screen = new TransfersScreen { DataContext = transfers };
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var box in screen.GetVisualDescendants().OfType<TextBox>())
|
||||||
|
{
|
||||||
|
// The picker's own password box is in the collapsed half of the invitation, so it
|
||||||
|
// lays out at zero width and has no room for anything. Only what is on screen.
|
||||||
|
if (box.Watermark is not { Length: > 0 } mark || !box.IsEffectivelyVisible)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var room = box.Bounds.Width - box.Padding.Left - box.Padding.Right;
|
||||||
|
|
||||||
|
room.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
Measured(mark, box.FontSize, box.FontFamily),
|
||||||
|
$"the placeholder '{mark}' has to fit its own box");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts one row of each pane's own shape in, with content of a real length.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The local pane is cleared first for the reason <see cref="TheRowTemplateShowsSizeAndDateColumns"/>
|
||||||
|
/// gives: it lists this fixture's real home directory, and a virtualizing ListBox realises only the rows
|
||||||
|
/// that fit — a row appended after those would never be built, and the assertion would find nothing to
|
||||||
|
/// have missed.
|
||||||
|
/// </remarks>
|
||||||
|
private void SeedBothListings()
|
||||||
|
{
|
||||||
|
transfers.IsConnected = true;
|
||||||
|
transfers.RemotePath = "/root";
|
||||||
|
|
||||||
|
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
|
".bash_history",
|
||||||
|
"/root/.bash_history",
|
||||||
|
SftpEntryKind.File,
|
||||||
|
2_150,
|
||||||
|
new DateTimeOffset(2026, 8, 21, 11, 36, 0, TimeSpan.Zero),
|
||||||
|
"-rw-r--r--")));
|
||||||
|
|
||||||
|
transfers.LocalEntries.Clear();
|
||||||
|
transfers.LocalEntries.Add(new LocalEntryRowViewModel(new LocalEntry(
|
||||||
|
".azure-devops",
|
||||||
|
Path.Combine(Path.GetTempPath(), ".azure-devops"),
|
||||||
|
IsDirectory: true,
|
||||||
|
Length: 0,
|
||||||
|
new DateTimeOffset(2026, 7, 4, 9, 0, 0, TimeSpan.Zero))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a session-shell screen gets on a maximised window, which is where the columns were reported
|
||||||
|
/// wrong.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A 1843-pixel window less the shell's own 26-pixel padding a side, its 1-pixel border a side, and
|
||||||
|
/// QUICK ACCESS's 300 — the arithmetic <see cref="LayoutHarness.SessionScreenWidth"/> does, at a width
|
||||||
|
/// that constant cannot express because it is pinned to the window's <em>minimum</em>. Both belong in
|
||||||
|
/// this file: the minimum is what everything else here measures against, and this is the width at which
|
||||||
|
/// the numeric columns reach their caps and the question "does the content fit" has a yes to give.
|
||||||
|
/// Below it they share the width instead, which is what
|
||||||
|
/// <see cref="TheNameColumnSurvivesTheNarrowestBudget"/> holds the other end of.
|
||||||
|
/// </remarks>
|
||||||
|
private const double MaximisedSessionScreenWidth = 1843 - (2 * 26) - (2 * 1) - 300;
|
||||||
|
|
||||||
|
/// <summary>What a rendered block would want if nothing bounded it.</summary>
|
||||||
|
private static double Unconstrained(TextBlock block) =>
|
||||||
|
Measured(block.Text ?? string.Empty, block.FontSize, block.FontFamily);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Unconstrained" />
|
||||||
|
private static double Measured(string text, double fontSize, Avalonia.Media.FontFamily family)
|
||||||
|
{
|
||||||
|
var block = new TextBlock { Text = text, FontSize = fontSize, FontFamily = family };
|
||||||
|
|
||||||
|
block.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||||
|
|
||||||
|
return block.DesiredSize.Width;
|
||||||
|
}
|
||||||
|
|
||||||
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
Reference in new issue
Block a user