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; }