private static void TryGetScreenshot(Control controlForScreenshotting) { SafeInvoke.InvokeIfPossible("Screen Shot", controlForScreenshotting, false, () => { try { var bounds = controlForScreenshotting.Bounds; var screenshot = new Bitmap(bounds.Width, bounds.Height); using (var g = Graphics.FromImage(screenshot)) { if (controlForScreenshotting.Parent == null) { g.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bounds.Size); // bounds already in screen coords } else { g.CopyFromScreen(controlForScreenshotting.PointToScreen(new Point(bounds.Left, bounds.Top)), Point.Empty, bounds.Size); } } _screenshotTempFile = TempFile.WithFilename(ScreenshotName); RobustImageIO.SaveImage(screenshot, _screenshotTempFile.Path, ImageFormat.Png); } catch (Exception e) { ResetScreenshotFile(); Logger.WriteError("Bloom was unable to create a screenshot.", e); } } ); }
/// <summary> /// Called when a file system watcher notices a new book (or some similar change) in our downloaded books folder. /// This will happen on a thread-pool thread. /// Since we are updating the UI in response we want to deal with it on the main thread. /// That also has the effect of a lock, preventing multiple threads trying to respond to changes. /// The main purpose of this method is to debounce such changes, since lots of them may /// happen in succession while downloading a book, and also some that we don't want /// to process may happen while we are selecting one. Debounced changes result in a websocket message /// that acts as an event for Javascript, and also raising a C# event. /// </summary> private void DebounceFolderChanged(string fullPath) { var shell = Application.OpenForms.Cast <Form>().FirstOrDefault(f => f is Shell); SafeInvoke.InvokeIfPossible("update downloaded books", shell, true, (Action)(() => { // We may notice a change to the downloaded books directory before the other Bloom instance has finished // copying the new book there. Finishing should not take long, because the download is done...at worst // we have to copy the book on our own filesystem. Typically we only have to move the directory. // As a safeguard, wait half a second before we update things. if (_folderChangeDebounceTimer != null) { // Things changed again before we started the update! Forget the old update and wait until things // are stable for the required interval. _folderChangeDebounceTimer.Stop(); _folderChangeDebounceTimer.Dispose(); } _folderChangeDebounceTimer = new Timer(); _folderChangeDebounceTimer.Tick += (o, args) => { _folderChangeDebounceTimer.Stop(); _folderChangeDebounceTimer.Dispose(); _folderChangeDebounceTimer = null; // Updating the books involves selecting the modified book, which might involve changing // some files (e.g., adding a branding image, BL-4914), which could trigger this again. // So don't allow it to be triggered by changes to a folder we're already sending // notifications about. // (It's PROBABLY overkill to maintain a set of these...don't expect a notification about // one folder to trigger a change to another...but better safe than sorry.) // (Note that we don't need synchronized access to this dictionary, because all this // happens only on the UI thread.) if (!_changingFolders.Contains(fullPath)) { try { _changingFolders.Add(fullPath); _webSocketServer.SendEvent("editableCollectionList", "reload:" + PathToDirectory); if (FolderContentChanged != null) { FolderContentChanged(this, new ProjectChangedEventArgs() { Path = fullPath }); } } finally { // Now we need to arrange to remove it again. Not right now, because // whatever changes might be made during event handling may get noticed slightly later. // But we don't want to miss it if the book gets downloaded again. RemoveFromChangingFoldersLater(fullPath); } } }; _folderChangeDebounceTimer.Interval = 500; _folderChangeDebounceTimer.Start(); })); }
public ErrorResult NotifyUserOfProblem(IRepeatNoticePolicy policy, string alternateButton1Label, ErrorResult resultIfAlternateButtonPressed, string message) { var returnResult = ErrorResult.OK; ReportButtonLabel = GetReportButtonLabel(alternateButton1Label); OnReportButtonPressed = (exceptionParam, messageParam) => { returnResult = resultIfAlternateButtonPressed; }; var control = GetControlToUse(); var forceSynchronous = true; SafeInvoke.InvokeIfPossible("Show Error Reporter", control, forceSynchronous, () => { NotifyUserOfProblem(policy, null, message); }); return(returnResult); }
private void ShowNotifyDialog(string severity, string messageText, Exception exception, string reportButtonLabel, string secondaryButtonLabel) { // Before we do anything that might be "risky", put the problem in the log. ProblemReportApi.LogProblem(exception, messageText, severity); // ENHANCE: Allow the caller to pass in the control, which would be at the front of this. //System.Windows.Forms.Control control = Form.ActiveForm ?? FatalExceptionHandler.ControlOnUIThread; var control = GetControlToUse(); var isSyncRequired = false; SafeInvoke.InvokeIfPossible("Show Error Reporter", control, isSyncRequired, () => { // Uses a browser dialog to show the problem report try { StartupScreenManager.CloseSplashScreen(); // if it's still up, it'll be on top of the dialog var message = GetMessage(messageText, exception); if (!Api.BloomServer.ServerIsListening) { // There's no hope of using the HtmlErrorReporter dialog if our server is not yet running. // We'll likely get errors, maybe Javascript alerts, that won't lead to a clean fallback to // the exception handler below. Besides, failure of HtmlErrorReporter in these circumstances // is expected; we just want to cleanly report the original problem, not to report a // failure of error handling. // Note: HtmlErrorReporter supports up to 3 buttons (OK, Report, and [Secondary action]), but the fallback reporter only supports a max of two. // Well, just going to have to drop the secondary action. ShowFallbackProblemDialog(severity, exception, messageText, null, false); return; } object props = new { level = ProblemLevel.kNotify, reportLabel = reportButtonLabel, secondaryLabel = secondaryButtonLabel, message = message }; // Precondition: we must be on the UI thread for Gecko to work. using (var dlg = BrowserDialogFactory.CreateReactDialog("problemReportBundle", props)) { dlg.FormBorderStyle = FormBorderStyle.FixedToolWindow; // Allows the window to be dragged around dlg.ControlBox = true; // Add controls like the X button back to the top bar dlg.Text = ""; // Remove the title from the WinForms top bar dlg.Width = 620; // 360px was experimentally determined as what was needed for the longest known text for NotifyUserOfProblem // (which is "Before saving, Bloom did an integrity check of your book [...]" from BookStorage.cs) // You can make this height taller if need be. // A scrollbar will appear if the height is not tall enough for the text dlg.Height = 360; // ShowDialog will cause this thread to be blocked (because it spins up a modal) until the dialog is closed. BloomServer.RegisterThreadBlocking(); try { dlg.ShowDialog(); // Take action if the user clicked a button other than Close if (dlg.CloseSource == "closedByAlternateButton" && OnSecondaryActionPressed != null) { OnSecondaryActionPressed(exception, message); } else if (dlg.CloseSource == "closedByReportButton") { if (OnReportButtonPressed != null) { OnReportButtonPressed(exception, message); } else { DefaultOnReportPressed(exception, message); } } // Note: With the way LibPalaso's ErrorReport is designed, // its intention is that after OnShowDetails is invoked and closed, you will not come back to the Notify Dialog // This code has been implemented to follow that model // // But now that we have more options, it might be nice to come back to this dialog. // If so, you'd need to add/update some code in this section. } finally { ResetToDefaults(); BloomServer.RegisterThreadUnblocked(); } } } catch (Exception errorReporterException) { Logger.WriteError("*** HtmlErrorReporter threw an exception trying to display", errorReporterException); // At this point our problem reporter has failed for some reason, so we want the old WinForms handler // to report both the original error for which we tried to open our dialog and this new one where // the dialog itself failed. // In order to do that, we create a new exception with the original exception (if there was one) as the // inner exception. We include the message of the exception we just caught. Then we call the // old WinForms fatal exception report directly. // In any case, both of the errors will be logged by now. var message = "Bloom's error reporting failed: " + errorReporterException.Message; // Fallback to Winforms in case of trouble getting the browser to work var fallbackReporter = new WinFormsErrorReporter(); // Food for thought: is it really fatal of the Notify Dialog had an exception? Maybe NonFatal makes more sense fallbackReporter.ReportFatalException(new ApplicationException(message, exception ?? errorReporterException)); } }); }
public LibraryView(LibraryModel model, LibraryListView.Factory libraryListViewFactory, LibraryBookView.Factory templateBookViewFactory, SelectedTabChangedEvent selectedTabChangedEvent, SendReceiveCommand sendReceiveCommand, TeamCollectionManager tcManager) { _model = model; InitializeComponent(); splitContainer1.BackColor = Palette.BookListSplitterColor; // controls the left vs. right splitter _toolStrip.Renderer = new NoBorderToolStripRenderer(); _toolStripLeft.Renderer = new NoBorderToolStripRenderer(); _collectionListView = libraryListViewFactory(); _collectionListView.Dock = DockStyle.Fill; splitContainer1.Panel1.Controls.Add(_collectionListView); _bookView = templateBookViewFactory(); _bookView.TeamCollectionMgr = tcManager; _bookView.Dock = DockStyle.Fill; splitContainer1.Panel2.Controls.Add(_bookView); // When going down to Shrink Stage 3 (see WorkspaceView), we want the right-side toolstrip to take precedence // (Settings, Other Collection). // This essentially makes the TC Status button's zIndex less than the buttons on the right side. _toolStripLeft.SendToBack(); splitContainer1.SplitterDistance = _collectionListView.PreferredWidth; _makeBloomPackButton.Visible = model.IsShellProject; _sendReceiveButton.Visible = Settings.Default.ShowSendReceive; if (sendReceiveCommand != null) { #if Chorus _sendReceiveButton.Click += (x, y) => sendReceiveCommand.Raise(this); _sendReceiveButton.Enabled = !SendReceiver.SendReceiveDisabled; #endif } else { _sendReceiveButton.Enabled = false; } if (SIL.PlatformUtilities.Platform.IsMono) { BackgroundColorsForLinux(); } selectedTabChangedEvent.Subscribe(c => { if (c.To == this) { Logger.WriteEvent("Entered Collections Tab"); } }); SetTeamCollectionStatus(tcManager); TeamCollectionManager.TeamCollectionStatusChanged += (sender, args) => { if (IsHandleCreated && !IsDisposed) { SafeInvoke.InvokeIfPossible("update TC status", this, false, () => SetTeamCollectionStatus(tcManager)); } }; _tcStatusButton.Click += (sender, args) => { // Any messages for which reloading the collection is a useful action? var showReloadButton = tcManager.MessageLog.ShouldShowReloadButton; // Reinstate this to see messages from before we started up. // We think it might be too expensive to show a list as long as this might get. // Instead, in the short term we may add a button to show the file. // Later we may implement some efficient way to scroll through them. // tcManager.CurrentCollection?.MessageLog?.LoadSavedMessages(); using (var dlg = new ReactDialog("teamCollectionDialogBundle", new { showReloadButton }, "Team Collection")) { dlg.ShowDialog(this); tcManager.CurrentCollectionEvenIfDisconnected?.MessageLog.WriteMilestone(MessageAndMilestoneType .LogDisplayed); } }; }
/// <summary> /// Shows a problem dialog. /// </summary> /// <param name="controlForScreenshotting"></param> /// <param name="exception"></param> /// <param name="detailedMessage"></param> /// <param name="levelOfProblem"></param> public static void ShowProblemDialog(Control controlForScreenshotting, Exception exception, string detailedMessage = "", string levelOfProblem = "user") { // Before we do anything that might be "risky", put the problem in the log. LogProblem(exception, detailedMessage, levelOfProblem); if (_showingProblemReport) { // If a problem is reported when already reporting a problem, that could // be an unbounded recursion that freezes the program and prevents the original // problem from being reported. So minimally report the recursive problem and stop // the recursion in its tracks. // // Alternatively, can happen if multiple async BloomAPI calls go out and return errors. // It's probably not helpful to have multiple problem report dialogs at the same time // in this case either (even if there are theoretically a finite (not infinite) number of them) const string msg = "MULTIPLE CALLS to ShowProblemDialog. Suppressing the subsequent calls"; Console.Write(msg); Logger.WriteEvent(msg); return; // Abort } _showingProblemReport = true; _currentException = exception; _detailedMessage = detailedMessage; if (controlForScreenshotting == null) { controlForScreenshotting = Form.ActiveForm; } if (controlForScreenshotting == null) // still possible if we come from a "Details" button { controlForScreenshotting = FatalExceptionHandler.ControlOnUIThread; } ResetScreenshotFile(); // Originally, we used SafeInvoke for both the screenshot and the new dialog display. SafeInvoke was great // for trying to get a screenshot, but having the actual dialog inside // of it was causing problems for handling any errors in showing the dialog. // Now we use SafeInvoke only inside of this extracted method. TryGetScreenshot(controlForScreenshotting); SafeInvoke.InvokeIfPossible("Show Problem Dialog", controlForScreenshotting, false, () => { // Uses a browser dialog to show the problem report try { var query = "?" + levelOfProblem; var problemDialogRootPath = BloomFileLocator.GetBrowserFile(false, "problemDialog", "loader.html"); var url = problemDialogRootPath.ToLocalhost() + query; // Precondition: we must be on the UI thread for Gecko to work. using (var dlg = new BrowserDialog(url)) { dlg.ShowDialog(); } } catch (Exception problemReportException) { Logger.WriteError("*** ProblemReportApi threw an exception trying to display", problemReportException); // At this point our problem reporter has failed for some reason, so we want the old WinForms handler // to report both the original error for which we tried to open our dialog and this new one where // the dialog itself failed. // In order to do that, we create a new exception with the original exception (if there was one) as the // inner exception. We include the message of the exception we just caught. Then we call the // old WinForms fatal exception report directly. // In any case, both of the errors will be logged by now. var message = "Bloom's error reporting failed: " + problemReportException.Message; ErrorReport.ReportFatalException(new ApplicationException(message, _currentException ?? problemReportException)); } finally { _showingProblemReport = false; } }); }
// ENHANCE: Reduce duplication in HtmlErrorReporter and ProblemReportApi code. Some of the ProblemReportApi code can move to HtmlErrorReporter code. // ENHANCE: I think levelOfProblem would benefit from being required and being an enum. /// <summary> /// Shows a problem dialog. /// </summary> /// <param name="controlForScreenshotting"></param> /// <param name="exception"></param> /// <param name="detailedMessage"></param> /// <param name="levelOfProblem">"user", "nonfatal", or "fatal"</param> /// <param name="additionalPathsToInclude"></param> public static void ShowProblemDialog(Control controlForScreenshotting, Exception exception, string detailedMessage = "", string levelOfProblem = "user", string shortUserLevelMessage = "", bool isShortMessagePreEncoded = false, string[] additionalPathsToInclude = null) { // Before we do anything that might be "risky", put the problem in the log. LogProblem(exception, detailedMessage, levelOfProblem); if (Program.RunningHarvesterMode) { Console.WriteLine(levelOfProblem + " Problem Detected: " + shortUserLevelMessage + " " + detailedMessage + " " + exception); return; } StartupScreenManager.CloseSplashScreen(); // if it's still up, it'll be on top of the dialog lock (_showingProblemReportLock) { if (_showingProblemReport) { // If a problem is reported when already reporting a problem, that could // be an unbounded recursion that freezes the program and prevents the original // problem from being reported. So minimally report the recursive problem and stop // the recursion in its tracks. // // Alternatively, can happen if multiple async BloomAPI calls go out and return errors. // It's probably not helpful to have multiple problem report dialogs at the same time // in this case either (even if there are theoretically a finite (not infinite) number of them) const string msg = "MULTIPLE CALLS to ShowProblemDialog. Suppressing the subsequent calls"; Console.Write(msg); Logger.WriteEvent(msg); return; // Abort } _showingProblemReport = true; } // We have a better UI for this problem // Note that this will trigger whether it's a plain 'ol System.IO.PathTooLongException, or our own enhanced subclass, Bloom.Utiles.PathTooLongException if (exception is System.IO.PathTooLongException) { Utils.LongPathAware.ReportLongPath((System.IO.PathTooLongException)exception); return; } GatherReportInfoExceptScreenshot(exception, detailedMessage, shortUserLevelMessage, isShortMessagePreEncoded); if (controlForScreenshotting == null) { controlForScreenshotting = Form.ActiveForm; } if (controlForScreenshotting == null) // still possible if we come from a "Details" button { controlForScreenshotting = FatalExceptionHandler.ControlOnUIThread; } ResetScreenshotFile(); // Originally, we used SafeInvoke for both the screenshot and the new dialog display. SafeInvoke was great // for trying to get a screenshot, but having the actual dialog inside // of it was causing problems for handling any errors in showing the dialog. // Now we use SafeInvoke only inside of this extracted method. TryGetScreenshot(controlForScreenshotting); if (BloomServer._theOneInstance == null) { // We got an error really early, before we can use HTML dialogs. Report using the old dialog. // Hopefully we're still on the one main thread. HtmlErrorReporter.ShowFallbackProblemDialog(levelOfProblem, exception, detailedMessage, shortUserLevelMessage, isShortMessagePreEncoded); return; } SafeInvoke.InvokeIfPossible("Show Problem Dialog", controlForScreenshotting, false, () => { // Uses a browser ReactDialog (if possible) to show the problem report try { // We call CloseSplashScreen() above too, where it might help in some cases, but // this one, while apparently redundant might be wise to keep since closing the splash screen // needs to be done on the UI thread. StartupScreenManager.CloseSplashScreen(); if (!BloomServer.ServerIsListening) { // We can't use the react dialog! HtmlErrorReporter.ShowFallbackProblemDialog(levelOfProblem, exception, detailedMessage, shortUserLevelMessage, isShortMessagePreEncoded); return; } // Precondition: we must be on the UI thread for Gecko to work. using (var dlg = new ReactDialog("problemReportBundle", new { level = levelOfProblem }, "Problem Report")) { _additionalPathsToInclude = additionalPathsToInclude; dlg.FormBorderStyle = FormBorderStyle.FixedToolWindow; // Allows the window to be dragged around dlg.ControlBox = true; // Add controls like the X button back to the top bar dlg.Text = ""; // Remove the title from the WinForms top bar dlg.Width = 731; dlg.Height = 616; // ShowDialog will cause this thread to be blocked (because it spins up a modal) until the dialog is closed. BloomServer._theOneInstance.RegisterThreadBlocking(); try { // Keep dialog on top of program window if possible. See https://issues.bloomlibrary.org/youtrack/issue/BL-10292. if (controlForScreenshotting is Bloom.Shell) { dlg.ShowDialog(controlForScreenshotting); } else { dlg.ShowDialog(); } } finally { BloomServer._theOneInstance.RegisterThreadUnblocked(); _additionalPathsToInclude = null; } } } catch (Exception problemReportException) { Logger.WriteError("*** ProblemReportApi threw an exception trying to display", problemReportException); // At this point our problem reporter has failed for some reason, so we want the old WinForms handler // to report both the original error for which we tried to open our dialog and this new one where // the dialog itself failed. // In order to do that, we create a new exception with the original exception (if there was one) as the // inner exception. We include the message of the exception we just caught. Then we call the // old WinForms fatal exception report directly. // In any case, both of the errors will be logged by now. var message = "Bloom's error reporting failed: " + problemReportException.Message; // Fallback to Winforms in case of trouble getting the browser up var fallbackReporter = new WinFormsErrorReporter(); // ENHANCE?: If reporting a non-fatal problem failed, why is the program required to abort? It might be able to handle other tasks successfully fallbackReporter.ReportFatalException(new ApplicationException(message, exception ?? problemReportException)); } finally { lock (_showingProblemReportLock) { _showingProblemReport = false; } } }); }
public delegate CollectionTabView Factory(); //autofac uses this public CollectionTabView(CollectionModel model, SelectedTabChangedEvent selectedTabChangedEvent, TeamCollectionManager tcManager, BookSelection bookSelection, WorkspaceTabSelection tabSelection, BloomWebSocketServer webSocketServer, LocalizationChangedEvent localizationChangedEvent) { _model = model; _tabSelection = tabSelection; _bookSelection = bookSelection; _webSocketServer = webSocketServer; _tcManager = tcManager; BookCollection.CollectionCreated += OnBookCollectionCreated; InitializeComponent(); _reactControl.SetLocalizationChangedEvent(localizationChangedEvent); // after InitializeComponent, which creates it. BackColor = _reactControl.BackColor = Palette.GeneralBackground; _toolStrip.Renderer = new NoBorderToolStripRenderer(); _toolStripLeft.Renderer = new NoBorderToolStripRenderer(); // When going down to Shrink Stage 3 (see WorkspaceView), we want the right-side toolstrip to take precedence // (Settings, Other Collection). // This essentially makes the TC Status button's zIndex less than the buttons on the right side. _toolStripLeft.SendToBack(); //TODO splitContainer1.SplitterDistance = _collectionListView.PreferredWidth; if (SIL.PlatformUtilities.Platform.IsMono) { BackgroundColorsForLinux(); } selectedTabChangedEvent.Subscribe(c => { if (c.To == this) { Logger.WriteEvent("Entered Collections Tab"); if (_bookChangesPending && _bookSelection.CurrentSelection != null) { UpdateForBookChanges(_bookSelection.CurrentSelection); } } }); SetTeamCollectionStatus(tcManager); TeamCollectionManager.TeamCollectionStatusChanged += (sender, args) => { if (IsHandleCreated && !IsDisposed) { SafeInvoke.InvokeIfPossible("update TC status", this, false, () => SetTeamCollectionStatus(tcManager)); } }; _tcStatusButton.Click += (sender, args) => { // Reinstate this to see messages from before we started up. // We think it might be too expensive to show a list as long as this might get. // Instead, in the short term we may add a button to show the file. // Later we may implement some efficient way to scroll through them. // tcManager.CurrentCollection?.MessageLog?.LoadSavedMessages(); dynamic messageBundle = new DynamicJson(); messageBundle.showReloadButton = tcManager.MessageLog.ShouldShowReloadButton; _webSocketServer.LaunchDialog("TeamCollectionDialog", messageBundle); tcManager.CurrentCollectionEvenIfDisconnected?.MessageLog.WriteMilestone(MessageAndMilestoneType.LogDisplayed); }; // We don't want this control initializing until team collections sync (if any) is done. // That could change, but for now we're not trying to handle async changes arriving from // the TC to the local collection, and as part of that, the collection tab doesn't expect // the local collection to change because of TC stuff once it starts loading. Controls.Remove(_reactControl); bookSelection.SelectionChanged += (sender, e) => BookSelectionChanged(bookSelection.CurrentSelection); }
public ReactCollectionTabView(LibraryModel model, LibraryListView.Factory libraryListViewFactory, LibraryBookView.Factory templateBookViewFactory, SelectedTabChangedEvent selectedTabChangedEvent, SendReceiveCommand sendReceiveCommand, TeamCollectionManager tcManager) { _model = model; InitializeComponent(); BackColor = _reactControl.BackColor = Palette.GeneralBackground; _toolStrip.Renderer = new NoBorderToolStripRenderer(); _toolStripLeft.Renderer = new NoBorderToolStripRenderer(); // When going down to Shrink Stage 3 (see WorkspaceView), we want the right-side toolstrip to take precedence // (Settings, Other Collection). // This essentially makes the TC Status button's zIndex less than the buttons on the right side. _toolStripLeft.SendToBack(); //TODO splitContainer1.SplitterDistance = _collectionListView.PreferredWidth; _makeBloomPackButton.Visible = model.IsShellProject; _sendReceiveButton.Visible = Settings.Default.ShowSendReceive; if (sendReceiveCommand != null) { #if Chorus _sendReceiveButton.Click += (x, y) => sendReceiveCommand.Raise(this); _sendReceiveButton.Enabled = !SendReceiver.SendReceiveDisabled; #endif } else { _sendReceiveButton.Enabled = false; } if (SIL.PlatformUtilities.Platform.IsMono) { BackgroundColorsForLinux(); } selectedTabChangedEvent.Subscribe(c => { if (c.To == this) { Logger.WriteEvent("Entered Collections Tab"); } }); SetTeamCollectionStatus(tcManager); TeamCollectionManager.TeamCollectionStatusChanged += (sender, args) => { return; if (!IsDisposed) { SafeInvoke.InvokeIfPossible("update TC status", this, false, () => SetTeamCollectionStatus(tcManager)); } }; _tcStatusButton.Click += (sender, args) => { // Any messages for which reloading the collection is a useful action? var showReloadButton = tcManager.MessageLog.ShouldShowReloadButton; // Reinstate this to see messages from before we started up. // We think it might be too expensive to show a list as long as this might get. // Instead, in the short term we may add a button to show the file. // Later we may implement some efficient way to scroll through them. // tcManager.CurrentCollection?.MessageLog?.LoadSavedMessages(); using (var dlg = new ReactDialog("teamCollectionDialog", new { showReloadButton })) { dlg.ShowDialog(this); tcManager.CurrentCollectionEvenIfDisconnected?.MessageLog.WriteMilestone(MessageAndMilestoneType.LogDisplayed); } }; // We don't want this control initializing until team collections sync (if any) is done. // That could change, but for now we're not trying to handle async changes arriving from // the TC to the local collection, and as part of that, the collection tab doesn't expect // the local collection to change because of TC stuff once it starts loading. Controls.Remove(_reactControl); }