fix(dock): restore transactional integrity and fix build breaks

This commit is contained in:
2026-03-28 17:12:17 +09:00
parent cda3b292b5
commit 0a0359ec06
5 changed files with 104 additions and 16 deletions

View File

@@ -0,0 +1,79 @@
using Ghost.Core;
using Microsoft.UI.Xaml.Controls;
using System.Collections;
namespace Ghost.Editor.Core.Controls.Internal.Docking;
/// <summary>
/// Service for handling tab tear-off operations across the editor.
/// </summary>
internal static class TabTearOffService
{
/// <summary>
/// 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.
/// </summary>
/// <param name="sourceItems">The collection to remove the item from.</param>
/// <param name="tabItem">The item to tear off.</param>
/// <param name="createCallback">The callback to create the new host (e.g. window) for the tab.</param>
/// <param name="selectionContainer">Optional container to restore selection to on failure.</param>
/// <returns>A result indicating success or failure.</returns>
public static Result TryTearOffTab(IList sourceItems, object tabItem, Action<object> 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;
}
}

View File

@@ -3,7 +3,6 @@ using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using Ghost.Core; using Ghost.Core;
using Ghost.Editor.Core.Controls.Internal.Docking; using Ghost.Editor.Core.Controls.Internal.Docking;
using Ghost.Editor.View.Windows;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data; 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 // Validate that the item actually belongs to this source node before attempting tear-off
if (sourceNode.Items.Contains(args.Item)) 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) if (result.IsSuccess)
{ {
// Raise event to let the host handle window creation
TabTornOff?.Invoke(this, new TabTornOffEventArgs(args.Item, sourceNode));
DockMutationEngine.CleanupEmptyNodes(sourceNode); DockMutationEngine.CleanupEmptyNodes(sourceNode);
} }
else else

View File

@@ -1,3 +1,5 @@
using Ghost.Editor.Core.Controls.Internal.Docking;
namespace Ghost.Editor.View.Controls; namespace Ghost.Editor.View.Controls;
/// <summary> /// <summary>
@@ -13,9 +15,9 @@ public sealed class TabTornOffEventArgs : EventArgs
/// <summary> /// <summary>
/// Gets the source node the tab is being torn off from. /// Gets the source node the tab is being torn off from.
/// </summary> /// </summary>
public object SourceNode { get; } public DockPanelNode SourceNode { get; }
public TabTornOffEventArgs(object tabContent, object sourceNode) public TabTornOffEventArgs(object tabContent, DockPanelNode sourceNode)
{ {
TabContent = tabContent; TabContent = tabContent;
SourceNode = sourceNode; SourceNode = sourceNode;

View File

@@ -1,3 +1,4 @@
using Ghost.Core;
using Ghost.Editor.Core.Controls.Internal.Docking; using Ghost.Editor.Core.Controls.Internal.Docking;
using Ghost.Editor.View.Controls; using Ghost.Editor.View.Controls;
using WinUIEx; using WinUIEx;
@@ -21,9 +22,16 @@ internal sealed partial class DockWindow : WindowEx
} }
private void OnTabTornOff(object? sender, TabTornOffEventArgs e) private void OnTabTornOff(object? sender, TabTornOffEventArgs e)
{
try
{ {
App.CreateAndShowDockWindow(e.TabContent); App.CreateAndShowDockWindow(e.TabContent);
} }
catch (Exception ex)
{
Logger.LogError(ex);
// The service handles rollback if this was called from TryTearOffTab
throw;
}
} }
} }

View File

@@ -43,13 +43,12 @@ internal sealed partial class EngineEditorWindow : WindowEx
// For static tabs in EngineEditorWindow, we remove the item from TabItems // For static tabs in EngineEditorWindow, we remove the item from TabItems
if (sender.TabItems is System.Collections.IList list && list.Contains(args.Item)) if (sender.TabItems is System.Collections.IList list && list.Contains(args.Item))
{ {
var result = TabTearOffService.TryTearOffTab(list, args.Item, sender); var result = TabTearOffService.TryTearOffTab(list, args.Item, (tab) =>
if (result.IsSuccess)
{ {
App.CreateAndShowDockWindow(args.Item); App.CreateAndShowDockWindow(tab);
} }, sender);
else
if (!result.IsSuccess)
{ {
Logger.LogWarning($"Tab tear-off failed: {result.Message}"); Logger.LogWarning($"Tab tear-off failed: {result.Message}");
} }