Files
tooll3--t3/Editor/UiModel/EditableSymbolProject.FileHandling.cs
T
2026-07-13 13:13:17 +08:00

329 lines
12 KiB
C#

#nullable enable
using System.Diagnostics;
using System.IO;
using System.Threading;
using Newtonsoft.Json;
using T3.Core.Model;
using T3.Core.Operator;
using T3.Core.Resource;
using T3.Editor.Compilation;
using T3.Editor.Gui.UiHelpers;
namespace T3.Editor.UiModel;
internal sealed partial class EditableSymbolProject
{
public void SaveAll()
{
lock (ProjectSetup.SymbolDataLock)
{
if (IsSaving)
{
Log.Error($"{CsProjectFile.Name}: Saving is already in progress.");
return;
}
if (UserSettings.Config.PreventSavingSymbolsWithMissingReferences && CorruptedSymbolFilePaths.Count > 0)
{
Log.Warning($"{CsProjectFile.Name}: Not saving — {CorruptedSymbolFilePaths.Count} operator file(s) "
+ "are corrupt. Restore an earlier backup to recover them; saving stays disabled until "
+ "then so nothing is overwritten. You can disable warning in Settings.");
return;
}
Log.Debug($"{CsProjectFile.Name}: Saving...");
MarkAsSaving();
WriteAllSymbolFilesOf(SymbolUiDict.Values);
UpdateLastModifiedDate();
UnmarkAsSaving();
}
}
internal void Update(out bool needsUpdating)
{
if (CodeExternallyModified)
{
CodeExternallyModified = false;
if (_lastRecompilationTimeUtc.HasValue && (DateTime.UtcNow - _lastRecompilationTimeUtc.Value).TotalSeconds < 0.5f)
{
Log.Info($"{DisplayName}: Skipping recompilation due to a presumed-misfired file change event");
needsUpdating = false;
return;
}
Log.Info($"{DisplayName}: Recompiling project due to external code change...");
needsUpdating = true;
if (!TryRecompile(false, out var failureLog))
{
Log.Error($"{DisplayName}: Recompilation failed.");
Log.Debug(failureLog);
}
}
else
{
needsUpdating = false;
}
}
internal void SaveModifiedSymbols()
{
lock (ProjectSetup.SymbolDataLock)
{
if (IsSaving)
{
Log.Error($"{CsProjectFile.Name}: Saving is already in progress.");
return;
}
if (CorruptedSymbolFilePaths.Count > 0)
{
// A corrupt file is present — skip the routine auto-save so nothing overwrites it.
// The prominent warning was already logged at startup.
Log.Debug($"{CsProjectFile.Name}: Skipping auto-save — corrupt operator file(s) present.");
return;
}
MarkAsSaving();
var modifiedSymbolUis = SymbolUiDict
.Select(x => x.Value)
.Where(symbolUi => symbolUi.NeedsSaving)
.ToArray();
if (modifiedSymbolUis.Length != 0)
{
Log.Debug($"{CsProjectFile.Name}: Saving {modifiedSymbolUis.Length} modified symbols...");
WriteAllSymbolFilesOf(modifiedSymbolUis);
UpdateLastModifiedDate();
}
UnmarkAsSaving();
}
}
public void UpdateLastModifiedDate() {
CsProjectFile.UpdateLastModifiedDate();
_allProjectsCache = null;
}
protected override void OnSymbolAdded(string? path, Symbol symbol)
{
path ??= SymbolPathHandler.GetCorrectPath(symbol.Name, symbol.Namespace, Folder, CsProjectFile.RootNamespace, SymbolExtension);
base.OnSymbolAdded(path, symbol);
if (!AutoOrganizeOnStartup)
return;
// ReSharper disable once HeuristicUnreachableCode
#pragma warning disable CS0162 // Unreachable code detected
FilePathHandlers[symbol.Id].AllFilesReady += CorrectFileLocations;
#pragma warning restore CS0162 // Unreachable code detected
}
protected override void OnSymbolUiLoaded(string? path, SymbolUi symbolUi)
{
symbolUi.ReadOnly = false;
path ??= SymbolPathHandler.GetCorrectPath(symbolUi.Symbol.Name, symbolUi.Symbol.Namespace, Folder, CsProjectFile.RootNamespace, SymbolUiExtension);
base.OnSymbolUiLoaded(path, symbolUi);
}
private void OnSymbolUpdated(Symbol symbol)
{
var filePathHandler = FilePathHandlers[symbol.Id];
if (symbol != filePathHandler.Symbol)
{
throw new Exception("Symbol mismatch when updating symbol files");
}
filePathHandler.UpdateFromSymbol();
}
/// <summary>
/// Removal is a feature unique to editable projects - all others are assumed to be read-only and unchanging
/// </summary>
/// <param name="id">Id of the symbol to be removed</param>
private void OnSymbolRemoved(Guid id)
{
SymbolDict.Remove(id, out var symbol);
Debug.Assert(symbol != null);
SymbolUiDict.Remove(id, out _);
// Drop the path handler too - a stale one would keep referencing the old Symbol object
// if a symbol with the same id is re-added later.
FilePathHandlers.Remove(id, out _);
Log.Info($"Removed symbol {symbol.Name}");
}
private static Action<SymbolPathHandler> CorrectFileLocations => handler =>
{
handler.AllFilesReady -= CorrectFileLocations;
handler.UpdateFromSymbol();
};
private void WriteAllSymbolFilesOf(IEnumerable<SymbolUi> symbolUis)
{
foreach (var symbolUi in symbolUis)
{
SaveSymbolFile(symbolUi);
}
}
private void SaveSymbolFile(SymbolUi symbolUi)
{
var symbol = symbolUi.Symbol;
// Data-loss guard: if children couldn't be resolved on load (usually a missing package), the
// in-memory symbol is a truncated copy. Writing it would strip those operators and their
// connections from the on-disk file. Leave the intact file untouched instead.
if (UserSettings.Config.PreventSavingSymbolsWithMissingReferences && symbol.HasUnresolvedChildren)
{
Log.Warning($"Not saving [{symbol.Name}]: {symbol.UnresolvedChildCount} operator(s) could not be "
+ "loaded — most likely a missing package. Leaving the existing file untouched so its "
+ "operators and connections aren't lost. Install the missing package and reload to edit it.");
return;
}
var id = symbol.Id;
var pathHandler = FilePathHandlers[id];
if (!pathHandler.TryCreateDirectory())
{
Log.Error($"Could not create directory for symbol {symbol.Id}");
return;
}
var sourceCodePath = pathHandler.SourceCodePath;
if (sourceCodePath != null)
{
WriteSymbolSourceToFile(id, sourceCodePath);
}
else
{
// This can happen when "saving all operators" and user project containing
// no longer defined operators
Log.Error($"{CsProjectFile.Name}: No source code path found for symbol {id}");
}
var symbolPath = pathHandler.SymbolFilePath ??= SymbolPathHandler.GetCorrectPath(symbol, this);
SaveSymbolDefinition(symbol, symbolPath);
pathHandler.SymbolFilePath = symbolPath;
var uiFilePath = pathHandler.UiFilePath ??= SymbolPathHandler.GetCorrectPath(symbolUi, this);
WriteSymbolUi(symbolUi, uiFilePath);
pathHandler.UiFilePath = uiFilePath;
#if DEBUG
var debug = $"{CsProjectFile.Name}: Saved [{symbol.Name}] to:\nSymbol: \"{symbolPath}\"\nUi: \"{uiFilePath}\"\nSource: \"{sourceCodePath}\"\n";
#else
var debug = $"{DisplayName}: Saved [{symbol.Name}]";
#endif
Log.Debug(debug);
}
private static void WriteSymbolUi(SymbolUi symbolUi, string uiFilePath)
{
using var sw = new StreamWriter(uiFilePath, _saveOptions);
using var writer = new JsonTextWriter(sw);
writer.Formatting = Formatting.Indented;
SymbolUiJson.WriteSymbolUi(symbolUi, writer);
symbolUi.ClearModifiedFlag();
}
private void SaveSymbolDefinition(Symbol symbol, string filePath)
{
using var sw = new StreamWriter(filePath, _saveOptions);
using var writer = new JsonTextWriter(sw);
writer.Formatting = Formatting.Indented;
SymbolJson.WriteSymbol(symbol, writer);
}
private void WriteSymbolSourceToFile(Guid id, string sourcePath)
{
if (!_pendingSource.Remove(id, out var sourceCode))
return;
using var sw = new StreamWriter(sourcePath, _saveOptions);
sw.Write(sourceCode);
}
public bool CodeExternallyModified { get; private set; }
private DateTime? _lastRecompilationTimeUtc;
private void MarkAsSaving() => _csFileWatcher.EnableRaisingEvents = Interlocked.Increment(ref _savingCount) <= 0;
private void UnmarkAsSaving()
{
var count = Interlocked.Decrement(ref _savingCount);
if (count < 0)
{
Log.Error($"Saving count is negative: {count}. This should not happen.");
_savingCount = count = 0;
}
_csFileWatcher.EnableRaisingEvents = count <= 0;
}
private void OnFileChanged(object sender, FileSystemEventArgs args)
{
var name = args.Name;
if (name == null)
return;
// generated file by dotnet - ignore
if (name.EndsWith("AssemblyInfo.cs"))
return;
if (name.StartsWith(@"bin\", StringComparison.Ordinal) || name.StartsWith("bin/", StringComparison.Ordinal))
return;
Log.Info($"{DisplayName}: Code file changed: {name}");
CodeExternallyModified = true;
ResourceFileWatcher.FileStateChangeCounter++;
//TryRecompile(true); // don't recompile here - we need to make sure this happens on the main thread
}
private void OnCodeFileRenamed(object sender, RenamedEventArgs args)
{
Log.Error($"{DisplayName}: File {args.OldFullPath} renamed to {args.FullPath}. Please do not do this while the editor is running.");
CodeExternallyModified = true;
//TryRecompile(true); // don't recompile here - we need to make sure this happens on the main thread
}
public override void LocateSourceCodeFiles()
{
MarkAsSaving();
base.LocateSourceCodeFiles();
UnmarkAsSaving();
}
public bool IsSaving => Interlocked.Read(ref _savingCount) > 0;
private long _savingCount;
private static readonly FileStreamOptions _saveOptions = new() { Mode = FileMode.Create, Access = FileAccess.ReadWrite };
private const bool AutoOrganizeOnStartup = false;
private readonly Dictionary<Guid, string> _pendingSource = new();
private sealed class CodeFileWatcher : FileSystemWatcher
{
public CodeFileWatcher(EditableSymbolProject project, FileSystemEventHandler onChange, RenamedEventHandler onRename) :
base(project.Folder, "*.cs")
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.FileName | NotifyFilters.DirectoryName;
IncludeSubdirectories = true;
Changed += onChange;
Created += onChange;
Renamed += onRename;
}
}
}