Editor/HotCodeEditor/CodeFileTree.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

/// <summary>
/// Whether a file is open in a tab, and whether it's the one being edited.
/// </summary>
public enum FileState
{
	Closed,
	Open,
	Active
}

/// <summary>
/// A lazily-built tree node for a file or folder on disk. Only shows code/text files
/// and skips build output folders.
/// </summary>
public class CodeFileNode : TreeNode
{
	public static readonly HashSet<string> HiddenFolders = new( StringComparer.OrdinalIgnoreCase ) { "bin", "obj", ".vs", ".git", ".idea", ".sbox", "node_modules" };

	static readonly HashSet<string> CodeExtensions = new( StringComparer.OrdinalIgnoreCase )
	{
		".cs", ".razor", ".scss", ".css", ".json", ".sbproj", ".csproj", ".sln", ".slnx",
		".md", ".txt", ".shader", ".hlsl", ".editorconfig", ".gitignore", ".xml", ".yml", ".yaml"
	};

	public FileSystemInfo Info { get; }
	public bool IsFolder => Info is DirectoryInfo;
	public string FullPath => Info.FullName;

	private readonly HotCodeEditor _host;

	public CodeFileNode( HotCodeEditor host, FileSystemInfo info ) : base( info.FullName )
	{
		_host = host;
		Info = info;
		_host.RegisterNode( this );
	}

	public override string Name => Info.Name;
	public override bool HasChildren => IsFolder;
	public override string GetTooltip() => Info.FullName;

	public static bool IsCodeFile( string path ) => CodeExtensions.Contains( Path.GetExtension( path ) ) || CodeExtensions.Contains( Path.GetFileName( path ) );

	/// <summary>
	/// True for paths inside bin/obj/.git etc, which the tree never shows.
	/// </summary>
	public static bool IsInHiddenFolder( string path ) => path.Split( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar ).Any( HiddenFolders.Contains );

	public override void OnPaint( VirtualWidget item )
	{
		PaintSelection( item );

		var state = IsFolder ? FileState.Closed : _host.StateOf( FullPath );

		// Folders in s&box's folder colour; files coloured by type
		var open = IsFolder && item.IsOpen;
		Paint.SetPen( IsFolder ? Theme.Folder : HotCodeStyle.FileIconColor( FullPath ) );
		Paint.DrawIcon( item.Rect, IsFolder ? (open ? "folder_open" : "folder") : HotCodeStyle.FileIcon( FullPath ), 15, TextFlag.LeftCenter );

		// The file you're editing is bold; other open files are bright, the rest are dimmer
		Paint.SetDefaultFont( HotCodeStyle.FontSize, state == FileState.Active ? 700 : 400 );
		Paint.SetPen( IsFolder || state != FileState.Closed || item.Selected ? Theme.Text : Theme.TextLight );
		Paint.DrawText( item.Rect.Shrink( 22, 0, 0, 0 ), Info.Name, TextFlag.LeftCenter );

		if ( state != FileState.Closed && _host.IsUnsaved( FullPath ) )
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.Text );
			Paint.DrawCircle( new Vector2( item.Rect.Right - 10, item.Rect.Center.y ), 6 );
		}
	}

	public override bool OnContextMenu()
	{
		_host.ShowFileContextMenu( this );
		return true;
	}


	protected override void BuildChildren()
	{
		if ( Info is not DirectoryInfo dir )
			return;

		Clear();

		IEnumerable<FileSystemInfo> entries;
		try
		{
			dir.Refresh();
			entries = dir.GetFileSystemInfos();
		}
		catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
		{
			Log.Warning( $"[HotCode] Can't read {dir.FullName}: {e.Message}" );
			return;
		}

		var folders = entries.OfType<DirectoryInfo>()
			.Where( x => !HiddenFolders.Contains( x.Name ) && ShouldShowFolder( x ) )
			.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase );

		var files = entries.OfType<FileInfo>()
			.Where( x => IsCodeFile( x.Name ) )
			.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase );

		AddItems( folders.Select( x => new CodeFileNode( _host, x ) ) );
		AddItems( files.Select( x => new CodeFileNode( _host, x ) ) );
	}

	/// <summary>
	/// Hide folders full of things we'd never open (e.g. Assets full of textures),
	/// but keep empty ones so a folder you just made shows up.
	/// </summary>
	static bool ShouldShowFolder( DirectoryInfo dir )
	{
		try
		{
			var any = false;
			foreach ( var f in dir.EnumerateFiles( "*", SearchOption.AllDirectories ) )
			{
				if ( IsInHiddenFolder( f.FullName ) ) continue;
				if ( IsCodeFile( f.Name ) ) return true;
				any = true;
			}

			return !any;
		}
		catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
		{
			return false;
		}
	}
}