Editor/Launcher/RecentEntries.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;

public enum HotCodeEntryKind
{
	Project,
	Solution
}

/// <summary>
/// Something the launcher can open: an s&amp;box project (.sbproj) or a solution (.sln/.slnx).
/// </summary>
public sealed record HotCodeEntry( string Path, HotCodeEntryKind Kind, string Title, string Type )
{
	/// <summary>
	/// When it was last opened from HotCode, if it's in the recents list.
	/// </summary>
	public DateTime? LastOpened { get; init; }

	/// <summary>
	/// Pinned entries stay at the top and never drop off the recents list.
	/// </summary>
	public bool Pinned { get; init; }

	public string Directory => System.IO.Path.GetDirectoryName( Path );

	public string Icon => Kind == HotCodeEntryKind.Solution ? "account_tree" : Type switch
	{
		"game" => "sports_esports",
		"library" => "local_library",
		"map" => "public",
		"content" => "perm_media",
		"tool" => "hardware",
		"addon" => "extension",
		_ => "folder_special"
	};

	/// <summary>
	/// "Game", "Library", "Solution"...
	/// </summary>
	public string TypeName => Kind == HotCodeEntryKind.Solution ? "Solution"
		: string.IsNullOrEmpty( Type ) ? "Project" : char.ToUpperInvariant( Type[0] ) + Type[1..];

	public Color TypeColor => Kind == HotCodeEntryKind.Solution ? Theme.Pink : Type switch
	{
		"game" => Theme.Green,
		"library" => Theme.Blue,
		"addon" or "content" or "map" => Theme.Yellow,
		_ => Theme.TextLight
	};

	public static HotCodeEntry FromPath( string path )
	{
		var ext = System.IO.Path.GetExtension( path ).ToLowerInvariant();

		if ( ext is ".sln" or ".slnx" )
			return new HotCodeEntry( path, HotCodeEntryKind.Solution, System.IO.Path.GetFileNameWithoutExtension( path ), "solution" );

		var title = System.IO.Path.GetFileNameWithoutExtension( path );
		var type = "";

		try
		{
			using var doc = JsonDocument.Parse( File.ReadAllText( path ) );
			if ( doc.RootElement.TryGetProperty( "Title", out var t ) && t.GetString() is { Length: > 0 } s ) title = s;
			if ( doc.RootElement.TryGetProperty( "Type", out var ty ) ) type = ty.GetString() ?? "";
		}
		catch ( Exception e )
		{
			Log.Warning( $"[HotCode] Couldn't read {path}: {e.Message}" );
		}

		return new HotCodeEntry( path, HotCodeEntryKind.Project, title, type );
	}
}

/// <summary>
/// One remembered project or solution. Stored in an editor cookie, so it's a plain class for JSON.
/// </summary>
public class RecentRecord
{
	public string Path { get; set; }
	public DateTime LastOpened { get; set; }
	public bool Pinned { get; set; }
}

/// <summary>
/// Recently opened projects/solutions, persisted across editor sessions.
/// </summary>
public static class RecentEntries
{
	const string CookieKey = "hotcode.recent.v2";
	const string OldCookieKey = "hotcode.recent";

	/// <summary>
	/// How many unpinned entries to keep. Pinned ones don't count.
	/// </summary>
	const int MaxEntries = 8;

	private static List<RecentRecord> Load()
	{
		var records = EditorCookie.Get<List<RecentRecord>>( CookieKey, null );
		if ( records is not null ) return records;

		// Carry over the old list of plain paths
		var old = EditorCookie.Get<List<string>>( OldCookieKey, null ) ?? new();
		records = old.Select( ( p, i ) => new RecentRecord { Path = p, LastOpened = DateTime.Now.AddMinutes( -i ) } ).ToList();
		Save( records );
		return records;
	}

	private static void Save( List<RecentRecord> records ) => EditorCookie.Set( CookieKey, records );

	/// <summary>
	/// Recent entries that still exist on disk: pinned first, then most recent.
	/// </summary>
	public static IEnumerable<HotCodeEntry> GetAll() => Load()
		.Where( r => File.Exists( r.Path ) )
		.OrderByDescending( r => r.Pinned )
		.ThenByDescending( r => r.LastOpened )
		.Select( r => HotCodeEntry.FromPath( r.Path ) with { LastOpened = r.LastOpened, Pinned = r.Pinned } );

	/// <summary>
	/// Marks a path as just opened (adding it if needed).
	/// </summary>
	public static void Touch( string path )
	{
		var records = Load();
		var record = Find( records, path );
		if ( record is null )
		{
			record = new RecentRecord { Path = Normalize( path ) };
			records.Add( record );
		}

		record.LastOpened = DateTime.Now;

		// Drop the oldest unpinned ones past the limit
		var extra = records.Where( r => !r.Pinned ).OrderByDescending( r => r.LastOpened ).Skip( MaxEntries ).ToList();
		records.RemoveAll( extra.Contains );

		Save( records );
	}

	public static void SetPinned( string path, bool pinned )
	{
		var records = Load();
		var record = Find( records, path );
		if ( record is null )
		{
			record = new RecentRecord { Path = Normalize( path ), LastOpened = DateTime.Now };
			records.Add( record );
		}

		record.Pinned = pinned;
		Save( records );
	}

	public static void Remove( string path )
	{
		var records = Load();
		records.RemoveAll( r => Normalize( r.Path ) == Normalize( path ) );
		Save( records );
	}

	private static RecentRecord Find( List<RecentRecord> records, string path ) => records.FirstOrDefault( r => Normalize( r.Path ) == Normalize( path ) );

	public static string Normalize( string path ) => System.IO.Path.GetFullPath( path ).Replace( '\\', '/' );

	/// <summary>
	/// "just now", "5 minutes ago", "yesterday", "12 Mar"...
	/// </summary>
	public static string Ago( DateTime time )
	{
		var span = DateTime.Now - time;
		if ( span.TotalMinutes < 1 ) return "just now";
		if ( span.TotalMinutes < 60 ) return $"{(int)span.TotalMinutes} min ago";
		if ( span.TotalHours < 24 ) return $"{(int)span.TotalHours} hour{((int)span.TotalHours == 1 ? "" : "s")} ago";
		if ( span.TotalDays < 2 ) return "yesterday";
		if ( span.TotalDays < 7 ) return $"{(int)span.TotalDays} days ago";
		return time.ToString( time.Year == DateTime.Now.Year ? "d MMM" : "d MMM yyyy" );
	}
}