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