XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using System.Xml.Linq;
using Ganss.Xss;
using Markdig;
using Microsoft.Web.WebView2.Core;
using Microsoft.Win32;
using XFEToolBox.Client.Model;
using XFEToolBox.Client.Utilities;
using XFEToolBox.WpfCore.Windowing;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Client.Views.Pages.Popups;
using XFEToolBox.Core.Model;
using XFEToolBox.Core.Tools;

namespace XFEToolBox.Client.Views.Windows;

public partial class ToolCodeEditorWindow : Window
{
    private const int EAccessDenied = unchecked((int)0x80070005);
    private static readonly object WebViewEnvironmentSync = new();
    private static Task<CoreWebView2Environment>? _sharedWebViewEnvironmentTask;

    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
    {
        PropertyNameCaseInsensitive = true
    };

    private static readonly JsonSerializerOptions ManifestJsonOptions = new(JsonSerializerDefaults.Web)
    {
        PropertyNameCaseInsensitive = true,
        WriteIndented = true,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
        .UseAdvancedExtensions()
        .UseAutoIdentifiers()
        .Build();

    private static readonly HashSet<string> EditableExtensions = new(StringComparer.OrdinalIgnoreCase)
    {
        ".cs", ".xaml", ".json", ".xml", ".resx", ".txt", ".md", ".markdown", ".mdown", ".mkd", ".mkdn",
        ".yml", ".yaml", ".config", ".props", ".targets"
    };

    private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
    {
        ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico"
    };

    private static readonly HashSet<string> CSharpKeywords = new(StringComparer.Ordinal)
    {
        "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class",
        "const", "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event",
        "explicit", "extern", "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit",
        "in", "int", "interface", "internal", "is", "lock", "long", "namespace", "new", "null", "object",
        "operator", "out", "override", "params", "private", "protected", "public", "readonly", "record", "ref",
        "return", "sbyte", "sealed", "short", "sizeof", "stackalloc", "static", "string", "struct", "switch",
        "this", "throw", "true", "try", "typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using",
        "virtual", "void", "volatile", "while"
    };

    private static readonly HashSet<string> PreviewEventAttributes = new(StringComparer.OrdinalIgnoreCase)
    {
        "Click", "Loaded", "Unloaded", "Initialized", "SelectionChanged", "TextChanged", "Checked", "Unchecked",
        "KeyDown", "KeyUp", "PreviewKeyDown", "PreviewKeyUp", "MouseDown", "MouseUp", "MouseMove",
        "MouseLeftButtonDown", "MouseLeftButtonUp", "PreviewMouseLeftButtonDown", "PreviewMouseLeftButtonUp",
        "DragEnter", "DragLeave", "DragOver", "Drop", "ValueChanged", "PasswordChanged"
    };

    private readonly ObservableCollection<EditorExplorerItem> _files = [];
    private readonly ObservableCollection<EditorExplorerItem> _explorerItems = [];
    private readonly ObservableCollection<string> _manifestTags = [];
    private readonly ObservableCollection<ToolNuGetPackageReference> _manifestNuGetPackages = [];
    private List<string> _explorerOrder = [];
    private ICollectionView? _fileView;
    private string _workspaceRoot = string.Empty;
    private bool _editorReady;
    private bool _ignoreSelection;
    private bool _dirty;
    private bool _allowClose;
    private bool _darkEditorTheme;
    private TaskCompletionSource<EditorDialogResponse>? _dialogCompletion;
    private Func<string, string?>? _dialogValidator;
    private bool _explorerOrderLoaded;
    private Point _explorerDragStart;
    private EditorExplorerItem? _explorerDragCandidate;
    private readonly DispatcherTimer _manifestSyncTimer = new() { Interval = TimeSpan.FromMilliseconds(350) };
    private readonly HtmlSanitizer _markdownSanitizer = CreateMarkdownSanitizer();
    private bool _loadingManifestDesigner;
    private bool _manifestDesignerPending;
    private bool _manifestSourceMode;
    private string? _activePath;
    private string? _lastSurfacePath;
    private bool _markdownPreviewInitialized;
    private bool _showingMarkdownPreview;
    private bool _previewDocumentTabOpen;
    private bool _previewDocumentActive;
    private bool _newItemDialogActive;
    private string? _newItemBaseDirectory;

    private const string ExplorerDragFormat = "XFEToolBox.EditorExplorerItem";

    public ToolCodeEditorWindow(string workspaceRoot)
    {
        if (string.IsNullOrWhiteSpace(workspaceRoot))
            throw new ArgumentException("必须指定工具项目目录。", nameof(workspaceRoot));
        _workspaceRoot = Path.GetFullPath(workspaceRoot);
        InitializeComponent();
        WindowWorkAreaHelper.Attach(this);
        Owner = MainWindow.Current;

        _fileView = CollectionViewSource.GetDefaultView(_explorerItems);
        _fileView.Filter = FileMatchesFilter;
        FileList.ItemsSource = _fileView;
        ManifestTagsItems.ItemsSource = _manifestTags;
        ManifestNuGetPackagesItems.ItemsSource = _manifestNuGetPackages;
        SaveButton.IsEnabled = false;
        EditorThemeButton.IsEnabled = false;
        PreviewButton.IsEnabled = false;
        RunButton.IsEnabled = false;
        PublishPackageMenuItem.IsEnabled = false;

        _manifestSyncTimer.Tick += async (_, _) =>
        {
            _manifestSyncTimer.Stop();
            try
            {
                await FlushManifestDesignerAsync();
            }
            catch (Exception exception)
            {
                ManifestDesignerStateText.Text = $"同步失败:{exception.Message}";
                ManifestDesignerStateText.Foreground = new SolidColorBrush(Color.FromRgb(198, 94, 105));
            }
        };

        Loaded += async (_, _) => await InitializeAsync();
        Closing += ToolCodeEditorWindow_Closing;
        Closed += (_, _) =>
        {
            EditorWebView.Dispose();
            MarkdownPreviewWebView.Dispose();
        };
    }

    private async Task InitializeAsync()
    {
        try
        {
            SetHostStatus("正在读取工具项目…");
            if (!Directory.Exists(_workspaceRoot))
                throw new DirectoryNotFoundException($"工具项目目录不存在:{_workspaceRoot}");
            await ToolProjectWorkspaceService.RememberProjectAsync(_workspaceRoot);
            await ReloadFileListAsync();

            SetHostStatus("正在启动 Monaco Editor…");
            var webViewEnvironment = await GetSharedWebViewEnvironmentAsync();
            await EditorWebView.EnsureCoreWebView2Async(webViewEnvironment);
            var editorAssets = Path.Combine(AppContext.BaseDirectory, "Resources", "Editor");
            if (!File.Exists(Path.Combine(editorAssets, "editor.html")))
                throw new FileNotFoundException("找不到本地 Monaco 编辑器资源。", editorAssets);

            EditorWebView.CoreWebView2.SetVirtualHostNameToFolderMapping(
                "editor.xfetoolbox", editorAssets, CoreWebView2HostResourceAccessKind.Allow);
            EditorWebView.CoreWebView2.Settings.IsZoomControlEnabled = true;
            EditorWebView.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = false;
            EditorWebView.CoreWebView2.WebMessageReceived += CoreWebView2_WebMessageReceived;
            EditorWebView.CoreWebView2.ProcessFailed += (_, args) =>
            {
                Dispatcher.Invoke(() =>
                {
                    EditorLoading.Visibility = Visibility.Visible;
                    EditorLoadingDetail.Text = $"编辑器进程异常退出:{args.ProcessFailedKind}";
                    SetHostStatus("编辑器进程异常");
                });
            };
            EditorWebView.CoreWebView2.Navigate("https://editor.xfetoolbox/editor.html");
        }
        catch (Exception exception)
        {
            EditorLoadingDetail.Text = FormatWebViewInitializationError(exception);
            SetHostStatus("启动失败");
        }
    }

    private static Task<CoreWebView2Environment> GetSharedWebViewEnvironmentAsync()
    {
        lock (WebViewEnvironmentSync)
        {
            if (_sharedWebViewEnvironmentTask is null
                || _sharedWebViewEnvironmentTask.IsCanceled
                || _sharedWebViewEnvironmentTask.IsFaulted)
                _sharedWebViewEnvironmentTask = CreateSharedWebViewEnvironmentAsync();
            return _sharedWebViewEnvironmentTask;
        }
    }

    private static async Task<CoreWebView2Environment> CreateSharedWebViewEnvironmentAsync()
    {
        var userDataFolder = GetWebViewUserDataFolder();
        Directory.CreateDirectory(userDataFolder);

        var writeProbePath = Path.Combine(userDataFolder, $".write-probe-{Guid.NewGuid():N}.tmp");
        using (new FileStream(
                   writeProbePath,
                   FileMode.CreateNew,
                   FileAccess.Write,
                   FileShare.None,
                   bufferSize: 1,
                   FileOptions.DeleteOnClose))
        {
        }

        return await CoreWebView2Environment.CreateAsync(
            browserExecutableFolder: null,
            userDataFolder: userDataFolder);
    }

    private static string FormatWebViewInitializationError(Exception exception)
    {
        if (exception is WebView2RuntimeNotFoundException)
            return "编辑器启动失败:未检测到 Microsoft Edge WebView2 Runtime。\n请安装或修复 WebView2 Runtime 后重试。";

        if (exception is UnauthorizedAccessException || exception.HResult == EAccessDenied)
            return $"编辑器启动失败:WebView2 缓存目录拒绝访问。\n目录:{GetWebViewUserDataFolder()}\n请确认当前用户对该目录具有读写权限。";

        return $"编辑器启动失败:{exception.Message}\nWebView2 缓存目录:{GetWebViewUserDataFolder()}";
    }

    private static string GetWebViewUserDataFolder() =>
        Path.Combine(AppPath.AppLocalData, "WebView2", "CodeEditor");

    private async void CoreWebView2_WebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
    {
        try
        {
            var message = JsonSerializer.Deserialize<EditorMessage>(e.WebMessageAsJson, JsonOptions);
            switch (message?.Type)
            {
                case "ready":
                    _editorReady = true;
                    SaveButton.IsEnabled = true;
                    EditorThemeButton.IsEnabled = true;
                    PreviewButton.IsEnabled = true;
                    RunButton.IsEnabled = true;
                    PublishPackageMenuItem.IsEnabled = true;
                    EditorLoading.Visibility = Visibility.Collapsed;
                    await SendWorkspaceAsync();
                    UpdateEditorWebViewVisibility();
                    SetHostStatus("Monaco 0.55.1 · IntelliSense 已就绪");
                    break;
                case "changed":
                    _dirty = true;
                    UpdateTitle();
                    SetHostStatus("有未保存更改");
                    break;
                case "saveWorkspace" when message.Files is not null:
                    await SaveFilesAsync(message.Files);
                    break;
                case "activated" when message.Path is not null:
                    SelectFile(message.Path);
                    await UpdateEditorSurfaceForFileAsync(message.Path);
                    break;
                case "command" when message.Command is not null:
                    ExecuteEditorCommand(message.Command);
                    break;
            }
        }
        catch (Exception exception)
        {
            SetHostStatus($"编辑器消息错误:{exception.Message}");
        }
    }

    private async Task SendWorkspaceAsync(string? activePath = null)
    {
        if (!_editorReady)
            return;

        var files = new List<EditorFileContent>();
        foreach (var item in _files.Where(item => item.IsEditable))
        {
            var fullPath = GetSafeFullPath(item.RelativePath);
            files.Add(new EditorFileContent(item.RelativePath, await File.ReadAllTextAsync(fullPath)));
        }

        var requestedActivePath = activePath is not null && EditableExtensions.Contains(Path.GetExtension(activePath))
            ? activePath
            : null;
        var payload = JsonSerializer.Serialize(
            new { files, activePath = requestedActivePath ?? files.FirstOrDefault()?.Path }, JsonOptions);
        await EditorWebView.ExecuteScriptAsync($"window.editorHost.loadWorkspace({payload})");
    }

    private async Task ReloadFileListAsync(string? selectedPath = null)
    {
        if (!_explorerOrderLoaded)
        {
            _explorerOrder = (await ToolProjectWorkspaceService.LoadExplorerOrderAsync(_workspaceRoot)).ToList();
            _explorerOrderLoaded = true;
        }

        _files.Clear();
        _explorerItems.Clear();
        if (Directory.Exists(_workspaceRoot))
        {
            var folderItems = Directory.EnumerateDirectories(_workspaceRoot, "*", SearchOption.AllDirectories)
                .Where(path => !IsBuildDirectory(path))
                .Select(path => Path.GetRelativePath(_workspaceRoot, path).Replace('\\', '/'))
                .Select(CreateFolderItem)
                .ToArray();

            var paths = Directory.EnumerateFiles(_workspaceRoot, "*", SearchOption.AllDirectories)
                .Where(path => !IsBuildDirectory(path))
                .Where(IsExplorerVisibleFile)
                .Select(path => Path.GetRelativePath(_workspaceRoot, path).Replace('\\', '/'))
                .OrderBy(path => path.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
                .ThenBy(path => path, StringComparer.OrdinalIgnoreCase);

            foreach (var relativePath in paths)
            {
                var item = CreateFileItem(relativePath);
                _files.Add(item);
            }

            var orderLookup = _explorerOrder
                .Select((path, index) => new { path, index })
                .GroupBy(item => item.path, StringComparer.OrdinalIgnoreCase)
                .ToDictionary(group => group.Key, group => group.First().index, StringComparer.OrdinalIgnoreCase);
            foreach (var item in BuildHierarchicalExplorerOrder(folderItems.Concat(_files), orderLookup))
                _explorerItems.Add(item);
        }

        WorkspacePathText.Text = _workspaceRoot;
        UpdateTitle();
        RefreshExplorerView();
        if (selectedPath is not null)
            SelectExplorerItem(selectedPath);
        await Task.CompletedTask;
    }

    private static IEnumerable<EditorExplorerItem> BuildHierarchicalExplorerOrder(
        IEnumerable<EditorExplorerItem> source,
        IReadOnlyDictionary<string, int> orderLookup)
    {
        var items = source.ToArray();
        var itemLookup = items.ToDictionary(item => item.RelativePath, StringComparer.OrdinalIgnoreCase);
        foreach (var item in items)
        {
            item.LogicalParentPath = GetParentPath(item.RelativePath);
            if (TryGetXamlPathForCodeBehind(item.RelativePath) is not { } xamlPath
                || !itemLookup.TryGetValue(xamlPath, out var xamlItem)
                || xamlItem.IsFolder)
                continue;

            item.LogicalParentPath = xamlPath;
            xamlItem.HasNestedItems = true;
        }

        IEnumerable<EditorExplorerItem> Visit(string parentPath, int depth)
        {
            var children = items
                .Where(item => item.LogicalParentPath.Equals(parentPath, StringComparison.OrdinalIgnoreCase))
                .OrderBy(item => item.RelativePath.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
                .ThenBy(item => orderLookup.ContainsKey(item.RelativePath) ? 0 : 1)
                .ThenBy(item => orderLookup.GetValueOrDefault(item.RelativePath, int.MaxValue))
                .ThenBy(item => item.IsFolder ? 0 : 1)
                .ThenBy(item => item.DisplayName, StringComparer.OrdinalIgnoreCase);

            foreach (var child in children)
            {
                child.Depth = depth;
                yield return child;
                if (!child.CanExpand)
                    continue;
                foreach (var descendant in Visit(child.RelativePath, depth + 1))
                    yield return descendant;
            }
        }

        return Visit(string.Empty, 0);
    }

    private static string GetParentPath(string relativePath) =>
        Path.GetDirectoryName(relativePath)?.Replace('\\', '/') ?? string.Empty;

    private static bool IsExplorerVisibleFile(string path)
    {
        var extension = Path.GetExtension(path);
        return EditableExtensions.Contains(extension) || ImageExtensions.Contains(extension);
    }

    private static string? TryGetXamlPathForCodeBehind(string relativePath) =>
        relativePath.EndsWith(".xaml.cs", StringComparison.OrdinalIgnoreCase)
            ? relativePath[..^3]
            : null;

    private async Task SaveFilesAsync(IReadOnlyCollection<EditorFileContent> files)
    {
        SetHostStatus("正在保存工程…");
        foreach (var file in files)
        {
            var path = GetSafeFullPath(file.Path);
            Directory.CreateDirectory(Path.GetDirectoryName(path)!);
            await File.WriteAllTextAsync(path, file.Content, new UTF8Encoding(false));
        }

        _dirty = false;
        UpdateTitle();
        SetHostStatus($"已保存 {files.Count} 个文件 · {DateTime.Now:HH:mm:ss}");
        await EditorWebView.ExecuteScriptAsync("window.editorHost.markSaved()");
        await ReloadFileListAsync(FileList.SelectedItem is EditorExplorerItem { IsFolder: false } item ? item.RelativePath : null);
        if (ManifestDesignerPanel.Visibility == Visibility.Visible)
        {
            ManifestDesignerStateText.Text = $"已保存 · {DateTime.Now:HH:mm:ss}";
            ManifestDesignerStateText.Foreground = new SolidColorBrush(Color.FromRgb(88, 137, 99));
        }
    }

    private async Task<IReadOnlyCollection<EditorFileContent>> GetEditorFilesAsync()
    {
        if (!_editorReady)
            return [];

        await FlushManifestDesignerAsync();
        var encoded = await EditorWebView.ExecuteScriptAsync("window.editorHost.getWorkspaceJson()");
        var json = JsonSerializer.Deserialize<string>(encoded, JsonOptions) ?? "[]";
        return JsonSerializer.Deserialize<EditorFileContent[]>(json, JsonOptions) ?? [];
    }

    private async Task<string?> GetEditorFileContentAsync(string path)
    {
        if (!_editorReady)
            return null;

        if (path.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
            await FlushManifestDesignerAsync();
        var encoded = await EditorWebView.ExecuteScriptAsync(
            $"window.editorHost.getFileContent({JsonSerializer.Serialize(path)})");
        return JsonSerializer.Deserialize<string?>(encoded, JsonOptions);
    }

    private async void SaveButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_editorReady)
        {
            SetHostStatus("编辑器仍在初始化");
            return;
        }

        try
        {
            await SaveFilesAsync(await GetEditorFilesAsync());
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("保存工程失败", exception);
        }
    }

    private async void FormatButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_editorReady)
        {
            SetHostStatus("编辑器仍在初始化");
            return;
        }

        try
        {
            await EditorWebView.ExecuteScriptAsync("editor.getAction('editor.action.formatDocument').run()");
            SetHostStatus("已格式化当前文件");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("格式化文件失败", exception);
        }
    }

    private async void OpenFolderButton_Click(object sender, RoutedEventArgs e)
    {
        var dialog = new OpenFolderDialog { Title = "打开工具源码文件夹", Multiselect = false };
        if (dialog.ShowDialog(this) != true || !await ConfirmDiscardChangesAsync())
            return;

        try
        {
            _workspaceRoot = Path.GetFullPath(dialog.FolderName);
            ResetManifestDesignerState();
            _explorerOrderLoaded = false;
            _dirty = false;
            FileSearchBox.Text = string.Empty;
            await ReloadFileListAsync();
            await SendWorkspaceAsync();
            SetHostStatus("已打开工程");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("打开工程失败", exception);
        }
    }

    private async void NewProjectButton_Click(object sender, RoutedEventArgs e)
    {
        if (!await ConfirmDiscardChangesAsync())
            return;

        var creator = new NewToolProjectPopupPage();
        var result = PopupHelper.ShowDialog(creator, new PopupWindowOptions
        {
            Title = "新建工具",
            Subtitle = "创建标准 XFEToolBox 工具工程",
            Width = 620,
            Height = 430,
            Owner = this,
            ContentMargin = new Thickness(0)
        });
        if (result != MessageBoxResult.OK || creator.CreatedProjectPath is null)
            return;

        try
        {
            _workspaceRoot = Path.GetFullPath(creator.CreatedProjectPath);
            ResetManifestDesignerState();
            _explorerOrderLoaded = false;
            _dirty = false;
            FileSearchBox.Text = string.Empty;
            await ReloadFileListAsync("manifest.json");
            await SendWorkspaceAsync("manifest.json");
            SetHostStatus("新工程已准备就绪");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("创建工程失败", exception);
        }
    }

    private async void NewFileButton_Click(object sender, RoutedEventArgs e)
    {
        _newItemBaseDirectory = FileList.SelectedItem switch
        {
            EditorExplorerItem { IsFolder: true } folder => folder.RelativePath,
            EditorExplorerItem file => GetParentPath(file.RelativePath),
            _ => null
        };
        _newItemDialogActive = true;
        var dialogTask = ShowEditorDialogAsync(
            "新建项",
            string.Empty,
            "创建",
            input: "NewClass",
            validator: ValidateNewItemName);
        EditorDialogTemplatePanel.Visibility = Visibility.Visible;
        EditorItemTemplateBox.SelectedIndex = 0;
        UpdateNewItemDialog(resetInput: true);
        var response = await dialogTask;
        _newItemDialogActive = false;
        if (response.Choice != EditorDialogChoice.Primary)
            return;

        var itemTemplate = GetSelectedNewItemTemplate();
        var className = response.Input.Trim();
        var directory = GetNewItemDirectory(itemTemplate);
        _newItemBaseDirectory = null;
        try
        {
            var generatedFiles = await CreateNewItemFilesAsync(itemTemplate, directory, className);
            foreach (var file in generatedFiles)
            {
                var fullPath = GetSafeFullPath(file.Path);
                Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
                await File.WriteAllTextAsync(fullPath, file.Content, new UTF8Encoding(false));
            }

            var activePath = generatedFiles[0].Path;
            await ReloadFileListAsync(activePath);
            if (_editorReady)
            {
                foreach (var file in generatedFiles)
                {
                    var json = JsonSerializer.Serialize(file, JsonOptions);
                    await EditorWebView.ExecuteScriptAsync($"window.editorHost.upsertFile({json})");
                }
                await EditorWebView.ExecuteScriptAsync(
                    $"window.editorHost.activateFile({JsonSerializer.Serialize(activePath)})");
            }
            SetHostStatus(itemTemplate == EditorNewItemTemplate.WpfPage
                ? $"已创建页面 {activePath} 及其代码后置文件"
                : $"已创建 {activePath}");
        }
        catch (Exception exception)
        {
            await ShowAlertAsync("无法新建项", exception.Message);
        }
    }

    private async void NewFolderButton_Click(object sender, RoutedEventArgs e)
    {
        var selectedDirectory = FileList.SelectedItem switch
        {
            EditorExplorerItem { IsFolder: true } folder => folder.RelativePath,
            EditorExplorerItem item => GetParentPath(item.RelativePath),
            _ => string.Empty
        };
        var suggestedPath = string.IsNullOrWhiteSpace(selectedDirectory)
            ? "Code/NewFolder"
            : $"{selectedDirectory}/NewFolder";
        var response = await ShowEditorDialogAsync(
            "新建工程文件夹",
            "输入相对于工程根目录的文件夹路径,可以一次创建多级目录。",
            "创建文件夹",
            input: suggestedPath,
            validator: ValidateNewFolderPath);
        if (response.Choice != EditorDialogChoice.Primary)
            return;

        var relativePath = response.Input.Trim().Replace('\\', '/').TrimEnd('/');
        try
        {
            Directory.CreateDirectory(GetSafeFullPath(relativePath));
            await ReloadFileListAsync(relativePath);
            SelectExplorerItem(relativePath);
            SetHostStatus($"已创建文件夹 {relativePath}");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("创建文件夹失败", exception);
        }
    }

    private async void DeleteFileButton_Click(object sender, RoutedEventArgs e)
    {
        if (FileList.SelectedItem is not EditorExplorerItem file)
        {
            SetHostStatus("请先选择要删除的文件或文件夹");
            return;
        }

        if (!file.IsFolder && file.RelativePath.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
        {
            await ShowAlertAsync("无法删除文件", "manifest.json 是工具包必需文件,不能删除。");
            return;
        }

        var itemKind = file.IsFolder ? "文件夹" : "工程文件";
        var response = await ShowEditorDialogAsync(
            $"删除{itemKind}",
            $"确定永久删除“{file.RelativePath}”吗?{(file.IsFolder ? "文件夹内的全部内容也会被删除。" : string.Empty)}此操作无法撤销。",
            "确认删除");
        if (response.Choice != EditorDialogChoice.Primary)
            return;

        try
        {
            var fullPath = GetSafeFullPath(file.RelativePath);
            if (file.IsFolder)
            {
                var removedPaths = _files
                    .Where(item => item.IsEditable
                                   && item.RelativePath.StartsWith(file.RelativePath.TrimEnd('/') + "/", StringComparison.OrdinalIgnoreCase))
                    .Select(item => item.RelativePath)
                    .ToArray();
                Directory.Delete(fullPath, recursive: true);
                foreach (var removedPath in removedPaths)
                {
                    if (_editorReady)
                        await EditorWebView.ExecuteScriptAsync($"window.editorHost.removeFile({JsonSerializer.Serialize(removedPath)})");
                }
            }
            else
            {
                File.Delete(fullPath);
                if (_editorReady && file.IsEditable)
                    await EditorWebView.ExecuteScriptAsync($"window.editorHost.removeFile({JsonSerializer.Serialize(file.RelativePath)})");
            }

            await ReloadFileListAsync();
            SetHostStatus($"已删除{itemKind} {file.RelativePath}");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("删除文件失败", exception);
        }
    }

    private async void PackageButton_Click(object sender, RoutedEventArgs e)
    {
        try
        {
            SetHostStatus("正在验证工具包…");
            var package = await BuildToolPackageAsync();

            var dialog = new SaveFileDialog
            {
                Filter = "XFEToolBox 工具包 (*.xfetool)|*.xfetool",
                FileName = $"{package.Manifest.Id}-{package.Manifest.Version}.xfetool",
                AddExtension = true,
                OverwritePrompt = true
            };
            if (dialog.ShowDialog(this) != true)
                return;

            var output = Path.GetFullPath(dialog.FileName);
            var workspacePrefix = Path.GetFullPath(_workspaceRoot).TrimEnd(Path.DirectorySeparatorChar)
                                  + Path.DirectorySeparatorChar;
            if (output.StartsWith(workspacePrefix, StringComparison.OrdinalIgnoreCase))
                throw new InvalidOperationException("请将工具包导出到工程目录之外。");
            await File.WriteAllBytesAsync(output, package.Bytes);

            SetHostStatus($"已导出 {Path.GetFileName(output)}");
            await ShowAlertAsync("工具包导出成功", "工具包已经保存到本地,也可以通过顶部“工具包”菜单直接发布。");
        }
        catch (Exception exception)
        {
            SetHostStatus("导出失败");
            await ShowAlertAsync("导出工具包失败", exception.Message);
        }
    }

    private async void PublishPackageButton_Click(object sender, RoutedEventArgs e)
    {
        if (!ClientSession.IsLoggedIn)
        {
            await ShowAlertAsync("请先登录", "发布工具需要登录工具箱账号。普通用户提交后将由管理员审核,通过后才会公开。");
            return;
        }

        var directPublish = ClientSession.IsAdministrator;
        using var activity = ActivityCenterService.Start(
            directPublish ? "发布工具包" : "提交工具审核",
            XFEToolBox.Client.Models.ActivityKind.Publish);
        PublishPackageMenuItem.IsEnabled = false;
        try
        {
            SetHostStatus("正在保存并验证工具包…");
            activity.Report(null, "正在保存并验证工具包…");
            var package = await BuildToolPackageAsync();
            var response = await ShowEditorDialogAsync(
                directPublish ? "发布工具包" : "提交工具审核",
                directPublish
                    ? $"即将把“{package.Manifest.Name}” {package.Manifest.Version} 直接发布到工具服务器。同一工具的版本号不可重复。"
                    : $"即将提交“{package.Manifest.Name}” {package.Manifest.Version}。管理员审核通过前不会出现在公共工具库中,同一工具的版本号不可重复。",
                directPublish ? "立即发布" : "提交审核");
            if (response.Choice != EditorDialogChoice.Primary)
            {
                SetHostStatus(directPublish ? "已取消发布" : "已取消提交");
                activity.Cancel(directPublish ? "用户取消发布" : "用户取消提交");
                return;
            }

            SetHostStatus($"正在{(directPublish ? "发布" : "提交")} {package.Manifest.Name} {package.Manifest.Version}…");
            activity.Report(null, $"正在{(directPublish ? "发布" : "提交")} {package.Manifest.Name} {package.Manifest.Version}…");
            var packageBase64 = Convert.ToBase64String(package.Bytes);
            var upload = directPublish
                ? await ClientSession.Requester.Request<ToolPackageUploadResult>(
                    "adminUploadTool", packageBase64, true, false)
                : await ClientSession.Requester.Request<ToolPackageUploadResult>(
                    "submitTool", packageBase64);
            if (upload.StatusCode == HttpStatusCode.Conflict)
            {
                SetHostStatus("版本已存在");
                activity.Fail("相同版本已经存在");
                await ShowAlertAsync(
                    "版本已存在",
                    string.IsNullOrWhiteSpace(upload.Message)
                        ? $"服务器中已存在“{package.Manifest.Name}” {package.Manifest.Version},请修改 manifest.json 中的 version 后重新发布。"
                        : upload.Message);
                return;
            }
            if (upload.StatusCode is not (HttpStatusCode.OK or HttpStatusCode.Created)
                || upload.Result is null)
            {
                throw new InvalidOperationException(string.IsNullOrWhiteSpace(upload.Message)
                    ? "服务器没有返回工具包信息。"
                    : upload.Message);
            }

            var successVerb = directPublish ? "已发布" : "已提交审核";
            SetHostStatus($"{successVerb} {upload.Result.Manifest.Name} {upload.Result.Manifest.Version}");
            activity.Succeed($"{successVerb} {upload.Result.Manifest.Name} {upload.Result.Manifest.Version}");
            await ShowAlertAsync(
                directPublish ? "工具包发布成功" : "工具已提交审核",
                directPublish
                    ? $"“{upload.Result.Manifest.Name}” {upload.Result.Manifest.Version} 已发布,工具箱用户现在可以在工具库中获取该版本。"
                    : $"“{upload.Result.Manifest.Name}” {upload.Result.Manifest.Version} 已进入待审核队列,管理员通过后将自动公开。");
        }
        catch (Exception exception)
        {
            SetHostStatus(directPublish ? "发布失败" : "提交失败");
            activity.Fail(exception.Message);
            await ShowAlertAsync(directPublish ? "发布工具包失败" : "提交工具审核失败", exception.Message);
        }
        finally
        {
            PublishPackageMenuItem.IsEnabled = _editorReady;
        }
    }

    private async Task<(ToolPackageManifest Manifest, byte[] Bytes)> BuildToolPackageAsync()
    {
        if (!_editorReady)
            throw new InvalidOperationException("编辑器尚未初始化完成。");

        var manifest = await SaveAndReadManifestAsync();
        if (string.IsNullOrWhiteSpace(manifest.Id) || string.IsNullOrWhiteSpace(manifest.Version))
            throw new InvalidDataException("manifest.json 缺少 id 或 version。");

        using var output = new MemoryStream();
        using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
        {
            foreach (var source in Directory.EnumerateFiles(_workspaceRoot, "*", SearchOption.AllDirectories)
                         .Where(path => !path.EndsWith(".xfetool", StringComparison.OrdinalIgnoreCase)))
            {
                var relative = Path.GetRelativePath(_workspaceRoot, source).Replace('\\', '/');
                archive.CreateEntryFromFile(source, relative, CompressionLevel.Optimal);
            }
        }

        return (manifest, output.ToArray());
    }

    private async void PreviewButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_editorReady)
        {
            SetHostStatus("编辑器仍在初始化");
            return;
        }

        PreviewButton.IsEnabled = false;
        try
        {
            var previewPath = _activePath;
            SetHostStatus("正在生成文件预览…");
            ShowPreviewPanel();
            ShowWpfPreviewSurface();
            PreviewContent.Content = null;
            PreviewErrorPanel.Visibility = Visibility.Collapsed;

            if (previewPath is not null && IsMarkdownPath(previewPath))
            {
                var markdown = await ReadEditorOrDiskFileAsync(previewPath);
                PreviewTitleText.Text = $"{previewPath} · Markdown 预览";
                PreviewSubtitleText.Text = "Markdig 高级语法 · 样式参考 XFEStudioWebSite";
                await ShowMarkdownPreviewAsync(markdown, previewPath);
                SetHostStatus("Markdown 预览已更新");
                return;
            }

            if (previewPath is not null && ImageExtensions.Contains(Path.GetExtension(previewPath)))
            {
                await ShowImagePreviewAsync(previewPath);
                return;
            }

            if (previewPath is not null && Path.GetExtension(previewPath).Equals(".xaml", StringComparison.OrdinalIgnoreCase))
            {
                var xaml = await ReadEditorOrDiskFileAsync(previewPath);
                PreviewTitleText.Text = $"{previewPath} · XAML 预览";
                PreviewSubtitleText.Text = "仅呈现界面;F5 可完整编译并运行代码";
                PreviewContent.Content = CreatePreviewContent(xaml, GetSafeFullPath(previewPath));
                SetHostStatus("XAML 快速预览已更新");
                return;
            }

            if (previewPath is not null && IsPlainTextPreviewPath(previewPath)
                && !previewPath.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
            {
                var content = await ReadEditorOrDiskFileAsync(previewPath);
                PreviewTitleText.Text = $"{previewPath} · 文本预览";
                PreviewSubtitleText.Text = "只读预览当前未保存内容";
                PreviewContent.Content = CreatePlainTextPreview(content);
                SetHostStatus("文本预览已更新");
                return;
            }

            var manifestJson = await ReadEditorOrDiskFileAsync("manifest.json");
            var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(manifestJson, JsonOptions)
                           ?? throw new InvalidDataException("manifest.json 内容为空。");
            var viewRelativePath = manifest.Entry.ViewXaml;
            var viewXaml = await ReadEditorOrDiskFileAsync(viewRelativePath);
            PreviewTitleText.Text = $"{viewRelativePath} · 入口预览";
            PreviewSubtitleText.Text = "当前文件不可直接呈现,已预览工具入口界面";
            PreviewContent.Content = CreatePreviewContent(viewXaml, GetSafeFullPath(viewRelativePath));
            SetHostStatus("工具入口预览已更新");
        }
        catch (Exception exception)
        {
            ShowPreviewPanel();
            ShowWpfPreviewSurface();
            PreviewContent.Content = null;
            PreviewErrorText.Text = exception.Message;
            PreviewErrorPanel.Visibility = Visibility.Visible;
            SetHostStatus("预览生成失败");
        }
        finally
        {
            PreviewButton.IsEnabled = true;
        }
    }

    private async Task<string> ReadEditorOrDiskFileAsync(string relativePath)
    {
        var editorContent = await GetEditorFileContentAsync(relativePath);
        if (editorContent is not null)
            return editorContent;

        var fullPath = GetSafeFullPath(relativePath);
        if (!File.Exists(fullPath))
            throw new FileNotFoundException($"预览文件不存在:{relativePath}", fullPath);
        return await File.ReadAllTextAsync(fullPath);
    }

    private async Task ShowMarkdownPreviewAsync(string markdown, string relativePath)
    {
        if (!_markdownPreviewInitialized)
        {
            var webViewEnvironment = await GetSharedWebViewEnvironmentAsync();
            await MarkdownPreviewWebView.EnsureCoreWebView2Async(webViewEnvironment);
            MarkdownPreviewWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
            MarkdownPreviewWebView.CoreWebView2.Settings.AreDevToolsEnabled = false;
            MarkdownPreviewWebView.CoreWebView2.NewWindowRequested += (_, args) =>
            {
                args.Handled = true;
                try
                {
                    Process.Start(new ProcessStartInfo(args.Uri) { UseShellExecute = true });
                }
                catch
                {
                    // 外部协议无法处理时保持预览页不变。
                }
            };
            _markdownPreviewInitialized = true;
        }

        MarkdownPreviewWebView.CoreWebView2.SetVirtualHostNameToFolderMapping(
            "workspace.xfetoolbox", _workspaceRoot, CoreWebView2HostResourceAccessKind.Allow);
        var directory = Path.GetDirectoryName(relativePath)?.Replace('\\', '/');
        var basePath = string.IsNullOrWhiteSpace(directory)
            ? string.Empty
            : string.Join('/', directory.Split('/').Select(Uri.EscapeDataString)) + "/";
        var body = _markdownSanitizer.Sanitize(Markdown.ToHtml(markdown, MarkdownPipeline))
            .Replace("<a href=\"http", "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"http", StringComparison.OrdinalIgnoreCase)
            .Replace("<a href=\"mailto:", "<a target=\"_blank\" rel=\"noopener noreferrer\" href=\"mailto:", StringComparison.OrdinalIgnoreCase);
        var html = CreateMarkdownDocument(body, $"https://workspace.xfetoolbox/{basePath}");
        _showingMarkdownPreview = true;
        PreviewWpfScrollViewer.Visibility = Visibility.Collapsed;
        MarkdownPreviewWebView.Visibility = EditorDialogOverlay.Visibility == Visibility.Visible
            ? Visibility.Hidden
            : Visibility.Visible;
        MarkdownPreviewWebView.NavigateToString(html);
    }

    private static TextBox CreatePlainTextPreview(string content) => new()
    {
        Text = content,
        IsReadOnly = true,
        BorderThickness = new Thickness(0),
        Background = Brushes.Transparent,
        Foreground = new SolidColorBrush(Color.FromRgb(65, 65, 86)),
        FontFamily = new FontFamily("Cascadia Code, Consolas"),
        FontSize = 12,
        TextWrapping = TextWrapping.NoWrap,
        AcceptsReturn = true,
        HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
        VerticalScrollBarVisibility = ScrollBarVisibility.Disabled
    };

    private static bool IsMarkdownPath(string path) =>
        Path.GetExtension(path).ToLowerInvariant() is ".md" or ".markdown" or ".mdown" or ".mkd" or ".mkdn";

    private static bool IsPlainTextPreviewPath(string path) =>
        Path.GetExtension(path).ToLowerInvariant() is ".json" or ".xml" or ".resx" or ".txt" or ".yml" or ".yaml" or ".config" or ".props" or ".targets";

    private async Task ShowImagePreviewAsync(string relativePath, bool openAsDocument = false)
    {
        if (_lastSurfacePath?.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) == true)
            await FlushManifestDesignerAsync();

        var fullPath = GetSafeFullPath(relativePath);
        if (!File.Exists(fullPath))
            throw new FileNotFoundException($"图像文件不存在:{relativePath}", fullPath);

        var bitmap = new BitmapImage();
        await using (var stream = new FileStream(
                         fullPath,
                         FileMode.Open,
                         FileAccess.Read,
                         FileShare.ReadWrite | FileShare.Delete,
                         81920,
                         FileOptions.Asynchronous | FileOptions.SequentialScan))
        {
            bitmap.BeginInit();
            bitmap.CacheOption = BitmapCacheOption.OnLoad;
            bitmap.StreamSource = stream;
            bitmap.EndInit();
        }
        bitmap.Freeze();

        _activePath = relativePath;
        _lastSurfacePath = relativePath;
        _manifestSourceMode = false;
        ManifestDesignerPanel.Visibility = Visibility.Collapsed;
        ManifestViewToggleButton.Visibility = Visibility.Collapsed;
        UpdateXamlRelatedFileButton(relativePath);
        ActiveFileText.Text = relativePath;

        ShowWpfPreviewSurface();
        PreviewErrorPanel.Visibility = Visibility.Collapsed;
        PreviewTitleText.Text = $"{relativePath} · 图像预览";
        PreviewSubtitleText.Text = $"{bitmap.PixelWidth} × {bitmap.PixelHeight} · {Path.GetExtension(relativePath).TrimStart('.').ToUpperInvariant()}";
        var previewScale = Math.Min(1d, Math.Min(360d / bitmap.PixelWidth, 360d / bitmap.PixelHeight));
        var image = new Image
        {
            Source = bitmap,
            Stretch = Stretch.Uniform,
            Width = Math.Max(1, bitmap.PixelWidth * previewScale),
            Height = Math.Max(1, bitmap.PixelHeight * previewScale),
            HorizontalAlignment = HorizontalAlignment.Center,
            VerticalAlignment = VerticalAlignment.Center,
            SnapsToDevicePixels = true
        };
        RenderOptions.SetBitmapScalingMode(image, BitmapScalingMode.HighQuality);
        PreviewContent.Content = image;

        if (openAsDocument)
        {
            _previewDocumentTabOpen = true;
            PreviewDocumentTabButton.Content = $"预览 · {Path.GetFileName(relativePath)}";
            PreviewDocumentTabStrip.Visibility = Visibility.Visible;
            ShowPreviewDocumentSurface();
        }
        else
        {
            ShowPreviewPanel();
        }

        UpdateEditorWebViewVisibility();
        SetHostStatus($"已预览图像 {relativePath}");
    }

    private static HtmlSanitizer CreateMarkdownSanitizer()
    {
        var sanitizer = new HtmlSanitizer();
        sanitizer.AllowedTags.Remove("button");
        sanitizer.AllowedTags.Remove("script");
        sanitizer.AllowedTags.Remove("style");
        sanitizer.AllowedAttributes.Add("id");
        sanitizer.AllowedAttributes.Add("class");
        sanitizer.AllowedAttributes.Add("checked");
        sanitizer.AllowedAttributes.Add("disabled");
        return sanitizer;
    }

    private static string CreateMarkdownDocument(string body, string baseUri) =>
        """
        <!doctype html>
        <html lang="zh-CN">
        <head>
          <meta charset="utf-8">
          <meta name="color-scheme" content="light">
          <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: https:; style-src 'unsafe-inline'; font-src data:">
          <base href="__BASE_URI__">
          <style>
            :root { --main:#9292e7; --main-deep:#7272c8; --text:#414156; --muted:#77778b; --line:#e3e2ed; --soft:#f2f1fc; }
            * { box-sizing:border-box; }
            ::-webkit-scrollbar { width:12px; height:12px; }
            ::-webkit-scrollbar-track { border-radius:6px; background:#ececfa; }
            ::-webkit-scrollbar-thumb { border:2px solid #ececfa; border-radius:6px; background:#aaa9e9; }
            ::-webkit-scrollbar-thumb:hover { background:#9292df; }
            html { background:#fff; }
            body { margin:0; padding:24px 26px 40px; color:var(--text); background:#fff; font:14px/1.72 "Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; overflow-wrap:anywhere; }
            .markdown-body { max-width:920px; margin:0 auto; }
            .markdown-body > :first-child { margin-top:0 !important; }
            .markdown-body > :last-child { margin-bottom:0 !important; }
            p { margin:0 0 1.15rem; }
            a { color:var(--main-deep); font-weight:600; text-decoration:none; }
            a:hover { color:#5f5faf; text-decoration:underline; }
            h1,h2,h3,h4,h5,h6 { color:#3f3f56; line-height:1.3; }
            h1 { margin:1.9em 0 .8em; padding-bottom:.35em; border-bottom:1px solid var(--line); font-size:2em; font-weight:700; }
            h2 { margin:1.7em 0 .8em; padding-bottom:.32em; border-bottom:1px solid var(--line); font-size:1.5em; font-weight:650; }
            h3 { margin:1.45em 0 .75em; font-size:1.24em; }
            h4 { margin:1.3em 0 .6em; font-size:1.08em; }
            h5,h6 { margin:1.1em 0 .5em; color:#5f5f73; }
            hr { height:4px; margin:25px 0; border:0; border-radius:999px; background:var(--main); opacity:.48; }
            blockquote { margin:1rem 0; padding:.65rem 1rem; border-left:4px solid var(--main); border-radius:0 8px 8px 0; color:#66667a; background:var(--soft); }
            blockquote > :last-child { margin-bottom:0; }
            ul,ol { margin:.5em 0 1.15rem; padding-left:1.8em; }
            li { margin-top:.36em; padding-left:.18em; }
            li::marker { color:var(--main-deep); font-weight:700; }
            ul ul,ul ol,ol ul,ol ol { margin:.25em 0; padding-left:1.5em; }
            code { padding:.15em .42em; border:1px solid #dfdeef; border-radius:5px; color:#6861aa; background:#f1f0fa; font: .9em/1.5 "Cascadia Code",Consolas,monospace; }
            pre { max-width:100%; margin:1.25rem 0; padding:1rem; overflow:auto; border:1px solid #55516d; border-radius:12px; color:#f1f1f4; background:#2b2b2b; box-shadow:0 6px 18px #0000002e; font: .9rem/1.62 "Cascadia Code",Consolas,monospace; white-space:pre; }
            pre code { display:block; min-width:max-content; padding:0; border:0; color:inherit; background:transparent; font:inherit; white-space:pre; }
            img { display:inline-block; max-width:100%; height:auto; margin:1rem 0; border:3px solid #9292e766; border-radius:8px; box-shadow:0 4px 12px #0000001a; }
            table { display:block; width:max-content; max-width:100%; margin:0 0 1rem; overflow-x:auto; border-spacing:0; border-collapse:collapse; }
            th,td { padding:7px 13px; border:1px solid var(--line); text-align:left; }
            th { font-weight:650; background:#f1f0f8; }
            tr:nth-child(2n) { background:#f9f9fc; }
            input[type=checkbox] { width:15px; height:15px; margin:0 7px 0 0; accent-color:var(--main); vertical-align:-2px; }
            .task-list-item { list-style:none; }
            dt { margin-top:1em; font-weight:650; }
            dd { margin-left:1.5em; color:var(--muted); }
            mark { padding:.08em .24em; border-radius:3px; background:#fff0a6; }
            kbd { padding:2px 6px; border:1px solid #d6d5e1; border-bottom-width:2px; border-radius:5px; background:#f7f7fa; font: .86em "Cascadia Code",Consolas,monospace; }
            .footnotes { margin-top:2.2rem; padding-top:1rem; border-top:1px solid var(--line); color:var(--muted); font-size:.92em; }
            @media (max-width:620px) { body { padding:18px 17px 30px; } h1 { font-size:1.72em; } h2 { font-size:1.38em; } }
          </style>
        </head>
        <body><article class="markdown-body">__MARKDOWN_BODY__</article></body>
        </html>
        """
        .Replace("__BASE_URI__", WebUtility.HtmlEncode(baseUri), StringComparison.Ordinal)
        .Replace("__MARKDOWN_BODY__", body, StringComparison.Ordinal);

    private async void RunButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_editorReady)
        {
            SetHostStatus("编辑器仍在初始化");
            return;
        }

        using var activity = ActivityCenterService.Start(
            "运行 Code Studio 工具", XFEToolBox.Client.Models.ActivityKind.Build, canCancel: true);
        RunButton.IsEnabled = false;
        try
        {
            SetHostStatus("正在保存并编译工具…");
            activity.Report(null, "正在保存并编译工具…");
            var manifest = await SaveAndReadManifestAsync();
            var result = await ToolProjectRunService.BuildAndRunAsync(_workspaceRoot, manifest, activity.CancellationToken);
            if (!result.Success)
            {
                SetHostStatus("工具编译失败");
                activity.Fail(result.Message);
                await ShowAlertAsync("工具运行失败", result.Message);
                return;
            }

            SetHostStatus(result.ProcessId is null
                ? result.Message
                : $"工具正在运行 · 进程 {result.ProcessId}");
            activity.Succeed(result.Message);
        }
        catch (OperationCanceledException)
        {
            SetHostStatus("已取消运行");
            activity.Cancel();
        }
        catch (Exception exception)
        {
            activity.Fail(exception.Message);
            await ReportOperationFailureAsync("运行工具失败", exception);
        }
        finally
        {
            RunButton.IsEnabled = true;
        }
    }

    private async void BuildProjectButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_editorReady)
        {
            SetHostStatus("编辑器仍在初始化");
            return;
        }

        using var activity = ActivityCenterService.Start(
            "生成 Code Studio 工具", XFEToolBox.Client.Models.ActivityKind.Build, canCancel: true);
        RunButton.IsEnabled = false;
        try
        {
            SetHostStatus("正在保存并生成工具工程…");
            activity.Report(null, "正在保存并生成工具工程…");
            var manifest = await SaveAndReadManifestAsync();
            var result = await ToolProjectRunService.BuildAsync(_workspaceRoot, manifest, activity.CancellationToken);
            if (!result.Success)
            {
                SetHostStatus("工具生成失败");
                activity.Fail(result.Message);
                await ShowAlertAsync("生成工具失败", result.Message);
                return;
            }

            SetHostStatus("工具工程生成成功 · 0 个错误");
            activity.Succeed("工具工程生成成功 · 0 个错误");
        }
        catch (OperationCanceledException)
        {
            SetHostStatus("已取消生成");
            activity.Cancel();
        }
        catch (Exception exception)
        {
            activity.Fail(exception.Message);
            await ReportOperationFailureAsync("生成工具失败", exception);
        }
        finally
        {
            RunButton.IsEnabled = true;
        }
    }

    private async Task<ToolPackageManifest> SaveAndReadManifestAsync()
    {
        await SaveFilesAsync(await GetEditorFilesAsync());
        var manifestPath = Path.Combine(_workspaceRoot, "manifest.json");
        if (!File.Exists(manifestPath))
            throw new InvalidDataException("工程缺少 manifest.json。");

        var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(
                           await File.ReadAllTextAsync(manifestPath), JsonOptions)
                       ?? throw new InvalidDataException("manifest.json 内容为空。");
        if (string.IsNullOrWhiteSpace(manifest.Entry.ViewXaml)
            || string.IsNullOrWhiteSpace(manifest.Entry.ViewClass))
            throw new InvalidDataException("manifest.json 缺少 entry.viewXaml 或 entry.viewClass。");
        return manifest;
    }

    private static UIElement CreatePreviewContent(string xaml, string viewPath)
    {
        var document = XDocument.Parse(xaml, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
        var root = document.Root ?? throw new InvalidDataException("入口 XAML 没有根元素。");
        XNamespace xNamespace = "http://schemas.microsoft.com/winfx/2006/xaml";
        root.Attribute(xNamespace + "Class")?.Remove();

        foreach (var attribute in root.DescendantsAndSelf().Attributes().ToArray())
        {
            if (attribute.Name.Namespace == XNamespace.None
                && PreviewEventAttributes.Contains(attribute.Name.LocalName)
                && IsEventHandlerName(attribute.Value))
                attribute.Remove();
        }

        var baseDirectory = Path.GetDirectoryName(viewPath)
                            ?? throw new InvalidDataException("无法确定入口 XAML 所在目录。");
        var parserContext = new ParserContext
        {
            BaseUri = new Uri(Path.GetFullPath(baseDirectory).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar)
        };
        var parsed = XamlReader.Parse(document.ToString(SaveOptions.DisableFormatting), parserContext);
        if (parsed is Window window)
        {
            var content = window.Content as UIElement
                          ?? throw new InvalidDataException("入口 Window 没有可预览的界面内容。");
            window.Content = null;
            return content;
        }

        return parsed as UIElement
               ?? throw new InvalidDataException("入口 XAML 的根元素必须是可显示的 WPF 控件。");
    }

    private static bool IsEventHandlerName(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return false;
        return value.All(character => char.IsLetterOrDigit(character) || character == '_')
               && (char.IsLetter(value[0]) || value[0] == '_');
    }

    private void ShowPreviewPanel()
    {
        if (_previewDocumentActive)
        {
            ShowPreviewDocumentSurface();
            return;
        }

        _previewDocumentActive = false;
        EditorSurfaceColumn.MinWidth = 360;
        EditorSurfaceColumn.Width = new GridLength(1, GridUnitType.Star);
        PreviewSplitterColumn.Width = new GridLength(13);
        PreviewColumn.Width = new GridLength(420);
        PreviewSplitter.Visibility = Visibility.Visible;
        PreviewPanel.Visibility = Visibility.Visible;
        UpdatePreviewDocumentTabAppearance();
        UpdateEditorWebViewVisibility();
    }

    private void ShowWpfPreviewSurface()
    {
        _showingMarkdownPreview = false;
        MarkdownPreviewWebView.Visibility = Visibility.Collapsed;
        PreviewWpfScrollViewer.Visibility = Visibility.Visible;
    }

    private void ClosePreviewButton_Click(object sender, RoutedEventArgs e)
    {
        ClosePreviewSurface();
        SetHostStatus("已关闭快速预览");
    }

    private void OpenPreviewDocumentTabButton_Click(object sender, RoutedEventArgs e)
    {
        _previewDocumentTabOpen = true;
        var previewName = PreviewTitleText.Text.Split('·', StringSplitOptions.TrimEntries)[0];
        PreviewDocumentTabButton.Content = $"预览 · {previewName}";
        PreviewDocumentTabStrip.Visibility = Visibility.Visible;
        ShowPreviewDocumentSurface();
        SetHostStatus("已在独立选项卡中打开预览");
    }

    private void PreviewDocumentTabButton_Click(object sender, RoutedEventArgs e)
    {
        if (_previewDocumentTabOpen)
            ShowPreviewDocumentSurface();
    }

    private void EditorSurfaceTabButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_previewDocumentTabOpen)
            return;

        _previewDocumentActive = false;
        EditorSurfaceColumn.MinWidth = 360;
        EditorSurfaceColumn.Width = new GridLength(1, GridUnitType.Star);
        PreviewSplitterColumn.Width = new GridLength(0);
        PreviewColumn.Width = new GridLength(0);
        PreviewSplitter.Visibility = Visibility.Collapsed;
        PreviewPanel.Visibility = Visibility.Collapsed;
        UpdatePreviewDocumentTabAppearance();
        UpdateEditorWebViewVisibility();
        SetHostStatus("已切换到代码编辑器");
    }

    private void ClosePreviewDocumentTabButton_Click(object sender, RoutedEventArgs e)
    {
        ClosePreviewSurface();
        SetHostStatus("已关闭预览选项卡");
    }

    private void ShowPreviewDocumentSurface()
    {
        _previewDocumentTabOpen = true;
        _previewDocumentActive = true;
        PreviewDocumentTabStrip.Visibility = Visibility.Visible;
        EditorSurfaceColumn.MinWidth = 0;
        EditorSurfaceColumn.Width = new GridLength(0);
        PreviewSplitterColumn.Width = new GridLength(0);
        PreviewColumn.Width = new GridLength(1, GridUnitType.Star);
        PreviewSplitter.Visibility = Visibility.Collapsed;
        PreviewPanel.Visibility = Visibility.Visible;
        UpdatePreviewDocumentTabAppearance();
        UpdateEditorWebViewVisibility();
    }

    private void ClosePreviewSurface()
    {
        _previewDocumentTabOpen = false;
        _previewDocumentActive = false;
        PreviewDocumentTabStrip.Visibility = Visibility.Collapsed;
        _showingMarkdownPreview = false;
        MarkdownPreviewWebView.Visibility = Visibility.Collapsed;
        PreviewWpfScrollViewer.Visibility = Visibility.Visible;
        PreviewContent.Content = null;
        PreviewErrorPanel.Visibility = Visibility.Collapsed;
        PreviewPanel.Visibility = Visibility.Collapsed;
        PreviewSplitter.Visibility = Visibility.Collapsed;
        EditorSurfaceColumn.MinWidth = 360;
        EditorSurfaceColumn.Width = new GridLength(1, GridUnitType.Star);
        PreviewSplitterColumn.Width = new GridLength(0);
        PreviewColumn.Width = new GridLength(0);
        UpdatePreviewDocumentTabAppearance();
        UpdateEditorWebViewVisibility();
    }

    private void UpdatePreviewDocumentTabAppearance()
    {
        var activeBackground = new SolidColorBrush(Color.FromRgb(237, 236, 249));
        var inactiveBackground = new SolidColorBrush(Color.FromRgb(248, 248, 252));
        var activeBorder = new SolidColorBrush(Color.FromRgb(183, 182, 230));
        var inactiveBorder = new SolidColorBrush(Color.FromRgb(226, 226, 239));
        EditorSurfaceTabButton.Background = _previewDocumentActive ? inactiveBackground : activeBackground;
        EditorSurfaceTabButton.BorderBrush = _previewDocumentActive ? inactiveBorder : activeBorder;
        PreviewDocumentTabButton.Background = _previewDocumentActive ? activeBackground : inactiveBackground;
        PreviewDocumentTabButton.BorderBrush = _previewDocumentActive ? activeBorder : inactiveBorder;
    }

    private async void FileList_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (_ignoreSelection || !_editorReady || FileList.SelectedItem is not EditorExplorerItem { IsFolder: false } file)
            return;

        try
        {
            ActiveFileText.Text = file.RelativePath;
            if (file.IsImage)
            {
                await ShowImagePreviewAsync(file.RelativePath);
                return;
            }
            await EditorWebView.ExecuteScriptAsync(
                $"window.editorHost.activateFile({JsonSerializer.Serialize(file.RelativePath)})");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("切换文件失败", exception);
        }
    }

    private void SelectFile(string path)
    {
        var item = _files.FirstOrDefault(file =>
            file.RelativePath.Equals(path, StringComparison.OrdinalIgnoreCase));
        if (item is null)
            return;

        SelectExplorerItem(item.RelativePath);
        ActiveFileText.Text = item.RelativePath;
    }

    private async Task UpdateEditorSurfaceForFileAsync(string path)
    {
        var wasManifest = _lastSurfacePath?.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) == true;
        var isManifest = path.Equals("manifest.json", StringComparison.OrdinalIgnoreCase);
        if (wasManifest && !isManifest)
            await FlushManifestDesignerAsync();

        _activePath = path;
        UpdateXamlRelatedFileButton(path);
        if (!isManifest)
        {
            _manifestSourceMode = false;
            ManifestDesignerPanel.Visibility = Visibility.Collapsed;
            ManifestViewToggleButton.Visibility = Visibility.Collapsed;
            ActiveFileText.Text = path;
        }
        else
        {
            if (!wasManifest)
                _manifestSourceMode = false;
            ManifestViewToggleButton.Visibility = Visibility.Visible;
            if (_manifestSourceMode)
            {
                ManifestDesignerPanel.Visibility = Visibility.Collapsed;
                ManifestViewToggleButton.Content = "返回可视化配置";
                ActiveFileText.Text = "manifest.json · JSON 源码";
            }
            else
            {
                await LoadManifestDesignerAsync();
            }
        }

        _lastSurfacePath = path;
        UpdateEditorWebViewVisibility();
    }

    private async Task LoadManifestDesignerAsync()
    {
        try
        {
            var json = await GetEditorFileContentAsync("manifest.json")
                       ?? await File.ReadAllTextAsync(GetSafeFullPath("manifest.json"));
            var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(json, JsonOptions)
                           ?? throw new InvalidDataException("manifest.json 内容为空。");
            var entry = manifest.Entry ?? throw new InvalidDataException("manifest.json 缺少 entry 配置。");

            _loadingManifestDesigner = true;
            ManifestPackageFormatText.Text = manifest.PackageFormatVersion.ToString();
            ManifestIdBox.Text = manifest.Id;
            ManifestNameBox.Text = manifest.Name;
            ManifestSubtitleBox.Text = manifest.Subtitle ?? string.Empty;
            ManifestVersionBox.Text = manifest.Version;
            ManifestDescriptionBox.Text = manifest.Description;
            ManifestAuthorBox.Text = manifest.Author;
            ManifestIconBox.Text = manifest.Icon ?? string.Empty;
            ManifestCategoryBox.Text = manifest.Category;
            ReplaceManifestTags(manifest.Tags ?? []);
            ReplaceManifestNuGetPackages(manifest.NuGetPackages ?? []);
            ManifestNuGetPackageIdBox.Text = string.Empty;
            ManifestNuGetPackageVersionBox.Text = string.Empty;
            ManifestMinimumHostVersionBox.Text = manifest.MinimumHostVersion ?? string.Empty;
            ManifestReleaseNotesBox.Text = manifest.ReleaseNotes ?? string.Empty;
            ManifestRequiresAdministratorCheckBox.IsChecked = manifest.RequiresAdministrator;
            ManifestViewXamlBox.Text = entry.ViewXaml;
            ManifestViewClassBox.Text = entry.ViewClass;
            ManifestViewCodeBehindBox.Text = entry.ViewCodeBehind;
            ManifestViewModelBox.Text = entry.ViewModel ?? string.Empty;
            ManifestViewModelClassBox.Text = entry.ViewModelClass ?? string.Empty;
            var window = manifest.Window ?? new ToolWindowManifest();
            ManifestWindowWidthBox.Text = FormatManifestWindowDimension(window.Width, ToolWindowManifest.DefaultWidth);
            ManifestWindowHeightBox.Text = FormatManifestWindowDimension(window.Height, ToolWindowManifest.DefaultHeight);
            ManifestWindowMinWidthBox.Text = FormatManifestWindowDimension(window.MinWidth, ToolWindowManifest.DefaultMinWidth);
            ManifestWindowMinHeightBox.Text = FormatManifestWindowDimension(window.MinHeight, ToolWindowManifest.DefaultMinHeight);
            ManifestAllowResizeCheckBox.IsChecked = window.AllowResize;
            ManifestAllowMaximizeCheckBox.IsChecked = window.AllowMaximize;
            ManifestShowMinimizeButtonCheckBox.IsChecked = window.ShowMinimizeButton;
            ManifestShowCloseButtonCheckBox.IsChecked = window.ShowCloseButton;
            LoadManifestPermissions(manifest.RequestedPermissions ?? []);
            _manifestDesignerPending = false;

            ManifestDesignerStateText.Text = "已与 manifest.json 同步";
            ManifestDesignerStateText.Foreground = new SolidColorBrush(Color.FromRgb(122, 122, 144));
            ManifestDesignerPanel.Visibility = Visibility.Visible;
            ManifestViewToggleButton.Content = "编辑 JSON 源码";
            ActiveFileText.Text = "manifest.json · 可视化配置";
        }
        catch (Exception exception)
        {
            _manifestSourceMode = true;
            ManifestDesignerPanel.Visibility = Visibility.Collapsed;
            ManifestViewToggleButton.Content = "返回可视化配置";
            ActiveFileText.Text = "manifest.json · JSON 源码";
            SetHostStatus($"manifest.json 无法载入表单:{exception.Message}");
        }
        finally
        {
            _loadingManifestDesigner = false;
            UpdateEditorWebViewVisibility();
        }
    }

    private void ManifestDesignerField_TextChanged(object sender, TextChangedEventArgs e)
    {
        if (_loadingManifestDesigner || !_editorReady || !IsInitialized
            || sender is not UIElement { IsKeyboardFocusWithin: true })
            return;

        MarkManifestDesignerChanged();
    }

    private void MarkManifestDesignerChanged()
    {
        if (_loadingManifestDesigner || !_editorReady || !IsInitialized)
            return;

        _manifestDesignerPending = true;
        _dirty = true;
        UpdateTitle();
        ManifestDesignerStateText.Text = "正在同步到 manifest.json…";
        ManifestDesignerStateText.Foreground = new SolidColorBrush(Color.FromRgb(122, 122, 144));
        SetHostStatus("manifest.json 有未保存更改");
        _manifestSyncTimer.Stop();
        _manifestSyncTimer.Start();
    }

    private async Task FlushManifestDesignerAsync()
    {
        if (!_manifestDesignerPending || !_editorReady)
            return;

        _manifestSyncTimer.Stop();
        var manifest = new ToolPackageManifest
        {
            PackageFormatVersion = ToolPackageManifest.CurrentPackageFormatVersion,
            Id = ManifestIdBox.Text.Trim(),
            Name = ManifestNameBox.Text.Trim(),
            Subtitle = NullIfWhiteSpace(ManifestSubtitleBox.Text),
            Version = ManifestVersionBox.Text.Trim(),
            Description = ManifestDescriptionBox.Text.Trim(),
            Author = ManifestAuthorBox.Text.Trim(),
            Icon = NullIfWhiteSpace(ManifestIconBox.Text),
            Category = ManifestCategoryBox.Text.Trim(),
            Tags = _manifestTags.ToArray(),
            NuGetPackages = _manifestNuGetPackages.ToArray(),
            MinimumHostVersion = NullIfWhiteSpace(ManifestMinimumHostVersionBox.Text),
            ReleaseNotes = NullIfWhiteSpace(ManifestReleaseNotesBox.Text),
            RequiresAdministrator = ManifestRequiresAdministratorCheckBox.IsChecked == true,
            Entry = new ToolEntryManifest
            {
                ViewXaml = ManifestViewXamlBox.Text.Trim().Replace('\\', '/'),
                ViewClass = ManifestViewClassBox.Text.Trim(),
                ViewCodeBehind = ManifestViewCodeBehindBox.Text.Trim().Replace('\\', '/'),
                ViewModel = NormalizeOptionalPath(ManifestViewModelBox.Text),
                ViewModelClass = NullIfWhiteSpace(ManifestViewModelClassBox.Text)
            },
            Window = new ToolWindowManifest
            {
                Width = ParseManifestWindowDimension(ManifestWindowWidthBox.Text, ToolWindowManifest.DefaultWidth),
                Height = ParseManifestWindowDimension(ManifestWindowHeightBox.Text, ToolWindowManifest.DefaultHeight),
                MinWidth = ParseManifestWindowDimension(ManifestWindowMinWidthBox.Text, ToolWindowManifest.DefaultMinWidth),
                MinHeight = ParseManifestWindowDimension(ManifestWindowMinHeightBox.Text, ToolWindowManifest.DefaultMinHeight),
                AllowResize = ManifestAllowResizeCheckBox.IsChecked == true,
                AllowMaximize = ManifestAllowMaximizeCheckBox.IsChecked == true,
                ShowMinimizeButton = ManifestShowMinimizeButtonCheckBox.IsChecked == true,
                ShowCloseButton = ManifestShowCloseButtonCheckBox.IsChecked == true
            },
            RequestedPermissions = ManifestPermissionsPanel.Children
                .OfType<CheckBox>()
                .Where(checkBox => checkBox.IsChecked == true && checkBox.Tag is string)
                .Select(checkBox => (string)checkBox.Tag)
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToArray()
        };
        var json = JsonSerializer.Serialize(manifest, ManifestJsonOptions) + Environment.NewLine;
        await EditorWebView.ExecuteScriptAsync(
            $"window.editorHost.setFileContent({JsonSerializer.Serialize("manifest.json")}, {JsonSerializer.Serialize(json)})");
        _manifestDesignerPending = false;
        ManifestDesignerStateText.Text = "已同步到 manifest.json · 等待保存";
        ManifestDesignerStateText.Foreground = new SolidColorBrush(Color.FromRgb(101, 101, 150));
    }

    private async void ManifestViewToggleButton_Click(object sender, RoutedEventArgs e) =>
        await ToggleManifestSourceModeAsync();

    private async void XamlRelatedFileButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_editorReady || XamlRelatedFileButton.Tag is not string relatedPath)
            return;

        try
        {
            SelectExplorerItem(relatedPath);
            await EditorWebView.ExecuteScriptAsync(
                $"window.editorHost.activateFile({JsonSerializer.Serialize(relatedPath)})");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("无法切换关联文件", exception);
        }
    }

    private void UpdateXamlRelatedFileButton(string path)
    {
        string? relatedPath;
        string buttonText;
        string toolTip;
        if (TryGetXamlPathForCodeBehind(path) is { } xamlPath)
        {
            relatedPath = xamlPath;
            buttonText = "转到 XAML";
            toolTip = "打开对应的 XAML 页面";
        }
        else if (path.EndsWith(".xaml", StringComparison.OrdinalIgnoreCase))
        {
            relatedPath = path + ".cs";
            buttonText = "转到代码";
            toolTip = "打开对应的代码后置文件";
        }
        else
        {
            relatedPath = null;
            buttonText = string.Empty;
            toolTip = string.Empty;
        }

        if (relatedPath is null || !_files.Any(file =>
                file.RelativePath.Equals(relatedPath, StringComparison.OrdinalIgnoreCase)))
        {
            XamlRelatedFileButton.Tag = null;
            XamlRelatedFileButton.Visibility = Visibility.Collapsed;
            return;
        }

        XamlRelatedFileButton.Tag = relatedPath;
        XamlRelatedFileButton.Content = buttonText;
        XamlRelatedFileButton.ToolTip = toolTip;
        XamlRelatedFileButton.Visibility = Visibility.Visible;
    }

    private async Task ToggleManifestSourceModeAsync()
    {
        if (_activePath?.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) != true)
            return;

        if (!_manifestSourceMode)
        {
            await FlushManifestDesignerAsync();
            _manifestSourceMode = true;
            ManifestDesignerPanel.Visibility = Visibility.Collapsed;
            ManifestViewToggleButton.Content = "返回可视化配置";
            ActiveFileText.Text = "manifest.json · JSON 源码";
        }
        else
        {
            _manifestSourceMode = false;
            await LoadManifestDesignerAsync();
        }
        UpdateEditorWebViewVisibility();
    }

    private void ResetManifestDesignerState()
    {
        _manifestSyncTimer.Stop();
        _manifestDesignerPending = false;
        _manifestSourceMode = false;
        _activePath = null;
        _lastSurfacePath = null;
        ManifestDesignerPanel.Visibility = Visibility.Collapsed;
        ManifestViewToggleButton.Visibility = Visibility.Collapsed;
        XamlRelatedFileButton.Visibility = Visibility.Collapsed;
        XamlRelatedFileButton.Tag = null;
        UpdateEditorWebViewVisibility();
    }

    private void ReplaceManifestTags(IEnumerable<string> tags)
    {
        _manifestTags.Clear();
        foreach (var tag in tags.Where(tag => !string.IsNullOrWhiteSpace(tag))
                     .Select(tag => tag.Trim())
                     .Distinct(StringComparer.OrdinalIgnoreCase))
            _manifestTags.Add(tag);
    }

    private async void AddManifestTagButton_Click(object sender, RoutedEventArgs e)
    {
        var response = await ShowEditorDialogAsync(
            "添加标签",
            "输入一个标签名称。标签会用于工具分类和检索。",
            "添加标签",
            input: string.Empty,
            validator: ValidateManifestTag);
        if (response.Choice != EditorDialogChoice.Primary)
            return;

        _manifestTags.Add(response.Input.Trim());
        MarkManifestDesignerChanged();
    }

    private void RemoveManifestTagButton_Click(object sender, RoutedEventArgs e)
    {
        if (sender is not Button { Tag: string tag })
            return;

        var existing = _manifestTags.FirstOrDefault(value => value.Equals(tag, StringComparison.OrdinalIgnoreCase));
        if (existing is null)
            return;
        _manifestTags.Remove(existing);
        MarkManifestDesignerChanged();
    }

    private string? ValidateManifestTag(string value)
    {
        var tag = value.Trim();
        if (tag.Length == 0)
            return "请输入标签名称。";
        if (tag.Length > 32)
            return "标签名称不能超过 32 个字符。";
        if (tag.IndexOfAny([',', ',', ';', ';', '\r', '\n']) >= 0)
            return "一次只能添加一个标签,请不要输入分隔符。";
        if (_manifestTags.Any(existing => existing.Equals(tag, StringComparison.OrdinalIgnoreCase)))
            return "这个标签已经存在。";
        return null;
    }

    private void ReplaceManifestNuGetPackages(IEnumerable<ToolNuGetPackageReference> packages)
    {
        _manifestNuGetPackages.Clear();
        foreach (var package in packages.Where(package => package is not null))
        {
            _manifestNuGetPackages.Add(new ToolNuGetPackageReference
            {
                Id = package.Id?.Trim() ?? string.Empty,
                Version = package.Version?.Trim() ?? string.Empty
            });
        }
    }

    private void AddManifestNuGetPackageButton_Click(object sender, RoutedEventArgs e)
    {
        var packageId = ManifestNuGetPackageIdBox.Text.Trim();
        var packageVersion = ManifestNuGetPackageVersionBox.Text.Trim();
        if (!ToolNuGetPackageRules.IsValidPackageId(packageId))
        {
            ManifestNuGetPackageValidationText.Text = $"包 ID 只能包含字母、数字、点、短横线和下划线,且不超过 {ToolNuGetPackageRules.MaximumPackageIdLength} 个字符。";
            return;
        }
        if (!ToolNuGetPackageRules.IsValidExactVersion(packageVersion))
        {
            ManifestNuGetPackageValidationText.Text = "请输入精确版本,例如 2.6.0 或 2.6.0-beta.1;不支持 * 和版本范围。";
            return;
        }

        var existingIndex = -1;
        for (var index = 0; index < _manifestNuGetPackages.Count; index++)
        {
            if (!_manifestNuGetPackages[index].Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))
                continue;
            existingIndex = index;
            break;
        }

        var package = new ToolNuGetPackageReference { Id = packageId, Version = packageVersion };
        if (existingIndex >= 0)
        {
            _manifestNuGetPackages[existingIndex] = package;
            ManifestNuGetPackageValidationText.Text = $"已更新 {packageId} 的版本。";
        }
        else
        {
            if (_manifestNuGetPackages.Count >= ToolNuGetPackageRules.MaximumPackageCount)
            {
                ManifestNuGetPackageValidationText.Text = $"每个工具最多添加 {ToolNuGetPackageRules.MaximumPackageCount} 个 NuGet 包。";
                return;
            }
            _manifestNuGetPackages.Add(package);
            ManifestNuGetPackageValidationText.Text = $"已添加 {packageId}。";
        }

        ManifestNuGetPackageIdBox.Text = string.Empty;
        ManifestNuGetPackageVersionBox.Text = string.Empty;
        MarkManifestDesignerChanged();
    }

    private void RemoveManifestNuGetPackageButton_Click(object sender, RoutedEventArgs e)
    {
        if (sender is not Button { Tag: ToolNuGetPackageReference package })
            return;
        _manifestNuGetPackages.Remove(package);
        ManifestNuGetPackageValidationText.Text = $"已移除 {package.Id}。";
        MarkManifestDesignerChanged();
    }

    private void LoadManifestPermissions(IEnumerable<string> permissions)
    {
        var selected = permissions
            .Where(permission => !string.IsNullOrWhiteSpace(permission))
            .Select(permission => permission.Trim())
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToArray();
        var checkBoxes = ManifestPermissionsPanel.Children.OfType<CheckBox>().ToArray();
        var commonPermissions = CommonManifestPermissionNames.ToHashSet(StringComparer.OrdinalIgnoreCase);

        foreach (var customCheckBox in checkBoxes.Where(checkBox => checkBox.Tag is string permission
                     && !CommonManifestPermissionNames.Contains(permission, StringComparer.OrdinalIgnoreCase)))
            ManifestPermissionsPanel.Children.Remove(customCheckBox);

        foreach (var checkBox in ManifestPermissionsPanel.Children.OfType<CheckBox>())
        {
            if (checkBox.Tag is string permission)
                checkBox.IsChecked = selected.Contains(permission, StringComparer.OrdinalIgnoreCase);
        }

        foreach (var permission in selected.Where(permission => !commonPermissions.Contains(permission)))
        {
            var checkBox = new CheckBox
            {
                Content = $"{permission}(自定义)",
                Tag = permission,
                ToolTip = "清单中已有的自定义权限",
                IsChecked = true,
                Style = (Style)FindResource("ManifestPermissionCheckBox")
            };
            checkBox.Checked += ManifestPermissionCheckBox_Changed;
            checkBox.Unchecked += ManifestPermissionCheckBox_Changed;
            ManifestPermissionsPanel.Children.Add(checkBox);
        }
    }

    private void ManifestPermissionCheckBox_Changed(object sender, RoutedEventArgs e) =>
        MarkManifestDesignerChanged();

    private void ManifestWindowOptionCheckBox_Changed(object sender, RoutedEventArgs e) =>
        MarkManifestDesignerChanged();

    private static readonly string[] CommonManifestPermissionNames =
    [
        "FileSystem", "Network", "Clipboard", "Process", "Shell", "Registry", "Notifications", "Environment",
        "InputSimulation", "Camera", "Microphone", "Location"
    ];

    private static string? NullIfWhiteSpace(string value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    private static string? NormalizeOptionalPath(string value) =>
        NullIfWhiteSpace(value)?.Replace('\\', '/');

    private static string FormatManifestWindowDimension(double value, double fallback) =>
        (double.IsFinite(value) && value > 0 ? value : fallback).ToString("0.##", CultureInfo.InvariantCulture);

    private static double ParseManifestWindowDimension(string value, double fallback) =>
        double.TryParse(value.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out var result)
        && double.IsFinite(result) && result > 0
            ? result
            : fallback;

    private void SelectExplorerItem(string path)
    {
        var item = _explorerItems.FirstOrDefault(entry =>
            entry.RelativePath.Equals(path, StringComparison.OrdinalIgnoreCase));
        if (item is null)
            return;

        var parentPath = item.LogicalParentPath;
        while (!string.IsNullOrWhiteSpace(parentPath))
        {
            var parent = _explorerItems.FirstOrDefault(entry =>
                entry.CanExpand && entry.RelativePath.Equals(parentPath, StringComparison.OrdinalIgnoreCase));
            if (parent is not null)
            {
                parent.IsExpanded = true;
                parentPath = parent.LogicalParentPath;
            }
            else
            {
                parentPath = GetParentPath(parentPath);
            }
        }
        _fileView?.Refresh();

        _ignoreSelection = true;
        FileList.SelectedItem = item;
        FileList.ScrollIntoView(item);
        _ignoreSelection = false;
    }

    private void ClearExplorerSelection()
    {
        _ignoreSelection = true;
        FileList.SelectedItem = null;
        _ignoreSelection = false;
    }

    private void BlankNewFileMenuItem_Click(object sender, RoutedEventArgs e)
    {
        ClearExplorerSelection();
        NewFileButton_Click(sender, e);
    }

    private void BlankNewFolderMenuItem_Click(object sender, RoutedEventArgs e)
    {
        ClearExplorerSelection();
        NewFolderButton_Click(sender, e);
    }

    private void ExplorerItem_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (sender is FrameworkElement { DataContext: EditorExplorerItem item })
            FileList.SelectedItem = item;
    }

    private async void ExplorerItem_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        if (e.ClickCount != 2 || sender is not FrameworkElement { DataContext: EditorExplorerItem item })
            return;

        if (item.IsFolder)
        {
            item.IsExpanded = !item.IsExpanded;
            RefreshExplorerView();
        }
        else if (item.IsImage)
        {
            SelectExplorerItem(item.RelativePath);
            try
            {
                await ShowImagePreviewAsync(item.RelativePath, openAsDocument: true);
            }
            catch (Exception exception)
            {
                await ReportOperationFailureAsync("打开图像失败", exception);
            }
        }
        e.Handled = true;
    }

    private void ExplorerExpander_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        if (sender is not FrameworkElement { DataContext: EditorExplorerItem { CanExpand: true } item })
            return;

        item.IsExpanded = !item.IsExpanded;
        RefreshExplorerView();
        e.Handled = true;
    }

    private async void ExplorerOpenMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (GetContextItem(sender) is not { } item)
            return;
        if (item.IsFolder)
        {
            item.IsExpanded = !item.IsExpanded;
            RefreshExplorerView();
            return;
        }

        SelectExplorerItem(item.RelativePath);
        try
        {
            ActiveFileText.Text = item.RelativePath;
            if (item.IsImage)
            {
                await ShowImagePreviewAsync(item.RelativePath, openAsDocument: true);
                return;
            }
            await EditorWebView.ExecuteScriptAsync(
                $"window.editorHost.activateFile({JsonSerializer.Serialize(item.RelativePath)})");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("打开文件失败", exception);
        }
    }

    private void ExplorerNewFileMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (GetContextItem(sender) is { } item)
            SelectCreationDirectory(item);
        NewFileButton_Click(sender, e);
    }

    private void ExplorerNewFolderMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (GetContextItem(sender) is { } item)
            SelectCreationDirectory(item);
        NewFolderButton_Click(sender, e);
    }

    private async void ExplorerMoveToRootMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (GetContextItem(sender) is not { } item)
            return;
        var destination = Path.GetFileName(item.RelativePath);
        if (destination.Equals(item.RelativePath, StringComparison.OrdinalIgnoreCase))
        {
            SetHostStatus("该项目已经位于工程根目录");
            return;
        }
        await MoveExplorerItemAsync(item, destination);
    }

    private async void ExplorerRenameMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (GetContextItem(sender) is not { } item)
            return;
        if (item.RelativePath.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
        {
            await ShowAlertAsync("无法重命名", "manifest.json 是工具项目的固定入口文件。");
            return;
        }

        var response = await ShowEditorDialogAsync(
            item.IsFolder ? "重命名文件夹" : "重命名文件",
            $"为“{item.DisplayName}”输入新名称。",
            "确认重命名",
            input: item.DisplayName,
            validator: value => ValidateExplorerRename(item, value));
        if (response.Choice != EditorDialogChoice.Primary)
            return;

        var parent = Path.GetDirectoryName(item.RelativePath)?.Replace('\\', '/');
        var destination = string.IsNullOrWhiteSpace(parent)
            ? response.Input.Trim()
            : $"{parent}/{response.Input.Trim()}";
        await MoveExplorerItemAsync(item, destination);
    }

    private void ExplorerRevealMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (GetContextItem(sender) is { } item)
            RevealExplorerItem(item);
    }

    private void ExplorerDeleteMenuItem_Click(object sender, RoutedEventArgs e)
    {
        if (GetContextItem(sender) is not { } item)
            return;
        FileList.SelectedItem = item;
        DeleteFileButton_Click(sender, e);
    }

    private static EditorExplorerItem? GetContextItem(object sender) =>
        (sender as FrameworkElement)?.Tag as EditorExplorerItem;

    private void SelectCreationDirectory(EditorExplorerItem item)
    {
        var directory = item.IsFolder
            ? item.RelativePath
            : Path.GetDirectoryName(item.RelativePath)?.Replace('\\', '/');
        if (!string.IsNullOrWhiteSpace(directory)
            && _explorerItems.Any(entry => entry.IsFolder && entry.RelativePath.Equals(directory, StringComparison.OrdinalIgnoreCase)))
            SelectExplorerItem(directory);
        else
            ClearExplorerSelection();
    }

    private void RevealExplorerItem(EditorExplorerItem item)
    {
        try
        {
            var fullPath = GetSafeFullPath(item.RelativePath);
            var startInfo = new ProcessStartInfo("explorer.exe") { UseShellExecute = true };
            if (item.IsFolder)
            {
                startInfo.ArgumentList.Add(fullPath);
            }
            else
            {
                startInfo.ArgumentList.Add("/select,");
                startInfo.ArgumentList.Add(fullPath);
            }
            Process.Start(startInfo);
            SetHostStatus($"已在资源管理器中显示 {item.RelativePath}");
        }
        catch (Exception exception)
        {
            SetHostStatus($"打开资源管理器失败:{exception.Message}");
        }
    }

    private string? ValidateExplorerRename(EditorExplorerItem item, string value)
    {
        var name = value.Trim();
        if (string.IsNullOrWhiteSpace(name))
            return "请输入新名称。";
        if (name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.Contains('/') || name.Contains('\\'))
            return "名称包含无效字符。";
        if (!item.IsFolder && !IsExplorerVisibleFile(name))
            return "重命名后的文件类型不受工程资源管理器支持。";

        var parent = Path.GetDirectoryName(item.RelativePath)?.Replace('\\', '/');
        var destination = string.IsNullOrWhiteSpace(parent) ? name : $"{parent}/{name}";
        if (destination.Equals(item.RelativePath, StringComparison.OrdinalIgnoreCase))
            return "新名称与当前名称相同。";
        var fullPath = GetSafeFullPath(destination);
        return File.Exists(fullPath) || Directory.Exists(fullPath) ? "目标名称已经存在。" : null;
    }

    private void FileList_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
        _explorerDragStart = e.GetPosition(FileList);
        _explorerDragCandidate = FindExplorerItem(e.OriginalSource as DependencyObject);
    }

    private void FileList_PreviewMouseMove(object sender, MouseEventArgs e)
    {
        if (e.LeftButton != MouseButtonState.Pressed || _explorerDragCandidate is null)
            return;
        var current = e.GetPosition(FileList);
        if (Math.Abs(current.X - _explorerDragStart.X) < SystemParameters.MinimumHorizontalDragDistance
            && Math.Abs(current.Y - _explorerDragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
            return;

        var item = _explorerDragCandidate;
        _explorerDragCandidate = null;
        var data = new DataObject(ExplorerDragFormat, item);
        DragDrop.DoDragDrop(FileList, data, DragDropEffects.Move);
    }

    private void FileList_DragOver(object sender, DragEventArgs e)
    {
        e.Effects = e.Data.GetDataPresent(ExplorerDragFormat) ? DragDropEffects.Move : DragDropEffects.None;
        e.Handled = true;
    }

    private async void FileList_Drop(object sender, DragEventArgs e)
    {
        e.Handled = true;
        if (e.Data.GetData(ExplorerDragFormat) is not EditorExplorerItem source)
            return;
        var originalSource = e.OriginalSource as DependencyObject;
        var target = FindExplorerItem(originalSource);
        var targetContainer = FindVisualParent<ListBoxItem>(originalSource);
        var insertBefore = targetContainer is not null
                           && e.GetPosition(targetContainer).Y <= Math.Max(7, targetContainer.ActualHeight * 0.28);
        await HandleExplorerDropAsync(source, target, insertBefore);
    }

    private async Task HandleExplorerDropAsync(EditorExplorerItem source, EditorExplorerItem? target, bool insertBefore)
    {
        if (target is not null && target.RelativePath.Equals(source.RelativePath, StringComparison.OrdinalIgnoreCase))
            return;

        var sourceParent = Path.GetDirectoryName(source.RelativePath)?.Replace('\\', '/') ?? string.Empty;
        var targetParent = target is null
            ? string.Empty
            : Path.GetDirectoryName(target.RelativePath)?.Replace('\\', '/') ?? string.Empty;
        if (target is not null && insertBefore && sourceParent.Equals(targetParent, StringComparison.OrdinalIgnoreCase))
        {
            await ReorderExplorerItemAsync(source.RelativePath, target.RelativePath);
            return;
        }

        var destinationDirectory = target switch
        {
            { IsFolder: true } when !insertBefore => target.RelativePath,
            { IsFolder: true } => targetParent,
            { IsFolder: false } => Path.GetDirectoryName(target.RelativePath)?.Replace('\\', '/') ?? string.Empty,
            _ => string.Empty
        };

        if (target is { IsFolder: false }
            && sourceParent.Equals(destinationDirectory, StringComparison.OrdinalIgnoreCase))
        {
            await ReorderExplorerItemAsync(source.RelativePath, target.RelativePath);
            return;
        }

        var destination = string.IsNullOrWhiteSpace(destinationDirectory)
            ? Path.GetFileName(source.RelativePath)
            : $"{destinationDirectory}/{Path.GetFileName(source.RelativePath)}";
        await MoveExplorerItemAsync(source, destination, insertBefore && target is not null ? target.RelativePath : target is { IsFolder: false } ? target.RelativePath : null);
    }

    private async Task ReorderExplorerItemAsync(string sourcePath, string targetPath)
    {
        var order = _explorerItems.Select(item => item.RelativePath).ToList();
        if (!order.Remove(sourcePath))
            return;
        var targetIndex = order.FindIndex(path => path.Equals(targetPath, StringComparison.OrdinalIgnoreCase));
        order.Insert(targetIndex < 0 ? order.Count : targetIndex, sourcePath);
        _explorerOrder = order;
        await ToolProjectWorkspaceService.SaveExplorerOrderAsync(_workspaceRoot, _explorerOrder);
        await ReloadFileListAsync(sourcePath);
        SetHostStatus($"已调整 {sourcePath} 的显示顺序");
    }

    private async Task MoveExplorerItemAsync(EditorExplorerItem source, string destinationPath, string? targetBefore = null)
    {
        destinationPath = destinationPath.Replace('\\', '/').Trim('/');
        if (source.RelativePath.Equals("manifest.json", StringComparison.OrdinalIgnoreCase))
        {
            await ShowAlertAsync("无法移动文件", "manifest.json 必须保留在工程根目录。" );
            return;
        }
        if (destinationPath.Equals(source.RelativePath, StringComparison.OrdinalIgnoreCase))
            return;
        if (source.IsFolder && (destinationPath.StartsWith(source.RelativePath.TrimEnd('/') + "/", StringComparison.OrdinalIgnoreCase)))
        {
            await ShowAlertAsync("无法移动文件夹", "不能将文件夹移动到它自身或它的子目录中。");
            return;
        }

        var sourceFullPath = GetSafeFullPath(source.RelativePath);
        var destinationFullPath = GetSafeFullPath(destinationPath);
        if (File.Exists(destinationFullPath) || Directory.Exists(destinationFullPath))
        {
            await ShowAlertAsync("无法移动项目", "目标位置已经存在同名文件或文件夹。");
            return;
        }

        try
        {
            if (_dirty)
                await SaveFilesAsync(await GetEditorFilesAsync());
            Directory.CreateDirectory(Path.GetDirectoryName(destinationFullPath)!);
            if (source.IsFolder)
                Directory.Move(sourceFullPath, destinationFullPath);
            else
                File.Move(sourceFullPath, destinationFullPath);

            await UpdateManifestPathsAfterMoveAsync(source.RelativePath, destinationPath, source.IsFolder);
            var order = _explorerItems.Select(item => ReplaceMovedPath(item.RelativePath, source.RelativePath, destinationPath, source.IsFolder)).ToList();
            if (targetBefore is not null)
            {
                order.RemoveAll(path => path.Equals(destinationPath, StringComparison.OrdinalIgnoreCase));
                var index = order.FindIndex(path => path.Equals(targetBefore, StringComparison.OrdinalIgnoreCase));
                order.Insert(index < 0 ? order.Count : index, destinationPath);
            }
            _explorerOrder = order;
            await ToolProjectWorkspaceService.SaveExplorerOrderAsync(_workspaceRoot, _explorerOrder);
            await ReloadFileListAsync(destinationPath);
            await SendWorkspaceAsync(source.IsFolder ? null : destinationPath);
            if (!source.IsFolder && ImageExtensions.Contains(Path.GetExtension(destinationPath)))
                await ShowImagePreviewAsync(destinationPath);
            SetHostStatus($"已移动到 {destinationPath}");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("移动项目失败", exception);
        }
    }

    private async Task UpdateManifestPathsAfterMoveAsync(string sourcePath, string destinationPath, bool isFolder)
    {
        var manifestPath = Path.Combine(_workspaceRoot, "manifest.json");
        if (!File.Exists(manifestPath))
            return;
        if (JsonNode.Parse(await File.ReadAllTextAsync(manifestPath)) is not JsonObject root)
            return;

        var changed = false;
        if (root["icon"]?.GetValue<string>() is { } iconPath)
        {
            var replacement = ReplaceMovedPath(iconPath.Replace('\\', '/'), sourcePath, destinationPath, isFolder);
            if (!replacement.Equals(iconPath, StringComparison.Ordinal))
            {
                root["icon"] = replacement;
                changed = true;
            }
        }

        if (root["entry"] is JsonObject entry)
        {
            foreach (var key in new[] { "viewXaml", "viewCodeBehind", "viewModel" })
            {
                var value = entry[key]?.GetValue<string>();
                if (value is null)
                    continue;
                var replacement = ReplaceMovedPath(value.Replace('\\', '/'), sourcePath, destinationPath, isFolder);
                if (replacement.Equals(value, StringComparison.Ordinal))
                    continue;
                entry[key] = replacement;
                changed = true;
            }
        }

        if (changed)
            await File.WriteAllTextAsync(manifestPath, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), new UTF8Encoding(false));
    }

    private static string ReplaceMovedPath(string path, string sourcePath, string destinationPath, bool isFolder)
    {
        if (path.Equals(sourcePath, StringComparison.OrdinalIgnoreCase))
            return destinationPath;
        if (isFolder && path.StartsWith(sourcePath.TrimEnd('/') + "/", StringComparison.OrdinalIgnoreCase))
            return destinationPath.TrimEnd('/') + path[sourcePath.Length..];
        return path;
    }

    private static EditorExplorerItem? FindExplorerItem(DependencyObject? source)
    {
        while (source is not null)
        {
            if (source is FrameworkElement { DataContext: EditorExplorerItem item })
                return item;
            source = VisualTreeHelper.GetParent(source);
        }
        return null;
    }

    private static T? FindVisualParent<T>(DependencyObject? source) where T : DependencyObject
    {
        while (source is not null)
        {
            if (source is T result)
                return result;
            source = VisualTreeHelper.GetParent(source);
        }
        return null;
    }

    private async Task<bool> ConfirmDiscardChangesAsync()
    {
        if (!_dirty)
            return true;

        var response = await ShowEditorDialogAsync(
            "切换当前工程",
            "当前工程仍有未保存更改。你可以先保存,也可以放弃这些更改后继续。",
            "保存并继续",
            secondaryText: "不保存");
        if (response.Choice == EditorDialogChoice.Cancel)
            return false;
        if (response.Choice == EditorDialogChoice.Primary)
        {
            try
            {
                await SaveFilesAsync(await GetEditorFilesAsync());
            }
            catch (Exception exception)
            {
                await ReportOperationFailureAsync("保存工程失败", exception);
                return false;
            }
        }
        return true;
    }

    private async void ToolCodeEditorWindow_Closing(object? sender, CancelEventArgs e)
    {
        if (_allowClose || !_dirty)
            return;

        e.Cancel = true;
        if (_dialogCompletion is not null)
            return;

        var response = await ShowEditorDialogAsync(
            "关闭代码工坊",
            "当前工程仍有未保存更改。确定放弃这些更改并关闭编辑器吗?",
            "放弃并关闭");
        if (response.Choice == EditorDialogChoice.Primary)
        {
            _allowClose = true;
            Close();
        }
    }

    private async void RefreshFilesButton_Click(object sender, RoutedEventArgs e)
    {
        if (!await ConfirmDiscardChangesAsync())
            return;

        try
        {
            var selectedPath = FileList.SelectedItem is EditorExplorerItem item ? item.RelativePath : null;
            var activePath = FileList.SelectedItem is EditorExplorerItem { IsFolder: false } ? selectedPath : null;
            ResetManifestDesignerState();
            _dirty = false;
            await ReloadFileListAsync(selectedPath);
            await SendWorkspaceAsync(activePath);
            if (activePath is not null && ImageExtensions.Contains(Path.GetExtension(activePath)))
                await ShowImagePreviewAsync(activePath);
            SetHostStatus("文件列表已刷新");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("刷新工程失败", exception);
        }
    }

    private async void RevealFolderButton_Click(object sender, RoutedEventArgs e)
    {
        if (!Directory.Exists(_workspaceRoot))
            return;

        try
        {
            var startInfo = new ProcessStartInfo("explorer.exe") { UseShellExecute = true };
            startInfo.ArgumentList.Add(_workspaceRoot);
            Process.Start(startInfo);
            SetHostStatus("已在资源管理器中打开工程目录");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("打开工程目录失败", exception);
        }
    }

    private async void ToggleThemeButton_Click(object sender, RoutedEventArgs e)
    {
        if (!_editorReady)
            return;

        try
        {
            var encoded = await EditorWebView.ExecuteScriptAsync("window.editorHost.toggleTheme()");
            var theme = JsonSerializer.Deserialize<string>(encoded, JsonOptions) ?? "light";
            _darkEditorTheme = theme.Equals("dark", StringComparison.OrdinalIgnoreCase);
            EditorThemeButton.Content = _darkEditorTheme ? "☼" : "◐";
            EditorThemeButton.ToolTip = _darkEditorTheme ? "切换到浅色代码主题" : "切换到深色代码主题";
            SetHostStatus(_darkEditorTheme ? "已切换到深色代码主题" : "已切换到浅色代码主题");
        }
        catch (Exception exception)
        {
            await ReportOperationFailureAsync("切换代码主题失败", exception);
        }
    }

    private void OpenControlGalleryButton_Click(object sender, RoutedEventArgs e) =>
        ControlGalleryWindow.ShowGallery(this);

    private void ExecuteEditorCommand(string command)
    {
        switch (command)
        {
            case "newFile":
                NewFileButton_Click(this, new RoutedEventArgs());
                break;
            case "newProject":
                NewProjectButton_Click(this, new RoutedEventArgs());
                break;
            case "openProject":
                OpenFolderButton_Click(this, new RoutedEventArgs());
                break;
            case "format":
                FormatButton_Click(this, new RoutedEventArgs());
                break;
            case "build":
                BuildProjectButton_Click(this, new RoutedEventArgs());
                break;
            case "export":
                PackageButton_Click(this, new RoutedEventArgs());
                break;
            case "preview":
                PreviewButton_Click(this, new RoutedEventArgs());
                break;
            case "run":
                RunButton_Click(this, new RoutedEventArgs());
                break;
        }
    }

    private void FileSearchBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        if (_fileView is null)
            return;
        RefreshExplorerView();
    }

    private void EditorItemTemplateBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (_newItemDialogActive)
            UpdateNewItemDialog(resetInput: true);
    }

    private void UpdateNewItemDialog(bool resetInput)
    {
        var itemTemplate = GetSelectedNewItemTemplate();
        var directory = GetNewItemDirectory(itemTemplate);
        EditorDialogMessage.Text = itemTemplate == EditorNewItemTemplate.WpfPage
            ? $"将在 {directory} 中创建 XAML 页面及对应的代码后置文件。"
            : $"将在 {directory} 中创建带命名空间和基础类定义的 C# 文件。";
        EditorDialogInput.HintText = itemTemplate == EditorNewItemTemplate.WpfPage ? "例如:SettingsPage" : "例如:ToolService";
        if (!resetInput)
            return;

        EditorDialogInput.Text = itemTemplate == EditorNewItemTemplate.WpfPage ? "NewPage" : "NewClass";
        EditorDialogInput.SelectAll();
    }

    private EditorNewItemTemplate GetSelectedNewItemTemplate() =>
        EditorItemTemplateBox.SelectedItem is ComboBoxItem { Tag: "WpfPage" }
            ? EditorNewItemTemplate.WpfPage
            : EditorNewItemTemplate.CSharpClass;

    private string GetNewItemDirectory(EditorNewItemTemplate itemTemplate)
    {
        if (!string.IsNullOrWhiteSpace(_newItemBaseDirectory))
            return _newItemBaseDirectory.Trim('/');
        return itemTemplate == EditorNewItemTemplate.WpfPage ? "Code/Views" : "Code";
    }

    private bool FileMatchesFilter(object item)
    {
        if (item is not EditorExplorerItem file)
            return false;

        var query = FileSearchBox?.Text?.Trim();
        if (!string.IsNullOrEmpty(query))
        {
            return file.RelativePath.Contains(query, StringComparison.OrdinalIgnoreCase)
                   || file.CanExpand && _explorerItems.Any(candidate =>
                       candidate.RelativePath.Contains(query, StringComparison.OrdinalIgnoreCase)
                       && IsLogicalAncestorOf(file, candidate));
        }

        var parentPath = file.LogicalParentPath;
        while (!string.IsNullOrWhiteSpace(parentPath))
        {
            var parent = _explorerItems.FirstOrDefault(candidate =>
                candidate.CanExpand && candidate.RelativePath.Equals(parentPath, StringComparison.OrdinalIgnoreCase));
            if (parent is { IsExpanded: false })
                return false;
            parentPath = parent?.LogicalParentPath ?? GetParentPath(parentPath);
        }
        return true;
    }

    private bool IsLogicalAncestorOf(EditorExplorerItem possibleAncestor, EditorExplorerItem item)
    {
        var parentPath = item.LogicalParentPath;
        while (!string.IsNullOrWhiteSpace(parentPath))
        {
            if (parentPath.Equals(possibleAncestor.RelativePath, StringComparison.OrdinalIgnoreCase))
                return true;
            var parent = _explorerItems.FirstOrDefault(candidate =>
                candidate.RelativePath.Equals(parentPath, StringComparison.OrdinalIgnoreCase));
            parentPath = parent?.LogicalParentPath ?? GetParentPath(parentPath);
        }
        return false;
    }

    private void RefreshExplorerView()
    {
        if (_fileView is null)
            return;

        _fileView.Refresh();
        var visibleCount = _fileView.Cast<object>().Count();
        FileCountText.Text = string.IsNullOrWhiteSpace(FileSearchBox.Text)
            ? $"{_files.Count} 文件 · {_explorerItems.Count - _files.Count} 文件夹"
            : $"{visibleCount} / {_explorerItems.Count}";
        EmptyExplorer.Visibility = visibleCount == 0 ? Visibility.Visible : Visibility.Collapsed;
    }

    private void UpdateTitle()
    {
        var projectName = string.IsNullOrWhiteSpace(_workspaceRoot)
            ? "正在准备工作区"
            : Path.GetFileName(Path.TrimEndingDirectorySeparator(_workspaceRoot));
        Title = $"{(_dirty ? "● " : string.Empty)}{projectName} — XFEToolBox Code Studio";
        WorkspaceTitleText.Text = projectName;
        ProjectNameText.Text = projectName;
        DirtyIndicator.Visibility = _dirty ? Visibility.Visible : Visibility.Collapsed;
    }

    private void SetHostStatus(string text) => HostStatusText.Text = text;

    private async Task ReportOperationFailureAsync(string title, Exception exception)
    {
        SetHostStatus(title);
        await ShowAlertAsync(title, exception.Message);
    }

    private string? ValidateNewItemName(string value)
    {
        var className = value.Trim();
        if (string.IsNullOrWhiteSpace(className))
            return "请输入类名。";
        if (!IsValidCSharpIdentifier(className))
            return "类名必须是有效的 C# 标识符,且不要包含文件扩展名。";

        var itemTemplate = GetSelectedNewItemTemplate();
        var directory = GetNewItemDirectory(itemTemplate);
        var paths = itemTemplate == EditorNewItemTemplate.WpfPage
            ? new[] { $"{directory}/{className}.xaml", $"{directory}/{className}.xaml.cs" }
            : new[] { $"{directory}/{className}.cs" };
        try
        {
            if (paths.Any(path => File.Exists(GetSafeFullPath(path)) || Directory.Exists(GetSafeFullPath(path))))
                return "目标目录中已经存在同名项。";
        }
        catch (Exception exception)
        {
            return exception.Message;
        }

        return null;
    }

    private static bool IsValidCSharpIdentifier(string value) =>
        !CSharpKeywords.Contains(value)
        && value.Length > 0
        && (char.IsLetter(value[0]) || value[0] == '_')
        && value.Skip(1).All(character => char.IsLetterOrDigit(character) || character == '_');

    private async Task<EditorFileContent[]> CreateNewItemFilesAsync(
        EditorNewItemTemplate itemTemplate,
        string directory,
        string className)
    {
        var itemNamespace = await GetNewItemNamespaceAsync(directory);
        if (itemTemplate == EditorNewItemTemplate.CSharpClass)
        {
            var path = $"{directory}/{className}.cs";
            var content = $$"""
                namespace {{itemNamespace}};

                public class {{className}}
                {
                }
                """ + Environment.NewLine;
            return [new EditorFileContent(path, content)];
        }

        var xamlPath = $"{directory}/{className}.xaml";
        var codeBehindPath = $"{xamlPath}.cs";
        var fullClassName = $"{itemNamespace}.{className}";
        var xaml = $$"""
            <Page x:Class="{{fullClassName}}"
                  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                  Title="{{className}}">
                <Grid>
                </Grid>
            </Page>
            """ + Environment.NewLine;
        var codeBehind = $$"""
            using System.Windows.Controls;

            namespace {{itemNamespace}};

            public partial class {{className}} : Page
            {
                public {{className}}()
                {
                    InitializeComponent();
                }
            }
            """ + Environment.NewLine;
        return
        [
            new EditorFileContent(xamlPath, xaml),
            new EditorFileContent(codeBehindPath, codeBehind)
        ];
    }

    private async Task<string> GetNewItemNamespaceAsync(string directory)
    {
        var projectNamespace = $"XFEToolBox.Tools.{ToNamespaceSegment(Path.GetFileName(_workspaceRoot))}";
        try
        {
            var manifestText = await ReadEditorOrDiskFileAsync("manifest.json");
            var viewClass = JsonNode.Parse(manifestText)?["entry"]?["viewClass"]?.GetValue<string>();
            var lastDot = viewClass?.LastIndexOf('.') ?? -1;
            if (lastDot > 0)
                projectNamespace = viewClass![..lastDot];
        }
        catch
        {
            // 清单暂不可读时使用由工程目录推导出的稳定命名空间。
        }

        var namespaceSegments = directory.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
        if (namespaceSegments.Count > 0 && namespaceSegments[0].Equals("Code", StringComparison.OrdinalIgnoreCase))
            namespaceSegments.RemoveAt(0);
        return namespaceSegments.Count == 0
            ? projectNamespace
            : $"{projectNamespace}.{string.Join('.', namespaceSegments.Select(ToNamespaceSegment))}";
    }

    private static string ToNamespaceSegment(string value)
    {
        var segment = new string(value.Where(character => char.IsLetterOrDigit(character) || character == '_').ToArray());
        if (string.IsNullOrWhiteSpace(segment))
            return "Items";
        if (char.IsDigit(segment[0]) || CSharpKeywords.Contains(segment))
            segment = $"_{segment}";
        return segment;
    }

    private string? ValidateNewFolderPath(string value)
    {
        var path = value.Trim().Replace('\\', '/').TrimEnd('/');
        if (string.IsNullOrWhiteSpace(path))
            return "请输入文件夹名称。";

        try
        {
            var fullPath = GetSafeFullPath(path);
            if (Directory.Exists(fullPath) || File.Exists(fullPath))
                return "该名称已经存在,请换一个名称。";
        }
        catch (Exception exception)
        {
            return exception.Message;
        }

        return null;
    }

    private string GetSafeFullPath(string relativePath)
    {
        if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath))
            throw new InvalidOperationException("文件路径无效。");

        var root = Path.GetFullPath(_workspaceRoot).TrimEnd(Path.DirectorySeparatorChar)
                   + Path.DirectorySeparatorChar;
        var fullPath = Path.GetFullPath(
            Path.Combine(root, relativePath.Replace('/', Path.DirectorySeparatorChar)));
        if (!fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException("文件路径超出工程目录。");
        return fullPath;
    }

    private bool IsBuildDirectory(string path)
    {
        var relativePath = Path.GetRelativePath(_workspaceRoot, path);
        return relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
            .Any(segment => segment.Equals("bin", StringComparison.OrdinalIgnoreCase)
                            || segment.Equals("obj", StringComparison.OrdinalIgnoreCase)
                            || segment.Equals(".git", StringComparison.OrdinalIgnoreCase));
    }

    private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
    {
        if (EditorDialogOverlay.Visibility == Visibility.Visible)
        {
            if (e.Key == Key.Escape)
            {
                CompleteEditorDialog(EditorDialogChoice.Cancel);
                e.Handled = true;
            }
            else if (e.Key == Key.Enter)
            {
                CompleteEditorDialog(EditorDialogChoice.Primary);
                e.Handled = true;
            }
            return;
        }

        var modifiers = Keyboard.Modifiers;
        if (e.Key == Key.F5 && modifiers == ModifierKeys.None)
        {
            RunButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (e.Key == Key.F5 && modifiers == ModifierKeys.Control)
        {
            RunButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if ((modifiers == ModifierKeys.Control
                  || modifiers == (ModifierKeys.Control | ModifierKeys.Shift))
                 && e.Key == Key.S)
        {
            SaveButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.O)
        {
            OpenFolderButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == ModifierKeys.Control && e.Key == Key.N)
        {
            NewFileButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N)
        {
            NewProjectButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.A)
        {
            NewFileButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.B)
        {
            BuildProjectButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.W)
        {
            PreviewButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.E)
        {
            PackageButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
        else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.G)
        {
            OpenControlGalleryButton_Click(this, new RoutedEventArgs());
            e.Handled = true;
        }
    }

    private Task<EditorDialogResponse> ShowEditorDialogAsync(
        string title,
        string message,
        string primaryText,
        string? secondaryText = null,
        string? input = null,
        Func<string, string?>? validator = null,
        bool showCancel = true)
    {
        if (_dialogCompletion is not null)
            return _dialogCompletion.Task;

        _dialogCompletion = new TaskCompletionSource<EditorDialogResponse>();
        _dialogValidator = validator;
        EditorDialogTitle.Text = title;
        EditorDialogMessage.Text = message;
        EditorDialogError.Text = string.Empty;
        EditorDialogTemplatePanel.Visibility = Visibility.Collapsed;
        DialogPrimaryButton.Content = primaryText;
        DialogSecondaryButton.Content = secondaryText ?? string.Empty;
        DialogSecondaryButton.Visibility = secondaryText is null ? Visibility.Collapsed : Visibility.Visible;
        DialogCancelButton.Visibility = showCancel ? Visibility.Visible : Visibility.Collapsed;
        EditorDialogInput.Visibility = input is null ? Visibility.Collapsed : Visibility.Visible;
        EditorDialogInput.Text = input ?? string.Empty;
        EditorDialogOverlay.Visibility = Visibility.Visible;
        UpdateEditorWebViewVisibility();

        if (input is not null)
        {
            _ = Dispatcher.InvokeAsync(() =>
            {
                EditorDialogInput.Focus();
                EditorDialogInput.SelectAll();
            });
        }
        else
        {
            _ = Dispatcher.InvokeAsync(() => DialogPrimaryButton.Focus());
        }

        return _dialogCompletion.Task;
    }

    private async Task ShowAlertAsync(string title, string message)
    {
        await ShowEditorDialogAsync(title, message, "知道了", showCancel: false);
    }

    private void CompleteEditorDialog(EditorDialogChoice choice)
    {
        if (_dialogCompletion is null)
            return;

        if (choice == EditorDialogChoice.Primary && _dialogValidator is not null)
        {
            var error = _dialogValidator(EditorDialogInput.Text);
            if (!string.IsNullOrWhiteSpace(error))
            {
                EditorDialogError.Text = error;
                EditorDialogInput.Focus();
                return;
            }
        }

        var completion = _dialogCompletion;
        var response = new EditorDialogResponse(choice, EditorDialogInput.Text);
        _dialogCompletion = null;
        _dialogValidator = null;
        EditorDialogOverlay.Visibility = Visibility.Collapsed;
        UpdateEditorWebViewVisibility();
        completion.SetResult(response);
    }

    private void UpdateEditorWebViewVisibility()
    {
        var dialogVisible = EditorDialogOverlay.Visibility == Visibility.Visible;
        EditorWebView.Visibility = !_editorReady || dialogVisible || _previewDocumentActive
            || ManifestDesignerPanel.Visibility == Visibility.Visible
            ? Visibility.Hidden
            : Visibility.Visible;
        MarkdownPreviewWebView.Visibility = !dialogVisible && _showingMarkdownPreview
            ? Visibility.Visible
            : Visibility.Hidden;
    }

    private void DialogPrimaryButton_Click(object sender, RoutedEventArgs e) =>
        CompleteEditorDialog(EditorDialogChoice.Primary);

    private void DialogSecondaryButton_Click(object sender, RoutedEventArgs e) =>
        CompleteEditorDialog(EditorDialogChoice.Secondary);

    private void DialogCancelButton_Click(object sender, RoutedEventArgs e) =>
        CompleteEditorDialog(EditorDialogChoice.Cancel);

    private static EditorExplorerItem CreateFileItem(string path) =>
        Path.GetExtension(path).ToLowerInvariant() switch
        {
            ".cs" => new(path, false, "C#", "#EDF4FF", "#5C82B8"),
            ".xaml" => new(path, false, "◇", "#F2ECFF", "#855FC0"),
            ".json" => new(path, false, "{}", "#FFF4E8", "#BF7A36"),
            ".xml" or ".resx" or ".config" or ".props" or ".targets" =>
                new(path, false, "<>", "#EDF8F1", "#4F9570"),
            ".md" or ".markdown" or ".mdown" or ".mkd" or ".mkdn" =>
                new(path, false, "M↓", "#F0F0FA", "#6868A6"),
            ".yml" or ".yaml" => new(path, false, "Y", "#FFF0F3", "#B96274"),
            ".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".ico" =>
                new(path, false, "▧", "#EAF8FC", "#4D8FA6"),
            _ => new(path, false, "·", "#F1F1F6", "#77778B")
        };

    private static EditorExplorerItem CreateFolderItem(string path) =>
        new(path, true, "▰", "#ECEBFC", "#7474C6");

    private sealed class EditorExplorerItem(
        string relativePath,
        bool isFolder,
        string icon,
        string iconBackground,
        string iconForeground) : INotifyPropertyChanged
    {
        private bool _isExpanded;

        public string RelativePath { get; } = relativePath;
        public bool IsFolder { get; } = isFolder;
        public bool IsEditable => !IsFolder && EditableExtensions.Contains(Path.GetExtension(RelativePath));
        public bool IsImage => !IsFolder && ImageExtensions.Contains(Path.GetExtension(RelativePath));
        public string Icon { get; } = icon;
        public string IconBackground { get; } = iconBackground;
        public string IconForeground { get; } = iconForeground;
        public string DisplayName => System.IO.Path.GetFileName(RelativePath);
        public string LogicalParentPath { get; set; } = GetParentPath(relativePath);
        public int Depth { get; set; } = relativePath.Count(character => character == '/');
        public bool HasNestedItems { get; set; }
        public bool CanExpand => IsFolder || HasNestedItems;
        public Thickness Indent => new(Depth * 14, 0, 0, 0);
        public bool IsExpanded
        {
            get => _isExpanded;
            set
            {
                if (_isExpanded == value)
                    return;
                _isExpanded = value;
                PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsExpanded)));
            }
        }

        public event PropertyChangedEventHandler? PropertyChanged;
    }

    private enum EditorNewItemTemplate
    {
        CSharpClass,
        WpfPage
    }

    private sealed record EditorFileContent(string Path, string Content);
    private sealed record EditorDialogResponse(EditorDialogChoice Choice, string Input);

    private enum EditorDialogChoice
    {
        Primary,
        Secondary,
        Cancel
    }

    private sealed class EditorMessage
    {
        public string? Type { get; set; }
        public string? Path { get; set; }
        public string? Command { get; set; }
        public EditorFileContent[]? Files { get; set; }
    }
}