diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml
index ea8bea9eb1..75863bdb78 100644
--- a/.github/workflows/prepare-release.yml
+++ b/.github/workflows/prepare-release.yml
@@ -48,12 +48,12 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Install GitVersion
- uses: gittools/actions/gitversion/setup@v4.5.0
+ uses: gittools/actions/gitversion/setup@v4.7.0
with:
versionSpec: '6.x'
- name: Determine Version
- uses: gittools/actions/gitversion/execute@v4.5.0
+ uses: gittools/actions/gitversion/execute@v4.7.0
with:
useConfigFile: true
id: gitversion
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 6f3ab1dcd3..4c64c8af5e 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -32,12 +32,12 @@ jobs:
# from git history + GitVersion.yml during build. We still run the CLI here to
# capture the SemVer for use in subsequent steps (push, template dispatch).
- name: Install GitVersion
- uses: gittools/actions/gitversion/setup@v4.5.0
+ uses: gittools/actions/gitversion/setup@v4.7.0
with:
versionSpec: '6.x'
- name: Determine Version
- uses: gittools/actions/gitversion/execute@v4.5.0
+ uses: gittools/actions/gitversion/execute@v4.7.0
with:
useConfigFile: true
id: gitversion # step id used as reference for output values
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d00e197c37..87cdab6cad 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,7 +8,7 @@
-
+
@@ -17,13 +17,13 @@
-
+
-
+
@@ -52,7 +52,7 @@
-
+
diff --git a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs
index 80e9a740d0..4b616a59e1 100644
--- a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs
+++ b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs
@@ -72,5 +72,15 @@ private IEnumerable TryGetChildren (IFileSystemInfo entry)
}
}
- internal static bool IsReparsePoint (IFileSystemInfo entry) => (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
+ internal static bool IsReparsePoint (IFileSystemInfo entry)
+ {
+ try
+ {
+ return (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
+ }
+ catch (Exception)
+ {
+ return false; // treat an unreadable entry as not a reparse point / not expandable
+ }
+ }
}
diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs
index 24340147f0..8c52720593 100644
--- a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs
+++ b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs
@@ -4,6 +4,10 @@ namespace Terminal.Gui.Views;
internal class FileDialogState
{
+ // AttributesToSkip defaults to Hidden | System; clear it so hidden/system entries (e.g. dotfiles on Unix)
+ // remain visible, matching the behavior of the GetDirectories ()/GetFileSystemInfos () calls this replaced.
+ private static readonly EnumerationOptions _ignoreInaccessibleEnumerationOptions = new () { IgnoreInaccessible = true, AttributesToSkip = FileAttributes.None };
+
public FileDialogState (IDirectoryInfo dir, FileDialog parent)
{
Parent = parent;
@@ -60,17 +64,31 @@ private void AddReadableChildren (List children, IDirectory
{
try
{
- IEnumerable entries;
-
- // if directories only
- if (Parent.OpenMode == OpenMode.Directory)
- {
- entries = dir.GetDirectories ();
- }
- else
+ foreach (IFileSystemInfo entry in EnumerateReadableEntries (dir))
{
- entries = dir.GetFileSystemInfos ();
+ AddReadableChild (children, entry);
}
+ }
+ catch (NotSupportedException)
+ {
+ // Some IFileSystem implementations (e.g. System.IO.Abstractions.TestingHelpers MockFileSystem)
+ // do not support clearing EnumerationOptions.AttributesToSkip; fall back to the eager listing
+ // methods, which never skip hidden/system entries.
+ AddReadableChildrenEagerly (children, dir);
+ }
+ catch (Exception)
+ {
+ // Access permission exceptions, missing directories, etc.
+ }
+ }
+
+ private void AddReadableChildrenEagerly (List children, IDirectoryInfo dir)
+ {
+ try
+ {
+ IEnumerable entries = Parent.OpenMode == OpenMode.Directory
+ ? dir.GetDirectories ()
+ : dir.GetFileSystemInfos ();
foreach (IFileSystemInfo entry in entries)
{
@@ -83,6 +101,17 @@ private void AddReadableChildren (List children, IDirectory
}
}
+ private IEnumerable EnumerateReadableEntries (IDirectoryInfo dir)
+ {
+ // if directories only
+ if (Parent.OpenMode == OpenMode.Directory)
+ {
+ return dir.EnumerateDirectories ("*", _ignoreInaccessibleEnumerationOptions);
+ }
+
+ return dir.EnumerateFileSystemInfos ("*", _ignoreInaccessibleEnumerationOptions);
+ }
+
private void AddReadableChild (List children, IFileSystemInfo entry)
{
try
diff --git a/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs b/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs
new file mode 100644
index 0000000000..3e916a86f4
--- /dev/null
+++ b/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs
@@ -0,0 +1,37 @@
+using System.IO.Abstractions;
+using Moq;
+
+namespace FileServicesTests;
+
+public class FileSystemTreeBuilderTests
+{
+ [Fact]
+ public void CanExpand_DirectoryWithUnreadableAttributes_DoesNotThrowAndReturnsFalse ()
+ {
+ Mock directory = new ();
+ directory.SetupGet (d => d.Attributes).Throws (new UnauthorizedAccessException ("Access denied"));
+ directory.SetupGet (d => d.Exists).Returns (true);
+ directory.Setup (d => d.GetFileSystemInfos ()).Returns ([]);
+
+ FileSystemTreeBuilder builder = new ();
+
+ bool canExpand = builder.CanExpand (directory.Object);
+
+ Assert.False (canExpand);
+ }
+
+ [Fact]
+ public void GetChildren_DirectoryWithUnreadableAttributes_DoesNotThrowAndReturnsEmpty ()
+ {
+ Mock directory = new ();
+ directory.SetupGet (d => d.Attributes).Throws (new UnauthorizedAccessException ("Access denied"));
+ directory.SetupGet (d => d.Exists).Returns (true);
+ directory.Setup (d => d.GetFileSystemInfos ()).Returns ([]);
+
+ FileSystemTreeBuilder builder = new ();
+
+ IEnumerable children = builder.GetChildren (directory.Object);
+
+ Assert.Empty (children);
+ }
+}
diff --git a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs
index d679958e7c..2e5d550b7d 100644
--- a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs
+++ b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs
@@ -265,6 +265,10 @@ public void FileDialog_MixedMode_SkipsUnreadableEntry_AndKeepsReadableEntries ()
.Setup (d => d.GetFileSystemInfos ())
.Returns ([goodFile, badFile, goodDirectory]);
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ()))
+ .Returns ([goodFile, badFile, goodDirectory]);
+
using FileDialog fd = new TestableFileDialog (fileSystem);
fd.OpenMode = OpenMode.Mixed;
@@ -288,6 +292,10 @@ public void FileDialog_DirectoryMode_SkipsUnreadableDirectory_AndKeepsParentNavi
.Setup (d => d.GetDirectories ())
.Returns ([goodDirectory, badDirectory]);
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateDirectories ("*", It.IsAny ()))
+ .Returns ([goodDirectory, badDirectory]);
+
using FileDialog fd = new TestableFileDialog (fileSystem);
fd.OpenMode = OpenMode.Directory;
@@ -299,6 +307,185 @@ public void FileDialog_DirectoryMode_SkipsUnreadableDirectory_AndKeepsParentNavi
Assert.DoesNotContain (fd.State.Children, c => c.Name == "bad-dir");
}
+ [Fact]
+ public void FileDialog_MixedMode_WhenEagerListingThrows_StillKeepsReadableEntries ()
+ {
+ IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
+ IFileInfo goodFile = CreateFile ("/testdir/good.txt", "good.txt");
+ IDirectoryInfo goodDirectory = CreateDirectory ("/testdir/good-dir", "good-dir", directory);
+
+ Mock.Get (directory)
+ .Setup (d => d.GetFileSystemInfos ())
+ .Throws (new UnauthorizedAccessException ());
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateFileSystemInfos ())
+ .Returns ([goodFile, goodDirectory]);
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ()))
+ .Returns ([goodFile, goodDirectory]);
+
+ using FileDialog fd = new TestableFileDialog (fileSystem);
+ fd.OpenMode = OpenMode.Mixed;
+
+ fd.Path = "/testdir";
+
+ Assert.NotNull (fd.State);
+ Assert.Contains (fd.State!.Children, c => c.Name == "good.txt");
+ Assert.Contains (fd.State.Children, c => c.Name == "good-dir");
+ Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == "..");
+ }
+
+ [Fact]
+ public void FileDialog_DirectoryMode_WhenEagerListingThrows_StillKeepsReadableDirectories ()
+ {
+ IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
+ IDirectoryInfo goodDirectory = CreateDirectory ("/testdir/good-dir", "good-dir", directory);
+
+ Mock.Get (directory)
+ .Setup (d => d.GetDirectories ())
+ .Throws (new UnauthorizedAccessException ());
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateDirectories ())
+ .Returns ([goodDirectory]);
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateDirectories ("*", It.IsAny ()))
+ .Returns ([goodDirectory]);
+
+ using FileDialog fd = new TestableFileDialog (fileSystem);
+ fd.OpenMode = OpenMode.Directory;
+
+ fd.Path = "/testdir";
+
+ Assert.NotNull (fd.State);
+ Assert.Contains (fd.State!.Children, c => c.Name == "good-dir");
+ Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == "..");
+ }
+
+ // Claude - Opus 4.8
+ [Fact]
+ public void FileDialog_MixedMode_EnumerationDoesNotSkipHiddenOrSystemEntries ()
+ {
+ IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
+ IFileInfo hiddenFile = CreateFile ("/testdir/.hidden", ".hidden");
+
+ EnumerationOptions? capturedOptions = null;
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ()))
+ .Callback ((_, options) => capturedOptions = options)
+ .Returns ([hiddenFile]);
+
+ using FileDialog fd = new TestableFileDialog (fileSystem);
+ fd.OpenMode = OpenMode.Mixed;
+
+ fd.Path = "/testdir";
+
+ Assert.NotNull (capturedOptions);
+ Assert.True (capturedOptions!.IgnoreInaccessible);
+ Assert.Equal (FileAttributes.None, capturedOptions.AttributesToSkip);
+ Assert.Contains (fd.State!.Children, c => c.Name == ".hidden");
+ }
+
+ // Claude - Opus 4.8
+ [Fact]
+ public void FileDialog_DirectoryMode_EnumerationDoesNotSkipHiddenOrSystemEntries ()
+ {
+ IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
+ IDirectoryInfo hiddenDirectory = CreateDirectory ("/testdir/.git", ".git", directory);
+
+ EnumerationOptions? capturedOptions = null;
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateDirectories ("*", It.IsAny ()))
+ .Callback ((_, options) => capturedOptions = options)
+ .Returns ([hiddenDirectory]);
+
+ using FileDialog fd = new TestableFileDialog (fileSystem);
+ fd.OpenMode = OpenMode.Directory;
+
+ fd.Path = "/testdir";
+
+ Assert.NotNull (capturedOptions);
+ Assert.True (capturedOptions!.IgnoreInaccessible);
+ Assert.Equal (FileAttributes.None, capturedOptions.AttributesToSkip);
+ Assert.Contains (fd.State!.Children, c => c.Name == ".git");
+ }
+
+ // Claude - Opus 4.8
+ [Fact]
+ public void FileDialog_MixedMode_WhenEnumerationOptionsUnsupported_FallsBackToEagerListing ()
+ {
+ IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
+ IFileInfo goodFile = CreateFile ("/testdir/good.txt", "good.txt");
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ()))
+ .Throws (new NotSupportedException ("The property AttributesToSkip is not yet implemented"));
+
+ Mock.Get (directory)
+ .Setup (d => d.GetFileSystemInfos ())
+ .Returns ([goodFile]);
+
+ using FileDialog fd = new TestableFileDialog (fileSystem);
+ fd.OpenMode = OpenMode.Mixed;
+
+ fd.Path = "/testdir";
+
+ Assert.NotNull (fd.State);
+ Assert.Contains (fd.State!.Children, c => c.Name == "good.txt");
+ Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == "..");
+ }
+
+ // Claude - Opus 4.8
+ [Fact]
+ public void FileDialog_DirectoryMode_WhenEnumerationOptionsUnsupported_FallsBackToEagerListing ()
+ {
+ IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
+ IDirectoryInfo goodDirectory = CreateDirectory ("/testdir/good-dir", "good-dir", directory);
+
+ Mock.Get (directory)
+ .Setup (d => d.EnumerateDirectories ("*", It.IsAny ()))
+ .Throws (new NotSupportedException ("The property AttributesToSkip is not yet implemented"));
+
+ Mock.Get (directory)
+ .Setup (d => d.GetDirectories ())
+ .Returns ([goodDirectory]);
+
+ using FileDialog fd = new TestableFileDialog (fileSystem);
+ fd.OpenMode = OpenMode.Directory;
+
+ fd.Path = "/testdir";
+
+ Assert.NotNull (fd.State);
+ Assert.Contains (fd.State!.Children, c => c.Name == "good-dir");
+ Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == "..");
+ }
+
+ // Claude - Opus 4.8
+ [Fact]
+ public void FileDialog_MixedMode_ListsHiddenAndSystemFiles ()
+ {
+ MockFileSystem fs = new ();
+ fs.AddDirectory ("/testdir");
+ fs.AddFile ("/testdir/visible.txt", new MockFileData ("visible"));
+ fs.AddFile ("/testdir/.hidden", new MockFileData ("hidden") { Attributes = FileAttributes.Hidden });
+ fs.AddFile ("/testdir/system.dat", new MockFileData ("system") { Attributes = FileAttributes.System });
+
+ using FileDialog fd = new TestableFileDialog (fs);
+ fd.OpenMode = OpenMode.Mixed;
+
+ fd.Path = "/testdir";
+
+ Assert.NotNull (fd.State);
+ Assert.Contains (fd.State!.Children, c => c.Name == "visible.txt");
+ Assert.Contains (fd.State.Children, c => c.Name == ".hidden");
+ Assert.Contains (fd.State.Children, c => c.Name == "system.dat");
+ }
+
[Fact]
public void FileDialog_Accepting_Directory_From_Table_Keeps_Path_On_Opened_Directory ()
{
@@ -418,6 +605,10 @@ private static Mock CreateDirectoryMock (string fullName, string
directory.SetupGet (d => d.LastWriteTime).Returns (new DateTime (2026, 1, 1));
directory.Setup (d => d.GetFileSystemInfos ()).Returns ([]);
directory.Setup (d => d.GetDirectories ()).Returns ([]);
+ directory.Setup (d => d.EnumerateFileSystemInfos ()).Returns ([]);
+ directory.Setup (d => d.EnumerateDirectories ()).Returns ([]);
+ directory.Setup (d => d.EnumerateFileSystemInfos ("*", It.IsAny ())).Returns ([]);
+ directory.Setup (d => d.EnumerateDirectories ("*", It.IsAny ())).Returns ([]);
return directory;
}