Name a root chip after the root, not with its whole path
ci / build and test (push) Successful in 2m15s
ci / android head (push) Successful in 3m24s
ci / desktop nightly (push) Successful in 43s
ci / api image (push) Successful in 33s

The chip derivation was TrimEnd(separator), which is a name only for the
Windows drives it was written against: on Unix it made the / chip an empty
pill and the home chip the entire home path, drawn at full width in a header
column nothing bounds. A home directory deep enough — CI's per-job HOME is
forty-six characters — had that one chip walk the header's own buttons out of
the window at the session shell's 472-pixel budget, which is the half of the
runner's red suite the star-column fix before this one did not reach.

A chip says C:, /, ~, or a mount's last segment now; the full path stays on
its command parameter, where length costs nothing. RootChipNameTests pins the
derivation with fixed strings, so it no longer takes a machine with a deep
profile path to ask the question.
This commit is contained in:
2026-08-08 22:38:41 +02:00
parent de0b5f12ae
commit 242280ce6b
2 changed files with 84 additions and 1 deletions
@@ -794,12 +794,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
foreach (var root in LocalDirectory.Roots())
{
LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root));
LocalRoots.Add(new CrumbViewModel(RootChipName(root), root));
}
RefreshLocalCommand.Execute(null);
}
/// <summary>What a root's chip in the pane header says: <c>C:</c>, <c>/</c>, <c>~</c>, or a mount's name.</summary>
/// <remarks>
/// <para>
/// A name, never a path — the full path is the chip's <see cref="CrumbViewModel.Path"/> and its command
/// parameter, and it stays there. This used to be <c>root.TrimEnd(separator)</c>, which is a name only
/// for a Windows drive: on Unix it made the <c>/</c> chip an empty pill and the home chip the entire
/// home path, drawn at full width in a header column nothing bounds. A machine whose home directory sat
/// deep enough — CI's per-job HOME is forty-six characters — had that one chip push the header's own
/// buttons past the window's edge at the session shell's 472-pixel budget.
/// </para>
/// <para>
/// <c>~</c> for home is the one substitution rather than a shortening: every shell a user of this
/// application has ever typed into already means "my home directory" by it, which is exactly what the
/// chip does when pressed.
/// </para>
/// </remarks>
internal static string RootChipName(string root)
{
if (string.Equals(root, LocalDirectory.Home, StringComparison.Ordinal))
{
return "~";
}
var trimmed = root.TrimEnd(Path.DirectorySeparatorChar);
if (trimmed.Length == 0)
{
// Unix's "/": trimming eats the whole string, and the root's name is the root itself.
return "/";
}
// A mount under /media or /run/media names itself by its last segment; a Windows drive ("C:") has
// no file-name segment at all, and the trimmed root is already the two-character name it always had.
var name = Path.GetFileName(trimmed);
return name.Length == 0 ? trimmed : name;
}
/// <summary>
/// Gives up the vault, keeping the connection and anything in flight.
/// </summary>
@@ -0,0 +1,47 @@
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.App.Tests;
/// <summary>What the local pane's root chips are called.</summary>
/// <remarks>
/// A chip carries a name and never a path — the path is the chip's command parameter, where its length costs
/// nothing. The derivation used to be <c>TrimEnd(separator)</c>, which is a name only for a Windows drive:
/// on Unix it made the <c>/</c> chip an empty pill and the home chip the whole home path, and a home
/// directory deep enough pushed the pane header's own buttons out of the window — CI's per-job HOME is what
/// finally said so. These pin the derivation with fixed strings, so the question no longer depends on how
/// long a path any particular machine keeps its profile under.
/// </remarks>
public sealed class RootChipNameTests
{
[Fact]
public void TheUnixRootIsASlash_NotAnEmptyPill() =>
TransfersViewModel.RootChipName("/").ShouldBe("/");
/// <remarks>
/// The one substitution rather than a shortening: every shell already means "my home directory" by
/// <c>~</c>, and the machine's real home path — however deep — stays on the chip's command alone.
/// </remarks>
[Fact]
public void TheHomeChipIsATilde_HoweverDeepHomeSits() =>
TransfersViewModel.RootChipName(LocalDirectory.Home).ShouldBe("~");
[Fact]
public void AMountNamesItselfByItsLastSegment() =>
TransfersViewModel.RootChipName("/media/usb0").ShouldBe("usb0");
/// <remarks>
/// Windows only, because the input only exists there — <c>LocalDirectory.Roots()</c> produces drive
/// roots on no other platform, and <c>Path.GetFileName</c> reads <c>C:\</c> differently on Unix.
/// </remarks>
[Fact]
public void AWindowsDriveKeepsItsTwoCharacterName()
{
if (!OperatingSystem.IsWindows())
{
return;
}
TransfersViewModel.RootChipName(@"C:\").ShouldBe("C:");
}
}