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..2121c0e9d2 100644 --- a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs +++ b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs @@ -72,5 +72,18 @@ 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) + { + // Fail closed: if the reparse status cannot be read, treat the entry as a reparse point so + // callers do not traverse it — an unreadable symlink/junction could otherwise recreate the + // directory-cycle risk this guard exists to prevent. + return true; + } + } } diff --git a/Terminal.Gui/ViewBase/View.Layout.cs b/Terminal.Gui/ViewBase/View.Layout.cs index c6688cdd4e..495789d6c6 100644 --- a/Terminal.Gui/ViewBase/View.Layout.cs +++ b/Terminal.Gui/ViewBase/View.Layout.cs @@ -67,11 +67,14 @@ public Rectangle Frame return; } - // BUGBUG: We set the internal fields here to avoid recursion. However, this means that - // BUGBUG: other logic in the property setters does not get executed. Specifically: - // BUGBUG: - Reset TextFormatter - // BUGBUG: - SetLayoutNeeded (not an issue as we explicitly call Layout below) - // BUGBUG: - If we add property change events for X/Y/Width/Height they will not be invoked + // We set the internal fields directly (not via the X/Y/Width/Height setters) to avoid recursion. + // This is intentional and the two side effects of the setters are accounted for elsewhere (#5498): + // - TextFormatter constraints: recomputed by the Layout () -> SetRelativeLayout call below + // (SetTextFormatterSize + FinalizeTextFormatterConstraints), so no reset is needed here. + // - Width/Height (and X/Y) change events: deliberately NOT raised here. Those events observe + // declarative Dim/Pos assignment; this path overwrites them with Absolute values as bookkeeping + // to keep state consistent with an imperatively-set Frame. Use FrameChanged (raised by SetFrame + // above) to observe resolved-size changes from any cause. // If Frame gets set, set all Pos/Dim to Absolute values. _x = _frame!.Value.X; _y = _frame!.Value.Y; @@ -152,6 +155,14 @@ protected virtual void OnFrameChanged (in Rectangle frame) { } /// Raised when the changes. This event is raised after the has been /// updated. /// + /// + /// This is the canonical event for observing resolved-size and position changes. It fires whenever the + /// absolute changes for any reason: assigning , , + /// , or ; setting directly; or a layout pass + /// resolving a relative / (e.g. ). By contrast, the + /// / events observe assignment of the declarative + /// / only. + /// public event EventHandler>? FrameChanged; /// Gets the with a screen-relative location. @@ -368,6 +379,13 @@ public Pos Y /// allowing customization or cancellation of the change. The event /// is raised before the change, and is raised after. /// + /// + /// / observe assignment of this declarative + /// only. They are deliberately not raised when a layout pass resolves the view's size + /// or when is set directly, because those paths change the resolved + /// rather than the declarative . To observe resolved-size + /// changes from any cause, subscribe to . + /// /// The default value is Dim.Absolute (0). /// /// @@ -462,6 +480,13 @@ protected virtual void OnHeightChanged (ValueChangedEventArgs args) { } /// allowing customization or cancellation of the change. The event /// is raised before the change, and is raised after. /// + /// + /// / observe assignment of this declarative + /// only. They are deliberately not raised when a layout pass resolves the view's size + /// or when is set directly, because those paths change the resolved + /// rather than the declarative . To observe resolved-size + /// changes from any cause, subscribe to . + /// /// The default value is Dim.Absolute (0). /// /// @@ -686,11 +711,14 @@ public bool SetRelativeLayout (Size superviewContentSize) _suppressNeedsDrawAfterLayout = false; } - // BUGBUG: We set the internal fields here to avoid recursion. However, this means that - // BUGBUG: other logic in the property setters does not get executed. Specifically: - // BUGBUG: - Reset TextFormatter - // BUGBUG: - SetLayoutNeeded (not an issue as we explicitly call Layout below) - // BUGBUG: - If we add property change events for X/Y/Width/Height they will not be invoked + // We update the internal fields directly (not via the X/Y/Width/Height setters) to avoid recursion. + // This is intentional and the two side effects of the setters are accounted for here (#5498): + // - TextFormatter constraints: already recomputed by SetTextFormatterSize () above and finalized by + // FinalizeTextFormatterConstraints () below, so no reset is needed here. + // - Width/Height (and X/Y) change events: deliberately NOT raised. A layout pass resolves the Frame + // without changing the declarative Dim/Pos (e.g. Dim.Fill () stays Dim.Fill ()); these assignments + // only collapse an already-Absolute dim onto its resolved value. FrameChanged (raised by SetFrame + // above) is the event for resolved-size changes. if (_x is PosAbsolute) { _x = Frame.X; diff --git a/Terminal.Gui/Views/CharMap/CharMap.cs b/Terminal.Gui/Views/CharMap/CharMap.cs index 69133b56bc..86c3f8cab3 100644 --- a/Terminal.Gui/Views/CharMap/CharMap.cs +++ b/Terminal.Gui/Views/CharMap/CharMap.cs @@ -103,7 +103,9 @@ public CharMap () KeyBindings.Add (Key.PageDown, Command.PageDown); KeyBindings.Add (Key.Home, Command.Start); KeyBindings.Add (Key.End, Command.End); - KeyBindings.Add (PopoverMenu.DefaultKey, Command.Context); + // ReplaceCommands, not Add: PopoverMenu.DefaultKey is configurable and may collide with a key + // that is already bound (e.g. Ctrl+P); the user's context-menu key wins. + KeyBindings.ReplaceCommands (PopoverMenu.DefaultKey, Command.Context); MouseBindings.ReplaceCommands (MouseFlags.LeftButtonClicked, Command.Activate); MouseBindings.Add (MouseFlags.LeftButtonDoubleClicked, Command.Accept); diff --git a/Terminal.Gui/Views/FileDialogs/FileDialog.cs b/Terminal.Gui/Views/FileDialogs/FileDialog.cs index d4425cfe3f..0529ac71d6 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialog.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialog.cs @@ -231,7 +231,10 @@ internal FileDialog (IFileSystem? fileSystem) // by default, Runnable doesn't bind to Command.Context, so // we can take advantage of the CommandNotBound event to handle it _tableView.CommandNotBound += TableViewHandleCommandNotBound; - _tableView.KeyBindings.Add (PopoverMenu.DefaultKey, Command.Context); + + // ReplaceCommands, not Add: PopoverMenu.DefaultKey is configurable and may collide with a key + // TableView already binds (e.g. Ctrl+P -> Command.Up); the user's context-menu key wins. + _tableView.KeyBindings.ReplaceCommands (PopoverMenu.DefaultKey, Command.Context); _tableView.MouseBindings.Add (MouseFlags.RightButtonClicked, Command.Context); _tbPath.TextChanged += (_, _) => PathChanged (); 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/Configuration/PopoverMenuDefaultKeyCollisionTests.cs b/Tests/UnitTestsParallelizable/Configuration/PopoverMenuDefaultKeyCollisionTests.cs new file mode 100644 index 0000000000..ff9277fe24 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Configuration/PopoverMenuDefaultKeyCollisionTests.cs @@ -0,0 +1,58 @@ +// Claude - Opus 4.8 + +using System.IO.Abstractions.TestingHelpers; +using Terminal.Gui.Configuration; + +namespace ConfigurationTests; + +/// +/// Verifies that views binding to +/// do not throw when the configured key collides with a key the view already binds +/// (e.g. Ctrl+P, which binds to by default). +/// +[Collection ("StaticSettingsTests")] +public class PopoverMenuDefaultKeyCollisionTests +{ + [Fact] + public void FileDialog_Ctor_DoesNotThrow_WhenDefaultKeyCollidesWithTableViewBinding () + { + PopoverMenuSettings original = PopoverMenuSettings.Defaults; + + try + { + // TableView binds Ctrl+P -> Command.Up by default; the context-menu key must win without throwing. + PopoverMenuSettings.Defaults = new () { DefaultKey = Key.P.WithCtrl }; + + MockFileSystem fs = new (); + fs.AddDirectory ("/testdir"); + + using FileDialog fd = new (fs); + + Assert.NotNull (fd); + } + finally + { + PopoverMenuSettings.Defaults = original; + } + } + + [Fact] + public void CharMap_Ctor_DoesNotThrow_WhenDefaultKeyCollidesWithExistingBinding () + { + PopoverMenuSettings original = PopoverMenuSettings.Defaults; + + try + { + // CharMap binds Key.End -> Command.End before binding the context-menu key. + PopoverMenuSettings.Defaults = new () { DefaultKey = Key.End }; + + using CharMap charMap = new (); + + Assert.NotNull (charMap); + } + finally + { + PopoverMenuSettings.Defaults = original; + } + } +} diff --git a/Tests/UnitTestsParallelizable/Configuration/StaticSettingsTestCollection.cs b/Tests/UnitTestsParallelizable/Configuration/StaticSettingsTestCollection.cs new file mode 100644 index 0000000000..7bc66418f5 --- /dev/null +++ b/Tests/UnitTestsParallelizable/Configuration/StaticSettingsTestCollection.cs @@ -0,0 +1,11 @@ +namespace ConfigurationTests; + +[CollectionDefinition ("StaticSettingsTests", DisableParallelization = true)] +public class StaticSettingsTestCollection +{ + // Marker collection for tests that mutate process-wide static settings facades + // (e.g. DriverSettings.Defaults, PopoverMenuSettings.Defaults). Without + // DisableParallelization, these tests race views constructed in parallel tests — + // e.g. temporarily making PopoverMenu.DefaultKey read Ctrl+P while a FileDialog + // is being constructed elsewhere. +} diff --git a/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs b/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs new file mode 100644 index 0000000000..fc0e24df17 --- /dev/null +++ b/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs @@ -0,0 +1,49 @@ +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); + } + + // Claude - Opus 4.8 + [Fact] + public void IsReparsePoint_WhenAttributesUnreadable_FailsClosed () + { + Mock directory = new (); + directory.SetupGet (d => d.Attributes).Throws (new UnauthorizedAccessException ("Access denied")); + + // Fail closed: an entry whose reparse status cannot be read must be treated as a reparse + // point so callers (CanExpand, GetChildren, FileDialog search) do not traverse it. + Assert.True (FileSystemTreeBuilder.IsReparsePoint (directory.Object)); + } +} diff --git a/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs index 6583f0a0b7..193bad175d 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs @@ -283,6 +283,172 @@ public void View_SubViewLayout_SubViewsLaidOut_Events_Fires_EvenWidthOrHeightIsZ Assert.Equal (new Size (0, 0), oldValue); } + // The following tests lock the Width/Height vs Frame event contract (#5498): + // WidthChanged/HeightChanged observe declarative Dim assignment only; FrameChanged observes + // resolved-size changes from any cause (Frame setter, declarative assignment, or a layout pass). + + // Claude - Opus 4.8 + [Fact] + public void View_FrameSetter_DoesNotRaise_WidthChanged () + { + View view = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + bool fired = false; + + view.WidthChanged += (_, _) => fired = true; + + view.Frame = new (0, 0, 10, 5); + + // The Frame setter converts Width to Dim.Absolute as bookkeeping, but that is not a + // declarative Width assignment, so WidthChanged must not fire. + Assert.False (fired); + Assert.Equal (Dim.Absolute (10), view.Width); + } + + // Claude - Opus 4.8 + [Fact] + public void View_FrameSetter_DoesNotRaise_HeightChanged () + { + View view = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + bool fired = false; + + view.HeightChanged += (_, _) => fired = true; + + view.Frame = new (0, 0, 10, 5); + + Assert.False (fired); + Assert.Equal (Dim.Absolute (5), view.Height); + } + + // Claude - Opus 4.8 + [Fact] + public void View_FrameSetter_Raises_FrameChanged () + { + View view = new (); + Rectangle? observed = null; + + view.FrameChanged += (_, args) => observed = args.Value; + + view.Frame = new (1, 2, 10, 5); + + Assert.Equal (new Rectangle (1, 2, 10, 5), observed); + } + + [Fact] + public void View_FrameSetter_DoesNotRaise_WidthHeightChangingChanged_Or_OnWidthHeightChangingChanged () + { + EventProbeView view = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + view.ResetCounts (); + int changingEvents = 0; + int changedEvents = 0; + + view.WidthChanging += (_, _) => changingEvents++; + view.HeightChanging += (_, _) => changingEvents++; + view.WidthChanged += (_, _) => changedEvents++; + view.HeightChanged += (_, _) => changedEvents++; + + view.Frame = new (0, 0, 10, 5); + + Assert.Equal (0, changingEvents); + Assert.Equal (0, changedEvents); + Assert.Equal (0, view.WidthChangingCount); + Assert.Equal (0, view.HeightChangingCount); + Assert.Equal (0, view.WidthChangedCount); + Assert.Equal (0, view.HeightChangedCount); + } + + // Claude - Opus 4.8 + [Fact] + public void View_LayoutDrivenResize_DoesNotRaise_WidthChanged_Or_HeightChanged () + { + View container = new () { Width = 20, Height = 10 }; + View child = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + container.Add (child); + container.Layout (); + Assert.Equal (20, child.Frame.Width); + + bool fired = false; + child.WidthChanged += (_, _) => fired = true; + child.HeightChanged += (_, _) => fired = true; + + // Growing the container re-resolves the child's Dim.Fill () to a new absolute size during + // layout. The declarative Width/Height (still Dim.Fill ()) does not change, so no event fires. + container.Width = 30; + container.Height = 14; + container.Layout (); + + Assert.False (fired); + Assert.Equal (30, child.Frame.Width); + Assert.Equal (14, child.Frame.Height); + Assert.Equal (Dim.Fill (), child.Width); + Assert.Equal (Dim.Fill (), child.Height); + } + + [Fact] + public void View_LayoutDrivenResize_DoesNotRaise_WidthHeightChangingChanged_Or_OnWidthHeightChangingChanged () + { + View container = new () { Width = 20, Height = 10 }; + EventProbeView child = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + container.Add (child); + container.Layout (); + child.ResetCounts (); + + int changingEvents = 0; + int changedEvents = 0; + child.WidthChanging += (_, _) => changingEvents++; + child.HeightChanging += (_, _) => changingEvents++; + child.WidthChanged += (_, _) => changedEvents++; + child.HeightChanged += (_, _) => changedEvents++; + + container.Width = 30; + container.Height = 14; + container.Layout (); + + Assert.Equal (0, changingEvents); + Assert.Equal (0, changedEvents); + Assert.Equal (0, child.WidthChangingCount); + Assert.Equal (0, child.HeightChangingCount); + Assert.Equal (0, child.WidthChangedCount); + Assert.Equal (0, child.HeightChangedCount); + } + + // Claude - Opus 4.8 + [Fact] + public void View_LayoutDrivenResize_Raises_FrameChanged () + { + View container = new () { Width = 20, Height = 10 }; + View child = new () { Width = Dim.Fill (), Height = Dim.Fill () }; + container.Add (child); + container.Layout (); + + bool fired = false; + child.FrameChanged += (_, _) => fired = true; + + container.Width = 30; + container.Layout (); + + Assert.True (fired); + Assert.Equal (30, child.Frame.Width); + } + + // Claude - Opus 4.8 + [Fact] + public void View_DeclarativeWidthAssignment_Raises_FrameChanged_AfterLayout () + { + View container = new () { Width = 50, Height = 20 }; + View child = new () { Width = 10, Height = 5 }; + container.Add (child); + container.Layout (); + + bool fired = false; + child.FrameChanged += (_, _) => fired = true; + + child.Width = 25; + container.Layout (); + + Assert.True (fired); + Assert.Equal (25, child.Frame.Width); + } + private class TestView : View { public bool CancelWidthChange { get; set; } @@ -298,4 +464,44 @@ protected override bool OnHeightChanging (ValueChangingEventArgs args) return CancelHeightChange; } } + + private class EventProbeView : View + { + public int HeightChangedCount { get; private set; } + public int HeightChangingCount { get; private set; } + public int WidthChangedCount { get; private set; } + public int WidthChangingCount { get; private set; } + + public void ResetCounts () + { + HeightChangedCount = 0; + HeightChangingCount = 0; + WidthChangedCount = 0; + WidthChangingCount = 0; + } + + protected override void OnHeightChanged (ValueChangedEventArgs args) + { + HeightChangedCount++; + } + + protected override bool OnHeightChanging (ValueChangingEventArgs args) + { + HeightChangingCount++; + + return false; + } + + protected override void OnWidthChanged (ValueChangedEventArgs args) + { + WidthChangedCount++; + } + + protected override bool OnWidthChanging (ValueChangingEventArgs args) + { + WidthChangingCount++; + + return false; + } + } } 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; }