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
+
+
+
+
+
+