From 0a0359ec0672973da2332091d5e9f05f0a849bdc Mon Sep 17 00:00:00 2001 From: Misaki Date: Sat, 28 Mar 2026 17:12:17 +0900 Subject: [PATCH] fix(dock): restore transactional integrity and fix build breaks --- .../Internal/Docking/TabTearOffService.cs | 79 +++++++++++++++++++ .../Ghost.Editor/View/Controls/DockLayout.cs | 10 +-- .../View/Controls/TabTornOffEventArgs.cs | 6 +- .../View/Windows/DockWindow.xaml.cs | 14 +++- .../View/Windows/EngineEditorWindow.xaml.cs | 11 ++- 5 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/TabTearOffService.cs diff --git a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/TabTearOffService.cs b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/TabTearOffService.cs new file mode 100644 index 0000000..ce3b5de --- /dev/null +++ b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/TabTearOffService.cs @@ -0,0 +1,79 @@ +using Ghost.Core; +using Microsoft.UI.Xaml.Controls; +using System.Collections; + +namespace Ghost.Editor.Core.Controls.Internal.Docking; + +/// +/// Service for handling tab tear-off operations across the editor. +/// +internal static class TabTearOffService +{ + /// + /// Attempts to tear off a tab by removing it from its source and executing a creation callback. + /// If the creation callback fails, the tab is restored to its original position and selection state. + /// + /// The collection to remove the item from. + /// The item to tear off. + /// The callback to create the new host (e.g. window) for the tab. + /// Optional container to restore selection to on failure. + /// A result indicating success or failure. + public static Result TryTearOffTab(IList sourceItems, object tabItem, Action createCallback, object? selectionContainer = null) + { + int originalIndex = sourceItems.IndexOf(tabItem); + if (originalIndex == -1) + { + return Result.Failure("Item not found in source collection."); + } + + object? originalSelection = GetSelection(selectionContainer); + + try + { + sourceItems.Remove(tabItem); + + try + { + createCallback(tabItem); + return Result.Success(); + } + catch (Exception ex) + { + Logger.LogError(ex); + + // Rollback collection and selection + try + { + sourceItems.Insert(originalIndex, tabItem); + RestoreSelection(selectionContainer, originalSelection); + } + catch (Exception rollbackEx) + { + Logger.LogError(rollbackEx); + return Result.Failure($"Failed to create tear-off host and rollback failed: {ex.Message}. Rollback error: {rollbackEx.Message}"); + } + + return Result.Failure($"Failed to create tear-off host: {ex.Message}"); + } + } + catch (Exception ex) + { + Logger.LogError(ex); + return Result.Failure($"Failed to remove item from source: {ex.Message}"); + } + } + + private static object? GetSelection(object? container) + { + if (container is DockPanelNode panel) return panel.SelectedItem; + if (container is TabView tabView) return tabView.SelectedItem; + return null; + } + + private static void RestoreSelection(object? container, object? selection) + { + if (selection == null) return; + if (container is DockPanelNode panel) panel.SelectedItem = selection; + else if (container is TabView tabView) tabView.SelectedItem = selection; + } +} diff --git a/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs b/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs index 43b6c7b..83bbaf0 100644 --- a/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs +++ b/src/Editor/Ghost.Editor/View/Controls/DockLayout.cs @@ -3,7 +3,6 @@ using System.ComponentModel; using System.Diagnostics; using Ghost.Core; using Ghost.Editor.Core.Controls.Internal.Docking; -using Ghost.Editor.View.Windows; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Data; @@ -435,13 +434,14 @@ public sealed partial class DockLayout : Control // Validate that the item actually belongs to this source node before attempting tear-off if (sourceNode.Items.Contains(args.Item)) { - var result = TabTearOffService.TryTearOffTab(sourceNode.Items, args.Item, sourceNode); + var result = TabTearOffService.TryTearOffTab(sourceNode.Items, args.Item, (tab) => + { + // Raise event to let the host handle window creation + TabTornOff?.Invoke(this, new TabTornOffEventArgs(tab, sourceNode)); + }, sourceNode); if (result.IsSuccess) { - // Raise event to let the host handle window creation - TabTornOff?.Invoke(this, new TabTornOffEventArgs(args.Item, sourceNode)); - DockMutationEngine.CleanupEmptyNodes(sourceNode); } else diff --git a/src/Editor/Ghost.Editor/View/Controls/TabTornOffEventArgs.cs b/src/Editor/Ghost.Editor/View/Controls/TabTornOffEventArgs.cs index 6d4f128..e08a2e1 100644 --- a/src/Editor/Ghost.Editor/View/Controls/TabTornOffEventArgs.cs +++ b/src/Editor/Ghost.Editor/View/Controls/TabTornOffEventArgs.cs @@ -1,3 +1,5 @@ +using Ghost.Editor.Core.Controls.Internal.Docking; + namespace Ghost.Editor.View.Controls; /// @@ -13,9 +15,9 @@ public sealed class TabTornOffEventArgs : EventArgs /// /// Gets the source node the tab is being torn off from. /// - public object SourceNode { get; } + public DockPanelNode SourceNode { get; } - public TabTornOffEventArgs(object tabContent, object sourceNode) + public TabTornOffEventArgs(object tabContent, DockPanelNode sourceNode) { TabContent = tabContent; SourceNode = sourceNode; diff --git a/src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml.cs b/src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml.cs index f86ed8a..d2c36d9 100644 --- a/src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml.cs +++ b/src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml.cs @@ -1,3 +1,4 @@ +using Ghost.Core; using Ghost.Editor.Core.Controls.Internal.Docking; using Ghost.Editor.View.Controls; using WinUIEx; @@ -22,8 +23,15 @@ internal sealed partial class DockWindow : WindowEx private void OnTabTornOff(object? sender, TabTornOffEventArgs e) { - App.CreateAndShowDockWindow(e.TabContent); + try + { + App.CreateAndShowDockWindow(e.TabContent); + } + catch (Exception ex) + { + Logger.LogError(ex); + // The service handles rollback if this was called from TryTearOffTab + throw; + } } } - -} diff --git a/src/Editor/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs b/src/Editor/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs index 29f7d65..77fb606 100644 --- a/src/Editor/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs +++ b/src/Editor/Ghost.Editor/View/Windows/EngineEditorWindow.xaml.cs @@ -43,13 +43,12 @@ internal sealed partial class EngineEditorWindow : WindowEx // For static tabs in EngineEditorWindow, we remove the item from TabItems if (sender.TabItems is System.Collections.IList list && list.Contains(args.Item)) { - var result = TabTearOffService.TryTearOffTab(list, args.Item, sender); - - if (result.IsSuccess) + var result = TabTearOffService.TryTearOffTab(list, args.Item, (tab) => { - App.CreateAndShowDockWindow(args.Item); - } - else + App.CreateAndShowDockWindow(tab); + }, sender); + + if (!result.IsSuccess) { Logger.LogWarning($"Tab tear-off failed: {result.Message}"); }