Skip to content

Commit 837f9f1

Browse files
authored
Merge pull request #5548 from BDisp/issue/filedialog-unreadable-entry
Fixes #5544. FileDialog/OpenDialog: one unreadable entry makes the entire directory render empty (`FileDialogState.GetChildren` swallows the exception)
2 parents f3d8be1 + 0b3a3ef commit 837f9f1

2 files changed

Lines changed: 172 additions & 22 deletions

File tree

‎Terminal.Gui/Views/FileDialogs/FileDialogState.cs‎

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,45 +34,81 @@ protected FileDialogState (IDirectoryInfo dir, FileDialog parent, bool skipIniti
3434
public FileSystemInfoStats? Selected { get; set; }
3535

3636
protected IEnumerable<FileSystemInfoStats> GetChildren (IDirectoryInfo dir)
37+
{
38+
List<FileSystemInfoStats> children = [];
39+
40+
AddReadableChildren (children, dir);
41+
42+
// if only allowing specific file types
43+
if (Parent.AllowedTypes.Count > 0 && Parent.OpenMode == OpenMode.File)
44+
{
45+
children = children.Where (c => c.IsDir || (c.FileSystemInfo is IFileInfo f && Parent.IsCompatibleWithAllowedExtensions (f))).ToList ();
46+
}
47+
48+
// if there's a UI filter in place too
49+
if (Parent.CurrentFilter is { })
50+
{
51+
children = children.Where (MatchesApiFilter).ToList ();
52+
}
53+
54+
AddParentNavigation (children, dir);
55+
56+
return children;
57+
}
58+
59+
private void AddReadableChildren (List<FileSystemInfoStats> children, IDirectoryInfo dir)
3760
{
3861
try
3962
{
40-
List<FileSystemInfoStats> children;
63+
IEnumerable<IFileSystemInfo> entries;
4164

4265
// if directories only
4366
if (Parent.OpenMode == OpenMode.Directory)
4467
{
45-
children = dir.GetDirectories ().Select (e => new FileSystemInfoStats (e, Parent.Style.Culture)).ToList ();
68+
entries = dir.GetDirectories ();
4669
}
4770
else
4871
{
49-
children = dir.GetFileSystemInfos ().Select (e => new FileSystemInfoStats (e, Parent.Style.Culture)).ToList ();
72+
entries = dir.GetFileSystemInfos ();
5073
}
5174

52-
// if only allowing specific file types
53-
if (Parent.AllowedTypes.Count > 0 && Parent.OpenMode == OpenMode.File)
75+
foreach (IFileSystemInfo entry in entries)
5476
{
55-
children = children.Where (c => c.IsDir || (c.FileSystemInfo is IFileInfo f && Parent.IsCompatibleWithAllowedExtensions (f))).ToList ();
77+
AddReadableChild (children, entry);
5678
}
79+
}
80+
catch (Exception)
81+
{
82+
// Access permission exceptions, missing directories, etc.
83+
}
84+
}
5785

58-
// if there's a UI filter in place too
59-
if (Parent.CurrentFilter is { })
60-
{
61-
children = children.Where (MatchesApiFilter).ToList ();
62-
}
86+
private void AddReadableChild (List<FileSystemInfoStats> children, IFileSystemInfo entry)
87+
{
88+
try
89+
{
90+
children.Add (new FileSystemInfoStats (entry, Parent.Style.Culture));
91+
}
92+
catch (Exception)
93+
{
94+
// A single unreadable entry should not hide the rest of the directory.
95+
}
96+
}
6397

64-
// allow navigating up as '..'
65-
if (dir.Parent is { })
66-
{
67-
children.Add (new FileSystemInfoStats (dir.Parent, Parent.Style.Culture) { IsParent = true });
68-
}
98+
private void AddParentNavigation (List<FileSystemInfoStats> children, IDirectoryInfo dir)
99+
{
100+
if (dir.Parent is not { } parent)
101+
{
102+
return;
103+
}
69104

70-
return children;
105+
try
106+
{
107+
children.Add (new FileSystemInfoStats (parent, Parent.Style.Culture) { IsParent = true });
71108
}
72109
catch (Exception)
73110
{
74-
// Access permissions Exceptions, Dir not exists etc
75-
return [];
111+
// If even the parent cannot be stat'ed/read metadata, keep the readable children.
76112
}
77113
}
78114

‎Tests/UnitTestsParallelizable/Views/FileDialogResultTests.cs‎

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// Copilot
22

33
using System.Reflection;
4+
using System.IO.Abstractions;
45
using System.IO.Abstractions.TestingHelpers;
6+
using Moq;
57

68
namespace UnitTests.Views;
79

@@ -162,7 +164,7 @@ public void OpenDialog_UsesInnerTableSeparatorsWithoutOuterBorders ()
162164
FieldInfo? tableViewField = typeof (FileDialog).GetField ("_tableView", BindingFlags.Instance | BindingFlags.NonPublic);
163165
Assert.NotNull (tableViewField);
164166

165-
TableView tableView = Assert.IsType<TableView> (tableViewField!.GetValue (od));
167+
TableView tableView = Assert.IsType<TableView> (tableViewField.GetValue (od));
166168

167169
Assert.True (tableView.Style.ShowVerticalCellLines);
168170
Assert.True (tableView.Style.ShowVerticalHeaderLines);
@@ -181,7 +183,7 @@ public void FileDialog_PathField_End_MovesInsertionPointToEnd ()
181183
FieldInfo? tbPathField = typeof (FileDialog).GetField ("_tbPath", BindingFlags.Instance | BindingFlags.NonPublic);
182184
Assert.NotNull (tbPathField);
183185

184-
TextField tbPath = Assert.IsType<TextField> (tbPathField!.GetValue (fd));
186+
TextField tbPath = Assert.IsType<TextField> (tbPathField.GetValue (fd));
185187
tbPath.Text = "/testdir/example.txt";
186188

187189
tbPath.NewKeyDownEvent (Key.Home);
@@ -208,7 +210,7 @@ public void FileDialog_PathField_BadChars_AreSuppressed (char badChar)
208210
FieldInfo? tbPathField = typeof (FileDialog).GetField ("_tbPath", BindingFlags.Instance | BindingFlags.NonPublic);
209211
Assert.NotNull (tbPathField);
210212

211-
TextField tbPath = Assert.IsType<TextField> (tbPathField!.GetValue (fd));
213+
TextField tbPath = Assert.IsType<TextField> (tbPathField.GetValue (fd));
212214
tbPath.Text = "/testdir/";
213215
tbPath.MoveEnd ();
214216

@@ -251,6 +253,52 @@ public void FileDialog_MixedMode_PathSetBeforeEndInit_RespectsAllowedTypes ()
251253
Assert.Equal (["allowed.Designer.cs", "subdir"], visibleEntries);
252254
}
253255

256+
[Fact]
257+
public void FileDialog_MixedMode_SkipsUnreadableEntry_AndKeepsReadableEntries ()
258+
{
259+
IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
260+
IFileInfo goodFile = CreateFile ("/testdir/good.txt", "good.txt");
261+
IDirectoryInfo goodDirectory = CreateDirectory ("/testdir/good-dir", "good-dir", directory);
262+
IFileInfo badFile = CreateUnreadableFile ("/testdir/bad.txt", "bad.txt");
263+
264+
Mock.Get (directory)
265+
.Setup (d => d.GetFileSystemInfos ())
266+
.Returns ([goodFile, badFile, goodDirectory]);
267+
268+
using FileDialog fd = new TestableFileDialog (fileSystem);
269+
fd.OpenMode = OpenMode.Mixed;
270+
271+
fd.Path = "/testdir";
272+
273+
Assert.NotNull (fd.State);
274+
Assert.Contains (fd.State!.Children, c => c.Name == "good.txt");
275+
Assert.Contains (fd.State.Children, c => c.Name == "good-dir");
276+
Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == "..");
277+
Assert.DoesNotContain (fd.State.Children, c => c.Name == "bad.txt");
278+
}
279+
280+
[Fact]
281+
public void FileDialog_DirectoryMode_SkipsUnreadableDirectory_AndKeepsParentNavigation ()
282+
{
283+
IFileSystem fileSystem = CreateFileSystemWithDirectory (out IDirectoryInfo directory);
284+
IDirectoryInfo goodDirectory = CreateDirectory ("/testdir/good-dir", "good-dir", directory);
285+
IDirectoryInfo badDirectory = CreateUnreadableDirectory ("/testdir/bad-dir", "bad-dir", directory);
286+
287+
Mock.Get (directory)
288+
.Setup (d => d.GetDirectories ())
289+
.Returns ([goodDirectory, badDirectory]);
290+
291+
using FileDialog fd = new TestableFileDialog (fileSystem);
292+
fd.OpenMode = OpenMode.Directory;
293+
294+
fd.Path = "/testdir";
295+
296+
Assert.NotNull (fd.State);
297+
Assert.Contains (fd.State!.Children, c => c.Name == "good-dir");
298+
Assert.Contains (fd.State.Children, c => c.IsParent && c.Name == "..");
299+
Assert.DoesNotContain (fd.State.Children, c => c.Name == "bad-dir");
300+
}
301+
254302
[Fact]
255303
public void FileDialog_Accepting_Directory_From_Table_Keeps_Path_On_Opened_Directory ()
256304
{
@@ -310,10 +358,76 @@ private static int FindRowByName (TableView tableView, string name)
310358
return -1;
311359
}
312360

361+
private static IFileSystem CreateFileSystemWithDirectory (out IDirectoryInfo directory)
362+
{
363+
Mock<IFileSystem> fileSystem = new ();
364+
Mock<IDirectoryInfoFactory> directoryInfoFactory = new ();
365+
Mock<IDirectoryInfo> parent = CreateDirectoryMock ("/", string.Empty, null);
366+
Mock<IDirectoryInfo> dir = CreateDirectoryMock ("/testdir", "testdir", parent.Object);
367+
368+
directoryInfoFactory.Setup (f => f.New ("/testdir")).Returns (dir.Object);
369+
fileSystem.SetupGet (f => f.DirectoryInfo).Returns (directoryInfoFactory.Object);
370+
371+
directory = dir.Object;
372+
373+
return fileSystem.Object;
374+
}
375+
376+
private static IFileInfo CreateFile (string fullName, string name)
377+
{
378+
Mock<IFileInfo> file = new ();
379+
file.SetupGet (f => f.Exists).Returns (true);
380+
file.SetupGet (f => f.FullName).Returns (fullName);
381+
file.SetupGet (f => f.Name).Returns (name);
382+
file.SetupGet (f => f.Extension).Returns (System.IO.Path.GetExtension (name));
383+
file.SetupGet (f => f.Length).Returns (12);
384+
file.SetupGet (f => f.LastWriteTime).Returns (new DateTime (2026, 1, 1));
385+
386+
return file.Object;
387+
}
388+
389+
private static IFileInfo CreateUnreadableFile (string fullName, string name)
390+
{
391+
Mock<IFileInfo> file = Mock.Get (CreateFile (fullName, name));
392+
file.SetupGet (f => f.LastWriteTime).Throws (new IOException ("Transport endpoint is not connected"));
393+
394+
return file.Object;
395+
}
396+
397+
private static IDirectoryInfo CreateDirectory (string fullName, string name, IDirectoryInfo? parent)
398+
{
399+
return CreateDirectoryMock (fullName, name, parent).Object;
400+
}
401+
402+
private static IDirectoryInfo CreateUnreadableDirectory (string fullName, string name, IDirectoryInfo? parent)
403+
{
404+
Mock<IDirectoryInfo> directory = CreateDirectoryMock (fullName, name, parent);
405+
directory.SetupGet (d => d.LastWriteTime).Throws (new UnauthorizedAccessException ());
406+
407+
return directory.Object;
408+
}
409+
410+
private static Mock<IDirectoryInfo> CreateDirectoryMock (string fullName, string name, IDirectoryInfo? parent)
411+
{
412+
Mock<IDirectoryInfo> directory = new ();
413+
directory.SetupGet (d => d.Exists).Returns (true);
414+
directory.SetupGet (d => d.FullName).Returns (fullName);
415+
directory.SetupGet (d => d.Name).Returns (name);
416+
directory.SetupGet (d => d.Extension).Returns (string.Empty);
417+
directory.SetupGet (d => d.Parent).Returns (parent);
418+
directory.SetupGet (d => d.LastWriteTime).Returns (new DateTime (2026, 1, 1));
419+
directory.Setup (d => d.GetFileSystemInfos ()).Returns ([]);
420+
directory.Setup (d => d.GetDirectories ()).Returns ([]);
421+
422+
return directory;
423+
}
424+
313425
/// <summary>Testable subclass that exposes the internal file-system constructor.</summary>
314426
private sealed class TestableFileDialog : FileDialog
315427
{
316428
public TestableFileDialog (MockFileSystem fs) : base (fs) { }
429+
430+
public TestableFileDialog (IFileSystem fileSystem) : base (fileSystem) { }
317431
}
318432

319433
/// <summary>Testable subclass for OpenDialog.</summary>

0 commit comments

Comments
 (0)