Editor/HotCodeEditor/ProjectFiles.cs
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;

/// <summary>
/// File operations for the file tree. All throw <see cref="InvalidOperationException"/> with a
/// user-facing message when something's wrong, so callers can show it.
/// </summary>
public static class ProjectFiles
{
	/// <summary>
	/// Body of a new component, from s&amp;box's own template.
	/// </summary>
	const string ComponentTemplate =
		"using Sandbox;\n" +
		"\n" +
		"public sealed class $name : Component\n" +
		"{\n" +
		"\tprotected override void OnUpdate()\n" +
		"\t{\n" +
		"\n" +
		"\t}\n" +
		"}\n";

	public static string CreateFile( string folder, string name, string contents = "" )
	{
		var path = Path.Combine( folder, ValidateName( name ) );
		if ( File.Exists( path ) || Directory.Exists( path ) )
			throw new InvalidOperationException( $"'{name}' already exists." );

		File.WriteAllText( path, contents );
		return path;
	}

	/// <summary>
	/// Creates Name.cs holding a component called Name.
	/// </summary>
	public static string CreateComponent( string folder, string name )
	{
		name = name.Trim();
		if ( name.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) ) name = name[..^3];

		var className = new string( name.Where( c => char.IsLetterOrDigit( c ) || c == '_' ).ToArray() );
		if ( className.Length == 0 || char.IsDigit( className[0] ) )
			throw new InvalidOperationException( "Component names need to start with a letter." );

		return CreateFile( folder, className + ".cs", ComponentTemplate.Replace( "$name", className ) );
	}

	public static string CreateFolder( string parent, string name )
	{
		var path = Path.Combine( parent, ValidateName( name ) );
		if ( File.Exists( path ) || Directory.Exists( path ) )
			throw new InvalidOperationException( $"'{name}' already exists." );

		Directory.CreateDirectory( path );
		return path;
	}

	/// <summary>
	/// Renames a file or folder in place and returns its new path.
	/// </summary>
	public static string Rename( string path, string newName )
	{
		var target = Path.Combine( Path.GetDirectoryName( path ), ValidateName( newName ) );
		if ( string.Equals( target, path, StringComparison.Ordinal ) ) return path;

		// Changing only the case of a name is fine on Windows; anything else must not exist yet
		var caseOnly = string.Equals( target, path, StringComparison.OrdinalIgnoreCase );
		if ( !caseOnly && (File.Exists( target ) || Directory.Exists( target )) )
			throw new InvalidOperationException( $"'{newName}' already exists." );

		if ( Directory.Exists( path ) ) Directory.Move( path, target );
		else File.Move( path, target );

		return target;
	}

	/// <summary>
	/// Sends a file or folder to the Recycle Bin rather than deleting it outright.
	/// </summary>
	public static void Delete( string path )
	{
		if ( !File.Exists( path ) && !Directory.Exists( path ) ) return;

		if ( !OperatingSystem.IsWindows() )
			throw new InvalidOperationException( "Sending to the Recycle Bin is only supported on Windows." );

		var op = new ShFileOperation
		{
			Func = FO_DELETE,
			From = Path.GetFullPath( path ) + "\0\0", // the API takes a double-null-terminated list
			Flags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI
		};

		var result = SHFileOperation( ref op );
		if ( result != 0 || op.AnyOperationsAborted )
			throw new InvalidOperationException( $"Couldn't move '{Path.GetFileName( path )}' to the Recycle Bin (error {result})." );
	}

	// Windows shell API for deleting to the Recycle Bin. s&box's compiler doesn't
	// include Microsoft.VisualBasic, which is the usual managed way to do this.
	const uint FO_DELETE = 3;
	const ushort FOF_SILENT = 0x4;
	const ushort FOF_NOCONFIRMATION = 0x10;
	const ushort FOF_ALLOWUNDO = 0x40;
	const ushort FOF_NOERRORUI = 0x400;

	[StructLayout( LayoutKind.Sequential, CharSet = CharSet.Unicode )]
	struct ShFileOperation
	{
		public IntPtr Hwnd;
		public uint Func;
		public string From;
		public string To;
		public ushort Flags;
		[MarshalAs( UnmanagedType.Bool )] public bool AnyOperationsAborted;
		public IntPtr NameMappings;
		public string ProgressTitle;
	}

	[DllImport( "shell32.dll", CharSet = CharSet.Unicode )]
	static extern int SHFileOperation( ref ShFileOperation op );

	public static bool IsUnder( string path, string folder )
	{
		var full = Path.GetFullPath( path ).TrimEnd( '\\', '/' );
		var root = Path.GetFullPath( folder ).TrimEnd( '\\', '/' );
		return full.Equals( root, StringComparison.OrdinalIgnoreCase )
			|| full.StartsWith( root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase );
	}

	private static string ValidateName( string name )
	{
		name = name?.Trim();

		if ( string.IsNullOrEmpty( name ) )
			throw new InvalidOperationException( "Name can't be empty." );

		if ( name is "." or ".." || name.IndexOfAny( Path.GetInvalidFileNameChars() ) >= 0 )
			throw new InvalidOperationException( $"'{name}' isn't a valid file name." );

		return name;
	}
}