Files
YachtDice/Packages/com.singularitygroup.hotreload/Editor/BugReport/HotReloadBugReportWindow.cs
T
2026-03-28 12:54:41 +07:00

617 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEditor;
using UnityEngine;
using UnityEditor.UIElements;
using UnityEngine.UIElements;
using SingularityGroup.HotReload.Editor.Localization;
using Application = UnityEngine.Application;
#if UNITY_2021_3_OR_NEWER
using System.IO;
using System.IO.Compression;
using SingularityGroup.HotReload.Editor.Cli;
using SingularityGroup.HotReload.EditorDependencies;
using CompressionLevel = System.IO.Compression.CompressionLevel;
#endif
namespace SingularityGroup.HotReload.Editor {
internal enum ReportMode {
BugReport,
Feedback
}
internal class HotReloadBugReportWindow : EditorWindow {
static List<string> SeverityChoices => new List<string> {
Translations.BugReport.SeverityNormal,
Translations.BugReport.SeverityLow,
Translations.BugReport.SeverityHigh,
};
static List<string> FrequencyChoices => new List<string> {
Translations.BugReport.FrequencyFirstTime,
Translations.BugReport.FrequencySometimes,
Translations.BugReport.FrequencyAlways,
};
[SerializeField] ReportMode _mode = ReportMode.BugReport;
[SerializeField] VisualTreeAsset visualTree;
[SerializeField] StyleSheet styleSheet;
string _discordUrl;
string _contactUrl;
TextField _titleField;
PopupField<string> _severityPopup;
PopupField<string> _frequencyPopup;
TextField _emailField;
IMGUIContainer _detailsContainer;
[SerializeField] string _detailsText = "";
[SerializeField] string _emailText = "";
[SerializeField] string _titleText = "";
Label _detailsLabel;
Label _validationLabel;
Button _submitButton;
VisualElement _severitySection;
VisualElement _frequencySection;
ScrollView _scrollView;
VisualElement _detailsSlot;
VisualElement _successScreen;
VisualElement _failureScreen;
Label _failureErrorLabel;
Button _tabBugReport;
Button _tabFeedback;
bool _isOnResultScreen;
static Vector2 GetMinSize(ReportMode mode) {
return mode == ReportMode.Feedback
? new Vector2(420, 500)
: new Vector2(420, 620);
}
#region Public API
/// <summary>
/// Opens the window with the given mode and configuration.
/// Prefer using <see cref="ReportWindowAPI"/> for a cleaner call site.
/// </summary>
public static HotReloadBugReportWindow Open(
ReportMode mode,
string discordUrl = null,
string contactUrl = null,
string title = null,
string email = null,
string details = null
) {
var wnd = GetWindow<HotReloadBugReportWindow>(utility: true);
wnd._mode = mode;
wnd._discordUrl = discordUrl;
wnd._contactUrl = contactUrl;
wnd.titleContent = new GUIContent(
mode == ReportMode.Feedback
? Translations.BugReport.WindowTitleFeedback
: Translations.BugReport.WindowTitleBugReport);
wnd.minSize = wnd.maxSize = GetMinSize(mode);
wnd._titleText = title;
wnd._emailText = email;
wnd._detailsText = details;
wnd.RebuildUI();
return wnd;
}
/// <summary>
/// Prefills form fields on an already-open window. Null values are ignored.
/// </summary>
public void Prefill() {
if (_titleText != null && _titleField != null) {
_titleField.SetValueWithoutNotify(_titleText);
}
if (_emailText != null && _emailField != null) {
_emailField.SetValueWithoutNotify(_emailText);
}
}
#endregion
void OnEnable() {
RebuildUI();
}
bool HasUnsavedFormContent() {
if (_isOnResultScreen) {
return false;
}
if (!string.IsNullOrWhiteSpace(_titleField.value)) {
return true;
}
if (!string.IsNullOrWhiteSpace(_detailsText) && _detailsText != Translations.BugReport.PlaceholderDetails) {
return true;
}
return false;
}
void OnDestroy() {
if (_detailsContainer != null && _detailsSlot != null) {
_detailsSlot.Remove(_detailsContainer);
_detailsContainer = null;
}
if (!HasUnsavedFormContent()) {
return;
}
bool discard = EditorUtility.DisplayDialog(
Translations.BugReport.DiscardDialogTitle,
Translations.BugReport.DiscardDialogMessage,
Translations.BugReport.DiscardDialogConfirm,
Translations.BugReport.DiscardDialogCancel);
if (!discard) {
_emailText = _emailField.value;
_titleText = _titleField.value;
EditorApplication.delayCall += () => {
Open(_mode, _discordUrl, _contactUrl);
};
} else {
_detailsText = null;
_emailText = null;
_titleText = null;
}
}
void RebuildUI() {
DetachCallbacks();
if (visualTree == null) {
Log.Warning($"Could not open bug report. Please reach out to our support: {_contactUrl}");
return;
}
rootVisualElement.Clear();
visualTree.CloneTree(rootVisualElement);
if (styleSheet != null) {
rootVisualElement.styleSheets.Add(styleSheet);
}
QueryElements();
AttachIMGUIContainers();
ApplyTranslations();
CreatePopupFields();
ApplyMode();
SetupSubmit();
SetupResultScreenButtons();
FixSingleLineFieldAlignment(_titleField);
FixSingleLineFieldAlignment(_emailField);
Prefill();
AttachCallbacks();
ShowFormView();
}
void AttachIMGUIContainers() {
_detailsSlot = rootVisualElement.Q<VisualElement>("details-field-slot");
// Build the IMGUI textarea and inject it
_detailsContainer = new IMGUIContainer(DrawDetailsField);
_detailsSlot.Add(_detailsContainer);
}
void QueryElements() {
_titleField = rootVisualElement.Q<TextField>("title-field");
_emailField = rootVisualElement.Q<TextField>("email-field");
_detailsLabel = rootVisualElement.Q<Label>("details-label");
_validationLabel = rootVisualElement.Q<Label>("validation-label");
_submitButton = rootVisualElement.Q<Button>("submit-button");
_severitySection = rootVisualElement.Q<VisualElement>("severity-section");
_frequencySection = rootVisualElement.Q<VisualElement>("frequency-section");
_scrollView = rootVisualElement.Q<ScrollView>("scroll-view");
_successScreen = rootVisualElement.Q<VisualElement>("success-screen");
_failureScreen = rootVisualElement.Q<VisualElement>("failure-screen");
_failureErrorLabel = rootVisualElement.Q<Label>("failure-error-label");
_tabBugReport = rootVisualElement.Q<Button>("tab-bug-report");
_tabFeedback = rootVisualElement.Q<Button>("tab-feedback");
}
private void DrawDetailsField() {
// GUILayout.ExpandHeight lets it grow; clamp min so it doesn't collapse
_detailsText = EditorGUILayout.TextArea(
_detailsText,
GUILayout.ExpandWidth(true),
GUILayout.MinHeight(160)
);
}
void ApplyTranslations() {
// Form labels
rootVisualElement.Q<Label>(className: "section-label") // Title section — first one
?.SetText(Translations.BugReport.LabelTitle);
rootVisualElement.Q("severity-section")?.Q<Label>(className: "section-label")
?.SetText(Translations.BugReport.LabelIssueSeverity);
rootVisualElement.Q("frequency-section")?.Q<Label>(className: "section-label")
?.SetText(Translations.BugReport.LabelHowOften);
rootVisualElement.Q("tabs")?.Q<Button>("tab-bug-report")
?.SetText(Translations.BugReport.TabBugReport);
rootVisualElement.Q("tabs")?.Q<Button>("tab-feedback")
?.SetText(Translations.BugReport.TabFeedback);
// Contact section — scoped by class since it has no name
var contactSection = rootVisualElement.Q(className: "contact-section");
contactSection?.Q<Label>(className: "section-label")?.SetText(Translations.BugReport.LabelContactEmail);
contactSection?.Q<Label>(className: "fine-print")?.SetText(Translations.BugReport.LabelContactEmailFinePrint);
// Submit button
_submitButton.text = Translations.BugReport.ButtonSubmit;
// Success screen
var successContent = _successScreen?.Q(className: "result-content");
successContent?.Q<Label>(className: "result-title")?.SetText(Translations.BugReport.SuccessTitle);
successContent?.Q<Label>(className: "result-message")?.SetText(Translations.BugReport.SuccessMessage);
_successScreen?.Q<Button>("success-discord-button")?.SetText(Translations.BugReport.ButtonJoinDiscord);
// Failure screen
var failureContent = _failureScreen?.Q(className: "result-content");
failureContent?.Q<Label>(className: "result-title")?.SetText(Translations.BugReport.FailureTitle);
// result-message--error is the dynamic error label, skip it; translate the static one
var failureMessages = failureContent?.Query<Label>(className: "result-message").ToList();
if (failureMessages != null) {
foreach (var lbl in failureMessages) {
if (!lbl.ClassListContains("result-message--error")) {
lbl.text = Translations.BugReport.FailureMessage;
}
}
}
_failureScreen?.Q<Button>("failure-discord-button")?.SetText(Translations.BugReport.ButtonJoinDiscord);
_failureScreen?.Q<Button>("failure-contact-button")?.SetText(Translations.BugReport.ButtonContactUs);
}
#region View switching
void ShowFormView() {
_isOnResultScreen = false;
_scrollView.style.display = DisplayStyle.Flex;
_successScreen.RemoveFromClassList("result-screen--visible");
_failureScreen.RemoveFromClassList("result-screen--visible");
}
void ShowSuccessScreen() {
_isOnResultScreen = true;
_scrollView.style.display = DisplayStyle.None;
_tabFeedback.style.display = DisplayStyle.None;
_tabBugReport.style.display = DisplayStyle.None;
_failureScreen.RemoveFromClassList("result-screen--visible");
_successScreen.AddToClassList("result-screen--visible");
}
void ShowFailureScreen(string errorMessage) {
_scrollView.style.display = DisplayStyle.None;
_tabFeedback.style.display = DisplayStyle.None;
_tabBugReport.style.display = DisplayStyle.None;
_successScreen.RemoveFromClassList("result-screen--visible");
if (_failureErrorLabel != null) {
if (string.IsNullOrEmpty(errorMessage)) {
_failureErrorLabel.style.display = DisplayStyle.None;
} else {
_failureErrorLabel.text = errorMessage;
_failureErrorLabel.style.display = DisplayStyle.Flex;
}
}
_failureScreen.AddToClassList("result-screen--visible");
_isOnResultScreen = true;
}
#endregion
void ApplyMode() {
bool isBugReport = _mode == ReportMode.BugReport;
this.minSize = this.maxSize = GetMinSize(_mode);
if (isBugReport) {
_severitySection.style.display = DisplayStyle.Flex;
_frequencySection.style.display = DisplayStyle.Flex;
_severityPopup.style.display = DisplayStyle.Flex;
_frequencyPopup.style.display = DisplayStyle.Flex;
_detailsLabel.text = Translations.BugReport.LabelDetails;
_tabBugReport.AddToClassList("tab--active");
_tabFeedback.RemoveFromClassList("tab--active");
if (string.IsNullOrEmpty(_detailsText)) {
_detailsText = Translations.BugReport.PlaceholderDetails;
_detailsContainer.MarkDirtyRepaint();
}
} else {
_severitySection.style.display = DisplayStyle.None;
_frequencySection.style.display = DisplayStyle.None;
_severityPopup.style.display = DisplayStyle.None;
_frequencyPopup.style.display = DisplayStyle.None;
_detailsLabel.text = Translations.BugReport.LabelFeedback;
_tabFeedback.AddToClassList("tab--active");
_tabBugReport.RemoveFromClassList("tab--active");
if (_detailsText == Translations.BugReport.PlaceholderDetails) {
_detailsText = string.Empty;
_detailsContainer.MarkDirtyRepaint();
}
}
}
void CreatePopupFields() {
var severityContainer = rootVisualElement.Q<VisualElement>("severity-container");
_severityPopup = new PopupField<string>(
string.Empty,
SeverityChoices,
0);
_severityPopup.AddToClassList("input-field");
severityContainer.Add(_severityPopup);
var frequencyContainer = rootVisualElement.Q<VisualElement>("frequency-container");
_frequencyPopup = new PopupField<string>(
string.Empty,
FrequencyChoices,
0);
_frequencyPopup.AddToClassList("input-field");
frequencyContainer.Add(_frequencyPopup);
}
void SelectBugReport() {
_mode = ReportMode.BugReport;
ApplyMode();
}
void SelectFeedback() {
_mode = ReportMode.Feedback;
ApplyMode();
}
void AttachCallbacks() {
if (_tabBugReport != null) {
_tabBugReport.clicked += SelectBugReport;
}
if (_tabFeedback != null) {
_tabFeedback.clicked += SelectFeedback;
}
if (_emailField != null) {
_emailField.RegisterValueChangedCallback(SetEmailText);
}
if (_titleField != null) {
_titleField.RegisterValueChangedCallback(SetTitleText);
}
}
void DetachCallbacks() {
if (_tabBugReport != null) {
_tabBugReport.clicked -= SelectBugReport;
}
if (_tabFeedback != null) {
_tabFeedback.clicked -= SelectFeedback;
}
if (_emailField != null) {
_emailField.UnregisterValueChangedCallback(SetEmailText);
}
if (_titleField != null) {
_titleField.UnregisterValueChangedCallback(SetTitleText);
}
}
void SetEmailText(ChangeEvent<string> @event) {
_emailText = @event.newValue;
}
void SetTitleText(ChangeEvent<string> @event) {
_titleText = @event.newValue;
}
static void FixSingleLineFieldAlignment(TextField field) {
field.RegisterCallback<GeometryChangedEvent>(evt => {
var input = field.Q(className: "unity-text-field__input");
if (input != null) {
input.style.alignItems = Align.Center;
}
});
}
#region Validation & submit
void SetupSubmit() {
_submitButton.clickable.clicked += OnSubmitClicked;
}
async void OnSubmitClicked() {
HideValidation();
if (string.IsNullOrWhiteSpace(_titleField.value)) {
ShowValidation(Translations.BugReport.ValidationTitleRequired);
return;
}
var details = _detailsText;
var report = new Report {
Mode = _mode,
Id = Guid.NewGuid().ToString("N").Substring(0, 10),
Title = _titleField.value.Trim(),
Severity = _severityPopup != null ? _severityPopup.value : null,
Frequency = _frequencyPopup != null ? _frequencyPopup.value : null,
ContactEmail = _emailField.value != null ? _emailField.value.Trim() : null,
Details = details != null ? details.Trim() : null
};
_submitButton.SetEnabled(false);
_submitButton.text = Translations.BugReport.ButtonSubmitting;
string error;
try {
error = await HandleBugReport(report);
} catch (Exception ex) {
error = string.Format(Translations.BugReport.SubmitHandlerError, ex.Message);
}
if (this == null) {
return;
}
_submitButton.SetEnabled(true);
_submitButton.text = Translations.BugReport.ButtonSubmit;
if (error == null) {
ShowSuccessScreen();
if (report.Mode == ReportMode.BugReport) {
// Back up log and patches locally for this bug report
// They can be later used to simplify reproducing and fixing the issue, but due to our privacy policy they can't be auto uploaded so we need to ask for them
#if UNITY_2021_3_OR_NEWER
var logName = LogsHelper.FindRecentLog(HotReloadAboutTab.logsPath);
try {
CreateBugReportZip(
Path.Combine(CliUtils.GetCliTempDir(), "Backup"),
logName == null ? null : Path.Combine(HotReloadAboutTab.logsPath, logName),
Path.Combine(PackageConst.LibraryCachePath, "BugReports", $"{report.Id}.zip")
);
} catch {
// Fail silently. If zip is not available we will have to ask for a reproduce
}
#endif
}
} else {
ShowFailureScreen(error);
}
}
private static Task<string> HandleBugReport(Report report) {
var hwId = HotReloadPrefs.HardwareId;
if (string.IsNullOrEmpty(hwId)) {
hwId = "unknown";
}
return RequestHelper.SubmitBugReport(new BugReport {
reportId = report.Id,
label = report.Mode == ReportMode.Feedback ? "Feedback" : "Bug Report",
title = report.Title,
description = report.Details,
email = report.ContactEmail,
hwId = hwId,
hotReloadVersion = PackageConst.Version,
unityVersion = Application.unityVersion,
operatingSystemVersionInfo = SystemInfo.operatingSystem,
});
}
#if UNITY_2021_3_OR_NEWER
private static void CreateBugReportZip(
string sourceDir,
string sourceFile,
string outputZipPath
) {
var files = Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories);
if (files.Length == 0 && sourceFile == null) {
return;
}
Directory.CreateDirectory(new FileInfo(outputZipPath).DirectoryName!);
using (var stream = new FileStream(outputZipPath, FileMode.Create, FileAccess.Write, FileShare.None, 65536)) {
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: false)) {
if (sourceFile != null) {
AddFileToArchive(archive, sourceFile, Path.GetFileName(sourceFile));
}
foreach (string filePath in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) {
string relativePath = Path.GetRelativePath(sourceDir, filePath);
string entryName = $"Patches/{relativePath}".Replace('\\', '/');
AddFileToArchive(archive, filePath, entryName);
}
}
}
}
private static void AddFileToArchive(ZipArchive archive, string filePath, string entryName) {
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
using (var entryStream = entry.Open()) {
using (var fileStream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite, // allow concurrent writers
65536)) {
fileStream.CopyTo(entryStream);
}
}
}
#endif
void ShowValidation(string message) {
_validationLabel.text = message;
_validationLabel.AddToClassList("validation-label--visible");
}
void HideValidation() {
_validationLabel.RemoveFromClassList("validation-label--visible");
}
#endregion
#region Result screen buttons
void SetupResultScreenButtons() {
var successDiscord = rootVisualElement.Q<Button>("success-discord-button");
var failureDiscord = rootVisualElement.Q<Button>("failure-discord-button");
var failureContact = rootVisualElement.Q<Button>("failure-contact-button");
if (successDiscord != null) {
successDiscord.clickable.clicked += OnDiscordClicked;
}
if (failureDiscord != null) {
failureDiscord.clickable.clicked += OnDiscordClicked;
}
if (failureContact != null) {
failureContact.clickable.clicked += OnContactClicked;
}
bool hasDiscord = !string.IsNullOrEmpty(_discordUrl);
bool hasContact = !string.IsNullOrEmpty(_contactUrl);
if (successDiscord != null) {
successDiscord.style.display = hasDiscord ? DisplayStyle.Flex : DisplayStyle.None;
}
if (failureDiscord != null) {
failureDiscord.style.display = hasDiscord ? DisplayStyle.Flex : DisplayStyle.None;
}
if (failureContact != null) {
failureContact.style.display = hasContact ? DisplayStyle.Flex : DisplayStyle.None;
}
}
void OnDiscordClicked() {
if (!string.IsNullOrEmpty(_discordUrl)) {
Application.OpenURL(_discordUrl);
}
}
void OnContactClicked() {
if (!string.IsNullOrEmpty(_contactUrl)) {
Application.OpenURL(_contactUrl);
}
}
#endregion
}
[Serializable]
internal class Report {
public string Id;
public ReportMode Mode;
public string Title;
public string Severity;
public string Frequency;
public string ContactEmail;
public string Details;
}
}
// Small extension to avoid repetitive null checks on label/button text assignment
namespace SingularityGroup.HotReload.Editor {
internal static class VisualElementExtensions {
public static void SetText(this UnityEngine.UIElements.Label label, string text) {
if (label != null) label.text = text;
}
public static void SetText(this UnityEngine.UIElements.Button button, string text) {
if (button != null) button.text = text;
}
}
}