namespace AIStudio.Tools;
///
/// Enumerates files with the directory rules shared by folder-based features.
///
///
/// Hidden files and folders follow the convention of a shell: a file or folder is hidden when its
/// name begins with a dot or when the operating system marks it as hidden. Unless they are asked
/// for, hidden folders are not entered and hidden files are not returned, just as * in a shell
/// does not match .env. A caller can still ask for single hidden files, e.g., because a file
/// pattern begins with a dot itself, as .gitignore does.
///
/// The metadata of operating systems, such as Thumbs.db or __MACOSX, is never returned, cf.
/// FileExclusions.
///
internal static class DirectoryFileTraversal
{
///
/// What the name of a hidden file or folder begins with on macOS and Linux.
///
public const char HIDDEN_NAME_PREFIX = '.';
private const FileAttributes ALWAYS_SKIPPED_DIRECTORY_ATTRIBUTES =
FileAttributes.ReparsePoint | FileAttributes.Offline | FileAttributes.System;
///
/// Enumerates files below an explicitly selected root. The root itself is always read, even
/// when it is hidden; the options apply to everything in it.
///
/// The selected folder.
/// Whether files in subfolders are returned as well.
/// Whether hidden files are returned and hidden folders are entered.
/// Asked for each hidden file while hidden files and folders are left out: whether this one is wanted anyway.
/// Whether a subfolder is entered at all.
/// Told about every folder which could not be read.
/// The full paths of the files.
public static IEnumerable EnumerateFiles(
string rootPath,
bool includeSubdirectories,
bool includeHiddenFilesAndFolders,
Func? includeHiddenFile = null,
Func? includeDirectory = null,
Action? reportInaccessibleDirectory = null)
{
var pendingDirectories = new Stack();
pendingDirectories.Push(rootPath);
while (pendingDirectories.Count > 0)
{
var currentPath = pendingDirectories.Pop();
FileInfo[] files;
DirectoryInfo[] subdirectories;
try
{
//
// Unlike plain paths, the entries carry the attributes the enumeration read anyway,
// so telling hidden files apart costs no extra call per file on Windows:
//
var currentDirectory = new DirectoryInfo(currentPath);
files = currentDirectory.GetFiles();
subdirectories = includeSubdirectories ? currentDirectory.GetDirectories() : [];
}
catch (Exception exception)
{
reportInaccessibleDirectory?.Invoke(currentPath, exception);
continue;
}
foreach (var file in files)
{
if (FileExclusions.IsAlwaysExcludedFile(file.Name))
continue;
if (!includeHiddenFilesAndFolders && IsHiddenFileLeftOut(file, includeHiddenFile))
continue;
yield return file.FullName;
}
foreach (var subdirectory in subdirectories)
{
if (FileExclusions.IsAlwaysExcludedFolder(subdirectory.Name))
continue;
try
{
if ((subdirectory.Attributes & ALWAYS_SKIPPED_DIRECTORY_ATTRIBUTES) != 0)
continue;
if (!includeHiddenFilesAndFolders && IsHidden(subdirectory))
continue;
}
catch (Exception exception)
{
reportInaccessibleDirectory?.Invoke(subdirectory.FullName, exception);
continue;
}
//
// Asked last, so that the caller hears only about folders which would have been
// entered otherwise, e.g., to tell the user which folders a list left out:
//
if (includeDirectory is not null && !includeDirectory(subdirectory.FullName))
continue;
pendingDirectories.Push(subdirectory.FullName);
}
}
}
///
/// Whether a path, e.g., one a file system watcher reports, is a hidden file or folder.
///
///
/// Once a file or folder is gone, only its name can tell whether it was hidden.
///
public static bool IsHiddenPath(string path)
{
var name = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
if (HasHiddenName(name))
return true;
try
{
FileSystemInfo entry = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path);
return entry.Exists && entry.Attributes.HasFlag(FileAttributes.Hidden);
}
catch
{
// The path vanished meanwhile, so its name is all there is to tell:
return false;
}
}
///
/// Whether a file or folder is hidden. The same rule applies to both.
///
private static bool IsHidden(FileSystemInfo entry) => HasHiddenName(entry.Name) || entry.Attributes.HasFlag(FileAttributes.Hidden);
private static bool HasHiddenName(string name) => name.StartsWith(HIDDEN_NAME_PREFIX);
private static bool IsHiddenFileLeftOut(FileInfo file, Func? includeHiddenFile)
{
try
{
if (!IsHidden(file))
return false;
}
catch
{
// The file vanished meanwhile. Whoever reads it next finds out and says so:
return false;
}
return includeHiddenFile?.Invoke(file.FullName) is not true;
}
}