Editor/HotCodeEditor/HotCodeEditor.cs
using Sandbox;
using Editor;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
/// <summary>
/// The HotCode IDE window: file tree on the left, tabbed code editors on the right,
/// problems and output underneath. Saving a file inside a project s&box has loaded
/// lets s&box pick it up and hotload it.
/// </summary>
public class HotCodeEditor : Window
{
private readonly HotCodeEntry _entry;
private readonly string _root;
private readonly TreeView _tree;
private readonly Widget _explorer;
private CodeFileNode _rootNode;
private readonly EditorTabBar _tabs;
private readonly Widget _editorStack;
private readonly WelcomePage _welcome;
// Status bar
private readonly StatusItem _buildStatus;
private readonly StatusItem _position;
private readonly StatusItem _indent;
private readonly StatusItem _lineEnding;
private readonly StatusItem _encoding;
private readonly StatusItem _language;
private bool _projectLoaded;
private double _nextLoadedCheck;
private readonly OutputPanel _output;
private readonly List<EditorDocument> _documents = new();
private EditorDocument _active;
// Tree nodes by folder path, so file system changes can refresh the right folder
private readonly Dictionary<string, CodeFileNode> _nodes = new( StringComparer.OrdinalIgnoreCase );
// Go to definition runs in the background; results come back through this queue
private readonly ConcurrentQueue<(EditorDocument Doc, DefinitionResult Result)> _definitionResults = new();
private bool _definitionPending;
// Autocomplete and parameter hints run in the background, one at a time, newest request wins
private readonly LatestOnlyWorker<CompletionResult> _completions = new();
private readonly LatestOnlyWorker<SignatureHelpResult> _signatures = new();
// Alt+Left / Alt+Right history of where we jumped from
private record NavPoint( string Path, TextPos Pos );
private readonly List<NavPoint> _navBack = new();
private readonly List<NavPoint> _navForward = new();
private readonly FileSystemWatcher _watcher;
// (path, structural): structural changes (add/delete/rename) refresh the tree; edits only reload open files
private readonly ConcurrentQueue<(string Path, bool Structural)> _changedPaths = new();
public HotCodeEditor( HotCodeEntry entry )
{
_entry = entry;
_root = Path.GetFullPath( entry.Directory );
DeleteOnClose = true;
SetWindowIcon( HotCodeLogo.WindowIcon );
Size = new Vector2( 1200, 800 );
MinimumSize = new Vector2( 600, 400 );
Canvas = new Widget( this );
Canvas.Layout = Layout.Column();
// Top: tree | editors. Bottom: problems / output.
var vertical = new Splitter( Canvas );
vertical.IsVertical = true;
Canvas.Layout.Add( vertical, 1 );
var splitter = new Splitter( vertical );
splitter.IsHorizontal = true;
// LEFT: explorer header + file tree. Single click previews, double click opens for keeps.
_explorer = new Widget( splitter );
_explorer.Layout = Layout.Column();
_explorer.Layout.Add( BuildExplorerHeader( _explorer, entry.Title ) );
_explorer.Layout.AddSeparator( 1, Theme.Border );
_tree = _explorer.Layout.Add( new TreeView( _explorer ), 1 );
_tree.MinimumWidth = 200;
_tree.ItemSelected += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: true ); };
_tree.ItemActivated += item => { if ( item is CodeFileNode { IsFolder: false } node ) OpenFile( node.FullPath, preview: false ); };
_rootNode = new CodeFileNode( this, new DirectoryInfo( _root ) );
_tree.AddItem( _rootNode );
_tree.Open( _rootNode );
// RIGHT: tabs + editors
var right = new Widget( splitter );
right.Layout = Layout.Column();
_tabs = right.Layout.Add( new EditorTabBar( right ) );
right.Layout.AddSeparator( 1, Theme.Border );
_tabs.OnTabSelected = Activate;
_tabs.OnTabCloseRequested = RequestClose;
_tabs.OnTabPinned = doc => { doc.IsPreview = false; _tabs.Update(); };
_tabs.GetTabTooltip = doc => Path.GetRelativePath( _root, doc.Path );
_editorStack = right.Layout.Add( new Widget( right ), 1 );
_editorStack.Layout = Layout.Column();
_welcome = _editorStack.Layout.Add( new WelcomePage( _editorStack, entry.Title, _root, path => OpenFile( path, preview: false ) ), 1 );
_welcome.SetRecentFiles( RecentFiles() );
splitter.AddWidget( _explorer );
splitter.AddWidget( right );
splitter.SetStretch( 0, 1 );
splitter.SetStretch( 1, 4 );
// BOTTOM: problems / output
_output = new OutputPanel( vertical, _root );
_output.ProblemActivated = GoToProblem;
_output.ProblemsChanged = _ => ApplyMarkers();
vertical.AddWidget( splitter );
vertical.AddWidget( _output );
vertical.SetStretch( 0, 4 );
vertical.SetStretch( 1, 1 );
// Status bar: build state on the left, facts about the open file on the right
if ( !StatusBar.IsValid() ) StatusBar = new StatusBar( this );
var flame = new LogoWidget( StatusBar, 14, badge: false ) { ToolTip = "HotCode" };
StatusBar.AddWidgetLeft( flame, 0 );
_buildStatus = new StatusItem( StatusBar, "Click to show problems" ) { Clicked = _output.ShowProblems };
StatusBar.AddWidgetLeft( _buildStatus, 0 );
_position = new StatusItem( StatusBar, "Go to line (Ctrl+G)" ) { Clicked = GoToLine };
_indent = new StatusItem( StatusBar, "Indentation, detected from the file" );
_lineEnding = new StatusItem( StatusBar, "Line endings, kept as they were when saving" );
_encoding = new StatusItem( StatusBar, "Encoding" );
_language = new StatusItem( StatusBar, "Language" );
foreach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )
{
if ( item != _position ) StatusBar.AddWidgetRight( new StatusDivider( StatusBar ), 0 );
StatusBar.AddWidgetRight( item, 0 );
}
BuildMenuBar();
// Keep the tree (and unmodified open files) in sync with changes made outside HotCode
try
{
_watcher = new FileSystemWatcher( _root )
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite
};
_watcher.Created += ( _, e ) => QueueChange( e.FullPath, structural: true );
_watcher.Deleted += ( _, e ) => QueueChange( e.FullPath, structural: true );
_watcher.Changed += ( _, e ) => QueueChange( e.FullPath, structural: false );
_watcher.Renamed += ( _, e ) => { QueueChange( e.OldFullPath, structural: true ); QueueChange( e.FullPath, structural: true ); };
_watcher.EnableRaisingEvents = true;
}
catch ( Exception e ) when ( e is IOException or ArgumentException or UnauthorizedAccessException )
{
Log.Warning( $"[HotCode] Can't watch {_root} for changes: {e.Message}" );
}
UpdateTitle();
Show();
}
public override void OnDestroyed()
{
_watcher?.Dispose();
base.OnDestroyed();
}
// --- OPENING / SWITCHING ---
private EditorDocument FindDocument( string path ) => _documents.FirstOrDefault( d => string.Equals( d.Path, path, StringComparison.OrdinalIgnoreCase ) );
private EditorDocument OpenFile( string path, bool preview )
{
var existing = FindDocument( path );
if ( existing is not null )
{
if ( !preview ) existing.IsPreview = false;
Activate( existing );
return existing;
}
if ( !TryReadFile( path, out var contents, out var hasBom ) )
return null;
var editor = new CodeView( _editorStack, SyntaxLanguage.FromPath( path ) );
editor.Text = contents;
editor.Visible = false;
_editorStack.Layout.Add( editor, 1 );
var doc = new EditorDocument( path, editor ) { IsPreview = preview, HasBom = hasBom };
editor.TextChanged += () => OnEdited( doc );
editor.DefinitionRequested = pos => GoToDefinition( doc, pos );
editor.NavigateRequested = Navigate;
editor.CompletionRequested = pos => RequestCompletion( doc, pos );
editor.SignatureHelpRequested = pos => RequestSignatureHelp( doc, pos );
// A new preview takes the place of the old one, if the old one wasn't touched
var oldPreview = preview ? _documents.FirstOrDefault( d => d.IsPreview && !d.IsDirty ) : null;
if ( oldPreview is not null )
{
var index = _documents.IndexOf( oldPreview );
RemoveDocument( oldPreview );
_documents.Insert( index, doc );
_tabs.Insert( index, doc );
}
else
{
_documents.Add( doc );
_tabs.Add( doc );
}
ApplyMarkers( doc );
Activate( doc );
return doc;
}
private static bool TryReadFile( string path, out string contents, out bool hasBom )
{
contents = null;
hasBom = false;
try
{
var bytes = File.ReadAllBytes( path );
hasBom = bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF;
contents = new UTF8Encoding( false ).GetString( bytes, hasBom ? 3 : 0, bytes.Length - (hasBom ? 3 : 0) );
return true;
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
Log.Warning( $"[HotCode] Couldn't open {path}: {e.Message}" );
return false;
}
}
private void Activate( EditorDocument doc )
{
if ( _active is not null && _active != doc && _active.IsDirty && HotCodeSettings.AutoSave == AutoSaveMode.WhenSwitchingTabs )
SaveDocument( _active );
if ( _active is not null && _active != doc )
_active.Editor.Visible = false;
_active = doc;
_welcome.Visible = doc is null;
if ( doc is not null )
{
doc.Editor.Visible = true;
doc.Editor.FocusEditor();
if ( !doc.IsPreview ) AddRecentFile( doc.Path );
}
else
{
_welcome.SetRecentFiles( RecentFiles() );
}
_tabs.SetActive( doc );
_tree.Update();
UpdateTitle();
}
private void OnEdited( EditorDocument doc )
{
// IsModified goes back to false when edits are undone back to the saved text
var dirty = doc.Editor.IsModified;
var changed = dirty != doc.IsDirty || (dirty && doc.IsPreview);
doc.LastEdit = RealTime.Now;
doc.IsDirty = dirty;
if ( dirty ) doc.IsPreview = false;
if ( changed )
{
_tabs.Update();
_tree.Update();
UpdateTitle();
}
}
// --- PROBLEMS ---
private void GoToProblem( ProblemItem item )
{
PushNavPoint();
var doc = OpenFile( item.Path, preview: false );
doc?.Editor.GoTo( item.Line, item.Column );
}
private void ApplyMarkers()
{
foreach ( var doc in _documents )
ApplyMarkers( doc );
}
/// <summary>
/// Underlines this file's compile errors and warnings.
/// </summary>
private void ApplyMarkers( EditorDocument doc )
{
var markers = _output.Problems
.Where( p => string.Equals( Path.GetFullPath( p.Path ), Path.GetFullPath( doc.Path ), StringComparison.OrdinalIgnoreCase ) )
.Select( p => new EditorMarker( new TextPos( p.Line, p.Column ), new TextPos( p.EndLine, p.EndColumn ), p.Severity == DiagnosticSeverity.Error, $"{p.Id}: {p.Message}" ) )
.ToList();
doc.Editor.SetMarkers( markers );
}
// --- GO TO DEFINITION ---
private void GoToDefinition( EditorDocument doc, TextPos pos )
{
if ( !doc.Path.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) )
{
doc.Editor.ShowMessage( "Go to Definition only works in C# files." );
return;
}
if ( _definitionPending ) return;
// Read s&box's compiler state here on the main thread; the slow Roslyn work happens in the background
RoslynProject.Snapshot snapshot;
try
{
snapshot = RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );
}
catch ( Exception e )
{
doc.Editor.ShowMessage( $"Couldn't look that up: {e.Message}" );
return;
}
_definitionPending = true;
doc.Editor.ShowMessage( "Finding definition...", 60 );
Task.Run( () => DefinitionFinder.Find( snapshot, pos.Line, pos.Col ) )
.ContinueWith( t => _definitionResults.Enqueue( (doc, t.IsFaulted ? DefinitionResult.Fail( t.Exception?.InnerException?.Message ) : t.Result) ) );
}
// --- AUTOCOMPLETE ---
/// <summary>
/// What Roslyn needs to know about the code right now. Read on the main thread, used in the background.
/// </summary>
private RoslynProject.Snapshot TakeSnapshot( EditorDocument doc )
{
if ( !doc.Path.EndsWith( ".cs", StringComparison.OrdinalIgnoreCase ) ) return null;
try
{
return RoslynProject.TakeSnapshot( doc.Path, _root, _documents.Select( d => (d.Path, d.Editor.Text) ) );
}
catch ( Exception e )
{
Log.Warning( $"[HotCode] Couldn't read the project for code help: {e.Message}" );
return null;
}
}
private void RequestCompletion( EditorDocument doc, TextPos pos )
{
var snapshot = TakeSnapshot( doc );
if ( snapshot is null ) return;
_completions.Request( () => CompletionProvider.Get( snapshot, pos.Line, pos.Col ), result =>
{
if ( result.Error is not null ) Log.Warning( $"[HotCode] Autocomplete failed: {result.Error}" );
else if ( _documents.Contains( doc ) ) doc.Editor.ShowCompletions( result );
} );
}
// --- PARAMETER HINTS ---
private void RequestSignatureHelp( EditorDocument doc, TextPos pos )
{
var snapshot = TakeSnapshot( doc );
if ( snapshot is null ) return;
_signatures.Request( () => SignatureHelpProvider.Get( snapshot, pos.Line, pos.Col ), result =>
{
if ( _documents.Contains( doc ) ) doc.Editor.ShowSignatureHelp( result );
} );
}
private void ShowDefinitionResult( EditorDocument doc, DefinitionResult result )
{
_definitionPending = false;
var open = _documents.Contains( doc );
if ( result.Error is not null )
{
if ( open ) doc.Editor.ShowMessage( result.Error );
return;
}
if ( result.Locations.Count == 0 )
{
if ( open ) doc.Editor.ShowMessage( $"'{result.Symbol}' is defined in {result.Assembly ?? "a compiled library"}, which has no source here.", 6 );
return;
}
if ( open ) doc.Editor.ShowMessage( "", 0 );
if ( result.Locations.Count == 1 )
{
JumpTo( result.Locations[0] );
return;
}
// Several (partial classes): let the user pick
var menu = new ContextMenu( this );
foreach ( var location in result.Locations )
{
var label = $"{Path.GetRelativePath( _root, location.Path )}:{location.Line + 1} {location.Preview}";
menu.AddOption( label, "code", () => JumpTo( location ) );
}
menu.OpenAtCursor();
}
private void JumpTo( DefinitionLocation location )
{
PushNavPoint();
var target = OpenFile( location.Path, preview: false );
target?.Editor.GoTo( location.Line, location.Column );
}
private void PushNavPoint()
{
if ( _active is null ) return;
_navBack.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );
if ( _navBack.Count > 100 ) _navBack.RemoveAt( 0 );
_navForward.Clear();
}
/// <summary>
/// Alt+Left / Alt+Right: back to where we jumped from, and forward again.
/// </summary>
private void Navigate( bool back )
{
var from = back ? _navBack : _navForward;
var to = back ? _navForward : _navBack;
// Skip spots in files that have since been deleted
while ( from.Count > 0 && !File.Exists( from[^1].Path ) ) from.RemoveAt( from.Count - 1 );
if ( from.Count == 0 ) return;
var target = from[^1];
from.RemoveAt( from.Count - 1 );
if ( _active is not null ) to.Add( new NavPoint( _active.Path, _active.Editor.CaretPosition ) );
var doc = OpenFile( target.Path, preview: false );
doc?.Editor.GoTo( target.Pos.Line, target.Pos.Col );
}
[Shortcut( "hotcode.toggle-output", "CTRL+J" )]
public void ToggleOutput() => _output.Visible = !_output.Visible;
// --- FILE TREE ---
internal FileState StateOf( string path )
{
var doc = FindDocument( path );
return doc is null ? FileState.Closed : doc == _active ? FileState.Active : FileState.Open;
}
internal bool IsUnsaved( string path ) => FindDocument( path )?.IsDirty == true;
// --- MENU BAR ---
/// <summary>
/// File / Edit / View / Go / Project / Help. Shortcuts are shown after a tab ("Save\tCtrl+S"), which
/// Qt displays right-aligned without registering a second key binding next to the ones the editor handles.
/// Menus that show on/off state rebuild each time they open.
/// </summary>
private void BuildMenuBar()
{
MenuBar.Clear();
var file = MenuBar.AddMenu( "File" );
file.AddOption( "New File in Code Folder...", "note_add", () => NewFileInCodeFolder( component: false ) );
file.AddOption( "New C# Component...", "code", () => NewFileInCodeFolder( component: true ) );
file.AddOption( "Open File...", "file_open", OpenFileDialog );
file.AddSeparator();
file.AddOption( "Save\tCtrl+S", "save", Save );
file.AddOption( "Save All\tCtrl+Shift+S", "save_as", SaveAll );
file.AddSeparator();
file.AddOption( "Close Tab\tCtrl+W", "close", CloseActiveTab );
file.AddOption( "Close Other Tabs", "tab_unselected", CloseOtherTabs );
file.AddOption( "Close All Tabs", "clear_all", CloseAllTabs );
file.AddSeparator();
file.AddOption( "Reveal Current File in Explorer", "folder_open", () => { if ( _active is not null ) EditorUtility.OpenFileFolder( _active.Path ); } );
file.AddOption( "Copy Current File Path", "content_copy", () => { if ( _active is not null ) EditorUtility.Clipboard.Copy( _active.Path ); } );
file.AddSeparator();
file.AddOption( "Settings...\tCtrl+,", "settings", HotCodeSettingsWindow.Open );
file.AddOption( "HotCode Launcher", HotCodeLogo.IconName, () => new HotCodeLauncher() );
file.AddOption( "Close Window", "logout", () => Close() );
var edit = MenuBar.AddMenu( "Edit" );
edit.AddOption( "Undo\tCtrl+Z", "undo", () => _active?.Editor.Undo() );
edit.AddOption( "Redo\tCtrl+Y", "redo", () => _active?.Editor.Redo() );
edit.AddSeparator();
edit.AddOption( "Cut\tCtrl+X", "content_cut", () => _active?.Editor.Cut() );
edit.AddOption( "Copy\tCtrl+C", "content_copy", () => _active?.Editor.Copy() );
edit.AddOption( "Paste\tCtrl+V", "content_paste", () => _active?.Editor.Paste() );
edit.AddOption( "Select All\tCtrl+A", "select_all", () => _active?.Editor.SelectAll() );
edit.AddSeparator();
edit.AddOption( "Find\tCtrl+F", "search", () => _active?.Editor.OpenFind( replace: false ) );
edit.AddOption( "Replace\tCtrl+H", "find_replace", () => _active?.Editor.OpenFind( replace: true ) );
edit.AddSeparator();
edit.AddOption( "Toggle Comment\tCtrl+/", "comment", () => _active?.Editor.ToggleComment() );
edit.AddOption( "Duplicate Line\tCtrl+D", "content_copy", () => _active?.Editor.DuplicateLines() );
edit.AddOption( "Move Line Up\tAlt+Up", "arrow_upward", () => _active?.Editor.MoveLines( -1 ) );
edit.AddOption( "Move Line Down\tAlt+Down", "arrow_downward", () => _active?.Editor.MoveLines( 1 ) );
edit.AddSeparator();
edit.AddOption( "Trigger Suggestions\tCtrl+Space", "auto_awesome", () => _active?.Editor.RequestCompletionAtCaret() );
edit.AddOption( "Parameter Hints\tCtrl+Shift+Space", "info", () => _active?.Editor.RequestSignatureHelpAtCaret() );
var view = MenuBar.AddMenu( "View" );
view.AboutToShow += () => FillViewMenu( view );
var go = MenuBar.AddMenu( "Go" );
go.AddOption( "Go to Definition\tF12", "start", () => _active?.Editor.RequestDefinitionAtCaret() );
go.AddOption( "Go to Line...\tCtrl+G", "format_list_numbered", GoToLine );
go.AddSeparator();
go.AddOption( "Back\tAlt+Left", "arrow_back", () => Navigate( back: true ) );
go.AddOption( "Forward\tAlt+Right", "arrow_forward", () => Navigate( back: false ) );
go.AddSeparator();
go.AddOption( "Next Problem\tF8", "error", NextProblem );
go.AddOption( "Previous Problem\tShift+F8", "error_outline", PreviousProblem );
var project = MenuBar.AddMenu( "Project" );
project.AboutToShow += () => FillProjectMenu( project );
var help = MenuBar.AddMenu( "Help" );
help.AddOption( "Welcome Page / Shortcuts", "home", ShowWelcomePage );
help.AddOption( "Settings...", "settings", HotCodeSettingsWindow.Open );
help.AddOption( "About HotCode", HotCodeLogo.IconName, () =>
new PopupWindow( "About HotCode", "HotCode - a code editor built into s&box.\n\nRoslyn-powered autocomplete, parameter hints and go to definition, with live compile errors from s&box.", "Close", new Dictionary<string, Action>() ).Show() );
}
private void FillProjectMenu( Menu project )
{
project.Clear();
project.AddOption( "Open Project Folder", "folder_open", () => EditorUtility.OpenFolder( _root ) );
project.AddOption( "Open in External Code Editor", "launch", () => CodeEditor.OpenSolution() );
project.AddOption( "Generate Solution", "account_tree", () =>
{
EditorUtility.Projects.GenerateSolution();
Log.Info( "[HotCode] Generated solution files" );
} );
project.AddSeparator();
project.AddOption( "Refresh File Tree", "refresh", () => _rootNode.Dirty() );
project.AddSeparator();
HotCodeInstallUi.AddMenuOptions( project, _entry, null );
project.AddOption( "Add HotCode to Another Project...", HotCodeLogo.IconName, () =>
{
var fd = new FileDialog( null );
fd.Title = "Add HotCode to Project";
fd.Directory = Path.GetDirectoryName( _root );
fd.SetFindExistingFile();
fd.SetModeOpen();
fd.SetNameFilter( "s&box Project (*.sbproj)" );
if ( fd.Execute() ) HotCodeInstallUi.AskInstall( HotCodeEntry.FromPath( fd.SelectedFile ) );
} );
}
private void FillViewMenu( Menu view )
{
view.Clear();
AddToggle( view, "File Tree", "account_tree", _explorer.Visible, on => _explorer.Visible = on );
AddToggle( view, "Bottom Panel\tCtrl+J", "dock_to_bottom", _output.Visible, on => _output.Visible = on );
AddToggle( view, "Status Bar", "power_input", StatusBar.Visible, on => StatusBar.Visible = on );
view.AddSeparator();
AddToggle( view, "Word Wrap\tAlt+Z", "wrap_text", CodeView.WordWrap, on => CodeView.WordWrap = on );
view.AddSeparator();
view.AddOption( "Zoom In\tCtrl+=", "zoom_in", () => CodeView.FontSize += 1 );
view.AddOption( "Zoom Out\tCtrl+-", "zoom_out", () => CodeView.FontSize -= 1 );
view.AddOption( $"Reset Zoom ({CodeView.FontSize:0}pt)", "youtube_searched_for", () => CodeView.FontSize = CodeView.DefaultFontSize );
view.AddSeparator();
view.AddOption( "Problems", "error_outline", _output.ShowProblems );
view.AddOption( "Output", "terminal", _output.ShowOutput );
}
private static void AddToggle( Menu menu, string text, string icon, bool on, Action<bool> set )
{
var option = menu.AddOption( text, icon );
option.Checkable = true;
option.Checked = on;
option.Toggled += set;
}
/// <summary>
/// New files from the menu go in the project's Code folder (or the project root if there isn't one).
/// </summary>
private void NewFileInCodeFolder( bool component )
{
var code = Path.Combine( _root, "Code" );
var folder = Directory.Exists( code ) ? code : _root;
if ( component )
AskCreate( folder, "New Component", "Component name:", "MyComponent", name => ProjectFiles.CreateComponent( folder, name ) );
else
AskCreate( folder, "New File", "File name:", "NewFile.cs", name => ProjectFiles.CreateFile( folder, name ) );
}
private void OpenFileDialog()
{
var fd = new FileDialog( null );
fd.Title = "Open File";
fd.Directory = _active is not null ? Path.GetDirectoryName( _active.Path ) : _root;
fd.SetFindExistingFile();
fd.SetModeOpen();
if ( fd.Execute() ) OpenFile( fd.SelectedFile, preview: false );
}
[Shortcut( "hotcode.settings", "CTRL+," )]
public void OpenSettings() => HotCodeSettingsWindow.Open();
[Shortcut( "hotcode.save-all", "CTRL+SHIFT+S" )]
public void SaveAll()
{
foreach ( var doc in _documents.Where( d => d.IsDirty ).ToList() )
SaveDocument( doc );
}
private void CloseOtherTabs()
{
foreach ( var doc in _documents.Where( d => d != _active ).ToList() )
RequestClose( doc );
}
private void CloseAllTabs()
{
foreach ( var doc in _documents.ToList() )
RequestClose( doc );
}
/// <summary>
/// Shows the welcome page without closing anything; clicking a tab goes back to it.
/// </summary>
private void ShowWelcomePage()
{
if ( _active is not null ) _active.Editor.Visible = false;
_active = null;
_welcome.SetRecentFiles( RecentFiles() );
_welcome.Visible = true;
_tabs.SetActive( null );
_tree.Update();
}
[Shortcut( "hotcode.next-problem", "F8" )]
public void NextProblem() => StepProblem( 1 );
[Shortcut( "hotcode.previous-problem", "SHIFT+F8" )]
public void PreviousProblem() => StepProblem( -1 );
private int _problemIndex = -1;
private void StepProblem( int direction )
{
var problems = _output.Problems;
if ( problems.Count == 0 ) return;
_problemIndex = ((_problemIndex + direction) % problems.Count + problems.Count) % problems.Count;
GoToProblem( problems[_problemIndex] );
}
// --- EXPLORER HEADER ---
private Widget BuildExplorerHeader( Widget parent, string title )
{
var header = new Widget( parent );
header.FixedHeight = HotCodeStyle.TabHeight;
header.OnPaintOverride = () =>
{
Paint.ClearPen();
Paint.SetBrush( Theme.TabBarBackground );
Paint.DrawRect( header.LocalRect );
Paint.SetDefaultFont( 7, 700 );
Paint.SetPen( Theme.TextLight );
Paint.DrawText( new Rect( 10, 0, header.Width - 120, header.Height ), Paint.GetElidedText( $"EXPLORER - {title.ToUpperInvariant()}", header.Width - 130, ElideMode.Right ), TextFlag.LeftCenter );
return false;
};
header.Layout = Layout.Row();
header.Layout.Margin = new Sandbox.UI.Margin( 0, 0, 4, 0 );
header.Layout.AddStretchCell();
var rootFolder = Directory.Exists( Path.Combine( _root, "Code" ) ) ? Path.Combine( _root, "Code" ) : _root;
header.Layout.Add( new IconButton( "note_add", () => NewFileInCodeFolder( component: false ) ) { ToolTip = "New File" } );
header.Layout.Add( new IconButton( "create_new_folder", () => AskCreate( rootFolder, "New Folder", "Folder name:", "NewFolder", name => ProjectFiles.CreateFolder( rootFolder, name ) ) ) { ToolTip = "New Folder" } );
header.Layout.Add( new IconButton( "refresh", () => _rootNode.Dirty() ) { ToolTip = "Refresh" } );
header.Layout.Add( new IconButton( "unfold_less", () =>
{
_tree.Close( _rootNode, recursive: true );
_tree.Open( _rootNode );
} ) { ToolTip = "Collapse All" } );
return header;
}
// --- RECENT FILES ---
private string RecentFilesCookie => "hotcode.recentfiles." + _root.ToLowerInvariant().Replace( '\\', '/' );
private List<string> RecentFiles() => EditorCookie.Get<List<string>>( RecentFilesCookie, null ) ?? new();
private void AddRecentFile( string path )
{
var files = RecentFiles();
files.RemoveAll( f => string.Equals( f, path, StringComparison.OrdinalIgnoreCase ) );
files.Insert( 0, path );
if ( files.Count > 20 ) files.RemoveRange( 20, files.Count - 20 );
EditorCookie.Set( RecentFilesCookie, files );
}
// --- GO TO LINE ---
/// <summary>
/// Ctrl+G: "42" or "42:7" (line:column).
/// </summary>
[Shortcut( "hotcode.goto-line", "CTRL+G" )]
public void GoToLine()
{
var doc = _active;
if ( doc is null ) return;
var current = doc.Editor.CaretPosition;
Dialog.AskString( text =>
{
var parts = text.Split( ':', ',' );
if ( !int.TryParse( parts[0].Trim(), out var line ) ) return;
var column = parts.Length > 1 && int.TryParse( parts[1].Trim(), out var c ) ? c : 1;
PushNavPoint();
doc.Editor.GoTo( Math.Clamp( line, 1, doc.Editor.LineCount ) - 1, Math.Max( 1, column ) - 1 );
doc.Editor.FocusEditor();
}, $"Line number (1 - {doc.Editor.LineCount}), or line:column", okay: "Go", initialName: (current.Line + 1).ToString(), title: "Go to Line" );
}
// --- AUTO SAVE ---
/// <summary>
/// With Auto Save "after a short pause", saves files once you've stopped typing for a moment.
/// Saving is what triggers s&box's hotload, so the game keeps up with your edits.
/// </summary>
private void AutoSaveIfIdle()
{
if ( HotCodeSettings.AutoSave != AutoSaveMode.AfterDelay ) return;
foreach ( var doc in _documents )
{
if ( doc.IsDirty && RealTime.Now - doc.LastEdit > HotCodeSettings.AutoSaveDelay && File.Exists( doc.Path ) )
SaveDocument( doc );
}
}
// --- STATUS BAR ---
private void UpdateStatusBar()
{
// Is s&box compiling this project? If not, there's no live build to report on.
if ( RealTime.Now > _nextLoadedCheck )
{
_nextLoadedCheck = RealTime.Now + 2;
_projectLoaded = EditorUtility.Projects.GetAll().Any( p =>
{
var root = p.GetRootPath();
return !string.IsNullOrEmpty( root ) && (ProjectFiles.IsUnder( _root, root ) || ProjectFiles.IsUnder( root, _root ));
} );
}
if ( !_projectLoaded )
_buildStatus.Set( "Not loaded in s&box - no live compile", "cloud_off", Theme.TextLight );
else if ( _output.IsBuilding )
_buildStatus.Set( "Building...", "sync", Theme.Primary );
else if ( _output.ErrorCount > 0 )
_buildStatus.Set( $"{_output.ErrorCount} error{(_output.ErrorCount == 1 ? "" : "s")}, {_output.WarningCount} warning{(_output.WarningCount == 1 ? "" : "s")}", "error", Theme.Red );
else if ( _output.WarningCount > 0 )
_buildStatus.Set( $"Build OK, {_output.WarningCount} warning{(_output.WarningCount == 1 ? "" : "s")}", "warning", Theme.Yellow );
else if ( _output.LastBuildSucceeded == true )
_buildStatus.Set( "Build succeeded", "check_circle", Theme.Green );
else
_buildStatus.Set( "Ready", "check", Theme.TextLight );
var doc = _active;
if ( doc is null )
{
foreach ( var item in new[] { _position, _indent, _lineEnding, _encoding, _language } )
item.Set( "" );
return;
}
var caret = doc.Editor.CaretPosition;
var selected = doc.Editor.SelectionLength;
_position.Set( $"Ln {caret.Line + 1}, Col {caret.Col + 1}" + (selected > 0 ? $" ({selected} selected)" : "") );
_indent.Set( doc.Editor.IndentDescription );
_lineEnding.Set( doc.Editor.LineEndingName );
_encoding.Set( doc.HasBom ? "UTF-8 with BOM" : "UTF-8" );
_language.Set( doc.Editor.LanguageName, HotCodeStyle.FileIcon( doc.Path ), HotCodeStyle.FileIconColor( doc.Path ) );
}
internal void RegisterNode( CodeFileNode node )
{
if ( node.IsFolder ) _nodes[Normalize( node.FullPath )] = node;
}
private static string Normalize( string path ) => Path.GetFullPath( path ).TrimEnd( '\\', '/' );
/// <summary>
/// Rebuilds a folder's children in the tree (and opens it, so new items are visible).
/// </summary>
private void RefreshFolder( string folder, bool open = false )
{
if ( !_nodes.TryGetValue( Normalize( folder ), out var node ) ) return;
node.Dirty();
if ( open ) _tree.Open( node );
}
internal void ShowFileContextMenu( CodeFileNode node )
{
var path = node.FullPath;
var folder = node.IsFolder ? path : Path.GetDirectoryName( path );
var isRoot = string.Equals( Normalize( path ), Normalize( _root ), StringComparison.OrdinalIgnoreCase );
var menu = new ContextMenu( this );
if ( !node.IsFolder )
{
menu.AddOption( "Open", "open_in_new", () => OpenFile( path, preview: false ) );
menu.AddOption( "Open in External Editor", "launch", () => CodeEditor.OpenFile( path ) );
menu.AddSeparator();
}
menu.AddOption( "New File...", "note_add", () => AskCreate( folder, "New File", "File name:", "NewFile.cs", name => ProjectFiles.CreateFile( folder, name ) ) );
menu.AddOption( "New C# Component...", "code", () => AskCreate( folder, "New Component", "Component name:", "MyComponent", name => ProjectFiles.CreateComponent( folder, name ) ) );
menu.AddOption( "New Folder...", "create_new_folder", () => AskCreate( folder, "New Folder", "Folder name:", "NewFolder", name => ProjectFiles.CreateFolder( folder, name ) ) );
menu.AddSeparator();
if ( !isRoot )
{
menu.AddOption( "Rename...", "drive_file_rename_outline", () => AskRename( node ) );
menu.AddOption( "Delete", "delete", () => AskDelete( node ) );
menu.AddSeparator();
}
menu.AddOption( "Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy( path ) );
menu.AddOption( "Copy Relative Path", "content_copy", () => EditorUtility.Clipboard.Copy( Path.GetRelativePath( _root, path ) ) );
menu.AddOption( "Reveal in File Explorer", "folder_open", () => EditorUtility.OpenFileFolder( path ) );
if ( node.IsFolder )
menu.AddOption( "Refresh", "refresh", () => node.Dirty() );
menu.OpenAtCursor();
}
private void AskCreate( string folder, string title, string question, string suggestion, Func<string, string> create )
{
Dialog.AskString( name =>
{
if ( !TryFileOp( () => create( name ), out var created ) ) return;
RefreshFolder( folder, open: true );
if ( File.Exists( created ) ) OpenFile( created, preview: false );
}, question, okay: "Create", initialName: suggestion, title: title );
}
private void AskRename( CodeFileNode node )
{
var oldPath = node.FullPath;
Dialog.AskString( name =>
{
if ( !TryFileOp( () => ProjectFiles.Rename( oldPath, name ), out var newPath ) ) return;
// Open tabs follow the file (or everything inside a renamed folder)
foreach ( var doc in _documents )
{
if ( string.Equals( doc.Path, oldPath, StringComparison.OrdinalIgnoreCase ) )
doc.Path = newPath;
else if ( ProjectFiles.IsUnder( doc.Path, oldPath ) )
doc.Path = newPath + doc.Path[oldPath.Length..];
}
_tabs.Update();
RefreshFolder( Path.GetDirectoryName( oldPath ) );
}, "New name:", okay: "Rename", initialName: node.Name, title: "Rename" );
}
private void AskDelete( CodeFileNode node )
{
var path = node.FullPath;
var affected = _documents.Where( d => ProjectFiles.IsUnder( d.Path, path ) ).ToList();
var warning = affected.Any( d => d.IsDirty ) ? " Unsaved changes in open files will be lost." : "";
Dialog.AskConfirm( () =>
{
if ( !TryFileOp( () => { ProjectFiles.Delete( path ); return path; }, out _ ) ) return;
foreach ( var doc in affected ) CloseDocument( doc );
RefreshFolder( Path.GetDirectoryName( path ) );
}, $"Move '{node.Name}' to the Recycle Bin?{warning}", "Delete", "Delete", "Cancel" );
}
/// <summary>
/// Runs a file operation and shows any error instead of throwing.
/// </summary>
private bool TryFileOp( Func<string> op, out string result )
{
try
{
result = op();
return true;
}
catch ( Exception e ) when ( e is InvalidOperationException or IOException or UnauthorizedAccessException or OperationCanceledException )
{
result = null;
Log.Warning( $"[HotCode] {e.Message}" );
new PopupWindow( "HotCode", e.Message, "OK", new Dictionary<string, Action>() ).Show();
return false;
}
}
// --- WATCHING THE DISK ---
private void QueueChange( string path, bool structural )
{
// Build output churns constantly and is never shown; ignore it
if ( CodeFileNode.IsInHiddenFolder( Path.GetRelativePath( _root, path ) ) ) return;
_changedPaths.Enqueue( (path, structural) );
}
[EditorEvent.Frame]
public void Frame()
{
while ( _definitionResults.TryDequeue( out var definition ) )
ShowDefinitionResult( definition.Doc, definition.Result );
_completions.Pump();
_signatures.Pump();
UpdateStatusBar();
AutoSaveIfIdle();
if ( _changedPaths.IsEmpty ) return;
var folders = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
var files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
while ( _changedPaths.TryDequeue( out var change ) )
{
files.Add( change.Path );
var parent = Path.GetDirectoryName( change.Path );
if ( change.Structural && parent is not null ) folders.Add( parent );
}
foreach ( var folder in folders )
RefreshFolder( folder );
foreach ( var file in files )
ReloadIfChangedOnDisk( file );
}
/// <summary>
/// Picks up edits made by other programs to a file that's open but has no unsaved changes here.
/// </summary>
private void ReloadIfChangedOnDisk( string path )
{
var doc = FindDocument( path );
if ( doc is null || doc.IsDirty || !File.Exists( path ) || !HotCodeSettings.ReloadChangedFiles ) return;
if ( !TryReadFile( path, out var contents, out _ ) ) return;
// Our own saves land here too; only reload when the text really differs
if ( TextBuffer.Normalize( contents ) == TextBuffer.Normalize( doc.Editor.Text ) ) return;
doc.Editor.ReloadText( contents );
ApplyMarkers( doc );
Log.Info( $"[HotCode] Reloaded {doc.Title} (changed outside HotCode)" );
}
// --- CLOSING ---
[Shortcut( "hotcode.close-tab", "CTRL+W" )]
public void CloseActiveTab()
{
if ( _active is not null )
RequestClose( _active );
}
private void RequestClose( EditorDocument doc )
{
if ( !doc.IsDirty )
{
CloseDocument( doc );
return;
}
var confirm = new PopupWindow(
"Unsaved Changes", $"Save changes to {doc.Title}?", "Cancel",
new Dictionary<string, Action>()
{
{ "Don't Save", () => CloseDocument( doc ) },
{ "Save", () => { if ( SaveDocument( doc ) ) CloseDocument( doc ); } }
}
);
confirm.Show();
}
private void CloseDocument( EditorDocument doc )
{
var index = _documents.IndexOf( doc );
if ( index < 0 ) return;
var wasActive = doc == _active;
RemoveDocument( doc );
if ( wasActive )
{
// Same as most IDEs: move to the tab that slid into this spot, or the one before it
var next = _documents.Count == 0 ? null : _documents[Math.Min( index, _documents.Count - 1 )];
_active = null;
Activate( next );
}
UpdateTitle();
}
/// <summary>
/// Drops a document without any prompts or activating anything else.
/// </summary>
private void RemoveDocument( EditorDocument doc )
{
_documents.Remove( doc );
_tabs.Remove( doc );
doc.Editor.Destroy();
if ( _active == doc )
_active = null;
}
// --- SAVING ---
[Shortcut( "editor.save", "CTRL+S" )]
public void Save()
{
if ( _active is not null )
SaveDocument( _active );
}
private bool SaveDocument( EditorDocument doc )
{
if ( !doc.IsDirty )
return true;
if ( HotCodeSettings.TrimTrailingWhitespace || HotCodeSettings.InsertFinalNewline )
doc.Editor.FormatForSave( HotCodeSettings.TrimTrailingWhitespace, HotCodeSettings.InsertFinalNewline );
try
{
File.WriteAllText( doc.Path, doc.Editor.Text, new UTF8Encoding( doc.HasBom ) );
Log.Info( $"[HotCode] Saved {doc.Title}" );
}
catch ( Exception e ) when ( e is IOException or UnauthorizedAccessException )
{
Log.Warning( $"[HotCode] Couldn't save {doc.Path}: {e.Message}" );
return false;
}
doc.Editor.MarkSaved();
doc.IsDirty = false;
_tabs.Update();
_tree.Update();
UpdateTitle();
return true;
}
private void UpdateTitle()
{
var unsaved = _documents.Count( d => d.IsDirty );
WindowTitle = unsaved > 0
? $"HotCode - {_entry.Title} ({unsaved} unsaved)"
: $"HotCode - {_entry.Title}";
}
protected override bool OnClose()
{
var dirty = _documents.Where( d => d.IsDirty ).ToList();
if ( dirty.Count == 0 )
return true;
var confirm = new PopupWindow(
"Unsaved Changes", $"{dirty.Count} file(s) have unsaved changes. Save before closing?", "Cancel",
new Dictionary<string, Action>()
{
{ "Don't Save", () => { dirty.ForEach( d => d.IsDirty = false ); Close(); } },
{ "Save", () => { if ( dirty.All( SaveDocument ) ) Close(); } }
}
);
confirm.Show();
return false;
}
}