From 006b6691987c25cc96089965c53f4d7dbfb1c6af Mon Sep 17 00:00:00 2001 From: sakuya Date: Sun, 25 Oct 2026 02:05:17 +0800 Subject: [PATCH 001/105] fix(editor): refresh ID-bound resources and rebase variant buffers Keep resource references ID-only while refreshing picker labels, paths and candidates. Share generation-scoped resource indexes, reuse bounded thumbnails, reject stale results, preserve registrations and retain old managers until readers and leases finish. Rebase variant folder relations in working and saved text buffers during move, undo and redo, preserving subsequently saved configuration. Validate both versions and document the behavior. Validation: isolated Editor.Tests build succeeded; all 7 new regressions passed; shared file/variant suite 21/21 passed. Selected Editor suite 51/52 passed; BrowserAddressBlocksDropAndGlobalSearchExitFollowSelection fails in the batch and passes alone. Scoped whitespace and IDE0005 checks passed. --- .agents/agent-knowledge.md | 2 + ...025-01-variant-buffer-structural-rebase.md | 15 ++ docs/spec/editor-files.md | 8 +- .../Services/ProjectFileOperation.Variants.cs | 36 ++- .../Services/ProjectFileOperation.cs | 16 +- .../Controls/AssetFieldEditor.cs | 3 +- .../Controls/AssetPicker.axaml.cs | 179 ++++++++----- .../Services/AssetCatalogService.cs | 2 +- .../Services/EditorAssetManager.cs | 201 +++++++++++--- .../Interfaces/IEditorAssetChanges.cs | 8 + .../ResourceIdentityTests.cs | 245 ++++++++++++++++++ 11 files changed, 612 insertions(+), 103 deletions(-) create mode 100644 .agents/lessons/L-20261025-01-variant-buffer-structural-rebase.md create mode 100644 src/Editor/GalNet.Editor/Services/Interfaces/IEditorAssetChanges.cs create mode 100644 test/GalNet.Editor.Tests/ResourceIdentityTests.cs diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 3536ffe9..e67dfd77 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -1,5 +1,7 @@ # GalNet Agent Knowledge +- [资源结构与缓冲重基](lessons/L-20261025-01-variant-buffer-structural-rebase.md):ManagedContent 的写盘内容来自项目文档 Data;移动时内部 folder 要分别重基工作版和保存版,Undo/Redo 保留之后保存的其他配置。 + - [必填字符串与 schema 默认值](lessons/L-20261024-01-required-string-defaults.md):Required 字段有默认值仍会被 Normalize 补齐;要求显式提供且空串合法时不声明 schema 默认,并显式 AllowNull=false。DTO 构造函数赋值不导出为 schema 默认。 - [契约与 Git 构建元数据](lessons/L-20261023-02-contract-git-metadata.md):SDK 的 InformationalVersion 可包含 commit;严格 DLL 哈希需要稳定非功能元数据,并用不同 SourceRevisionId 重建比较验证。 diff --git a/.agents/lessons/L-20261025-01-variant-buffer-structural-rebase.md b/.agents/lessons/L-20261025-01-variant-buffer-structural-rebase.md new file mode 100644 index 00000000..ab861fd3 --- /dev/null +++ b/.agents/lessons/L-20261025-01-variant-buffer-structural-rebase.md @@ -0,0 +1,15 @@ +# 资源结构重写必须覆盖已跟踪缓冲 + +状态:confirmed +日期:2026-10-25 +来源:资源 ID、选择器缓存与文件移动同步修复 + +现象:移动 `.variants` 或其图片目录时,文件事务已计算新 folder,但打开过的描述文件由 ProjectContentSession 的 ManagedContent 写出;仅修改事务备份不能改变实际写出的缓冲内容。 + +根因:ProjectFileOperation 的非托管 Copies 会跳过 ManagedContent;ProjectContentSession 从保存版 EditorFileDocument.Data 写出文件。原 FileChange.Apply 只移动 Path,没有对工作版和保存版的 Data 分别重基。 + +正确做法:在结构变更的 Apply 内根据当前文档位置和当前 folder 重算路径,分别转换工作内容与最近保存内容;验证也覆盖两份转换后的文档。Undo/Redo 从当时的配置重算,不能恢复首次捕获的整份 JSON,否则会回退之后保存的 default 或 dimensions。 + +验证:ResourceIdentityTests.cs 中 OpenVariantBufferRebasesFolderAndPreservesSavedAndUnsavedChanges 的描述移动/图片目录移动两例均通过,覆盖打开的 Inspector、未保存 default、磁盘保存基线、Save 后 Undo/Redo。关联规范:docs/spec/editor-files.md。 + +适用:文件结构操作会重写内部关系,且文件可进入项目文本缓冲的场景。禁止仅改路径/磁盘备份或以未打开文件的测试替代缓冲生命周期验证。 diff --git a/docs/spec/editor-files.md b/docs/spec/editor-files.md index 740d9dc4..c2a840d5 100644 --- a/docs/spec/editor-files.md +++ b/docs/spec/editor-files.md @@ -39,6 +39,10 @@ Apply 或失焦提交输入,Cancel input 只取消当前输入;文本控件 资源 metadata 通过项目的 `IResourceTypeCatalog` 与 `AssetMetaCodec` 处理,保留 Gallery 和未知扩展字段。移动保留 Gallery 身份;复制/导入时,已被项目或同批内容占用的 Gallery ID 为副本重新分配,保留 type/title,避免生成重复解锁身份。无法完整类型化的 metadata 拒绝重写;检查受影响资源与其他 sidecar 的 ID 冲突。Assets 索引仅负责读取、缩略图与通知,不在刷新/监听时创建或改写 metadata。 +剧情资源字段只以 sidecar 的资源 ID 关联资源;名称、目录和扩展名不参与身份,不能在 ID 缺失时按同路径的新文件自动重新绑定。移动/改名保留资源 ID,复制/导入分配新 ID。已挂载的 Entry 资源字段和打开中的选择面板随资源代次刷新名称、路径和候选,保留当前 ID 与搜索条件;过期结果和旧候选不能提交。读取失败与确实缺失分别显示,不把索引错误表示为 Missing。 + +EditorAssetManager 每代共享一次资源索引任务,以 ID 查询并拒绝重复身份;调用者取消只结束自己的等待,失败索引允许重试。资源变更或项目切换推进代次,自定义 decoder/provider 注册保留,旧代在活动查询和资源租约结束后释放。选择器按代次和 ID 复用候选及缩略图,单面板最多缓存 128 张宽 128 像素的图片缩略图,其余显示图标;代次变化、解绑或销毁时取消旧加载并释放 bitmap。 + ## 文档与引用 文件结构操作立即发布。相同结构意图分别应用于工作版本与最近保存版本,磁盘只写变换后的保存版本,未保存文本不被顺带保存。Group source path 改变后仍使用同一会话文档;之后保存再撤销路径操作,保留刚保存的文本。放弃未保存内容不会撤销已完成的文件操作。详见 [历史与保存](editor-history-and-save.md)。 @@ -57,6 +61,8 @@ Assets 文件面板可从当前或选中的图片目录创建 `.variants`,使 维度 key/顺序变化显示引用影响提示,不迁移剧情参数;label 不参与身份。图片移动/改名继续由既有资源事务预检维护或拒绝无效关系。操作见 [图片差分图集](../manual/image-variants.md)。 +移动描述文件或图片目录时,`folder` 按目标位置重算,并在同一结构事务中分别作用于工作内容和最近保存内容,包括已打开的图集文本缓冲。未保存配置不被顺带保存;保存后撤销/重做移动会重算当前配置的路径,保留最新保存的其他配置。预检分别验证工作版与保存版的资源关系,不能只验证磁盘快照。 + ## 事务、撤销材料与恢复 普通文件备份在 `.galnet/editor/history//`,导入的重做读取保留副本,不再依赖外部源。文件复制和 SHA-256 通过磁盘/流处理,不将普通大文件整体读入托管内存;作者 JSON 和小型 sidecar 仍需要序列化。保留字节计入项目历史软预算,记录淘汰、redo 截断和项目关闭时释放。活动记录持有独占 owner lock;项目打开后清理已失去所有者的历史材料。Undo 栈不跨重启恢复。 @@ -67,4 +73,4 @@ Assets 文件面板可从当前或选中的图片目录创建 `.variants`,使 ## 资源面板常用入口 -导入、新建文本和新建文件夹在工具栏直接可见,原右键菜单仍可用。类型筛选与缩略图/列表视图只影响浏览;搜索明确覆盖整个项目。空目录、搜索或筛选无结果、读取错误分别呈现,不把加载失败当作空文件夹。排队刷新使用已捕获的项目内容服务,不在 scope 释放后重新解析服务。浏览与布局不进入作者历史。 \ No newline at end of file +导入、新建文本和新建文件夹在工具栏直接可见,原右键菜单仍可用。类型筛选与缩略图/列表视图只影响浏览;搜索明确覆盖整个项目。空目录、搜索或筛选无结果、读取错误分别呈现,不把加载失败当作空文件夹。排队刷新使用已捕获的项目内容服务,不在 scope 释放后重新解析服务。浏览与布局不进入作者历史。 diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.Variants.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.Variants.cs index 06e72107..37114c80 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.Variants.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.Variants.cs @@ -2,12 +2,14 @@ using System.Text.Json; using System.Text.Json.Nodes; using GalNet.Assets.Variants; +using GalNet.Editor.Abstraction.Documents; namespace GalNet.Editor.Shared.Services; public sealed partial class ProjectFileOperation { private readonly List<(string From, string To)> _variantCopies = []; + private readonly HashSet _variantDescriptors = new(ProjectFilePaths.Comparer); private (Dictionary Files, HashSet Directories) AssetSnapshot() { @@ -31,12 +33,14 @@ private void PrepareVariantRelations() foreach (var pair in snapshot.Files.Where(pair => IsVariants(pair.Key, snapshot.Files)).ToArray()) { var destination = pair.Key; + _variantDescriptors.Add(destination); var origin = destination; (string From, string To)? copied = null; foreach (var move in _moves.Where(move => !move.From.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) if (ProjectFilePaths.Within(destination, move.To)) { origin = move.From + destination[move.To.Length..]; break; } foreach (var copy in _variantCopies) if (ProjectFilePaths.Within(destination, copy.To)) { origin = copy.From + destination[copy.To.Length..]; copied = copy; break; } + _variantDescriptors.Add(origin); var bytes = File.ReadAllBytes(pair.Value); var source = ImageVariantsCodec.ReadSource(bytes); var oldFolder = "Assets/" + LocalAssetCompiler.ResolveFolder(origin[7..], source.Folder); @@ -44,7 +48,7 @@ private void PrepareVariantRelations() var newFolder = Map(oldFolder, true); if (copied is { } relation && ProjectFilePaths.Within(oldFolder, relation.From)) newFolder = relation.To + oldFolder[relation.From.Length..]; - if (ProjectFilePaths.Comparer.Equals(origin, destination) && ProjectFilePaths.Comparer.Equals(oldFolder, newFolder)) continue; + if (origin == destination && oldFolder == newFolder) continue; var parent = Path.GetDirectoryName(_reader.Resolve(destination))!; var relative = Path.GetRelativePath(parent, _reader.Resolve(newFolder)).Replace('\\', '/'); var json = JsonNode.Parse(bytes)!.AsObject(); @@ -57,6 +61,19 @@ private void PrepareVariantRelations() } } + private void RebaseVariantBuffer(EditorFileDocument file, bool forward) + { + if (!_variantDescriptors.Contains(file.Path)) return; + var source = ImageVariantsCodec.ReadSource(file.Data); + var folder = ("Assets/" + LocalAssetCompiler.ResolveFolder(file.Path[7..], source.Folder)).TrimEnd('/'); + var destination = Map(file.Path, forward); + var mappedFolder = Map(folder, forward); + if (file.Path == destination && folder == mappedFolder) return; + var json = JsonNode.Parse(file.Data)!.AsObject(); + json["folder"] = Path.GetRelativePath(Path.GetDirectoryName(_reader.Resolve(destination))!, _reader.Resolve(mappedFolder)).Replace('\\', '/'); + file.Data = Encoding.UTF8.GetBytes(json.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + } + private bool IsVariants(string path, IReadOnlyDictionary files) { if (path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false; @@ -66,7 +83,7 @@ private bool IsVariants(string path, IReadOnlyDictionary files) } private void ValidateVariants(IReadOnlyDictionary current, IReadOnlyDictionary desired, - IReadOnlyCollection currentDirectories, IReadOnlyCollection desiredDirectories) + IReadOnlyCollection currentDirectories, IReadOnlyCollection desiredDirectories, EditorProjectDocument document) { if (!current.Keys.Concat(desired.Keys).Any(path => path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))) return; var snapshot = AssetSnapshot(); @@ -77,6 +94,19 @@ private void ValidateVariants(IReadOnlyDictionary current, IRe snapshot.Directories.UnionWith(desiredDirectories.Where(path => path == "Assets" || path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))); var files = snapshot.Files.ToDictionary(pair => pair.Key[7..], pair => pair.Value, StringComparer.OrdinalIgnoreCase); var directories = snapshot.Directories.Select(path => path == "Assets" ? "" : path[7..]).ToArray(); - _ = LocalAssetCompiler.CompileAsync(files, directories, _types).GetAwaiter().GetResult(); + var temporary = new List(); + try + { + foreach (var file in document.Files.Values.Where(file => file.ManagedContent && file.Exists + && file.Path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && IsVariants(file.Path, snapshot.Files))) + { + var path = Path.Combine(_retained, "validate-" + Guid.NewGuid().ToString("N")); + temporary.Add(path); + File.WriteAllBytes(path, file.Data); + files[file.Path[7..]] = path; + } + _ = LocalAssetCompiler.CompileAsync(files, directories, _types).GetAwaiter().GetResult(); + } + finally { foreach (var path in temporary) File.Delete(path); } } } diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.cs index 535ab3cc..41a28698 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.cs @@ -128,15 +128,14 @@ await publish((working, saved) => var expected = current.Keys.Where(path => ProjectFilePaths.Within(path, root)).ToHashSet(ProjectFilePaths.Comparer); if (!actual.SetEquals(expected)) throw ProjectFilePaths.Error("changed", root); } - ValidateVariants(current, desired, currentDirectories, desiredDirectories); var files = new ProjectFilePlan(); foreach (var pair in current.Where(pair => !_managed.Contains(pair.Key))) files.ExpectedHashes[pair.Key] = pair.Value.Hash; foreach (var path in desired.Keys.Where(path => !current.Keys.Contains(path, ProjectFilePaths.Comparer))) files.ExpectedHashes[path] = null; if (_caseOnly) { foreach (var (from, to) in _moves) files.CaseRenames.Add(forward ? new(from, to) : new(to, from)); - foreach (var pair in desired.Where(pair => pair.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) - || !current.TryGetValue(pair.Key, out var old) || old.Hash != pair.Value.Hash)) files.Writes[pair.Key] = File.ReadAllBytes(pair.Value.Backup); + foreach (var pair in desired.Where(pair => !_managed.Contains(pair.Key) && (pair.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) + || !current.TryGetValue(pair.Key, out var old) || old.Hash != pair.Value.Hash))) files.Writes[pair.Key] = File.ReadAllBytes(pair.Value.Backup); } else { @@ -149,7 +148,15 @@ await publish((working, saved) => { removedWorking = CaptureRemoved(working); removedSaved = CaptureRemoved(saved); } - return new FileChange(this, forward, files, removedWorking ?? _removedWorking, removedSaved ?? _removedSaved); + var change = new FileChange(this, forward, files, removedWorking ?? _removedWorking, removedSaved ?? _removedSaved); + if (current.Keys.Concat(desired.Keys).Any(path => path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))) + foreach (var (document, version) in new[] { (working, AuthorDocumentVersion.Working), (saved, AuthorDocumentVersion.Saved) }) + { + var next = Clone(document); + change.Apply(next, version); + ValidateVariants(current, desired, currentDirectories, desiredDirectories, next); + } + return change; }); if (removedWorking is not null) { _removedWorking = removedWorking; _removedSaved = removedSaved; _removedBytes = JsonSerializer.SerializeToUtf8Bytes(new[] { removedWorking, removedSaved }).LongLength; } } @@ -400,6 +407,7 @@ public void Apply(EditorProjectDocument document, AuthorDocumentVersion version) var removed = version == AuthorDocumentVersion.Working ? Working : Saved; foreach (var file in document.Files.Values) { + if (Owner.Action == ProjectFileAction.Move && file.Exists && file.ManagedContent) Owner.RebaseVariantBuffer(file, Forward); if (Owner.Action == ProjectFileAction.Move) file.Path = Owner.Map(file.Path, Forward); if (Owner.Action == ProjectFileAction.Delete && Owner._roots.Any(root => ProjectFilePaths.Within(file.Path, root))) { diff --git a/src/Editor/GalNet.Editor/Controls/AssetFieldEditor.cs b/src/Editor/GalNet.Editor/Controls/AssetFieldEditor.cs index 76e66ebd..7c4ee611 100644 --- a/src/Editor/GalNet.Editor/Controls/AssetFieldEditor.cs +++ b/src/Editor/GalNet.Editor/Controls/AssetFieldEditor.cs @@ -1,7 +1,6 @@ using System.ComponentModel; using System.Text.Json; using Avalonia; -using Avalonia.Controls; using GalNet.Core.Assets; using GalNet.Editor.Models; using GalNet.Game.Controls.Forms; @@ -31,6 +30,6 @@ async void Selected(object? sender, AvaloniaPropertyChangedEventArgs args) finally { Refresh(); } } Refresh(); context.PropertyChanged += Changed; picker.PropertyChanged += Selected; - return new(picker, () => { disposed = true; lifetime.Cancel(); context.PropertyChanged -= Changed; picker.PropertyChanged -= Selected; picker.FindControl("PickerButton")?.Flyout?.Hide(); picker.AssetManager = null; lifetime.Dispose(); }); + return new(picker, () => { disposed = true; lifetime.Cancel(); context.PropertyChanged -= Changed; picker.PropertyChanged -= Selected; picker.Dispose(); picker.AssetManager = null; lifetime.Dispose(); }); } } diff --git a/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs b/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs index 6702caa1..4f8eb410 100644 --- a/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs +++ b/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs @@ -1,22 +1,17 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Media.Imaging; using Avalonia.Threading; using GalNet.Core.Assets; using GalNet.Editor.Models; +using GalNet.Editor.Services.Interfaces; using GalNet.Game.Controls.Forms; namespace GalNet.Editor.Controls; -/// Editor-only ID-backed media picker. Game runtime UI never references this control. -public partial class AssetPicker : UserControl +/// Editor-only media picker. Only IDs are authored; paths and labels follow resource generations. +public partial class AssetPicker : UserControl, IDisposable { public static readonly StyledProperty SelectedAssetIdProperty = AvaloniaProperty.Register(nameof(SelectedAssetId), string.Empty, defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); public static readonly StyledProperty FilterProperty = AvaloniaProperty.Register(nameof(Filter)); @@ -24,13 +19,12 @@ public partial class AssetPicker : UserControl public static readonly DirectProperty DisplayTextProperty = AvaloniaProperty.RegisterDirect(nameof(DisplayText), x => x.DisplayText); public static readonly DirectProperty DisplayIconProperty = AvaloniaProperty.RegisterDirect(nameof(DisplayIcon), x => x.DisplayIcon); public static readonly DirectProperty DetailPathProperty = AvaloniaProperty.RegisterDirect(nameof(DetailPath), x => x.DetailPath); - private string _displayText = "None", _displayIcon = "—", _detailPath = string.Empty; - private IReadOnlyList _allItems = []; - private AssetPickerItem? _current; - private int _resolveVersion; - private bool _flyoutOpen; - + private readonly Dictionary> _cache = new(StringComparer.OrdinalIgnoreCase); + private CancellationTokenSource _lifetime = new(); + private IEditorAssetChanges? _changes; + private int _generation, _resolveVersion, _listVersion, _thumbnailCount; + private bool _flyoutOpen, _attached, _disposed; public ObservableCollection Items { get; } = []; public string SelectedAssetId { get => GetValue(SelectedAssetIdProperty); set => SetValue(SelectedAssetIdProperty, value ?? string.Empty); } public AssetPickerFilter Filter { get => GetValue(FilterProperty); set => SetValue(FilterProperty, value); } @@ -38,82 +32,144 @@ public partial class AssetPicker : UserControl public string DisplayText => _displayText; public string DisplayIcon => _displayIcon; public string DetailPath => _detailPath; - + public ResourcePickerPanel ResourcePanel => PickerPanel; static AssetPicker() { SelectedAssetIdProperty.Changed.AddClassHandler((picker, args) => { _ = picker.ResolveCurrentAsync(); }); - AssetManagerProperty.Changed.AddClassHandler((picker, args) => { _ = picker.ResolveCurrentAsync(); }); + AssetManagerProperty.Changed.AddClassHandler((picker, args) => picker.ManagerChanged()); FilterProperty.Changed.AddClassHandler((picker, args) => { if (picker._flyoutOpen) _ = picker.LoadItemsAsync(); }); } - public AssetPicker() { InitializeComponent(); PickerPanel.Search.PlaceholderText = "Search assets"; PickerPanel.Confirmed += () => { - if (PickerPanel.Selected is not { } item) return; + if (PickerPanel.Selected is not { } item || _disposed) return; + // The source may already have changed before its UI notification was processed. + if (_loadedRevision != (AssetManager as IEditorAssetChanges)?.Revision) { RefreshResources(); return; } SelectedAssetId = item.Id; PickerButton.Flyout?.Hide(); }; PickerPanel.Cancelled += () => PickerButton.Flyout?.Hide(); } - public ResourcePickerPanel ResourcePanel => PickerPanel; - + private long? _loadedRevision; + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); _attached = true; + if (!_disposed) ManagerChanged(); + } + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _attached = false; Unsubscribe(); PickerButton.Flyout?.Hide(); if (!_disposed) ClearCache(); + base.OnDetachedFromVisualTree(e); + } + private void Unsubscribe() { if (_changes is not null) _changes.Changed -= OnAssetChanges; _changes = null; } + private void ManagerChanged() + { + if (_disposed) return; + Unsubscribe(); + if (_attached && AssetManager is IEditorAssetChanges changes) { _changes = changes; changes.Changed += OnAssetChanges; } + RefreshResources(); + } + private void OnAssetChanges() => Dispatcher.UIThread.Post(() => { if (!_disposed && _attached) RefreshResources(); }); + private void RefreshResources() + { + ClearCache(); + if (_disposed) return; + _ = ResolveCurrentAsync(); + if (_flyoutOpen) _ = LoadItemsAsync(); + } + private void ClearCache() + { + _generation++; _resolveVersion++; _listVersion++; _loadedRevision = null; + _lifetime.Cancel(); _lifetime.Dispose(); _lifetime = new(); + Items.Clear(); PickerPanel.SetItems([]); + foreach (var task in _cache.Values) if (task.IsCompletedSuccessfully) task.Result.Dispose(); + _cache.Clear(); _thumbnailCount = 0; + } private async void OnFlyoutOpened(object? sender, EventArgs e) { - _flyoutOpen = true; - await LoadItemsAsync(); - Dispatcher.UIThread.Post(() => PickerPanel.Search.Focus()); + _flyoutOpen = true; await LoadItemsAsync(); + if (_flyoutOpen && !_disposed) Dispatcher.UIThread.Post(() => { if (_flyoutOpen && !_disposed) PickerPanel.Search.Focus(); }); + } + private void OnFlyoutClosed(object? sender, EventArgs e) { _flyoutOpen = false; _listVersion++; } + private bool Current(int generation, IAssetManager? manager, long? revision) => !_disposed && generation == _generation + && ReferenceEquals(manager, AssetManager) && revision == (manager as IEditorAssetChanges)?.Revision; + private Task ItemAsync(IGameFile file) + { + if (_cache.TryGetValue(file.Id, out var cached)) return cached; + var thumbnail = file.TypeId == "sprite" && _thumbnailCount++ < 128; + return _cache[file.Id] = CreateItemAsync(file, thumbnail, _generation, _lifetime.Token); + } + private async Task CreateItemAsync(IGameFile file, bool thumbnail, int generation, CancellationToken ct) + { + var item = await AssetPickerItem.CreateAsync(file, thumbnail, ct); + if (_disposed || generation != _generation) { item.Dispose(); throw new OperationCanceledException(); } + return item; } - - private void OnFlyoutClosed(object? sender, EventArgs e) => _flyoutOpen = false; - private async Task ResolveCurrentAsync() { - var version = Interlocked.Increment(ref _resolveVersion); - var assetId = SelectedAssetId; - var assetManager = AssetManager; - AssetPickerItem? current = null; - if (!string.IsNullOrWhiteSpace(assetId) && assetManager is not null) + if (_disposed) return; + var version = ++_resolveVersion; var generation = _generation; var manager = AssetManager; + var revision = (manager as IEditorAssetChanges)?.Revision; var id = SelectedAssetId; var ct = _lifetime.Token; + try + { + AssetPickerItem? current = null; + if (!string.IsNullOrWhiteSpace(id) && manager is not null) + { + Display("Loading…", "…", id); + var file = await manager.GetFileAsync(id, ct); + if (!Current(generation, manager, revision) || version != _resolveVersion) return; + if (file is not null) current = await ItemAsync(file); + } + if (!Current(generation, manager, revision) || version != _resolveVersion) return; + Display(string.IsNullOrWhiteSpace(id) ? "None" : current?.Name ?? "Missing!", string.IsNullOrWhiteSpace(id) ? "—" : current?.Icon ?? "!", current?.Path ?? id); + } + catch (OperationCanceledException) { } + catch (Exception error) { - var file = await assetManager.GetFileAsync(assetId); - if (file is not null) current = await AssetPickerItem.CreateAsync(file); + if (Current(generation, manager, revision) && version == _resolveVersion) Display("Unable to load", "!", error.Message); } - if (version != _resolveVersion || !string.Equals(assetId, SelectedAssetId, StringComparison.Ordinal)) return; - _current = current; - NotifyDisplay(assetId); } - private async Task LoadItemsAsync() { - var assetManager = AssetManager; - if (assetManager is null) { _allItems = []; ApplyFilter(null); return; } - var type = Filter switch { AssetPickerFilter.Image => "sprite", AssetPickerFilter.Audio => "audio", AssetPickerFilter.Video => "video", AssetPickerFilter.EffectProgram => "effect-program", AssetPickerFilter.ImageVariants => "image-variants", _ => null }; - var files = await assetManager.GetFilesAsync(type); - if (Filter == AssetPickerFilter.All) files = files.Where(x => x.TypeId is "sprite" or "audio" or "video" or "effect-program" or "image-variants").ToArray(); - else if (Filter == AssetPickerFilter.Text) files = files.Where(x => IsTextPath(x.Path)).ToArray(); - _allItems = await Task.WhenAll(files.Select(AssetPickerItem.CreateAsync)); - ApplyFilter(null); + if (_disposed) return; + var version = ++_listVersion; var generation = _generation; var manager = AssetManager; + var revision = (manager as IEditorAssetChanges)?.Revision; var filter = Filter; var ct = _lifetime.Token; + try + { + if (manager is null) { Items.Clear(); PickerPanel.SetItems([]); return; } + var type = filter switch { AssetPickerFilter.Image => "sprite", AssetPickerFilter.Audio => "audio", AssetPickerFilter.Video => "video", AssetPickerFilter.EffectProgram => "effect-program", AssetPickerFilter.ImageVariants => "image-variants", _ => null }; + var files = await manager.GetFilesAsync(type, ct); + if (!Current(generation, manager, revision) || version != _listVersion) return; + if (filter == AssetPickerFilter.All) files = files.Where(file => file.TypeId is "sprite" or "audio" or "video" or "effect-program" or "image-variants").ToArray(); + else if (filter == AssetPickerFilter.Text) files = files.Where(file => IsTextPath(file.Path)).ToArray(); + var items = await Task.WhenAll(files.Select(ItemAsync)); + if (!Current(generation, manager, revision) || version != _listVersion) return; + Items.Clear(); Items.Add(AssetPickerItem.Empty); foreach (var item in items) Items.Add(item); + PickerPanel.Hint = ""; + PickerPanel.SetItems(Items.Select(item => new ResourcePickerItem(item.File?.Id ?? "", item.Name, item.Path, item.Icon, item.Thumbnail)).ToArray()); + _loadedRevision = revision; + } + catch (OperationCanceledException) { } + catch (Exception error) + { + if (Current(generation, manager, revision) && version == _listVersion) { Items.Clear(); PickerPanel.SetItems([]); PickerPanel.Hint = error.Message; } + } } - internal static bool IsTextPath(string path) => AssetPickerTextFileExtensions.IsSupported(path); - - private void ApplyFilter(string? search) + private void Display(string text, string icon, string path) { - Items.Clear(); Items.Add(AssetPickerItem.Empty); - foreach (var item in _allItems) Items.Add(item); - PickerPanel.SetItems(Items.Select(item => new ResourcePickerItem(item.File?.Id ?? "", item.Name, item.Path, item.Icon, item.Thumbnail)).ToArray()); + SetAndRaise(DisplayTextProperty, ref _displayText, text); SetAndRaise(DisplayIconProperty, ref _displayIcon, icon); SetAndRaise(DetailPathProperty, ref _detailPath, path); } - - private void NotifyDisplay(string assetId) + public void Dispose() { - SetAndRaise(DisplayTextProperty, ref _displayText, string.IsNullOrWhiteSpace(assetId) ? "None" : _current?.Name ?? "Missing!"); - SetAndRaise(DisplayIconProperty, ref _displayIcon, string.IsNullOrWhiteSpace(assetId) ? "—" : _current is null ? "!" : _current.Icon); - SetAndRaise(DetailPathProperty, ref _detailPath, _current?.Path ?? string.Empty); + if (_disposed) return; + _disposed = true; Unsubscribe(); PickerButton.Flyout?.Hide(); ClearCache(); _lifetime.Dispose(); GC.SuppressFinalize(this); } } -public sealed class AssetPickerItem +public sealed class AssetPickerItem : IDisposable { public static AssetPickerItem Empty { get; } = new(null, "None", string.Empty, "×", null); private AssetPickerItem(IGameFile? file, string name, string path, string icon, Bitmap? thumbnail) { File = file; Name = name; Path = path; Icon = file is not null && AssetPicker.IsTextPath(path) ? "T" : icon; Thumbnail = thumbnail; } @@ -123,13 +179,16 @@ public sealed class AssetPickerItem public string Icon { get; } public Bitmap? Thumbnail { get; } public bool HasThumbnail => Thumbnail is not null; - public static async Task CreateAsync(IGameFile file) + public static async Task CreateAsync(IGameFile file, bool thumbnail = true, CancellationToken ct = default) { Bitmap? bitmap = null; - if (file.TypeId == "sprite") + if (thumbnail && file.TypeId == "sprite") { - try { var bytes = await file.ReadAllBytesAsync(); using var stream = new MemoryStream(bytes); bitmap = new Bitmap(stream); } catch { } + try { var bytes = await file.ReadAllBytesAsync(ct); using var stream = new MemoryStream(bytes); bitmap = Bitmap.DecodeToWidth(stream, 128); } + catch (OperationCanceledException) { throw; } + catch (Exception error) when (error is IOException or ArgumentException or InvalidDataException or UnauthorizedAccessException) { } } return new(file, System.IO.Path.GetFileName(file.Path), file.Path, file.TypeId switch { "audio" => "♪", "video" => "▶", _ => "▣" }, bitmap); } + public void Dispose() => Thumbnail?.Dispose(); } diff --git a/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs b/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs index 16d81d43..839c81e4 100644 --- a/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs +++ b/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs @@ -110,7 +110,7 @@ private AssetEntry CreateFile(string fullPath) try { var metaPath = file + ".meta"; - return File.Exists(metaPath) ? _metaCodec.Deserialize(File.ReadAllText(metaPath)) : null; + return File.Exists(metaPath) ? _metaCodec.Deserialize(File.ReadAllText(metaPath), Relative(file)) : null; } catch { return null; } } diff --git a/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs b/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs index a6cd48bf..6f8496ba 100644 --- a/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs +++ b/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using GalNet.Assets; using GalNet.Assets.Provider; using GalNet.Assets.Variants; @@ -11,41 +7,182 @@ namespace GalNet.Editor.Services; -/// Project-aware bridge from the editor's Assets directory to the shared asset manager contract. -public sealed class EditorAssetManager : IAssetManager +/// ID-indexed editor resources. A generation shares one index and outlives its active readers and leases. +public sealed class EditorAssetManager : IAssetManager, IEditorAssetChanges { private readonly IProjectService _projects; private readonly IAssetCatalogService _catalog; private readonly IResourceTypeCatalog _resourceTypes; - private AssetManager _inner = new(); + private readonly object _gate = new(); + private readonly Dictionary<(string, Type), Action> _decoders = []; + private readonly List _providers = []; + private Generation _current = new([], []); + private long _revision; + private bool _disposed; + public event Action? Changed; + public long Revision { get { lock (_gate) return _revision; } } + public EditorAssetManager(IProjectService projects, IAssetCatalogService catalog, IResourceTypeCatalog resourceTypes) { - _projects = projects; - _catalog = catalog; - _resourceTypes = resourceTypes; - _projects.CurrentChanged += OnProjectChanged; - _catalog.Changed += OnCatalogChanged; - Rebuild(_projects.Current); - } - private void OnProjectChanged(Abstraction.Project.GalProject? project) => Rebuild(project); - private void OnCatalogChanged() => Rebuild(_projects.Current); - private void Rebuild(Abstraction.Project.GalProject? project) - { - var next = project is null ? new AssetManager() : new AssetManager([new LocalFileProvider(project.AssetsPath, _resourceTypes, optional: true)]); - next.RegisterImageVariantsDecoder(); - var previous = Interlocked.Exchange(ref _inner, next); - previous.Dispose(); - } - public Task GetFileAsync(string assetId, CancellationToken ct = default) => _inner.GetFileAsync(assetId, ct); - public Task> GetFilesAsync(string? typeId = null, CancellationToken ct = default) => _inner.GetFilesAsync(typeId, ct); - public Task?> AcquireAsync(string assetId, CancellationToken ct = default) where T : class => _inner.AcquireAsync(assetId, ct); - public Task?> AcquireByPathAsync(string path, CancellationToken ct = default) where T : class => _inner.AcquireByPathAsync(path, ct); - public void RegisterDecoder(string resourceTypeId, IAssetDecoder decoder) where T : class => _inner.RegisterDecoder(resourceTypeId, decoder); - public void RegisterProvider(IAssetProvider provider) => _inner.RegisterProvider(provider); + _projects = projects; _catalog = catalog; _resourceTypes = resourceTypes; + _projects.CurrentChanged += OnProjectChanged; _catalog.Changed += OnCatalogChanged; + Rebuild(); + } + private void OnProjectChanged(Abstraction.Project.GalProject? project) => Rebuild(); + private void OnCatalogChanged() => Rebuild(); + private void Rebuild() + { + Generation previous; + lock (_gate) + { + if (_disposed) return; + var providers = new List(); + if (_projects.Current is { } project) providers.Add(new LocalFileProvider(project.AssetsPath, _resourceTypes, optional: true)); + providers.AddRange(_providers.Select(provider => provider.Borrow())); + previous = _current; + _current = new(providers, _decoders.Values); + _revision++; + } + Release(previous); + Changed?.Invoke(); + } + private Generation Retain() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _current.Readers++; + return _current; + } + } + private void Release(Generation generation) + { + bool dispose; + lock (_gate) dispose = --generation.Readers == 0; + if (dispose) generation.Manager.Dispose(); + } + private async Task IndexAsync(Generation generation, CancellationToken ct) + { + Task task; + lock (_gate) + { + if (generation.Index is null || generation.Index.IsFaulted || generation.Index.IsCanceled) + { + generation.Readers++; + generation.Index = Task.Run(() => LoadIndexAsync(generation)); + } + task = generation.Index; + } + return await task.WaitAsync(ct).ConfigureAwait(false); + } + private async Task LoadIndexAsync(Generation generation) + { + try + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var provider in generation.Providers) + { + if (!provider.Exists("assets")) continue; + using var archive = await provider.OpenArchiveAsync("assets").ConfigureAwait(false); + foreach (var id in archive.AssetIds) + if (archive.GetAsset(id) is { } file && !files.TryAdd(file.Id, file)) + throw new InvalidDataException($"Duplicate resource ID '{file.Id}'."); + } + return new(files, files.Values.OrderBy(file => file.Path, StringComparer.OrdinalIgnoreCase).ToArray()); + } + finally { Release(generation); } + } + public async Task GetFileAsync(string assetId, CancellationToken ct = default) + { + var generation = Retain(); + try { return string.IsNullOrWhiteSpace(assetId) ? null : (await IndexAsync(generation, ct).ConfigureAwait(false)).ById.GetValueOrDefault(assetId.Trim()); } + finally { Release(generation); } + } + public async Task> GetFilesAsync(string? typeId = null, CancellationToken ct = default) + { + var generation = Retain(); + try + { + var index = await IndexAsync(generation, ct).ConfigureAwait(false); + return typeId is null ? index.Files : index.Files.Where(file => file.TypeId.Equals(typeId.Trim(), StringComparison.OrdinalIgnoreCase)).ToArray(); + } + finally { Release(generation); } + } + public Task?> AcquireAsync(string assetId, CancellationToken ct = default) where T : class + => AcquireAsync(manager => manager.AcquireAsync(assetId, ct)); + public Task?> AcquireByPathAsync(string path, CancellationToken ct = default) where T : class + => AcquireAsync(manager => manager.AcquireByPathAsync(path, ct)); + private async Task?> AcquireAsync(Func?>> acquire) where T : class + { + var generation = Retain(); + var transferred = false; + try + { + var handle = await acquire(generation.Manager).ConfigureAwait(false); + if (handle is null) return null; + var result = new AssetHandle(handle.AssetId, handle.TypeId, handle.Value, () => { try { handle.Dispose(); } finally { Release(generation); } }); + transferred = true; + return result; + } + finally { if (!transferred) Release(generation); } + } + public void RegisterDecoder(string resourceTypeId, IAssetDecoder decoder) where T : class + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _current.Manager.RegisterDecoder(resourceTypeId, decoder); + _decoders[(resourceTypeId.Trim().ToLowerInvariant(), typeof(T))] = manager => manager.RegisterDecoder(resourceTypeId, decoder); + } + } + public void RegisterProvider(IAssetProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); _providers.Add(new(provider)); } + Rebuild(); + } public void Dispose() { - _projects.CurrentChanged -= OnProjectChanged; - _catalog.Changed -= OnCatalogChanged; - _inner.Dispose(); + Generation current; + SharedProvider[] providers; + lock (_gate) + { + if (_disposed) return; + _disposed = true; + _projects.CurrentChanged -= OnProjectChanged; _catalog.Changed -= OnCatalogChanged; + current = _current; Changed = null; + providers = _providers.ToArray(); + _providers.Clear(); + } + Release(current); + foreach (var provider in providers) provider.Release(); + } + private sealed record AssetIndex(IReadOnlyDictionary ById, IReadOnlyList Files); + private sealed class Generation + { + public Generation(IReadOnlyList providers, IEnumerable> decoders) + { + Providers = providers; Manager = new(providers); Manager.RegisterImageVariantsDecoder(); + foreach (var register in decoders) register(Manager); + } + public AssetManager Manager { get; } + public IReadOnlyList Providers { get; } + public Task? Index { get; set; } + public int Readers { get; set; } = 1; + } + private sealed class SharedProvider(IAssetProvider provider) + { + private int _references = 1; + public BorrowedProvider Borrow() { Interlocked.Increment(ref _references); return new BorrowedProvider(provider, Release); } + public void Release() { if (Interlocked.Decrement(ref _references) == 0 && provider is IDisposable disposable) disposable.Dispose(); } + } + private sealed class BorrowedProvider(IAssetProvider provider, Action release) : IAssetProvider, IDisposable + { + private Action? _release = release; + public string Name => provider.Name; + public bool Exists(string name) => provider.Exists(name); + public IArchive OpenArchive(string name) => provider.OpenArchive(name); + public Task OpenArchiveAsync(string name, CancellationToken ct = default) => provider.OpenArchiveAsync(name, ct); + public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke(); } } diff --git a/src/Editor/GalNet.Editor/Services/Interfaces/IEditorAssetChanges.cs b/src/Editor/GalNet.Editor/Services/Interfaces/IEditorAssetChanges.cs new file mode 100644 index 00000000..c9e846ee --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/Interfaces/IEditorAssetChanges.cs @@ -0,0 +1,8 @@ +namespace GalNet.Editor.Services.Interfaces; + +/// Editor resource generations; paths and labels are attributes of an ID. +public interface IEditorAssetChanges +{ + long Revision { get; } + event Action? Changed; +} diff --git a/test/GalNet.Editor.Tests/ResourceIdentityTests.cs b/test/GalNet.Editor.Tests/ResourceIdentityTests.cs new file mode 100644 index 00000000..ac52aef6 --- /dev/null +++ b/test/GalNet.Editor.Tests/ResourceIdentityTests.cs @@ -0,0 +1,245 @@ +using System.Text; +using System.Text.Json; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.VisualTree; +using GalNet.Assets; +using GalNet.Contracts.Builtins; +using GalNet.Core.Assets; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Controls; +using GalNet.Editor.History; +using GalNet.Editor.Inspector.ViewModels; +using GalNet.Editor.Models; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Services; +using GalNet.Editor.Services.Interfaces; +using GalNet.Editor.Shared.Services; +using GalNet.Editor.ViewModels; +using Microsoft.Extensions.DependencyInjection; + +namespace GeneralTest.Editor; + +public sealed partial class ProjectWorkspaceLifecycleTests +{ + [Test] + public async Task MountedEntryResourceFollowsIdAcrossRenameMoveUndoAndReopen() + { + await using var ui = HeadlessUnitTestSession.StartNew(typeof(EditorFormTestApplication)); + await ui.Dispatch(async () => + { + PrepareGroupTheme(); using var services = Services(); var projects = services.GetRequiredService(); + var project = await projects.CreateAsync(Path.Combine(_root, "ResourceIdentity"), "Identity", new(), Path.Combine(AppContext.BaseDirectory, "DefaultContracts")); + var scope = project.Services; var files = scope.GetRequiredService(); var workspace = scope.GetRequiredService(); + await files.ExecuteAsync(new(ProjectFileAction.CreateFile, [], "Assets/voice.ogg", Content: [1])); + await files.ExecuteAsync(new(ProjectFileAction.CreateDirectory, [], "Assets/voice-folder")); + var id = scope.GetRequiredService().GetEntry("voice.ogg")!.Id!; + var group = workspace.Nodes.Single(node => node.NodeKind == GraphNodeKind.LinearGroup); + var entry = workspace.InsertEntriesInto(group, 0, 1, BuiltinEntryIds.PlayAudio).Single(); + scope.GetRequiredService().SetParameters(group.Id, entry.Source, entry.Type, + entry.Source.Parameters.Append(new KeyValuePair("asset", JsonSerializer.SerializeToElement(id))).GroupBy(pair => pair.Key).ToDictionary(items => items.Key, items => items.Last().Value)); + using var page = ActivatorUtilities.CreateInstance(scope, group.Id); page.SelectedEntry = entry; + var view = new EntryListEditorControl { DataContext = page }; var window = new Window { Content = view, Width = 1200, Height = 900 }; window.Show(); TimelinePump(window); + try + { + var picker = view.GetVisualDescendants().OfType().Single(control => control.Filter == AssetPickerFilter.Audio && control.IsEffectivelyVisible); + await WaitForResourceUi(window, () => picker.DisplayText == "voice.ogg"); + var button = picker.FindControl - + + + + + + + + + + + + + + + + diff --git a/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml b/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml index b8214410..19a67149 100644 --- a/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml +++ b/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml @@ -6,6 +6,7 @@ x:Name="Root"> InspectedAsset?.IsImage == true; public bool IsSelectedAssetAudio => InspectedAsset?.IsAudio == true; public bool IsSelectedAssetVideo => InspectedAsset?.IsVideo == true; + public bool HasPreview => IsSelectedAssetImage || IsSelectedAssetAudio || Variants is not null; public IReadOnlyList FilterOptions { get; } = ["point", "bilinear"]; public IReadOnlyList CompressionOptions { get; } = ["none", "deflate", "gzip", "brotli"]; public string AudioPreviewActionText => _localization[IsAudioPreviewPlaying ? "Inspector.Asset.Pause" : "Inspector.Asset.Play"]; @@ -134,6 +135,7 @@ private void SetAsset(AssetEntry? asset) OnPropertyChanged(nameof(IsSelectedAssetImage)); OnPropertyChanged(nameof(IsSelectedAssetAudio)); OnPropertyChanged(nameof(IsSelectedAssetVideo)); + OnPropertyChanged(nameof(HasPreview)); } private void Load() diff --git a/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml b/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml index fcfac266..d2ac129c 100644 --- a/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml +++ b/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml @@ -1,31 +1,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + @@ -37,13 +41,9 @@ - - - - - - - - - + + + + + diff --git a/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml.cs b/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml.cs index 89fa30f0..1c27446c 100644 --- a/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml.cs +++ b/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml.cs @@ -5,7 +5,11 @@ namespace GalNet.Editor.Inspector.Views; public partial class AssetInspectorControl : UserControl { - public AssetInspectorControl() => InitializeComponent(); + public AssetInspectorControl() + { + InitializeComponent(); + SizeChanged += (_, _) => AssetPreview.MaxHeight = Math.Max(40, Bounds.Height * 0.5); + } private void OnAudioSeekStarted(object? sender, PointerPressedEventArgs e) => (DataContext as AssetInspectorControlViewModel)?.BeginAudioSeek(); private void OnAudioSeekCompleted(object? sender, PointerReleasedEventArgs e) { if (sender is Slider slider) (DataContext as AssetInspectorControlViewModel)?.EndAudioSeek(slider.Value); } } diff --git a/src/Editor/GalNet.Editor/Inspector/Views/ImageVariantsEditor.axaml b/src/Editor/GalNet.Editor/Inspector/Views/ImageVariantsEditor.axaml index 404d54d7..4d674f23 100644 --- a/src/Editor/GalNet.Editor/Inspector/Views/ImageVariantsEditor.axaml +++ b/src/Editor/GalNet.Editor/Inspector/Views/ImageVariantsEditor.axaml @@ -28,24 +28,9 @@ - - + + + - - - + + + + + - - - - - - + + + + diff --git a/src/Editor/GalNet.Editor/Views/CharactersPanelView.axaml.cs b/src/Editor/GalNet.Editor/Views/CharactersPanelView.axaml.cs index 00b3c6c5..9d56db77 100644 --- a/src/Editor/GalNet.Editor/Views/CharactersPanelView.axaml.cs +++ b/src/Editor/GalNet.Editor/Views/CharactersPanelView.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; namespace GalNet.Editor.Views; public partial class CharactersPanelView : UserControl { public CharactersPanelView() => InitializeComponent(); + private void OnCharacterAdded(object? sender, RoutedEventArgs e) => Dispatcher.UIThread.Post(() => { CharacterName.Focus(); CharacterName.SelectAll(); }); } diff --git a/src/Editor/GalNet.Editor/Views/GameLocalizationPanelView.axaml b/src/Editor/GalNet.Editor/Views/GameLocalizationPanelView.axaml index 182db76e..2ec4e8b2 100644 --- a/src/Editor/GalNet.Editor/Views/GameLocalizationPanelView.axaml +++ b/src/Editor/GalNet.Editor/Views/GameLocalizationPanelView.axaml @@ -2,29 +2,60 @@ xmlns:vm="using:GalNet.Editor.ViewModels" xmlns:services="using:GalNet.Editor.Services" xmlns:loc="clr-namespace:GalNet.Editor.MarkupExtensions" x:Class="GalNet.Editor.Views.GameLocalizationPanelView" x:DataType="vm:GameLocalizationPanelViewModel"> - + + - + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + @@ -25,9 +47,9 @@ - + - - + diff --git a/src/Editor/GalNet.Editor/Views/VariablesPanelView.axaml.cs b/src/Editor/GalNet.Editor/Views/VariablesPanelView.axaml.cs index 3c2a8fcc..13347742 100644 --- a/src/Editor/GalNet.Editor/Views/VariablesPanelView.axaml.cs +++ b/src/Editor/GalNet.Editor/Views/VariablesPanelView.axaml.cs @@ -1,8 +1,31 @@ using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using GalNet.Editor.ViewModels; namespace GalNet.Editor.Views; public partial class VariablesPanelView : UserControl { - public VariablesPanelView() => InitializeComponent(); + private bool? _narrow; + public VariablesPanelView() + { + InitializeComponent(); + SizeChanged += (_, _) => + { + var narrow = Bounds.Width < 560; + if (_narrow == narrow) return; + _narrow = narrow; + VariableColumns.ColumnDefinitions = new(narrow ? "*" : "220,10,*"); + VariableColumns.RowDefinitions = new(narrow ? "160,8,*" : "*"); + for (var i = 0; i < VariableColumns.Children.Count; i++) + { Grid.SetColumn(VariableColumns.Children[i], narrow ? 0 : i); Grid.SetRow(VariableColumns.Children[i], narrow ? i : 0); } + if (VariableColumns.Children[1] is GridSplitter splitter) splitter.ResizeDirection = narrow ? GridResizeDirection.Rows : GridResizeDirection.Columns; + }; + } + private void OnVariableAdded(object? sender, RoutedEventArgs e) => Dispatcher.UIThread.Post(() => + { + if (DataContext is not VariablesPanelViewModel { Selected: { } selected }) return; + VariableRows.ScrollIntoView(selected); NameInput.Focus(); NameInput.SelectAll(); + }); } diff --git a/test/GalNet.Editor.Tests/AuthoringUiContinuityTests.cs b/test/GalNet.Editor.Tests/AuthoringUiContinuityTests.cs new file mode 100644 index 00000000..1810b3cb --- /dev/null +++ b/test/GalNet.Editor.Tests/AuthoringUiContinuityTests.cs @@ -0,0 +1,243 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Threading; +using Avalonia.VisualTree; +using GalNet.Core.Variable; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Controls; +using GalNet.Editor.History; +using GalNet.Editor.Inspector.ViewModels; +using GalNet.Editor.Inspector.Views; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Services; +using GalNet.Editor.ViewModels; +using GalNet.Editor.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace GeneralTest.Editor; + +public sealed partial class ProjectWorkspaceLifecycleTests +{ + [Test] + public async Task VariableSubpagesRestoreIdentityDraftAndBaselineAfterSwitchRefreshAndReopen() + { + using var services = Services(); var projects = services.GetRequiredService(); + var project = await projects.CreateAsync(Path.Combine(_root, "VariableSubpages"), "Variables", new(), Path.Combine(AppContext.BaseDirectory, "DefaultContracts")); + try + { + var author = project.Services.GetRequiredService(); + var player = author.Add(VariableScope.Player); var save = author.Add(VariableScope.Save); + var history = project.Services.GetRequiredService().Content; history.Clear(); + var vm = project.Services.GetRequiredService(); + vm.Selected = vm.Rows.Single(); vm.DraftName = "pendingPlayer"; + vm.ScopeIndex = 1; vm.Selected = vm.Rows.Single(); vm.DraftDescription = "pending save description"; + vm.ScopeIndex = 0; + Assert.That(vm.Selected?.Uid, Is.EqualTo(player.DefaultValue.Uid)); Assert.That(vm.DraftName, Is.EqualTo("pendingPlayer")); + Assert.That(history.CanUndo, Is.False); + author.Update(VariableScope.Player, player.DefaultValue.Uid, "external", VariableType.Int, "42", ""); + vm.ScopeIndex = 1; Assert.That(vm.Selected?.Uid, Is.EqualTo(save.DefaultValue.Uid)); Assert.That(vm.DraftDescription, Is.EqualTo("pending save description")); + vm.Dispose(); + using var reopened = project.Services.GetRequiredService(); + Assert.That(reopened.ScopeIndex, Is.EqualTo(1)); Assert.That(reopened.Selected?.Uid, Is.EqualTo(save.DefaultValue.Uid)); + Assert.That(reopened.DraftDescription, Is.EqualTo("pending save description")); + reopened.ScopeIndex = 0; reopened.ApplyCommand.Execute(null); + Assert.That(reopened.MessageText, Is.Not.Empty); Assert.That(author.Get(VariableScope.Player).Single().Name, Is.EqualTo("external")); + reopened.RevertCommand.Execute(null); Assert.That(reopened.DraftName, Is.EqualTo("external")); + reopened.DraftName = "retained"; + author.Delete(VariableScope.Player, player.DefaultValue.Uid); + Assert.That(reopened.Selected, Is.Null); Assert.That(reopened.MissingSelection, Is.True); + var replacement = author.Add(VariableScope.Player); + Assert.That(reopened.Selected, Is.Null, "A new UID must not acquire the old draft."); + await history.UndoAsync(); await history.UndoAsync(); + Assert.That(reopened.Selected?.Uid, Is.EqualTo(player.DefaultValue.Uid)); Assert.That(reopened.DraftName, Is.EqualTo("retained")); + reopened.Search = "no matching variable"; Assert.That(reopened.Rows, Is.Empty); + reopened.Search = ""; Assert.That(reopened.Selected?.Uid, Is.EqualTo(player.DefaultValue.Uid)); Assert.That(reopened.DraftName, Is.EqualTo("retained")); + } + finally { await projects.CloseAsync(); } + } + + [Test] + public Task CharacterDraftsFollowStableNameIdentityAcrossFilterRenameSelectionAndReopen() => WithLocalizationProject(async project => + { + var author = project.Services.GetRequiredService(); + var first = await author.CreateAsync("First", "first"); var second = await author.CreateAsync("Second", "second"); + var vm = project.Services.GetRequiredService(); vm.Selected = vm.Rows.Single(row => row.Id == first.Id); + vm.DraftName = "Unapplied first"; vm.Search = "Second"; Assert.That(vm.Selected, Is.Null); + vm.Search = ""; Assert.That(vm.Selected?.DisplayNameKey, Is.EqualTo(first.DisplayNameKey)); Assert.That(vm.DraftName, Is.EqualTo("Unapplied first")); + vm.Selected = vm.Rows.Single(row => row.Id == second.Id); vm.DraftName = "Unapplied second"; + vm.Selected = vm.Rows.Single(row => row.Id == first.Id); Assert.That(vm.DraftName, Is.EqualTo("Unapplied first")); + await author.UpdateAsync(first, "external", "External name"); + Assert.That(vm.Selected?.Id, Is.EqualTo("external")); Assert.That(vm.DraftName, Is.EqualTo("Unapplied first")); + await vm.ApplyCommand.ExecuteAsync(null); Assert.That(vm.Message, Is.Not.Empty); + vm.RevertCommand.Execute(null); Assert.That(vm.DraftName, Is.EqualTo("External name")); + vm.Selected = vm.Rows.Single(row => row.Id == second.Id); vm.Dispose(); + using var reopened = project.Services.GetRequiredService(); + Assert.That(reopened.Selected?.Id, Is.EqualTo(second.Id)); Assert.That(reopened.DraftName, Is.EqualTo("Unapplied second")); + }); + + [Test] + public Task CharactersSharingANameTextNeverShareDraftsOrAcquireADeletedCharactersSelection() => WithLocalizationProject(async project => + { + WriteTranslation(project, """[{"id":"first","displayNameKey":"shared"},{"id":"second","displayNameKey":"shared"}]""", "I18n/catalog/characters.json"); + WriteTranslation(project, """{"shared":"Shared name"}"""); + var author = project.Services.GetRequiredService(); + using var vm = project.Services.GetRequiredService(); + vm.Selected = vm.Rows.Single(row => row.Id == "first"); vm.DraftKey = "first_draft"; + vm.Selected = vm.Rows.Single(row => row.Id == "second"); Assert.That(vm.DraftKey, Is.EqualTo("second")); vm.DraftKey = "second_draft"; + vm.Selected = vm.Rows.Single(row => row.Id == "first"); Assert.That(vm.DraftKey, Is.EqualTo("first_draft")); + await author.DeleteAsync(author.Rows().Single(row => row.Id == "first")); + Assert.That(vm.Selected, Is.Null, "A shared name-text key cannot identify the remaining character as the removed selection."); + vm.Selected = vm.Rows.Single(); Assert.That(vm.DraftKey, Is.EqualTo("second_draft")); + }); + + [Test] + public Task CharacterApplyCompletionCannotDiscardAnotherSelectionOrAnUnrelatedNewDraft() => WithLocalizationProject(async project => + { + var author = project.Services.GetRequiredService(); + var first = await author.CreateAsync("First", "first"); var second = await author.CreateAsync("Second", "second"); + using var vm = project.Services.GetRequiredService(); + vm.Selected = vm.Rows.Single(row => row.Id == second.Id); vm.DraftName = "Pending second"; + vm.Selected = vm.Rows.Single(row => row.Id == first.Id); vm.DraftName = "Applied first"; + var started = new TaskCompletionSource(); var release = new TaskCompletionSource(); + var history = project.Services.GetRequiredService().Content; + var held = history.ExecuteAsync(new CallbackEdit("Hold character apply", async () => { started.SetResult(); await release.Task; }, () => ValueTask.CompletedTask)); + await started.Task; + var apply = vm.ApplyCommand.ExecuteAsync(null); + try { vm.Selected = vm.Rows.Single(row => row.Id == second.Id); } + finally { release.SetResult(); } + await held; await apply; + Assert.That(author.Rows().Single(row => row.Id == first.Id).Name, Is.EqualTo("Applied first")); + Assert.That(vm.Selected?.Id, Is.EqualTo(second.Id)); Assert.That(vm.DraftName, Is.EqualTo("Pending second")); + vm.AddCommand.Execute(null); vm.DraftName = "Pending new character"; + vm.Selected = vm.Rows.Single(row => row.Id == first.Id); vm.DraftName = "Applied again"; + await vm.ApplyCommand.ExecuteAsync(null); vm.AddCommand.Execute(null); + Assert.That(vm.DraftName, Is.EqualTo("Pending new character")); + vm.Selected = vm.Rows.Single(row => row.Id == first.Id); vm.RevertCommand.Execute(null); vm.AddCommand.Execute(null); + Assert.That(vm.DraftName, Is.EqualTo("Pending new character"), "Reverting an existing character must not discard a separate new-character draft."); + vm.Selected = vm.Rows.Single(row => row.Id == first.Id); vm.DraftName = "Applied after close"; + started = new(); release = new(); + held = history.ExecuteAsync(new CallbackEdit("Hold across close", async () => { started.SetResult(); await release.Task; }, () => ValueTask.CompletedTask)); + await started.Task; apply = vm.ApplyCommand.ExecuteAsync(null); vm.Dispose(); + try + { + using var reopened = project.Services.GetRequiredService(); reopened.DraftName = "Newer input"; + } + finally { release.SetResult(); } + await held; await apply; + using var latest = project.Services.GetRequiredService(); + Assert.That(author.Rows().Single(row => row.Id == first.Id).Name, Is.EqualTo("Applied after close")); + Assert.That(latest.DraftName, Is.EqualTo("Newer input"), "An old view's completion must not discard a newer view's draft."); + }); + + [Test] + public Task LocalizationFiltersKeepMissingEmptyAndDraftIdentityDistinct() => WithLocalizationProject(async project => + { + var item = CharacterEntry(project); WriteTranslation(project, """{"line":"Source"}"""); + var service = project.Services.GetRequiredService(); await service.AddLanguageAsync("en-US"); + var model = project.Services.GetRequiredService(); model.Selected = model.Categories.Single(category => category.Id == item.Group); model.ExchangeLanguage = "en-US"; + var cell = model.Rows.Single().Cells.Single(cell => cell.Culture == "en-US"); + model.FilterIndex = 1; Assert.That(model.Rows, Has.Count.EqualTo(1)); + await cell.CommitAsync(true); Assert.That(cell.IsMissing, Is.False); Assert.That(model.Rows, Is.Empty, "Explicit empty translation is not missing."); + model.FilterIndex = 0; cell.Text = "Unapplied translation"; + model.FilterIndex = 3; Assert.That(model.Rows.Single().Cells, Does.Contain(cell)); + model.Search = "no matching source"; Assert.That(model.Rows, Is.Empty); + model.Search = "Unapplied"; Assert.That(model.Rows.Single().Cells, Does.Contain(cell)); + model.Selected = model.Categories.First(); model.Selected = model.Categories.Single(category => category.Id == item.Group); + Assert.That(model.Rows.Single().Cells.Single(c => c.Culture == "en-US"), Is.SameAs(cell)); + Assert.That(cell.Text, Is.EqualTo("Unapplied translation")); cell.Revert(); Assert.That(model.Rows, Is.Empty); + model.Search = ""; model.FilterIndex = 0; + }); + + [Test] + public async Task BranchPrimaryInputsStayAlignedWhenSemanticHintsOrErrorsAppear() + { + await using var session = HeadlessUnitTestSession.StartNew(typeof(EditorFormTestApplication)); + await session.Dispatch(async () => + { + PrepareGroupTheme(); await WithLocalizationProject(async project => + { + var workspace = project.Services.GetRequiredService(); + var branch = await workspace.AddNodeAsync(GraphNodeKind.ChoiceBranch, 300, 0); workspace.SelectNode(branch); + var view = new NodeInspectorControl { DataContext = project.Services.GetRequiredService() }; + var window = new Window { Content = view, Width = 800, Height = 700 }; window.Show(); Dispatcher.UIThread.RunJobs(); + try + { + void Check() + { + window.UpdateLayout(); + var text = view.GetVisualDescendants().OfType().Single(box => box.Name == "GameTextInput"); + var condition = view.GetVisualDescendants().OfType().Single().FindControl("EditorBox")!; + var textCenter = text.TranslatePoint(new Point(0, text.Bounds.Height / 2), window)!.Value.Y; + var conditionCenter = condition.TranslatePoint(new Point(0, condition.Bounds.Height / 2), window)!.Value.Y; + Assert.That(textCenter, Is.EqualTo(conditionCenter).Within(1)); Assert.That(text.Bounds.Width, Is.GreaterThan(50)); + } + Check(); branch.Source.Options![0].Text = "missing_text"; + project.Services.GetRequiredService().Refresh(); Dispatcher.UIThread.RunJobs(); Check(); + var condition = view.GetVisualDescendants().OfType().Single(); + condition.Text = "("; condition.FindControl("EditorBox")!.Focus(); view.Focus(); Dispatcher.UIThread.RunJobs(); Check(); + } + finally { window.Close(); } + }); return true; + }, CancellationToken.None); + } + + [Test] + public async Task AuthoringPanelsMountNarrowAndLocalizationKeepsFrozenContextWhileScrolling() + { + await using var session = HeadlessUnitTestSession.StartNew(typeof(EditorFormTestApplication)); + await session.Dispatch(async () => + { + PrepareGroupTheme(); await WithLocalizationProject(async project => + { + var item = CharacterEntry(project); WriteTranslation(project, """{"line":"A longer source text for the localization workspace."}"""); + var service = project.Services.GetRequiredService(); await service.AddLanguageAsync("en-US"); await service.AddLanguageAsync("ja-JP"); + var model = project.Services.GetRequiredService(); model.Selected = model.Categories.Single(category => category.Id == item.Group); + var view = new GameLocalizationPanelView { DataContext = model }; + var window = new Window { Content = view, Width = 900, Height = 650 }; window.Show(); Dispatcher.UIThread.RunJobs(); + void Capture(string name) + { + if (Environment.GetEnvironmentVariable("GALNET_EDITOR_TEST_ARTIFACTS") is not { Length: > 0 } artifacts) return; + using var frame = window.CaptureRenderedFrame(); frame!.Save(Path.Combine(artifacts, name + ".png")); + } + try + { + var header = view.FindControl("ContextHeader")!; var scroll = view.FindControl("LocalizationTableScroll")!; + var left = header.TranslatePoint(default, window)!.Value.X; + scroll.Offset = new Vector(220, 0); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); + Assert.That(scroll.Offset.X, Is.GreaterThan(0)); + Assert.That(header.TranslatePoint(default, window)!.Value.X, Is.EqualTo(left).Within(1)); + var sourceInput = view.GetVisualDescendants().OfType().Single(box => box.Name == "LocalizationCellInput" && box.DataContext is LocalizationGridCell cell && cell.Culture == "zh-CN"); + sourceInput.Focus(); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); + Assert.That(sourceInput.TranslatePoint(default, scroll)!.Value.X, Is.GreaterThanOrEqualTo(188), "Keyboard focus must reveal the cell beside the frozen context."); + scroll.Offset = new Vector(220, 0); Dispatcher.UIThread.RunJobs(); + var rowContext = view.GetVisualDescendants().OfType - - - - + - - - - - - - - - - - + -