624 lines
23 KiB
C#
624 lines
23 KiB
C#
#nullable enable
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Threading.Tasks;
|
|
using ImGuiNET;
|
|
using T3.Core.SystemUi;
|
|
using T3.Core.Utils;
|
|
using T3.Editor.Compilation;
|
|
using T3.Editor.Gui.Window;
|
|
using T3.Editor.Gui.Input;
|
|
using T3.Editor.Gui.Styling;
|
|
using T3.Editor.Gui.UiHelpers;
|
|
using T3.Editor.Gui.UiHelpers.Thumbnails;
|
|
using T3.Editor.UiModel;
|
|
using T3.Editor.UiModel.ProjectHandling;
|
|
|
|
namespace T3.Editor.Gui.Hub;
|
|
|
|
[HelpUiID("ProjectPanel")]
|
|
internal static class ProjectsPanel
|
|
{
|
|
public static void Draw(GraphWindow window)
|
|
{
|
|
var heightForSkillQuest = UserSettings.Config.ShowSkillQuestInHub ? SkillQuestPanel.Height + 10 : 0;
|
|
|
|
ContentPanel.Begin("Projects", null, DrawProjectTools, -heightForSkillQuest);
|
|
|
|
FormInputs.AddVerticalSpace(20);
|
|
|
|
ImGui.BeginChild("content", new Vector2(0, 0), ImGuiChildFlags.Borders, ImGuiWindowFlags.NoBackground);
|
|
{
|
|
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(5, 5));
|
|
|
|
foreach (var package in EditableSymbolProject.AllProjects)
|
|
{
|
|
DrawProjectItem(window, package);
|
|
}
|
|
|
|
// 2. Draw Archived Projects (Lightweight)
|
|
if (ProjectSetup.ArchivedProjects.Count > 0)
|
|
{
|
|
ImGui.Separator();
|
|
FormInputs.AddSectionHeader("Archived");
|
|
ImGui.TextDisabled("Note: Loading reactivated projects might require a restart.");
|
|
for (var index = 0; index < ProjectSetup.ArchivedProjects.Count; index++)
|
|
{
|
|
var archived = ProjectSetup.ArchivedProjects[index];
|
|
DrawArchivedItem(archived);
|
|
}
|
|
}
|
|
|
|
// 3. Draw Broken Projects (failed to load — offer recovery)
|
|
if (ProjectSetup.BrokenProjects.Count > 0)
|
|
{
|
|
ImGui.Separator();
|
|
FormInputs.AddSectionHeader("Broken");
|
|
ImGui.TextDisabled("These projects could not be loaded. Right-click to restore an earlier backup.");
|
|
foreach (var broken in ProjectSetup.BrokenProjects)
|
|
{
|
|
DrawBrokenItem(broken);
|
|
}
|
|
}
|
|
|
|
ImGui.PopStyleVar();
|
|
}
|
|
ImGui.EndChild();
|
|
ContentPanel.End();
|
|
}
|
|
|
|
private static void DrawProjectTools()
|
|
{
|
|
var addProject = "Add Project...";
|
|
var size = CustomComponents.GetCtaButtonSize(addProject);
|
|
var state = EditableSymbolProject.AllProjects.Any()
|
|
? CustomComponents.ButtonStates.Default
|
|
: CustomComponents.ButtonStates.Activated;
|
|
CustomComponents.RightAlign(size.X + 10 * T3Ui.UiScaleFactor);
|
|
if (CustomComponents.DrawCtaButton(addProject, Icon.None, state))
|
|
{
|
|
T3Ui.NewProjectDialog.ShowNextFrame();
|
|
}
|
|
}
|
|
|
|
/// <summary>Draws one project row. Returns true when a click loaded the project into the given window.</summary>
|
|
internal static bool DrawProjectItem(GraphWindow window, EditorSymbolPackage package)
|
|
{
|
|
if (!package.HasHome)
|
|
return false;
|
|
|
|
var dl = ImGui.GetWindowDrawList();
|
|
|
|
ImGui.PushID(package.DisplayName);
|
|
var isOpened = OpenedProject.OpenedProjects.TryGetValue(package, out var openedProject);
|
|
var name = package.DisplayName;
|
|
var clicked = ImGui.InvisibleButton(name, ProjectItemSize);
|
|
var isHovered = ImGui.IsItemHovered();
|
|
var backgroundColor = isHovered
|
|
? UiColors.ForegroundFull.Fade(0.1f)
|
|
: UiColors.ForegroundFull.Fade(0.05f);
|
|
|
|
var min = ImGui.GetItemRectMin();
|
|
var max = ImGui.GetItemRectMax();
|
|
dl.AddRectFilled(min, max, backgroundColor, 6);
|
|
|
|
var padding = 3f * T3Ui.UiScaleFactor;
|
|
var hasCorruptFiles = package.HasCorruptedSymbolFiles;
|
|
if (hasCorruptFiles || isOpened)
|
|
{
|
|
// Magenta "needs attention" bar for a project with corrupt files; the normal active bar otherwise.
|
|
dl.AddRectFilled(min + Vector2.One * padding,
|
|
new Vector2(min.X + padding + 4, max.Y - padding),
|
|
hasCorruptFiles ? UiColors.StatusAttention : UiColors.BackgroundActive, 2);
|
|
}
|
|
|
|
var rootName = package.RootNamespace.Split(".")[^1];
|
|
if (isOpened)
|
|
rootName += " (loaded)";
|
|
|
|
var y = padding;
|
|
var x = 20f;
|
|
dl.AddText(Fonts.FontBold,
|
|
Fonts.FontBold.FontSize,
|
|
min + new Vector2(x, y),
|
|
UiColors.Text, rootName);
|
|
|
|
y += Fonts.FontNormal.FontSize + 5;
|
|
|
|
dl.AddText(Fonts.FontSmall,
|
|
Fonts.FontSmall.FontSize,
|
|
min + new Vector2(x, y),
|
|
UiColors.TextMuted, package.RootNamespace);
|
|
|
|
y += Fonts.FontSmall.FontSize + 5;
|
|
|
|
if (hasCorruptFiles)
|
|
{
|
|
var count = package.CorruptedSymbolFilePaths.Count;
|
|
var corruptLabel = count == 1
|
|
? "1 corrupt file - restore from backup"
|
|
: $"{count} corrupt files - restore from backup";
|
|
dl.AddText(Fonts.FontSmall, Fonts.FontSmall.FontSize, min + new Vector2(x, y),
|
|
UiColors.StatusAttention, corruptLabel);
|
|
}
|
|
else
|
|
{
|
|
dl.AddText(Fonts.FontSmall, Fonts.FontSmall.FontSize, min + new Vector2(x, y),
|
|
UiColors.TextMuted, GetSavedInfoLabel(package));
|
|
}
|
|
|
|
if (hasCorruptFiles && isHovered)
|
|
{
|
|
ImGui.BeginTooltip();
|
|
ImGui.TextColored(UiColors.StatusAttention, "Corrupt operator file(s) - restore an earlier backup to recover:");
|
|
foreach (var path in package.CorruptedSymbolFilePaths)
|
|
ImGui.TextUnformatted(path);
|
|
ImGui.EndTooltip();
|
|
}
|
|
|
|
var thumbnail = ThumbnailManager.GetThumbnail(package.Id, package, ThumbnailManager.Categories.PackageMeta);
|
|
if (thumbnail.IsReady && ThumbnailManager.AtlasSrv != null)
|
|
{
|
|
var height = ProjectItemSize.Y - padding * 2;
|
|
var size = new Vector2(height * 4 / 3f, height);
|
|
var pos = new Vector2(max.X - size.X - padding, min.Y + padding);
|
|
dl.AddImage(ThumbnailManager.AtlasSrv.NativePointer,
|
|
pos,
|
|
pos + size,
|
|
thumbnail.UvMin, thumbnail.UvMax);
|
|
}
|
|
|
|
var wasOpened = false;
|
|
if (clicked && hasCorruptFiles)
|
|
{
|
|
// Don't open a project with corrupt files — it would only show the empty shell that loaded
|
|
// in their place. Recovery is restore-from-backup (right-click), then restart.
|
|
Log.Warning($"'{package.DisplayName}' has corrupt operator file(s) and can't be opened. "
|
|
+ "Right-click it and restore an earlier backup first.");
|
|
}
|
|
else if (clicked)
|
|
{
|
|
// No early return on failure — the PushID/PopID pair below must stay balanced.
|
|
if (!isOpened && package is EditorSymbolPackage editorPackage2)
|
|
{
|
|
if (!OpenedProject.TryCreate(editorPackage2, out openedProject, out var error))
|
|
{
|
|
Log.Warning($"Failed to load project: {error}");
|
|
}
|
|
}
|
|
|
|
if (openedProject != null)
|
|
{
|
|
wasOpened = window.TrySetToProject(openedProject);
|
|
}
|
|
}
|
|
|
|
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(5, 5));
|
|
if (ImGui.BeginPopupContextItem("windows_context_menu"))
|
|
{
|
|
if (ImGui.MenuItem("Reveal in Explorer"))
|
|
{
|
|
CoreUi.Instance.OpenWithDefaultApplication(package.Folder);
|
|
}
|
|
|
|
if (ImGui.BeginMenu("Restore from backup"))
|
|
{
|
|
DrawRestoreBackupItems(package.DisplayName, package.Folder);
|
|
ImGui.EndMenu();
|
|
}
|
|
|
|
if (ImGui.MenuItem("Unload project", "", false, isOpened))
|
|
{
|
|
if (OpenedProject.TryUnload(package))
|
|
{
|
|
Log.Debug($"Project '{package.DisplayName}' unloaded successfully");
|
|
}
|
|
}
|
|
|
|
if(ImGui.MenuItem("Archive project", "", false))
|
|
{
|
|
if (package is EditableSymbolProject editableProject)
|
|
{
|
|
if (isOpened)
|
|
OpenedProject.TryUnload(package);
|
|
|
|
ProjectSetup.SetProjectArchived(editableProject.CsProjectFile, true);
|
|
}
|
|
}
|
|
|
|
ImGui.EndPopup();
|
|
}
|
|
|
|
ImGui.PopStyleVar();
|
|
|
|
ImGui.PopID();
|
|
return wasOpened;
|
|
}
|
|
|
|
private static void DrawArchivedItem(ProjectSetup.ArchivedProjectInfo archivedProject)
|
|
{
|
|
var dl = ImGui.GetWindowDrawList();
|
|
|
|
ImGui.PushID(archivedProject.Name);
|
|
var name = archivedProject.Name;
|
|
var clicked = ImGui.InvisibleButton(name, ProjectItemSize);
|
|
var isHovered = ImGui.IsItemHovered();
|
|
var backgroundColor = isHovered
|
|
? UiColors.ForegroundFull.Fade(0.1f)
|
|
: UiColors.ForegroundFull.Fade(0.05f);
|
|
|
|
var min = ImGui.GetItemRectMin();
|
|
var max = ImGui.GetItemRectMax();
|
|
dl.AddRectFilled(min, max, backgroundColor, 6);
|
|
|
|
var padding = 3f * T3Ui.UiScaleFactor;
|
|
|
|
var rootName = archivedProject.RootNamespace?.Split(".")[^1];
|
|
|
|
var y = padding;
|
|
var x = 20f;
|
|
dl.AddText(Fonts.FontBold,
|
|
Fonts.FontBold.FontSize,
|
|
min + new Vector2(x, y),
|
|
UiColors.Text, rootName);
|
|
|
|
y += Fonts.FontNormal.FontSize + 5;
|
|
|
|
dl.AddText(Fonts.FontSmall,
|
|
Fonts.FontSmall.FontSize,
|
|
min + new Vector2(x, y),
|
|
UiColors.TextMuted, archivedProject.Folder);
|
|
|
|
y += Fonts.FontSmall.FontSize + 5;
|
|
|
|
dl.AddText(Fonts.FontSmall,
|
|
Fonts.FontSmall.FontSize,
|
|
min + new Vector2(x, y),
|
|
UiColors.TextMuted, archivedProject.Folder);
|
|
|
|
var thumbnail = ThumbnailManager.GetThumbnail(archivedProject.Id, archivedProject, ThumbnailManager.Categories.PackageMeta);
|
|
if (thumbnail.IsReady && ThumbnailManager.AtlasSrv != null)
|
|
{
|
|
var height = ProjectItemSize.Y - padding * 2;
|
|
var size = new Vector2(height * 4 / 3f, height);
|
|
var pos = new Vector2(max.X - size.X - padding, min.Y + padding);
|
|
dl.AddImage(ThumbnailManager.AtlasSrv.NativePointer,
|
|
pos,
|
|
pos + size,
|
|
thumbnail.UvMin, thumbnail.UvMax);
|
|
}
|
|
|
|
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(5, 5));
|
|
if (ImGui.BeginPopupContextItem("windows_context_menu"))
|
|
{
|
|
if(ImGui.MenuItem("Reactivate project", "", false))
|
|
{
|
|
ProjectSetup.SetProjectArchived(archivedProject.ProjectFile, false);
|
|
}
|
|
ImGui.EndPopup();
|
|
}
|
|
|
|
ImGui.PopStyleVar();
|
|
|
|
ImGui.PopID();
|
|
}
|
|
|
|
private static void DrawBrokenItem(ProjectSetup.BrokenProjectInfo broken)
|
|
{
|
|
var dl = ImGui.GetWindowDrawList();
|
|
|
|
ImGui.PushID(broken.Folder);
|
|
ImGui.InvisibleButton(broken.Name, ProjectItemSize);
|
|
var isHovered = ImGui.IsItemHovered();
|
|
var backgroundColor = isHovered
|
|
? UiColors.ForegroundFull.Fade(0.1f)
|
|
: UiColors.ForegroundFull.Fade(0.05f);
|
|
|
|
var min = ImGui.GetItemRectMin();
|
|
var max = ImGui.GetItemRectMax();
|
|
dl.AddRectFilled(min, max, backgroundColor, 6);
|
|
|
|
// A magenta bar on the left flags "needs attention", matching the status-color convention.
|
|
var padding = 3f * T3Ui.UiScaleFactor;
|
|
dl.AddRectFilled(min + Vector2.One * padding,
|
|
new Vector2(min.X + padding + 4, max.Y - padding),
|
|
UiColors.StatusAttention, 2);
|
|
|
|
var y = padding;
|
|
var x = 20f;
|
|
dl.AddText(Fonts.FontBold, Fonts.FontBold.FontSize, min + new Vector2(x, y), UiColors.Text, broken.Name);
|
|
|
|
y += Fonts.FontNormal.FontSize + 5;
|
|
dl.AddText(Fonts.FontSmall, Fonts.FontSmall.FontSize, min + new Vector2(x, y),
|
|
UiColors.TextMuted, broken.RootNamespace ?? broken.Folder);
|
|
|
|
y += Fonts.FontSmall.FontSize + 5;
|
|
dl.AddText(Fonts.FontSmall, Fonts.FontSmall.FontSize, min + new Vector2(x, y),
|
|
UiColors.StatusAttention, broken.Reason);
|
|
|
|
if (isHovered && !string.IsNullOrEmpty(broken.Hint))
|
|
{
|
|
ImGui.BeginTooltip();
|
|
ImGui.TextUnformatted(broken.Hint);
|
|
ImGui.EndTooltip();
|
|
}
|
|
|
|
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(5, 5));
|
|
if (ImGui.BeginPopupContextItem("broken_context_menu"))
|
|
{
|
|
if (ImGui.BeginMenu("Restore from backup"))
|
|
{
|
|
DrawRestoreBackupItems(broken.Name, broken.Folder);
|
|
ImGui.EndMenu();
|
|
}
|
|
|
|
if (ImGui.MenuItem("Reveal in Explorer"))
|
|
{
|
|
CoreUi.Instance.OpenWithDefaultApplication(broken.Folder);
|
|
}
|
|
|
|
ImGui.EndPopup();
|
|
}
|
|
|
|
ImGui.PopStyleVar();
|
|
|
|
ImGui.PopID();
|
|
}
|
|
|
|
public static Vector2 ProjectItemSize => new Vector2(400, 65) * T3Ui.UiScaleFactor;
|
|
|
|
/// <summary>
|
|
/// "Saved 23 days ago with v4.2" for a project row. Composing it needs file mtime IO, so the
|
|
/// label is cached per project and refreshed only every few seconds.
|
|
/// </summary>
|
|
private static string GetSavedInfoLabel(EditorSymbolPackage package)
|
|
{
|
|
var now = ImGui.GetTime();
|
|
if (_savedInfoLabels.TryGetValue(package.Folder, out var cached) && now - cached.LastUpdate < 10)
|
|
return cached.Label;
|
|
|
|
var label = BuildSavedInfoLabel(package);
|
|
_savedInfoLabels[package.Folder] = (now, label);
|
|
return label;
|
|
}
|
|
|
|
private static string BuildSavedInfoLabel(EditorSymbolPackage package)
|
|
{
|
|
DateTime? savedAt = null;
|
|
try
|
|
{
|
|
// The editor rewrites the csproj on every save, so its mtime tracks the last save.
|
|
foreach (var csproj in Directory.EnumerateFiles(package.Folder, "*.csproj", SearchOption.TopDirectoryOnly))
|
|
{
|
|
savedAt = File.GetLastWriteTime(csproj);
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Debug($"Could not read save time for {package.DisplayName}: {e.Message}");
|
|
}
|
|
|
|
var savedPart = savedAt == null
|
|
? "Save time unknown"
|
|
: $"Saved {savedAt.GetReadableRelativeTime()}";
|
|
|
|
if (package.AssemblyInformation.TryGetReleaseInfo(out var releaseInfo))
|
|
{
|
|
return $"{savedPart} with v{releaseInfo.EditorVersion.Major}.{releaseInfo.EditorVersion.Minor}";
|
|
}
|
|
|
|
return savedPart;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Items of the "Restore from backup" submenu, newest first. Labels and the underlying
|
|
/// directory scan are cached and refreshed only every couple of seconds — the submenu body
|
|
/// runs every frame while open and must not do per-frame IO or string building.
|
|
/// </summary>
|
|
private static void DrawRestoreBackupItems(string displayName, string folder)
|
|
{
|
|
var now = ImGui.GetTime();
|
|
if (_backupMenuFolder != folder || now - _backupMenuUpdateTime > 2)
|
|
{
|
|
RefreshBackupMenuItems(folder);
|
|
_backupMenuFolder = folder;
|
|
_backupMenuUpdateTime = now;
|
|
}
|
|
|
|
if (_backupMenuItems.Count == 0)
|
|
{
|
|
ImGui.MenuItem("No backups found", "", false, false);
|
|
return;
|
|
}
|
|
|
|
foreach (var (label, backup) in _backupMenuItems)
|
|
{
|
|
// Pre-restore snapshots are the "before I restored" safety copies — often of the very state
|
|
// being recovered from — so mute them; they're rarely the version to restore *to*.
|
|
var isPreRestore = backup.KeepTag != null && backup.KeepTag.StartsWith("preRestore", StringComparison.Ordinal);
|
|
if (isPreRestore)
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
|
|
|
|
if (ImGui.MenuItem(label))
|
|
{
|
|
T3Ui.RestoreBackupDialog.ShowNextFrame(displayName, folder, backup);
|
|
}
|
|
|
|
if (ImGui.IsItemHovered())
|
|
DrawBackupTooltip(backup);
|
|
|
|
if (isPreRestore)
|
|
ImGui.PopStyleColor();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hover detail for a backup: absolute timestamp plus operator/connection counts. The counts need
|
|
/// decompressing the .t3 files, so they're computed lazily (only for hovered backups) on a
|
|
/// background thread and shown as "counting..." until ready.
|
|
/// </summary>
|
|
private static void DrawBackupTooltip(AutoBackup.AutoBackup.BackupEntry backup)
|
|
{
|
|
QueueBackupContentStats(backup.FilePath);
|
|
|
|
ImGui.BeginTooltip();
|
|
|
|
var absolute = backup.Timestamp == DateTime.MinValue
|
|
? "Unknown time"
|
|
: backup.Timestamp.ToString("yyyy-MM-dd HH:mm:ss");
|
|
ImGui.TextUnformatted(absolute);
|
|
|
|
var detail = _backupContentStats.TryGetValue(backup.FilePath, out var stats)
|
|
? $"{stats.Operators} operators, {stats.Connections} connections"
|
|
: "counting...";
|
|
ImGui.TextColored(UiColors.TextMuted, detail);
|
|
|
|
ImGui.EndTooltip();
|
|
}
|
|
|
|
private static void RefreshBackupMenuItems(string projectFolder)
|
|
{
|
|
_backupMenuItems.Clear();
|
|
foreach (var backup in AutoBackup.AutoBackup.EnumerateBackupsFor(projectFolder))
|
|
{
|
|
_backupMenuItems.Add((BuildBackupMenuLabel(backup), backup));
|
|
}
|
|
}
|
|
|
|
private static string BuildBackupMenuLabel(AutoBackup.AutoBackup.BackupEntry backup)
|
|
{
|
|
var when = backup.Timestamp == DateTime.MinValue
|
|
? "unknown time"
|
|
: ((DateTime?)backup.Timestamp).GetReadableRelativeTime();
|
|
|
|
var symbolCount = GetBackupSymbolCount(backup.FilePath);
|
|
var symbolsPart = symbolCount < 0 ? "" : $" · {symbolCount} symbols";
|
|
|
|
var markers = "";
|
|
if (backup.IsMinimal)
|
|
markers += " · minimal";
|
|
var keepLabel = FriendlyKeepTag(backup.KeepTag);
|
|
if (keepLabel != null)
|
|
markers += $" · {keepLabel}";
|
|
|
|
// Operator/connection counts need decompression and are shown in the hover tooltip instead.
|
|
return $"v{backup.Index} {when}{symbolsPart} · {FormatSize(backup.SizeBytes)}{markers}";
|
|
}
|
|
|
|
/// <summary>Human-readable label for a pinned backup's keep-tag ("preRestore…" / "preFormat…").</summary>
|
|
private static string? FriendlyKeepTag(string? tag)
|
|
{
|
|
if (string.IsNullOrEmpty(tag))
|
|
return null;
|
|
if (tag.StartsWith("preRestore", StringComparison.Ordinal))
|
|
return "pre-restore";
|
|
if (tag.StartsWith("preFormat", StringComparison.Ordinal))
|
|
return "pre-format-upgrade";
|
|
return "kept";
|
|
}
|
|
|
|
/// <summary>Number of symbol (.t3) files in a backup zip, from the zip index only. Cached by path (zips are immutable).</summary>
|
|
private static int GetBackupSymbolCount(string zipPath)
|
|
{
|
|
if (_backupSymbolCountsByPath.TryGetValue(zipPath, out var cached))
|
|
return cached;
|
|
|
|
var count = 0;
|
|
try
|
|
{
|
|
using var archive = ZipFile.OpenRead(zipPath);
|
|
foreach (var entry in archive.Entries)
|
|
{
|
|
if (entry.Name.EndsWith(".t3", StringComparison.OrdinalIgnoreCase))
|
|
count++;
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Debug($"Could not read backup archive {zipPath}: {e.Message}");
|
|
count = -1;
|
|
}
|
|
|
|
_backupSymbolCountsByPath[zipPath] = count;
|
|
return count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Counts operators (symbol-children) and connections inside a backup's .t3 files. This needs
|
|
/// decompression, so it runs once on a background thread and caches by path (zips are immutable);
|
|
/// the menu label picks the result up on its next refresh.
|
|
/// </summary>
|
|
private static void QueueBackupContentStats(string zipPath)
|
|
{
|
|
if (_backupContentStats.ContainsKey(zipPath) || !_contentStatsQueued.Add(zipPath))
|
|
return;
|
|
|
|
Task.Run(() =>
|
|
{
|
|
try
|
|
{
|
|
_backupContentStats[zipPath] = ComputeBackupContentStats(zipPath);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Log.Debug($"Could not compute backup stats for {zipPath}: {e.Message}");
|
|
}
|
|
});
|
|
}
|
|
|
|
private static BackupContentStats ComputeBackupContentStats(string zipPath)
|
|
{
|
|
var operators = 0;
|
|
var connections = 0;
|
|
|
|
using var archive = ZipFile.OpenRead(zipPath);
|
|
foreach (var entry in archive.Entries)
|
|
{
|
|
if (!entry.Name.EndsWith(".t3", StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
using var reader = new StreamReader(entry.Open());
|
|
var text = reader.ReadToEnd();
|
|
|
|
// Each symbol-child serializes exactly one "SymbolId" key, each connection one
|
|
// "SourceParentOrChildId". Counting the quoted keys avoids parsing the whole document.
|
|
operators += CountOccurrences(text, "\"SymbolId\"");
|
|
connections += CountOccurrences(text, "\"SourceParentOrChildId\"");
|
|
}
|
|
|
|
return new BackupContentStats(operators, connections);
|
|
}
|
|
|
|
private static int CountOccurrences(string haystack, string needle)
|
|
{
|
|
var count = 0;
|
|
var index = 0;
|
|
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
index += needle.Length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static string FormatSize(long bytes)
|
|
{
|
|
const long mb = 1024 * 1024;
|
|
return bytes >= mb
|
|
? $"{bytes / (float)mb:0.0} MB"
|
|
: $"{bytes / 1024} KB";
|
|
}
|
|
|
|
private readonly record struct BackupContentStats(int Operators, int Connections);
|
|
|
|
private static readonly Dictionary<string, (double LastUpdate, string Label)> _savedInfoLabels = new();
|
|
private static readonly List<(string Label, AutoBackup.AutoBackup.BackupEntry Entry)> _backupMenuItems = new();
|
|
private static readonly Dictionary<string, int> _backupSymbolCountsByPath = new();
|
|
private static readonly ConcurrentDictionary<string, BackupContentStats> _backupContentStats = new();
|
|
private static readonly HashSet<string> _contentStatsQueued = new();
|
|
private static string? _backupMenuFolder;
|
|
private static double _backupMenuUpdateTime;
|
|
} |