diff --git a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs index aa072c8..6442662 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs @@ -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); } + /// What a root's chip in the pane header says: C:, /, ~, or a mount's name. + /// + /// + /// A name, never a path — the full path is the chip's and its command + /// parameter, and it stays there. This used to be root.TrimEnd(separator), which is a name only + /// for a Windows drive: 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 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. + /// + /// + /// ~ 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. + /// + /// + 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; + } + /// /// Gives up the vault, keeping the connection and anything in flight. /// diff --git a/tests/DodoSSH.Client.App.Tests/RootChipNameTests.cs b/tests/DodoSSH.Client.App.Tests/RootChipNameTests.cs new file mode 100644 index 0000000..04464af --- /dev/null +++ b/tests/DodoSSH.Client.App.Tests/RootChipNameTests.cs @@ -0,0 +1,47 @@ +using DodoSSH.Client.Shell.ViewModels; +using DodoSSH.Client.Transfer; + +namespace DodoSSH.Client.App.Tests; + +/// What the local pane's root chips are called. +/// +/// 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 TrimEnd(separator), which is a name only for a Windows drive: +/// on Unix it made the / 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. +/// +public sealed class RootChipNameTests +{ + [Fact] + public void TheUnixRootIsASlash_NotAnEmptyPill() => + TransfersViewModel.RootChipName("/").ShouldBe("/"); + + /// + /// The one substitution rather than a shortening: every shell already means "my home directory" by + /// ~, and the machine's real home path — however deep — stays on the chip's command alone. + /// + [Fact] + public void TheHomeChipIsATilde_HoweverDeepHomeSits() => + TransfersViewModel.RootChipName(LocalDirectory.Home).ShouldBe("~"); + + [Fact] + public void AMountNamesItselfByItsLastSegment() => + TransfersViewModel.RootChipName("/media/usb0").ShouldBe("usb0"); + + /// + /// Windows only, because the input only exists there — LocalDirectory.Roots() produces drive + /// roots on no other platform, and Path.GetFileName reads C:\ differently on Unix. + /// + [Fact] + public void AWindowsDriveKeepsItsTwoCharacterName() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + TransfersViewModel.RootChipName(@"C:\").ShouldBe("C:"); + } +}