From b88fcd5d40750cd5105bea90023f3b266d96d30c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:46:13 +0000 Subject: [PATCH 01/11] Bump Microsoft.Extensions.Options from 10.0.7 to 10.0.9 --- updated-dependencies: - dependency-name: Microsoft.Extensions.Options dependency-version: 10.0.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d00e197c37..b85c66ace4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ - + From e3f94a2963b0c2052db77eeae3247d29c7eeaade Mon Sep 17 00:00:00 2001 From: BDisp Date: Wed, 1 Jul 2026 09:46:15 -0400 Subject: [PATCH 02/11] =?UTF-8?q?Fixes=20#5544=20#issuecomment-4836836706.?= =?UTF-8?q?=20FileDialog/OpenDialog:=20one=20unre=E2=80=A6=20(#5569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixes #5544 #issuecomment-4836836706. FileDialog/OpenDialog: one unreadable entry makes the entire directory render empty (`FileDialogState.GetChildren` swallows the exception) * Fixes #5570. FileDialog/OpenDialog tree builder crash on unreadable entries --- .../FileServices/FileSystemTreeBuilder.cs | 12 +++- .../Views/FileDialogs/FileDialogState.cs | 27 +++---- .../FileSystemTreeBuilderTests.cs | 37 ++++++++++ .../Views/FileDialogResultTests.cs | 70 +++++++++++++++++++ 4 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs 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..2842534052 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs @@ -4,6 +4,8 @@ namespace Terminal.Gui.Views; internal class FileDialogState { + private static readonly EnumerationOptions _ignoreInaccessibleEnumerationOptions = new () { IgnoreInaccessible = true }; + public FileDialogState (IDirectoryInfo dir, FileDialog parent) { Parent = parent; @@ -60,19 +62,7 @@ private void AddReadableChildren (List children, IDirectory { try { - IEnumerable entries; - - // if directories only - if (Parent.OpenMode == OpenMode.Directory) - { - entries = dir.GetDirectories (); - } - else - { - entries = dir.GetFileSystemInfos (); - } - - foreach (IFileSystemInfo entry in entries) + foreach (IFileSystemInfo entry in EnumerateReadableEntries (dir)) { AddReadableChild (children, entry); } @@ -83,6 +73,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..74cdfa74a6 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,64 @@ 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 == ".."); + } + [Fact] public void FileDialog_Accepting_Directory_From_Table_Keeps_Path_On_Opened_Directory () { @@ -418,6 +484,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; } From dcc4dd7651d50f62c860b7ffb188c65efdcfb86c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:45:07 +0000 Subject: [PATCH 03/11] Bump GitVersion.MsBuild from 6.7.0 to 6.8.0 --- updated-dependencies: - dependency-name: GitVersion.MsBuild dependency-version: 6.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d00e197c37..4dbf0eaa95 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,7 +52,7 @@ - + From fc68bbb0d0e8cfc0dabf8d29ac789a27228a62b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:44:50 +0000 Subject: [PATCH 04/11] Bump GitVersion.MsBuild from 6.8.0 to 6.8.1 --- updated-dependencies: - dependency-name: GitVersion.MsBuild dependency-version: 6.8.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d67eca267d..16e73c4ca4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -52,7 +52,7 @@ - + From 9f5db8f99f21ab423bcf58ceb490bcad39495b6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:46:47 +0000 Subject: [PATCH 05/11] Bump Microsoft.Net.Compilers.Toolset from 5.3.0 to 5.6.0 --- updated-dependencies: - dependency-name: Microsoft.Net.Compilers.Toolset dependency-version: 5.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d67eca267d..7a02c1709e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + From 3c3f10875a25362edd71b396d460b9168b29705a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:47:30 +0000 Subject: [PATCH 06/11] Bump Spectre.Console from 0.57.1 to 0.57.2 --- updated-dependencies: - dependency-name: Spectre.Console dependency-version: 0.57.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d67eca267d..15dd7141b7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,7 @@ - + From 62e826981105b610e62e694960d02e42e628fc9c Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Tue, 30 Jun 2026 16:44:20 -0500 Subject: [PATCH 07/11] Fixes #5498. Document Width/Height vs Frame event contract #5498 (split off from #4522) flagged that Frame/layout-driven size changes bypass the Width/Height change events and skip the TextFormatter reset. Re-evaluated after the related layout/TextFormatter perf work landed (#4522, #5357, #5358/#5359, #5499, formatter-constraint fixes): - TextFormatter reset is now a non-issue. SetRelativeLayout recomputes the constraints via SetTextFormatterSize () + FinalizeTextFormatterConstraints (), and the Frame setter flows through the same path via Layout (). The "Reset TextFormatter" BUGBUG bullets were stale. - The Width/Height events "gap" is actually the correct design once declarative (Width/Height = Dim) is separated from resolved (Frame = absolute Rectangle). A layout pass changes the Frame, not the Dim (Dim.Fill () stays Dim.Fill ()), and the Frame setter's _width = absolute is bookkeeping, not a user-initiated Width change. FrameChanged already covers resolved-size changes from any cause. Changes (no behavior change): - Replace the two stale BUGBUG blocks in View.Layout.cs (Frame setter + SetRelativeLayout) with accurate design notes. - Document the contract on Width/Height (declarative-assignment only) and on FrameChanged (canonical resolved-size event). - Add ViewLayoutEventTests locking the contract: Width/HeightChanged do NOT fire from the Frame setter or a layout-driven resize; FrameChanged DOES fire in those cases and on declarative assignment after layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- Terminal.Gui/ViewBase/View.Layout.cs | 48 ++++++-- .../ViewBase/Layout/ViewLayoutEventTests.cs | 115 ++++++++++++++++++ 2 files changed, 153 insertions(+), 10 deletions(-) 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/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs index 6583f0a0b7..ea48429ddb 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs @@ -283,6 +283,121 @@ 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); + } + + // 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); + } + + // 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; } From c1eb3ea4331f8d1e49ccd72288f8dbb8f8856446 Mon Sep 17 00:00:00 2001 From: Kevin Harder Date: Sat, 4 Jul 2026 15:24:20 -0500 Subject: [PATCH 08/11] Add #5498 event hook contract coverage --- .../ViewBase/Layout/ViewLayoutEventTests.cs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs b/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs index ea48429ddb..193bad175d 100644 --- a/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs +++ b/Tests/UnitTestsParallelizable/ViewBase/Layout/ViewLayoutEventTests.cs @@ -333,6 +333,29 @@ public void View_FrameSetter_Raises_FrameChanged () 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 () @@ -360,6 +383,34 @@ public void View_LayoutDrivenResize_DoesNotRaise_WidthChanged_Or_HeightChanged ( 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 () @@ -413,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; + } + } } From b720fafc7e6a9fbb5aab45fd86d1a03419a07d9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:42:34 +0000 Subject: [PATCH 09/11] Bump gittools/actions from 4.5.0 to 4.7.0 Bumps [gittools/actions](https://github.com/gittools/actions) from 4.5.0 to 4.7.0. - [Release notes](https://github.com/gittools/actions/releases) - [Commits](https://github.com/gittools/actions/compare/v4.5.0...v4.7.0) --- updated-dependencies: - dependency-name: gittools/actions dependency-version: 4.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/prepare-release.yml | 4 ++-- .github/workflows/publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 From da4d9a2ebb8047386e38f0f6b150c88c5124a1cd Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 6 Jul 2026 20:56:06 -0600 Subject: [PATCH 10/11] Fixes FileDialog hiding hidden/system entries (dotfiles) after IgnoreInaccessible change (#5581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch to EnumerateDirectories/EnumerateFileSystemInfos with EnumerationOptions { IgnoreInaccessible = true } (from the #5544 follow-up) kept the framework default AttributesToSkip = Hidden | System, so the file dialog silently filtered out hidden/system entries — including dotfiles on Unix, which .NET marks as Hidden. The GetDirectories ()/GetFileSystemInfos () calls it replaced never skipped them. - Clear AttributesToSkip (FileAttributes.None) so hidden/system entries stay visible while still ignoring inaccessible ones. - Fall back to the eager listing methods when the IFileSystem implementation throws NotSupportedException for the non-default AttributesToSkip (e.g. System.IO.Abstractions.TestingHelpers MockFileSystem). - Add regression tests covering the enumeration options, the fallback, and hidden/system files via MockFileSystem. Flagged by Codex review on #5580. Co-authored-by: Claude Opus 4.8 (1M context) --- .../Views/FileDialogs/FileDialogState.cs | 30 ++++- .../Views/FileDialogResultTests.cs | 121 ++++++++++++++++++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs index 2842534052..8c52720593 100644 --- a/Terminal.Gui/Views/FileDialogs/FileDialogState.cs +++ b/Terminal.Gui/Views/FileDialogs/FileDialogState.cs @@ -4,7 +4,9 @@ namespace Terminal.Gui.Views; internal class FileDialogState { - private static readonly EnumerationOptions _ignoreInaccessibleEnumerationOptions = new () { IgnoreInaccessible = true }; + // 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) { @@ -67,6 +69,32 @@ private void AddReadableChildren (List children, IDirectory 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) + { + AddReadableChild (children, entry); + } + } catch (Exception) { // Access permission exceptions, missing directories, etc. diff --git a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs index 74cdfa74a6..2e5d550b7d 100644 --- a/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs +++ b/Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs @@ -365,6 +365,127 @@ public void FileDialog_DirectoryMode_WhenEagerListingThrows_StillKeepsReadableDi 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 () { From 6d9f0963e13ed65839f3e8cc0eeee96219b0c56c Mon Sep 17 00:00:00 2001 From: Tig Date: Mon, 6 Jul 2026 21:27:47 -0600 Subject: [PATCH 11/11] Fixes IsReparsePoint fail-open and PopoverMenu.DefaultKey binding collision (#5583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes flagged on release PR #5582: 1. Codex CR feedback: FileSystemTreeBuilder.IsReparsePoint returned false when reading Attributes throws, so an unreadable symlink/junction was treated as safe to traverse — recreating the directory-cycle risk the guard exists to prevent. Now fails closed (treats unreadable status as a reparse point), so CanExpand/GetChildren and FileDialog.SearchState do not traverse it. 2. CI failure (Parallel Unit Tests, windows): FileDialog's ctor crashed with "A binding for Ctrl+P exists" when PopoverMenu.DefaultKey read Ctrl+P — a key TableView already binds to Command.Up. Root cause was MecDottedKeyTests mutating the process-wide PopoverMenuSettings.Defaults while running in parallel with view-constructing tests; but the crash is also reachable by real users who configure PopoverMenu.DefaultKey to an already-bound key. - FileDialog/CharMap now use KeyBindings.ReplaceCommands instead of Add for the context-menu key (the user's configured key wins). - The "StaticSettingsTests" xUnit collection now has a CollectionDefinition with DisableParallelization = true, so the five Mec* test classes that swap static settings facades no longer race the rest of the suite. Tests: fail-closed IsReparsePoint regression test; FileDialog/CharMap ctor no-throw tests under a colliding DefaultKey. Co-authored-by: Claude Opus 4.8 (1M context) --- .../FileServices/FileSystemTreeBuilder.cs | 5 +- Terminal.Gui/Views/CharMap/CharMap.cs | 4 +- Terminal.Gui/Views/FileDialogs/FileDialog.cs | 5 +- .../PopoverMenuDefaultKeyCollisionTests.cs | 58 +++++++++++++++++++ .../StaticSettingsTestCollection.cs | 11 ++++ .../FileSystemTreeBuilderTests.cs | 12 ++++ 6 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 Tests/UnitTestsParallelizable/Configuration/PopoverMenuDefaultKeyCollisionTests.cs create mode 100644 Tests/UnitTestsParallelizable/Configuration/StaticSettingsTestCollection.cs diff --git a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs index 4b616a59e1..2121c0e9d2 100644 --- a/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs +++ b/Terminal.Gui/FileServices/FileSystemTreeBuilder.cs @@ -80,7 +80,10 @@ internal static bool IsReparsePoint (IFileSystemInfo entry) } catch (Exception) { - return false; // treat an unreadable entry as not a reparse point / not expandable + // 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/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/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 index 3e916a86f4..fc0e24df17 100644 --- a/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs +++ b/Tests/UnitTestsParallelizable/FileServices/FileSystemTreeBuilderTests.cs @@ -34,4 +34,16 @@ public void GetChildren_DirectoryWithUnreadableAttributes_DoesNotThrowAndReturns 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)); + } }