diff --git a/ImGui.Popups/FilesystemBrowser.cs b/ImGui.Popups/FilesystemBrowser.cs index 077a6a7..cbfa57b 100644 --- a/ImGui.Popups/FilesystemBrowser.cs +++ b/ImGui.Popups/FilesystemBrowser.cs @@ -5,11 +5,11 @@ namespace ktsu.ImGui.Popups; using System; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Numerics; -using System.Text; using System.Text.Json.Serialization; using Hexa.NET.ImGui; using ktsu.Extensions; @@ -59,6 +59,22 @@ public enum FilesystemBrowserTarget /// public class FilesystemBrowser { + /// + /// Mount point prefixes that are hidden from the drive list on Unix-like systems. + /// + /// + /// reports every mount point on Unix, so the raw list is + /// dominated by kernel pseudo filesystems (/proc, /sys, /dev) and runtime state + /// (/run) that hold nothing a user would browse to. Removable media is commonly mounted under + /// /run/media, so that subtree is kept — see . + /// + private static readonly string[] HiddenUnixMountPrefixes = ["/proc", "/sys", "/dev", "/run"]; + + /// + /// The mount point prefix for removable media, kept even though it sits under a hidden prefix. + /// + private const string RemovableMediaPrefix = "/run/media"; + /// /// Gets or sets the mode of the browser (Open or Save). /// @@ -205,7 +221,7 @@ private void OpenPopup(string title, FilesystemBrowserMode mode, FilesystemBrows Matcher = new(); Matcher.AddInclude(Glob); Drives.Clear(); - Environment.GetLogicalDrives().ForEach(Drives.Add); + GetNavigableDrives().ForEach(Drives.Add); RefreshContents(); Modal.Open(title, ShowContent, customSize); } @@ -237,18 +253,19 @@ private void ShowContent() /// private void DrawDrivesCombo() { - if (Drives.Count != 0 && ImGui.BeginCombo("##Drives", Drives[0])) + if (Drives.Count == 0) { - StringBuilder currentDriveStringBuilder = new(); - currentDriveStringBuilder.Append(CurrentDirectory.Split(Path.VolumeSeparatorChar).Current); - currentDriveStringBuilder.Append(Path.VolumeSeparatorChar); - currentDriveStringBuilder.Append(Path.DirectorySeparatorChar); - string currentDrive = currentDriveStringBuilder.ToString(); + return; + } + string currentDrive = CurrentDriveOf(CurrentDirectory, Drives[0]); + + if (ImGui.BeginCombo("##Drives", currentDrive)) + { #pragma warning disable S3267 // Explicit loop is clearer; LINQ rewrite would add unnecessary allocation and complicate ImGui immediate-mode usage. foreach (string drive in Drives) { - if (ImGui.Selectable(drive, drive == currentDrive)) + if (ImGui.Selectable(drive, string.Equals(drive, currentDrive, StringComparison.OrdinalIgnoreCase))) { CurrentDirectory = drive.As(); RefreshContents(); @@ -260,6 +277,45 @@ private void DrawDrivesCombo() } } + /// + /// Gets the logical drives worth offering in the drive combo, deduplicated and ordered. + /// + /// The drives to display. + internal static IEnumerable GetNavigableDrives() => + Environment.GetLogicalDrives() + .Where(IsNavigableDrive) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(drive => drive, StringComparer.OrdinalIgnoreCase); + + /// + /// Determines whether a logical drive is user-navigable storage rather than a pseudo filesystem. + /// + /// The drive reported by . + /// if the drive should be listed; otherwise, . + internal static bool IsNavigableDrive(string drive) + { + if (OperatingSystem.IsWindows()) + { + return true; + } + + return drive.StartsWith(RemovableMediaPrefix, StringComparison.Ordinal) + || !HiddenUnixMountPrefixes.Any(prefix => drive.StartsWith(prefix, StringComparison.Ordinal)); + } + + /// + /// Gets the root of the given directory in the same form reports, + /// so it can be matched against . + /// + /// The directory to take the root of. + /// The value to return when the directory has no determinable root. + /// The directory's root, or when the root cannot be determined. + internal static string CurrentDriveOf(AbsoluteDirectoryPath directory, string fallback) + { + string? root = Path.GetPathRoot(directory.WeakString); + return string.IsNullOrEmpty(root) ? fallback : root; + } + /// /// Draws the table listing the parent directory entry and the current directory contents. /// @@ -273,7 +329,7 @@ private void DrawFileTable() ImGuiSelectableFlags flags = ImGuiSelectableFlags.SpanAllColumns | ImGuiSelectableFlags.AllowDoubleClick | ImGuiSelectableFlags.NoAutoClosePopups; DrawParentDirectoryRow(flags); - foreach (IAbsolutePath? path in CurrentContents.OrderBy(p => p is not AbsoluteDirectoryPath).ThenBy(p => p).ToCollection()) + foreach (IAbsolutePath path in SortContents(CurrentContents)) { DrawContentRow(path, flags); } @@ -284,6 +340,26 @@ private void DrawFileTable() ImGui.EndChild(); } + /// + /// Orders filesystem entries for display, listing directories before files and sorting each group by name. + /// + /// The filesystem entries to order. + /// The ordered entries. + /// + /// The secondary sort key is the entry's string value rather than the entry itself because + /// does not implement . Sorting by the entry + /// would make LINQ fall back to , which calls the non-generic + /// on the underlying semantic string, which throws + /// when handed a path object in ktsu.Semantics.Strings 2.7.10 and + /// earlier. Sorting on the string value also gives case-insensitive display order. + /// + internal static Collection SortContents(IEnumerable contents) => + contents + .OrderBy(p => p is not AbsoluteDirectoryPath) + .ThenBy(p => p.ToString(), StringComparer.OrdinalIgnoreCase) + .ThenBy(p => p.ToString(), StringComparer.Ordinal) + .ToCollection(); + /// /// Draws the ".." row that navigates to the parent directory. /// diff --git a/ImGui.Popups/ImGui.Popups.csproj b/ImGui.Popups/ImGui.Popups.csproj index 12b80a7..3384124 100644 --- a/ImGui.Popups/ImGui.Popups.csproj +++ b/ImGui.Popups/ImGui.Popups.csproj @@ -6,6 +6,10 @@ net10.0;net9.0;net8.0 + + + + diff --git a/ImGui.sln b/ImGui.sln index 3aa44a4..d363cd1 100644 --- a/ImGui.sln +++ b/ImGui.sln @@ -49,6 +49,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImGui.Markdown.Tests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImGuiMarkdownDemo", "examples\ImGuiMarkdownDemo\ImGuiMarkdownDemo.csproj", "{8766DCC8-BA77-4983-8653-2ABFBC2D3CE1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImGui.Popups.Tests", "tests\ImGui.Popups.Tests\ImGui.Popups.Tests.csproj", "{43E27D79-983E-471B-ACC4-D1F73ECB53D0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -311,6 +313,18 @@ Global {8766DCC8-BA77-4983-8653-2ABFBC2D3CE1}.Release|x64.Build.0 = Release|Any CPU {8766DCC8-BA77-4983-8653-2ABFBC2D3CE1}.Release|x86.ActiveCfg = Release|Any CPU {8766DCC8-BA77-4983-8653-2ABFBC2D3CE1}.Release|x86.Build.0 = Release|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Debug|x64.ActiveCfg = Debug|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Debug|x64.Build.0 = Debug|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Debug|x86.ActiveCfg = Debug|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Debug|x86.Build.0 = Debug|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Release|Any CPU.Build.0 = Release|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Release|x64.ActiveCfg = Release|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Release|x64.Build.0 = Release|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Release|x86.ActiveCfg = Release|Any CPU + {43E27D79-983E-471B-ACC4-D1F73ECB53D0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -327,6 +341,7 @@ Global {D9556A7D-9672-47F7-9348-68E0B13777EE} = {5F0E6F1C-44C1-42B7-ADF4-770ABB471B40} {4CC3D936-4F07-4C65-9EA1-D55662268D89} = {5F0E6F1C-44C1-42B7-ADF4-770ABB471B40} {8766DCC8-BA77-4983-8653-2ABFBC2D3CE1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {43E27D79-983E-471B-ACC4-D1F73ECB53D0} = {5F0E6F1C-44C1-42B7-ADF4-770ABB471B40} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {14515AC1-A74C-4CBB-98F2-9DA660E967EF} diff --git a/tests/ImGui.Popups.Tests/FilesystemBrowserDriveTests.cs b/tests/ImGui.Popups.Tests/FilesystemBrowserDriveTests.cs new file mode 100644 index 0000000..7c273f8 --- /dev/null +++ b/tests/ImGui.Popups.Tests/FilesystemBrowserDriveTests.cs @@ -0,0 +1,118 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.ImGui.Popups.Tests; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class FilesystemBrowserDriveTests +{ + [TestMethod] + public void CurrentDriveOf_ReturnsRootOfDirectory() + { + string root = Path.GetPathRoot(Path.GetTempPath())!; + AbsoluteDirectoryPath directory = Path.Combine(Path.GetTempPath(), "some", "nested", "directory").As(); + + Assert.AreEqual(root, ImGuiPopups.FilesystemBrowser.CurrentDriveOf(directory, "fallback")); + } + + [TestMethod] + public void CurrentDriveOf_WithoutDeterminableRoot_ReturnsFallback() + { + AbsoluteDirectoryPath directory = string.Empty.As(); + + Assert.AreEqual("fallback", ImGuiPopups.FilesystemBrowser.CurrentDriveOf(directory, "fallback")); + } + + [TestMethod] + public void GetNavigableDrives_IsSortedAndDeduplicated() + { + List drives = [.. ImGuiPopups.FilesystemBrowser.GetNavigableDrives()]; + + CollectionAssert.AreEqual(drives.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), drives, "Drives should be deduplicated."); + CollectionAssert.AreEqual(drives.OrderBy(d => d, StringComparer.OrdinalIgnoreCase).ToList(), drives, "Drives should be sorted."); + } + + [TestMethod] + public void GetNavigableDrives_IncludesRootOfCurrentDirectory() + { + string root = Path.GetPathRoot(Environment.CurrentDirectory)!; + + List drives = [.. ImGuiPopups.FilesystemBrowser.GetNavigableDrives()]; + + Assert.IsTrue( + drives.Contains(root, StringComparer.OrdinalIgnoreCase), + $"Expected the root of the current directory ('{root}') among [{string.Join(", ", drives)}]."); + } + + [TestMethod] + [TestCategory("OS-Specific")] + public void IsNavigableDrive_OnUnix_ExcludesPseudoFilesystems() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Unix-only mount point filtering."); + return; + } + + Assert.IsFalse(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/proc")); + Assert.IsFalse(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/proc/sys/fs/binfmt_misc")); + Assert.IsFalse(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/sys/kernel/debug")); + Assert.IsFalse(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/dev/shm")); + Assert.IsFalse(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/run/user/1000/gvfs")); + } + + [TestMethod] + [TestCategory("OS-Specific")] + public void IsNavigableDrive_OnUnix_KeepsRealStorage() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Unix-only mount point filtering."); + return; + } + + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/")); + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/home")); + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/tmp")); + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/mnt/data")); + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/media/usb")); + } + + [TestMethod] + [TestCategory("OS-Specific")] + public void IsNavigableDrive_OnUnix_KeepsRemovableMediaUnderRun() + { + if (OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Unix-only mount point filtering."); + return; + } + + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive("/run/media/user/USB")); + } + + [TestMethod] + [TestCategory("OS-Specific")] + public void IsNavigableDrive_OnWindows_KeepsEveryDrive() + { + if (!OperatingSystem.IsWindows()) + { + Assert.Inconclusive("Windows-only drive handling."); + return; + } + + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive(@"C:\")); + Assert.IsTrue(ImGuiPopups.FilesystemBrowser.IsNavigableDrive(@"Z:\")); + } +} diff --git a/tests/ImGui.Popups.Tests/FilesystemBrowserSortTests.cs b/tests/ImGui.Popups.Tests/FilesystemBrowserSortTests.cs new file mode 100644 index 0000000..50cff26 --- /dev/null +++ b/tests/ImGui.Popups.Tests/FilesystemBrowserSortTests.cs @@ -0,0 +1,106 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.ImGui.Popups.Tests; + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; + +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class FilesystemBrowserSortTests +{ + private static readonly string Root = Path.Combine(Path.GetTempPath(), "FilesystemBrowserSortTests"); + + private static AbsoluteDirectoryPath MakeDirectory(string name) => Path.Combine(Root, name).As(); + + private static AbsoluteFilePath MakeFile(string name) => Path.Combine(Root, name).As(); + + /// + /// Regression test for https://github.com/ktsu-dev/ImGuiApp/issues/273 — sorting a mixed + /// collection of paths used to throw because has no generic + /// comparer and the semantic string fallback only accepts arguments. + /// + [TestMethod] + public void SortContents_MixedDirectoriesAndFiles_DoesNotThrow() + { + Collection contents = + [ + MakeFile("b.txt"), + MakeDirectory("zebra"), + MakeFile("a.txt"), + MakeDirectory("alpha"), + ]; + + Collection sorted = ImGuiPopups.FilesystemBrowser.SortContents(contents); + + Assert.AreEqual(contents.Count, sorted.Count); + } + + [TestMethod] + public void SortContents_ListsDirectoriesBeforeFiles() + { + Collection contents = + [ + MakeFile("a.txt"), + MakeDirectory("zebra"), + MakeFile("b.txt"), + MakeDirectory("alpha"), + ]; + + List sorted = [.. ImGuiPopups.FilesystemBrowser.SortContents(contents).Select(p => Path.GetFileName(p.ToString()!))]; + + CollectionAssert.AreEqual(new List { "alpha", "zebra", "a.txt", "b.txt" }, sorted); + } + + [TestMethod] + public void SortContents_SortsByNameIgnoringCase() + { + Collection contents = + [ + MakeFile("Zebra.txt"), + MakeFile("apple.txt"), + MakeFile("Banana.txt"), + ]; + + List sorted = [.. ImGuiPopups.FilesystemBrowser.SortContents(contents).Select(p => Path.GetFileName(p.ToString()!))]; + + CollectionAssert.AreEqual(new List { "apple.txt", "Banana.txt", "Zebra.txt" }, sorted); + } + + [TestMethod] + public void SortContents_EntriesDifferingOnlyByCase_OrderIsDeterministic() + { + Collection contents = + [ + MakeFile("b.txt"), + MakeFile("B.txt"), + ]; + + Collection reversed = + [ + MakeFile("B.txt"), + MakeFile("b.txt"), + ]; + + List sorted = [.. ImGuiPopups.FilesystemBrowser.SortContents(contents).Select(p => p.ToString()!)]; + List sortedReversed = [.. ImGuiPopups.FilesystemBrowser.SortContents(reversed).Select(p => p.ToString()!)]; + + CollectionAssert.AreEqual(sorted, sortedReversed); + } + + [TestMethod] + public void SortContents_EmptyCollection_ReturnsEmpty() + { + Collection contents = []; + + Assert.AreEqual(0, ImGuiPopups.FilesystemBrowser.SortContents(contents).Count); + } +} diff --git a/tests/ImGui.Popups.Tests/ImGui.Popups.Tests.csproj b/tests/ImGui.Popups.Tests/ImGui.Popups.Tests.csproj new file mode 100644 index 0000000..f53c230 --- /dev/null +++ b/tests/ImGui.Popups.Tests/ImGui.Popups.Tests.csproj @@ -0,0 +1,18 @@ + + + + + + true + net10.0 + + ktsu.ImGui.Popups.Tests + $(RootNamespace) + false + CA1051;CA1002;CA1062;CA1515;CA1707;CA1815;CA1819;CA1822;CA2227;CS8604;IDE0060;MSTEST0039 + + + + + +