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