2 Commits
Author SHA1 Message Date
jaap-jan de0b5f12ae Let the pane headers' paths actually trim
ci / build and test (push) Failing after 2m11s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m18s
TextTrimming only acts when measure hands the block a finite width, and a
horizontal StackPanel never does — it measures every child at infinity and an
Auto grid column passes the full answer on. So both SFTP pane headers grew
with their path, and a directory deep enough pushed the header's own icon
buttons past the window's edge at the session shell's 472-pixel budget.

The layout suite has said so on every CI run since v5b landed, and nowhere
else: the runner's per-job HOME is a 46-character path, which is what the
local pane opens on, and every developer machine's short profile path left
the same test green. The path sits alone in the star column now — bounded
width, working ellipsis — and the narrowest-budget test pins both panes to
sixty-character paths so the question is asked on every machine alike;
against the old markup that test fails on Windows too.
2026-08-08 22:28:26 +02:00
jaap-jan 009b35e069 Cover the mixed-keychain regroup refusal headlessly
The two-keychain branch of VaultViewModel.RegroupChosenHosts had no test:
7.6a's manual walk was the only thing asserting that a mixed set gets the
sentence instead of the picker. A ShellFlowTests case now ticks a host in
each of two vaults, reads the refusal off the status line, and shows the
same command opening the picker once the set is one keychain's again.
Check 7.6a cites the test and keeps only the popup wiring for the eye.
2026-08-08 22:19:46 +02:00
4 changed files with 104 additions and 18 deletions
+6 -4
View File
@@ -984,10 +984,12 @@ across that line would leave everyone else in the shared vault seeing a machine
**Failure means:** the set's write is `ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce` again — one **Failure means:** the set's write is `ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce` again — one
command reads the whole set, so "filed one of three" has no half-gesture to hide in the way the old drag's command reads the whole set, so "filed one of three" has no half-gesture to hide in the way the old drag's
payload did. The refusal, though, is asserted by **nothing automated at all**: `VaultViewModel.RegroupChosenHosts` payload did. The refusal is covered headlessly too:
counts the distinct vaults and no test raises it, so this check is the only thing between that sentence and `RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens` ticks a host in each of two
silence. A panel that does open over a mixed set is the worse half — it would offer one keychain's groups keychains and asserts no picker opens and the sentence is on the status line. What is left for the eye is
for another keychain's machines, which is the half-filed set the refusal exists to prevent. 7.9's wiring, as in 7.6 — and a panel that does open over a mixed set is the worse half: it would offer one
keychain's groups for another keychain's machines, which is the half-filed set the refusal exists to
prevent.
### 7.7 A click still selects, and a double click still connects ### 7.7 A click still selects, and a double click still connects
@@ -177,16 +177,27 @@
thing (go to the parent directory), and the pair reading differently is the design's own choice 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. 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"> <Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> <TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="LOCAL"
<TextBlock Classes="label" FontSize="10" Text="LOCAL" VerticalAlignment="Center" /> VerticalAlignment="Center" Margin="0,0,12,0" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium" <TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis" HorizontalAlignment="Left"
ToolTip.Tip="{Binding LocalPath}" /> Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
</StackPanel> ToolTip.Tip="{Binding LocalPath}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"> <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 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 directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
@@ -332,19 +343,26 @@
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two 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. distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
--> -->
<!--
The path is alone in the star column for the reason the local pane's header remark spells out:
TextTrimming needs the finite width only a star column gives it, and this is the pane where the
long path is not even unusual — it is any host with a deep directory tree.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0"> <Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center" <TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" /> IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center" <TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" /> IsVisible="{Binding ShowsBucketPicker}" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding RemotePath}" />
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4"> <TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
HorizontalAlignment="Left"
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding RemotePath}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}" <Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory"> IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" /> <TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" />
@@ -1481,17 +1481,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
} }
/// <remarks> /// <remarks>
/// <para>
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own /// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote /// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable /// listing carrying every colour state a row can show (a directory, an executable, a world-writable
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either /// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
/// pane. /// pane.
/// </para>
/// <para>
/// Both pane headers carry a deep path on purpose, and the paths are set here rather than left to the
/// fixture's real filesystem. The header's ellipsis only works because the path sits alone in a star
/// column — see the pane header remark in <c>TransfersScreen.axaml</c> — and the regression it guards
/// against armed itself only on a machine whose home directory happened to be long: CI's per-job HOME
/// found it, every developer machine's short profile path missed it. A pinned sixty-character path asks
/// the question on every machine alike.
/// </para>
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget() public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
{ {
transfers.IsConnected = true; transfers.IsConnected = true;
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222"; transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
transfers.LocalPath = "/home/deployment-service/.cache/build/workspaces/site/artefacts";
transfers.RemotePath = "/srv/releases/site/shared/uploads/production/2026/08/nightly";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry( transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File, "docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
@@ -5845,6 +5845,60 @@ public sealed class ShellFlowTests : IAsyncLifetime
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked"); Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
} }
/// <remarks>
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
/// seeing a machine filed under nothing. Checked when the picker is asked for and over the whole set —
/// see <see cref="VaultViewModel.RegroupChosenHosts"/> — rather than once per host mid-write, which is
/// why no panel opens at all and the status line's sentence has to carry the whole explanation.
/// </remarks>
[Fact]
public async Task RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
await AddHostAsync(vault, "prod-db");
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault =
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.EditorLabel = "prod-web";
vault.EditorHostname = "web.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
vault.RegroupChosenHostsCommand.Execute(null);
vault.IsRegroupingChosenHosts.ShouldBeFalse("a group belongs to one keychain");
vault.Status.ShouldStartWith("These hosts are in more than one keychain");
vault.IsChoosingHosts.ShouldBeTrue("the set was refused, not dissolved");
// Unticking the visitor is all it takes: the refusal is about the set, not a latch the screen has
// to be talked out of.
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
vault.RegroupChosenHostsCommand.Execute(null);
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
}
/// <remarks> /// <remarks>
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into /// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
/// another vault: the copy stays in the same keychain, so everything it points at is still there. /// another vault: the copy stays in the same keychain, so everything it points at is still there.