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&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;
}
}