using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace FluidExplorer.Services.Operations;
///
/// 路径与文件系统元数据工具。
///
/// 设计约定(整个 Operations 引擎统一遵守):
/// 1. 引擎内部、作业描述、UndoEntry 里保存的都是"普通路径"(不带 \\?\ 前缀),
/// 只有真正触达 BCL / Win32 IO 的那一刻才通过 转换,
/// 避免 \\?\ 前缀泄漏到 UI 显示、$I 解析、Shell API 调用里(Shell API 不接受前缀)。
/// 2. 所有拼接都用 (手工拼分隔符),不用 Path.Combine 后直接丢给 API,
/// 以保证超长路径(>260)在所有环节都能正确走到 \\?\ 分支。
/// 3. 所有 IO 调用一律走 ,NET8 虽然自身也会兜底长路径,
/// 但统一处理后行为可预期(尤其是 UNC:\\server\share → \\?\UNC\server\share)。
///
internal static partial class PathHelper
{
internal const string ExtendedPrefix = @"\\?\";
internal const string ExtendedUncPrefix = @"\\?\UNC\";
[GeneratedRegex(@"^(.*) \((\d+)\)$", RegexOptions.CultureInvariant)]
private static partial Regex CopySuffixRegex();
/// 安全取全路径;非法路径(空、含非法字符、超长到无法规范化)返回 null 而不抛。
internal static string? TryGetFullPath(string? path)
{
if (string.IsNullOrWhiteSpace(path)) return null;
try
{
return Path.GetFullPath(path);
}
catch (Exception)
{
return null;
}
}
/// 转成 Win32 长路径形式(\\?\ 或 \\?\UNC\)。已是前缀形式则原样返回。
internal static string ToExtended(string path)
{
if (string.IsNullOrEmpty(path)) return path;
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
return path;
string full;
try
{
full = Path.GetFullPath(path);
}
catch (Exception)
{
// 无法规范化:原样返回,让后续 API 抛出可读异常,由重试/失败统计兜住。
return path;
}
if (full.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal) ||
full.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
return full;
// UNC:\\server\share\x → \\?\UNC\server\share\x
if (full.StartsWith(@"\\", StringComparison.Ordinal))
return ExtendedUncPrefix + full[2..];
return ExtendedPrefix + full;
}
/// 去掉长路径前缀,得到可显示/可交给 Shell API 的普通路径。
internal static string StripExtended(string path)
{
if (string.IsNullOrEmpty(path)) return path;
if (path.StartsWith(ExtendedUncPrefix, StringComparison.Ordinal))
return @"\\" + path[ExtendedUncPrefix.Length..];
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
return path[ExtendedPrefix.Length..];
return path;
}
/// 手工拼接子路径(不依赖 Path.Combine 的根路径语义)。
internal static string Combine(string directory, string name)
{
if (string.IsNullOrEmpty(directory)) return name;
var last = directory[^1];
return last is '\\' or '/' ? directory + name : directory + Path.DirectorySeparatorChar + name;
}
/// 取最后一段名字(同时兼容带/不带 \\?\ 前缀)。
internal static string GetFileName(string path)
{
var p = StripExtended(path);
if (string.IsNullOrEmpty(p)) return p;
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (trimmed.Length == 0) return p; // 卷根,如 "E:\"
var idx = trimmed.LastIndexOfAny(['\\', '/']);
return idx < 0 ? trimmed : trimmed[(idx + 1)..];
}
/// 取父目录;已是卷根时返回卷根本身(不返回 null,方便调用方继续拼接)。
internal static string GetDirectoryName(string path)
{
var p = StripExtended(path);
var trimmed = p.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var idx = trimmed.LastIndexOfAny(['\\', '/']);
if (idx < 0) return p;
var parent = trimmed[..idx];
// "E:" → "E:\"
if (parent.Length == 2 && parent[1] == ':') return parent + Path.DirectorySeparatorChar;
if (parent.Length == 0) return @"\";
return parent;
}
/// 取卷根(用于同卷判定);UNC 时返回 \\server\share\。
internal static string GetVolumeRoot(string path)
{
var full = TryGetFullPath(path) ?? StripExtended(path);
var root = Path.GetPathRoot(full);
return string.IsNullOrEmpty(root) ? full : root;
}
/// 是否同一卷(同卷 Move 才能走瞬时的 File.Move/Directory.Move)。
internal static bool SameVolume(string a, string b)
=> string.Equals(GetVolumeRoot(a), GetVolumeRoot(b), StringComparison.OrdinalIgnoreCase);
internal static bool FileExists(string path)
{
try { return File.Exists(ToExtended(path)); } catch (Exception) { return false; }
}
internal static bool DirectoryExists(string path)
{
try { return Directory.Exists(ToExtended(path)); } catch (Exception) { return false; }
}
internal static bool Exists(string path) => FileExists(path) || DirectoryExists(path);
internal static FileAttributes? TryGetAttributes(string path)
{
try { return File.GetAttributes(ToExtended(path)); }
catch (Exception) { return null; }
}
internal static bool IsDirectory(string path)
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.Directory) != 0;
internal static bool IsReparsePoint(string path)
=> (TryGetAttributes(path) ?? 0) is var a && (a & FileAttributes.ReparsePoint) != 0;
internal static long TryGetLength(string path)
{
try { return new FileInfo(ToExtended(path)).Length; }
catch (Exception) { return 0; }
}
/// 清掉只读属性,否则覆盖/删除会抛 UnauthorizedAccessException。
internal static void ClearReadOnly(string path)
{
try
{
var attrs = File.GetAttributes(ToExtended(path));
if ((attrs & FileAttributes.ReadOnly) != 0)
File.SetAttributes(ToExtended(path), attrs & ~FileAttributes.ReadOnly);
}
catch (Exception)
{
// 不存在或无权访问:交给真正的操作去抛错,这里不吞掉信息。
}
}
/// 确保父目录存在(撤销时目标父目录可能已被删掉)。
internal static void EnsureParentDirectory(string path)
{
var parent = GetDirectoryName(path);
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(ToExtended(parent));
}
///
/// 生成 "名字 (2).ext" 风格的不冲突路径;若本身已带 " (n)" 后缀,先剥离再递增,
/// 避免出现 "a (2) (2).txt" 这种叠加命名。
///
internal static string MakeUniquePath(string desiredPath)
{
if (!Exists(desiredPath)) return desiredPath;
var directory = GetDirectoryName(desiredPath);
var name = GetFileName(desiredPath);
var ext = Path.GetExtension(name);
var stem = ext.Length > 0 ? name[..^ext.Length] : name;
var m = CopySuffixRegex().Match(stem);
if (m.Success) stem = m.Groups[1].Value;
for (var i = 2; i < 100_000; i++)
{
var candidate = Combine(directory, $"{stem} ({i}){ext}");
if (!Exists(candidate)) return candidate;
}
throw new IOException($"无法为“{desiredPath}”生成不冲突的新名称。");
}
/// 校验文件名(重命名/新建文件夹用),错误信息为中文。
internal static bool TryValidateFileName(string? name, out string? error)
{
error = null;
if (string.IsNullOrWhiteSpace(name))
{
error = "名称不能为空。";
return false;
}
if (name.Length > 255)
{
error = "名称过长(最多 255 个字符)。";
return false;
}
var invalid = Path.GetInvalidFileNameChars();
if (name.IndexOfAny(invalid) >= 0)
{
var bad = new string(name.Where(c => Array.IndexOf(invalid, c) >= 0).Distinct().ToArray());
error = $"名称包含非法字符:{bad}";
return false;
}
if (name.EndsWith(' ') || name.EndsWith('.'))
{
error = "名称不能以空格或点结尾。";
return false;
}
if (name.TrimEnd(' ', '.').Length == 0)
{
error = "名称不能只由空格或点组成。";
return false;
}
var stem = Path.GetFileNameWithoutExtension(name);
if (IsReservedDeviceName(stem))
{
error = $"“{stem}”是 Windows 保留设备名,不能用作文件名。";
return false;
}
return true;
}
private static bool IsReservedDeviceName(string stem)
{
if (stem.Length is 3 or 4)
{
if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) ||
stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) ||
stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) ||
stem.Equals("NUL", StringComparison.OrdinalIgnoreCase))
return true;
if (stem.Length == 4 && stem[3] is >= '1' and <= '9')
{
var head = stem[..3];
if (head.Equals("COM", StringComparison.OrdinalIgnoreCase) ||
head.Equals("LPT", StringComparison.OrdinalIgnoreCase))
return true;
}
}
return false;
}
///
/// 安全枚举某个目录的直接子项(返回普通路径)。
/// 单个子目录无权限/枚举中途出错时返回已拿到的部分并回调警告,绝不抛出。
///
internal static List EnumerateChildrenSafe(string directory, Action? onWarning = null)
{
var result = new List();
IEnumerator? enumerator = null;
try
{
enumerator = Directory.EnumerateFileSystemEntries(ToExtended(directory)).GetEnumerator();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
onWarning?.Invoke($"无法枚举目录“{directory}”:{ex.Message}");
return result;
}
try
{
while (true)
{
string current;
try
{
if (!enumerator.MoveNext()) break;
current = enumerator.Current;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
onWarning?.Invoke($"枚举目录“{directory}”时中断:{ex.Message}");
break;
}
result.Add(StripExtended(current));
}
}
finally
{
enumerator.Dispose();
}
return result;
}
/// 把 \\?\ 前缀路径还原成普通路径(供 P/Invoke Shell API 使用)。
internal static string ToShellPath(string path) => StripExtended(path);
/// 判断 extended 前缀是否已存在(调试用)。
internal static bool HasExtendedPrefix(string path)
=> path.StartsWith(ExtendedPrefix, StringComparison.Ordinal);
/// 分配 UTF-16 双 null 结尾路径列表(SHFileOperationW 要求)。
internal static IntPtr AllocDoubleNullList(IEnumerable paths)
{
var joined = string.Join('\0', paths) + "\0\0";
return Marshal.StringToHGlobalUni(joined);
}
}