Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b968e15
Merge pull request #5564 from tui-cs/backmerge/v2.4.16
tig Jun 28, 2026
b88fcd5
Bump Microsoft.Extensions.Options from 10.0.7 to 10.0.9
dependabot[bot] Jun 29, 2026
e3f94a2
Fixes #5544 #issuecomment-4836836706. FileDialog/OpenDialog: one unre…
BDisp Jul 1, 2026
dcc4dd7
Bump GitVersion.MsBuild from 6.7.0 to 6.8.0
dependabot[bot] Jul 1, 2026
31636e0
Merge pull request #5571 from tui-cs/dependabot/nuget/GitVersion.MsBu…
tig Jul 1, 2026
76dfb25
Merge pull request #5568 from tui-cs/dependabot/nuget/Microsoft.Exten…
tig Jul 1, 2026
fc68bbb
Bump GitVersion.MsBuild from 6.8.0 to 6.8.1
dependabot[bot] Jul 3, 2026
9f5db8f
Bump Microsoft.Net.Compilers.Toolset from 5.3.0 to 5.6.0
dependabot[bot] Jul 3, 2026
3c3f108
Bump Spectre.Console from 0.57.1 to 0.57.2
dependabot[bot] Jul 3, 2026
62e8269
Fixes #5498. Document Width/Height vs Frame event contract
harder Jun 30, 2026
c1eb3ea
Add #5498 event hook contract coverage
harder Jul 4, 2026
b720faf
Bump gittools/actions from 4.5.0 to 4.7.0
dependabot[bot] Jul 5, 2026
e097de3
Merge pull request #5572 from tui-cs/dependabot/nuget/GitVersion.MsBu…
tig Jul 7, 2026
78c5378
Merge pull request #5575 from tui-cs/dependabot/nuget/Microsoft.Net.C…
tig Jul 7, 2026
d542246
Merge pull request #5576 from tui-cs/dependabot/nuget/Spectre.Console…
tig Jul 7, 2026
6099a28
Merge pull request #5578 from tui-cs/dependabot/github_actions/gittoo…
tig Jul 7, 2026
da4d9a2
Fixes FileDialog hiding hidden/system entries (dotfiles) after Ignore…
tig Jul 7, 2026
c7030e7
Merge pull request #5577 from harder/kh/5498-width-height-event-contract
harder Jul 7, 2026
6d9f096
Fixes IsReparsePoint fail-open and PopoverMenu.DefaultKey binding col…
tig Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.VisualBasic.Workspaces" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
<PackageVersion Include="Microsoft.Net.Compilers.Toolset" Version="5.3.0" />
<PackageVersion Include="Microsoft.Net.Compilers.Toolset" Version="5.6.0" />
<PackageVersion Include="Microsoft.DotNet.PlatformAbstractions" Version="3.1.6" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.203" />
<PackageVersion Include="Microsoft.CodeAnalysis" Version="4.14.0" />
Expand All @@ -17,13 +17,13 @@
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Spectre.Console" Version="0.57.1" />
<PackageVersion Include="Spectre.Console" Version="0.57.2" />
<PackageVersion Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
Expand Down Expand Up @@ -52,7 +52,7 @@
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="ReportGenerator" Version="5.5.9" />
<PackageVersion Include="coverlet.collector" Version="10.0.0" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.7.0" />
<PackageVersion Include="GitVersion.MsBuild" Version="6.8.1" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<PackageVersion Include="Terminal.Gui" Version="2.0.0" />
Expand Down
15 changes: 14 additions & 1 deletion Terminal.Gui/FileServices/FileSystemTreeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,18 @@ private IEnumerable<IFileSystemInfo> 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;
}
}
}
48 changes: 38 additions & 10 deletions Terminal.Gui/ViewBase/View.Layout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -152,6 +155,14 @@ protected virtual void OnFrameChanged (in Rectangle frame) { }
/// Raised when the <see cref="Frame"/> changes. This event is raised after the <see cref="Frame"/> has been
/// updated.
/// </summary>
/// <remarks>
/// This is the canonical event for observing resolved-size and position changes. It fires whenever the
/// absolute <see cref="Frame"/> changes for any reason: assigning <see cref="X"/>, <see cref="Y"/>,
/// <see cref="Width"/>, or <see cref="Height"/>; setting <see cref="Frame"/> directly; or a layout pass
/// resolving a relative <see cref="Dim"/>/<see cref="Pos"/> (e.g. <see cref="DimFill"/>). By contrast, the
/// <see cref="WidthChanged"/>/<see cref="HeightChanged"/> events observe assignment of the declarative
/// <see cref="Width"/>/<see cref="Height"/> <see cref="Dim"/> only.
/// </remarks>
public event EventHandler<EventArgs<Rectangle>>? FrameChanged;

/// <summary>Gets the <see cref="Frame"/> with a screen-relative location.</summary>
Expand Down Expand Up @@ -368,6 +379,13 @@ public Pos Y
/// allowing customization or cancellation of the change. The <see cref="HeightChanging"/> event
/// is raised before the change, and <see cref="HeightChanged"/> is raised after.
/// </para>
/// <para>
/// <see cref="HeightChanging"/>/<see cref="HeightChanged"/> observe assignment of this declarative
/// <see cref="Dim"/> only. They are deliberately not raised when a layout pass resolves the view's size
/// or when <see cref="Frame"/> is set directly, because those paths change the resolved
/// <see cref="Frame"/> rather than the declarative <see cref="Height"/>. To observe resolved-size
/// changes from any cause, subscribe to <see cref="FrameChanged"/>.
/// </para>
/// <para>The default value is <c>Dim.Absolute (0)</c>.</para>
/// </remarks>
/// <seealso cref="HeightChanging"/>
Expand Down Expand Up @@ -462,6 +480,13 @@ protected virtual void OnHeightChanged (ValueChangedEventArgs<Dim> args) { }
/// allowing customization or cancellation of the change. The <see cref="WidthChanging"/> event
/// is raised before the change, and <see cref="WidthChanged"/> is raised after.
/// </para>
/// <para>
/// <see cref="WidthChanging"/>/<see cref="WidthChanged"/> observe assignment of this declarative
/// <see cref="Dim"/> only. They are deliberately not raised when a layout pass resolves the view's size
/// or when <see cref="Frame"/> is set directly, because those paths change the resolved
/// <see cref="Frame"/> rather than the declarative <see cref="Width"/>. To observe resolved-size
/// changes from any cause, subscribe to <see cref="FrameChanged"/>.
/// </para>
/// <para>The default value is <c>Dim.Absolute (0)</c>.</para>
/// </remarks>
/// <seealso cref="WidthChanging"/>
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion Terminal.Gui/Views/CharMap/CharMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion Terminal.Gui/Views/FileDialogs/FileDialog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ();
Expand Down
47 changes: 38 additions & 9 deletions Terminal.Gui/Views/FileDialogs/FileDialogState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,17 +64,31 @@ private void AddReadableChildren (List<FileSystemInfoStats> children, IDirectory
{
try
{
IEnumerable<IFileSystemInfo> 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<FileSystemInfoStats> children, IDirectoryInfo dir)
{
try
{
IEnumerable<IFileSystemInfo> entries = Parent.OpenMode == OpenMode.Directory
? dir.GetDirectories ()
: dir.GetFileSystemInfos ();

foreach (IFileSystemInfo entry in entries)
{
Expand All @@ -83,6 +101,17 @@ private void AddReadableChildren (List<FileSystemInfoStats> children, IDirectory
}
}

private IEnumerable<IFileSystemInfo> EnumerateReadableEntries (IDirectoryInfo dir)
{
// if directories only
if (Parent.OpenMode == OpenMode.Directory)
{
return dir.EnumerateDirectories ("*", _ignoreInaccessibleEnumerationOptions);
}

return dir.EnumerateFileSystemInfos ("*", _ignoreInaccessibleEnumerationOptions);
}

private void AddReadableChild (List<FileSystemInfoStats> children, IFileSystemInfo entry)
{
try
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Claude - Opus 4.8

using System.IO.Abstractions.TestingHelpers;
using Terminal.Gui.Configuration;

namespace ConfigurationTests;

/// <summary>
/// Verifies that views binding <see cref="PopoverMenu.DefaultKey"/> to <see cref="Command.Context"/>
/// do not throw when the configured key collides with a key the view already binds
/// (e.g. Ctrl+P, which <see cref="TableView"/> binds to <see cref="Command.Up"/> by default).
/// </summary>
[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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.IO.Abstractions;
using Moq;

namespace FileServicesTests;

public class FileSystemTreeBuilderTests
{
[Fact]
public void CanExpand_DirectoryWithUnreadableAttributes_DoesNotThrowAndReturnsFalse ()
{
Mock<IDirectoryInfo> 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<IDirectoryInfo> 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<IFileSystemInfo> children = builder.GetChildren (directory.Object);

Assert.Empty (children);
}

// Claude - Opus 4.8
[Fact]
public void IsReparsePoint_WhenAttributesUnreadable_FailsClosed ()
{
Mock<IDirectoryInfo> 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));
}
}
Loading
Loading