diff --git a/editor/Assets/AssetBrowserView.axaml.cs b/editor/Assets/AssetBrowserView.axaml.cs index 28900aaed..830a5e886 100644 --- a/editor/Assets/AssetBrowserView.axaml.cs +++ b/editor/Assets/AssetBrowserView.axaml.cs @@ -46,15 +46,31 @@ public AssetBrowserView() { AssetBrowserViewModel? subscribed = null; DataContextChanged += (_, _) => { - if (subscribed is not null) subscribed.PropertyChanged -= OnViewModelPropertyChanged; + if (subscribed is not null) { + subscribed.PropertyChanged -= OnViewModelPropertyChanged; + subscribed.ItemRevealed -= OnItemRevealed; + } + subscribed = DataContext as AssetBrowserViewModel; - if (subscribed is not null) subscribed.PropertyChanged += OnViewModelPropertyChanged; + if (subscribed is not null) { + subscribed.PropertyChanged += OnViewModelPropertyChanged; + subscribed.ItemRevealed += OnItemRevealed; + } + ApplyCardSize(); }; } private AssetBrowserViewModel Vm => (AssetBrowserViewModel)DataContext!; + private void OnItemRevealed(object item) { + Avalonia.Threading.Dispatcher.UIThread.Post(() => { + var index = Vm.CurrentItems.IndexOf(item); + if (index < 0) return; + AssetRepeater.GetOrCreateElement(index).BringIntoView(); + }, Avalonia.Threading.DispatcherPriority.Loaded); + } + private void OnSelectTemplateKey(object? sender, SelectTemplateEventArgs e) { e.TemplateKey = e.DataContext is AssetFolder ? "folder" : "file"; } diff --git a/editor/Assets/AssetBrowserViewModel.cs b/editor/Assets/AssetBrowserViewModel.cs index d00672df4..1b03a1802 100644 --- a/editor/Assets/AssetBrowserViewModel.cs +++ b/editor/Assets/AssetBrowserViewModel.cs @@ -338,12 +338,18 @@ private void OnThumbnailUpdated(string uid) { public new event PropertyChangedEventHandler? PropertyChanged; + public event Action? ItemRevealed; + public void RevealAsset(string uid) { foreach (var root in Folders) foreach (var file in GetAllFiles(root)) { if (file.Uid != uid) continue; SearchText = ""; SelectedFolder = FindParentFolder(root, file); + var card = CurrentItems.OfType().FirstOrDefault(f => f.Uid == uid); + if (card is null) return; + SelectItem(card, KeyModifiers.None); + ItemRevealed?.Invoke(card); return; } } diff --git a/editor/Assets/AssetDatabase.cs b/editor/Assets/AssetDatabase.cs index 2542bb775..0721829de 100644 --- a/editor/Assets/AssetDatabase.cs +++ b/editor/Assets/AssetDatabase.cs @@ -48,11 +48,17 @@ public static bool IsUpToDate(string sourceVirtualPath, string hash) { } public static void UpdateArtworkDatabase( - string sourceVirtualPath, string hash, IEnumerable outputUids) { + string sourceVirtualPath, string hash, IEnumerable artworkOutputs) { var db = LoadArtworkDatabase(); var outputs = new JsonArray(); - foreach (var uid in outputUids) outputs.Add(uid); + foreach (var output in artworkOutputs) + outputs.Add(new JsonObject { + ["uid"] = output.Uid, + ["name"] = output.Name, + ["hash"] = output.Hash, + ["uri"] = output.Uri + }); db[sourceVirtualPath] = new JsonObject { ["last_hash"] = hash, @@ -62,6 +68,27 @@ public static void UpdateArtworkDatabase( SaveArtworkDatabase(db); } + public static IReadOnlyList GetArtworkOutputs(string sourceVirtualPath) { + if (LoadArtworkDatabase()[sourceVirtualPath] is not JsonObject entry) return []; + return ReadOutputs(entry); + } + + private static List ReadOutputs(JsonObject entry) { + var result = new List(); + if (entry["outputs"] is not JsonArray outputs) return result; + + foreach (var node in outputs) + if (node is JsonObject obj && obj["uid"]?.GetValue() is { } uid) + result.Add(new ArtworkOutput(uid, obj["name"]?.GetValue() ?? "", obj["hash"]?.GetValue(), + obj["uri"]?.GetValue() ?? "")); + else if (node?.GetValue() is { } legacyUid) { + var uri = TryResolve(legacyUid, out var path, out _) ? path : ""; + result.Add(new ArtworkOutput(legacyUid, Path.GetFileName(uri), null, uri)); + } + + return result; + } + // full re-scan of all .meta files; writes one .json per content database + core.json public static void RebuildAssetDatabase(bool notifyListeners = true) { var now = DateTime.UtcNow.ToString("o"); @@ -357,9 +384,8 @@ public static async Task Reimport( return; } - var uids = (entry["outputs"] as JsonArray)? - .Select(n => n!.GetValue()) - .ToList() ?? []; + var previous = ReadOutputs(entry); + var uids = previous.Select(o => o.Uid).ToList(); // find where the existing outputs live + the settings they were imported with var (destDir, textureSection, psdSection, gltfSection, voxSection) = LocateOutputs(uids); @@ -379,15 +405,10 @@ public static async Task Reimport( _ => new TextureImporter(textureSettings) }; - var ctx = new ImportContext { - DestDir = destDir ?? ProjectContext.AssetsPath, - SourceVirtualPath = sourceVirtualPath, - ReuseUids = uids - }; - log($"Reimporting {sourceVirtualPath}..."); - var newUids = await importer.Import(realSource, ctx, log, progress); - UpdateArtworkDatabase(sourceVirtualPath, ComputeHash(realSource), newUids); + var outputs = await ArtworkImport.RunAsync(importer, realSource, sourceVirtualPath, + destDir ?? ProjectContext.AssetsPath, previous, log, progress); + UpdateArtworkDatabase(sourceVirtualPath, ComputeHash(realSource), outputs); log("Reimport done."); } @@ -615,7 +636,8 @@ public static void RemoveArtworkOutputs(IEnumerable uids) { if (db[key] is not JsonObject entry) continue; if (entry["outputs"] is not JsonArray outputs) continue; for (var i = outputs.Count - 1; i >= 0; i--) { - if (outputs[i]?.GetValue() is not { } output || !targets.Contains(output)) continue; + var output = outputs[i] is JsonObject record ? record["uid"]?.GetValue() : outputs[i]?.GetValue(); + if (output is null || !targets.Contains(output)) continue; outputs.RemoveAt(i); changed = true; } diff --git a/editor/Assets/Importers/ArtworkImport.cs b/editor/Assets/Importers/ArtworkImport.cs new file mode 100644 index 000000000..33ff12cb8 --- /dev/null +++ b/editor/Assets/Importers/ArtworkImport.cs @@ -0,0 +1,143 @@ +// +// ArtworkImport.cs by Xein +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace editor.Assets.Importers; + +public sealed record ArtworkOutput(string Uid, string Name, string? Hash, string Uri); + +public static class ArtworkImport { + public static async Task> RunAsync( + IAssetImporter importer, string realSource, string sourceVirtual, string destDir, + IReadOnlyList previous, Action log, Action? progress = null) { + var writes = new List<(string Uid, string Path)>(); + using var scope = MetaFile.Capture(writes); + + if (previous.Count == 0) { + var ctx = new ImportContext { DestDir = destDir, SourceVirtualPath = sourceVirtual }; + await importer.Import(realSource, ctx, log, progress); + return Describe(Produced(writes, null)); + } + + var stageRoot = Path.Combine(ProjectContext.CachePath, "import_staging", Guid.NewGuid().ToString("N")); + // Named like the real folder + var stageDest = Path.Combine(stageRoot, new DirectoryInfo(destDir).Name); + Directory.CreateDirectory(stageDest); + try { + var ctx = new ImportContext { DestDir = stageDest, SourceVirtualPath = sourceVirtual }; + await importer.Import(realSource, ctx, log, progress); + return Reconcile(Produced(writes, stageRoot), previous, stageDest, destDir, log); + } finally { + try { + Directory.Delete(stageRoot, true); + } catch { + /* the cache is disposable */ + } + } + } + + private static List<(string Uid, string Path)> Produced(List<(string Uid, string Path)> writes, string? root) { + var byUid = new Dictionary(); + foreach (var (uid, path) in writes) byUid[uid] = path; + return byUid + .Where(kv => File.Exists(kv.Value) && (root is null || kv.Value.StartsWith(root, StringComparison.OrdinalIgnoreCase))) + .Select(kv => (kv.Key, kv.Value)) + .ToList(); + } + + private static IReadOnlyList Describe(IEnumerable<(string Uid, string Path)> produced) { + return produced.Select(p => Describe(p.Uid, p.Path)).ToList(); + } + + private static ArtworkOutput Describe(string uid, string realPath) { + return new ArtworkOutput(uid, Path.GetFileName(realPath), AssetDatabase.ComputeHash(realPath), + ProjectContext.ToVirtual(realPath) ?? realPath); + } + + private static IReadOnlyList Reconcile( + List<(string Uid, string Path)> produced, IReadOnlyList previous, + string stageDest, string destDir, Action log) { + var fresh = produced.Select(p => (p.Uid, p.Path, Name: Path.GetFileName(p.Path), + Hash: AssetDatabase.ComputeHash(p.Path))).ToList(); + + var free = previous.ToList(); + var matches = new Dictionary(); + + void Match(Func same) { + foreach (var f in fresh) { + if (matches.ContainsKey(f.Uid)) continue; + var old = free.FirstOrDefault(o => same(o, f)); + if (old is null) continue; + matches[f.Uid] = old; + free.Remove(old); + } + } + + Match((o, f) => o.Hash == f.Hash); + Match((o, f) => string.Equals(o.Name, f.Name, StringComparison.OrdinalIgnoreCase)); + + foreach (var f in fresh) { + if (matches.ContainsKey(f.Uid)) continue; + var old = free.FirstOrDefault(o => o.Hash is null); + if (old is null) break; + matches[f.Uid] = old; + free.Remove(old); + } + + var uidMap = matches.ToDictionary(m => m.Key, m => m.Value.Uid); + if (uidMap.Count > 0) + foreach (var file in Directory.EnumerateFiles(stageDest, "*", SearchOption.AllDirectories)) + ReplaceUids(file, uidMap); + + var outputs = new List(); + var moved = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var f in fresh) { + string target; + if (matches.TryGetValue(f.Uid, out var old)) { + var uri = AssetDatabase.TryResolve(old.Uid, out var current, out _) ? current : old.Uri; + target = ProjectContext.Resolve(uri); + } else { + target = Path.Combine(destDir, Path.GetRelativePath(stageDest, f.Path)); + } + + var tags = MetaFile.ReadHeader(target)?.Tags; + Place(f.Path, target); + Place(f.Path + ".meta", target + ".meta"); + if (tags is { Count: > 0 }) MetaFile.SetTags(target, tags); + + moved.Add(f.Path); + moved.Add(f.Path + ".meta"); + var uid = matches.TryGetValue(f.Uid, out var kept) ? kept.Uid : f.Uid; + outputs.Add(Describe(uid, target)); + log($"{(kept is null ? "New" : "Replaced")} {ProjectContext.ToVirtual(target) ?? target}"); + } + + foreach (var file in Directory.EnumerateFiles(stageDest, "*", SearchOption.AllDirectories)) + if (!moved.Contains(file)) + Place(file, Path.Combine(destDir, Path.GetRelativePath(stageDest, file))); + + return outputs; + } + + private static void Place(string from, string to) { + if (!File.Exists(from)) return; + Directory.CreateDirectory(Path.GetDirectoryName(to)!); + File.Move(from, to, true); + } + + private static void ReplaceUids(string file, IReadOnlyDictionary uidMap) { + var bytes = File.ReadAllBytes(file); + if (Array.IndexOf(bytes, (byte)0, 0, Math.Min(bytes.Length, 8192)) >= 0) return; + + var text = System.Text.Encoding.UTF8.GetString(bytes); + var changed = text; + foreach (var (from, to) in uidMap) changed = changed.Replace(from, to, StringComparison.Ordinal); + if (changed != text) File.WriteAllText(file, changed); + } +} diff --git a/editor/Assets/Importers/CompactImportWindowViewModel.cs b/editor/Assets/Importers/CompactImportWindowViewModel.cs index 9507abfd8..e04550f98 100644 --- a/editor/Assets/Importers/CompactImportWindowViewModel.cs +++ b/editor/Assets/Importers/CompactImportWindowViewModel.cs @@ -127,9 +127,9 @@ private async Task ImportFile(string filePath, string? artworkDir, Action + diff --git a/editor/Assets/Importers/ImportWindowViewModel.cs b/editor/Assets/Importers/ImportWindowViewModel.cs index 7c54ad2eb..bde1a3536 100644 --- a/editor/Assets/Importers/ImportWindowViewModel.cs +++ b/editor/Assets/Importers/ImportWindowViewModel.cs @@ -289,7 +289,7 @@ public partial class ImportWindowViewModel : ViewModelBase { [ObservableProperty] private string m_externalSourcePath = string.Empty; [ObservableProperty] private bool m_isExternal; - + [ObservableProperty] private bool m_keepFolderStructure; [ObservableProperty] private string m_locationPath = "assets://"; [ObservableProperty] private bool m_moveToArtwork = true; [ObservableProperty] private string m_searchText = string.Empty; @@ -486,10 +486,16 @@ private async Task ImportFromTree() { var tasks = new List(); - foreach (var file in freshFiles) + var baseDir = ProjectContext.Resolve(LocationPath); + var commonDir = KeepFolderStructure ? CommonDirectory(freshFiles) : null; + foreach (var file in freshFiles) { + var fileDir = commonDir is null + ? baseDir + : Path.GetFullPath(Path.Combine(baseDir, Path.GetRelativePath(commonDir, Path.GetDirectoryName(file)!))); tasks.Add(LoaderTask.DoWithProgress( Path.GetFileName(file), - (log, progress) => ImportSingleFile(file, log, progress))); + (log, progress) => ImportSingleFile(file, fileDir, log, progress))); + } foreach (var file in reimportFiles) { var sourceVirtual = ProjectContext.ToVirtual(file); @@ -523,7 +529,7 @@ private async Task ImportExternal() { var task = LoaderTask.DoWithProgress(Path.GetFileName(realSourcePath), async (log, progress) => { if (MoveToArtwork) { - await ImportSingleFile(realSourcePath, log, progress); + await ImportSingleFile(realSourcePath, ProjectContext.Resolve(LocationPath), log, progress); } else { // Import in-place without artwork-DB tracking log("Note: source is outside the project — reimport won't be available."); @@ -552,7 +558,8 @@ private void Cancel() { } - private async Task ImportSingleFile(string realSourcePath, Action log, Action? progress = null) { + private async Task ImportSingleFile( + string realSourcePath, string destDir, Action log, Action? progress = null) { try { var sourceVirtual = ProjectContext.ToVirtual(realSourcePath) ?? throw new InvalidOperationException( @@ -564,12 +571,10 @@ private async Task ImportSingleFile(string realSourcePath, Action log, A return; } - var destDir = ProjectContext.Resolve(LocationPath); - var ext = Path.GetExtension(realSourcePath).ToLowerInvariant(); var importer = m_importers.First(i => i.CanHandle(realSourcePath)); - var ctx = new ImportContext { DestDir = destDir, SourceVirtualPath = sourceVirtual }; - var uids = await importer.Import(realSourcePath, ctx, log, progress); - AssetDatabase.UpdateArtworkDatabase(sourceVirtual, hash, uids); + var outputs = await ArtworkImport.RunAsync(importer, realSourcePath, sourceVirtual, destDir, + AssetDatabase.GetArtworkOutputs(sourceVirtual), log, progress); + AssetDatabase.UpdateArtworkDatabase(sourceVirtual, hash, outputs); log("Done."); } catch (Exception ex) { log($"ERROR: {ex.Message}"); @@ -612,6 +617,24 @@ private static HashSet LoadImportedFiles(string dbPath) { return result; } + private static string? CommonDirectory(IReadOnlyList files) { + if (files.Count == 0) return null; + var common = Path.GetDirectoryName(files[0])!; + foreach (var file in files.Skip(1)) + while (!IsInside(common, Path.GetDirectoryName(file)!)) { + var parent = Path.GetDirectoryName(common); + if (parent is null) return null; + common = parent; + } + + return common; + } + + private static bool IsInside(string folder, string path) { + var relative = Path.GetRelativePath(folder, path); + return relative == "." || (!relative.StartsWith("..", StringComparison.Ordinal) && !Path.IsPathRooted(relative)); + } + private static (List fresh, List reimport) GetSelectedFiles(IEnumerable nodes) { var fresh = new List(); var reimport = new List(); diff --git a/editor/Assets/MetaFile.cs b/editor/Assets/MetaFile.cs index 3d57cade4..bb45ed18c 100644 --- a/editor/Assets/MetaFile.cs +++ b/editor/Assets/MetaFile.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using Tomlyn; using Tomlyn.Model; using Tomlyn.Serialization; @@ -62,7 +63,22 @@ public record AudioStringMetaSection : IMetaSection { } public static class MetaFile { + private static readonly AsyncLocal?> s_capture = new(); + + public static IDisposable Capture(List<(string Uid, string Path)> writes) { + s_capture.Value = writes; + return new CaptureScope(); + } + + private sealed class CaptureScope : IDisposable { + public void Dispose() { + s_capture.Value = null; + } + } + public static void Write(string outputAssetRealPath, MetaHeader header, params IMetaSection[] sections) { + s_capture.Value?.Add((header.Uid, outputAssetRealPath)); + var tags = header.Tags ?? ReadHeader(outputAssetRealPath)?.Tags; var dto = new MetaFileDto { Uid = header.Uid, diff --git a/editor/Components/Elements/AssetBox.cs b/editor/Components/Elements/AssetBox.cs index f034decc9..27e1131a8 100644 --- a/editor/Components/Elements/AssetBox.cs +++ b/editor/Components/Elements/AssetBox.cs @@ -59,6 +59,8 @@ public sealed class AssetBox : TemplatedControl { private readonly MenuItem m_selectItem; + private Border? m_pickerButton; + private string? m_displayName; private bool m_hasAsset; @@ -175,10 +177,17 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); if (!IsEnabled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; - OpenPicker(); + var onButton = m_pickerButton is { } button && new Rect(button.Bounds.Size).Contains(e.GetPosition(button)); + if (HasAsset && !onButton) ShowInBrowser(); + else OpenPicker(); e.Handled = true; } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { + base.OnApplyTemplate(e); + m_pickerButton = e.NameScope.Find("PART_Button"); + } + protected override void OnPointerEntered(PointerEventArgs e) { base.OnPointerEntered(e); HookKeys(); @@ -278,7 +287,9 @@ private async void OpenPicker() { } private void ShowInBrowser() { - if (!string.IsNullOrEmpty(Value)) AssetBrowserViewModel.Current?.RevealAsset(Value!); + if (string.IsNullOrEmpty(Value)) return; + MainWindowViewModel.Current?.ShowAssetBrowser(); + AssetBrowserViewModel.Current?.RevealAsset(Value!); } private void Clear() { diff --git a/editor/Engine/ToastEngine.cs b/editor/Engine/ToastEngine.cs index 29efb46f1..0321729cc 100644 --- a/editor/Engine/ToastEngine.cs +++ b/editor/Engine/ToastEngine.cs @@ -625,10 +625,10 @@ public static void TestHaptic(string tomlText) { private static partial void toast_pop_application(); [LibraryImport(EngineLib, StringMarshalling = StringMarshalling.Utf8)] - private static partial void toast_bake_asset(string uid, string outPath); + private static partial int toast_bake_asset(string uid, string outPath); - public static void BakeAsset(string uid, string outPath) { - if (IsEngineReady) toast_bake_asset(uid, outPath); + public static bool BakeAsset(string uid, string outPath) { + return IsEngineReady && toast_bake_asset(uid, outPath) != 0; } [LibraryImport(EngineLib, StringMarshalling = StringMarshalling.Utf8)] diff --git a/editor/Git/GitLockGuard.cs b/editor/Git/GitLockGuard.cs index 37d64c69e..48076ff69 100644 --- a/editor/Git/GitLockGuard.cs +++ b/editor/Git/GitLockGuard.cs @@ -8,15 +8,33 @@ namespace editor.Git; +public enum OpenLockKind { + None, + Mine, + Other +} + +public readonly record struct OpenLockState(OpenLockKind Kind, string? Owner); + public static class GitLockGuard { private static GitService? Usable => GitService.Current is { IsAvailable: true, HasLfs: true } git ? git : null; public static async Task LockOnOpenAsync(string realPath) { - if (Usable is not { } git) return; - if (git.GetLock(realPath) is not null || !git.IsLockable(realPath)) return; + await AcquireOnOpenAsync(realPath); + } + + public static async Task AcquireOnOpenAsync(string realPath) { + if (Usable is not { } git || !git.IsLockable(realPath)) return default; + + var held = git.GetLock(realPath); + if (held is null) { + var result = await git.LockAsync(realPath); + if (!result.Ok) Log.Warn($"Git: could not lock {Path.GetFileName(realPath)} on open ({result.Message})"); + held = git.GetLock(realPath); + } - var result = await git.LockAsync(realPath); - if (!result.Ok) Log.Warn($"Git: could not lock {Path.GetFileName(realPath)} on open ({result.Message})"); + if (held is null) return default; + return new OpenLockState(held.OwnedByMe ? OpenLockKind.Mine : OpenLockKind.Other, held.Owner); } public static async Task CheckSaveAsync(string realPath) { diff --git a/editor/Git/LockedFileModal.cs b/editor/Git/LockedFileModal.cs index 6a18ff316..f946854dd 100644 --- a/editor/Git/LockedFileModal.cs +++ b/editor/Git/LockedFileModal.cs @@ -1,8 +1,4 @@ using System.Threading.Tasks; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Layout; -using Avalonia.Media; using editor.Components.Modals; using Lucide.Avalonia; @@ -15,71 +11,39 @@ public enum LockedSaveChoice { SaveAs } -public sealed class LockedFileModal : Window { - private LockedFileModal(string fileName, string owner) { - Title = "File locked"; - Width = 480; - SizeToContent = SizeToContent.Height; - CanResize = false; - ShowInTaskbar = false; - WindowStartupLocation = WindowStartupLocation.CenterOwner; - Background = GitStatusStyle.Brush("Bg2"); - - var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 }; - header.Children.Add(new LucideIcon { - Kind = LucideIconKind.Lock, Size = 28, StrokeWidth = 2.5, - Foreground = GitStatusStyle.Orange, VerticalAlignment = VerticalAlignment.Center - }); - header.Children.Add(new TextBlock { - Text = $"This file is locked by {owner}", - FontSize = 16, - FontWeight = FontWeight.Bold, - VerticalAlignment = VerticalAlignment.Center, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 380 - }); - - var body = new TextBlock { - Text = $"{fileName} can't be saved while {owner} holds the lock. " + - "Save your work as a new file, throw your changes away, or cancel and keep editing.", - Foreground = GitStatusStyle.Brush("TextMuted"), - TextWrapping = TextWrapping.Wrap - }; - - var buttons = new StackPanel { - Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right - }; - buttons.Children.Add(MakeButton("Cancel", null, null, LockedSaveChoice.Cancel)); - buttons.Children.Add(MakeButton("Discard", LucideIconKind.Trash2, GitStatusStyle.Brush("Red"), - LockedSaveChoice.Discard)); - buttons.Children.Add(MakeButton("Save As...", LucideIconKind.Save, null, LockedSaveChoice.SaveAs)); - - var root = new StackPanel { Spacing = 16, Margin = new Thickness(20) }; - root.Children.Add(header); - root.Children.Add(body); - root.Children.Add(buttons); - Content = root; +public static class LockedFileModal { + public static Task ShowAsync(string fileName, string owner) { + return Show(new ModalConfig( + "File locked", + $"{fileName} is locked by {owner}.", + ModalButtons.OkNoCancel, + LucideIconKind.Lock, + OkLabel: "Save As", + NoLabel: "Discard", + OkIcon: LucideIconKind.Save, + NoIcon: LucideIconKind.Trash2), LockedSaveChoice.Discard, LockedSaveChoice.Cancel); } - public static Task ShowAsync(string fileName, string owner) { - var parent = ModalService.FindActiveWindow(); - if (parent is null) return Task.FromResult(LockedSaveChoice.Cancel); - return new LockedFileModal(fileName, owner).ShowDialog(parent); + public static Task ShowOpenAsync(string fileName, string owner) { + return Show(new ModalConfig( + "File locked", + $"{fileName} is locked by {owner}.", + ModalButtons.OkNoCancel, + LucideIconKind.Lock, + OkLabel: "Save As", + NoLabel: "Open anyway", + OkIcon: LucideIconKind.Save, + NoIcon: LucideIconKind.FolderOpen), LockedSaveChoice.Proceed, LockedSaveChoice.Proceed); } - private Button MakeButton(string text, LucideIconKind? icon, IBrush? iconBrush, LockedSaveChoice choice) { - var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; - if (icon is { } kind) - content.Children.Add(new LucideIcon { Kind = kind, Size = 14, StrokeWidth = 2.5, Foreground = iconBrush }); - content.Children.Add(new TextBlock { Text = text }); + private static async Task Show(ModalConfig config, LockedSaveChoice no, LockedSaveChoice noWindow) { + var parent = ModalService.FindActiveWindow(); + if (parent is null) return noWindow; - var button = new Button { - Content = content, - Background = GitStatusStyle.Brush("Bg4"), - CornerRadius = new CornerRadius(6), - Padding = new Thickness(14, 6) + return await new MessageModal(config).ShowDialog(parent) switch { + true => LockedSaveChoice.SaveAs, + false => no, + _ => LockedSaveChoice.Cancel }; - button.Click += (_, _) => Close(choice); - return button; } } diff --git a/editor/VoxelEditor/VoxelEditorView.axaml b/editor/VoxelEditor/VoxelEditorView.axaml index fb7611185..b7adb892d 100644 --- a/editor/VoxelEditor/VoxelEditorView.axaml +++ b/editor/VoxelEditor/VoxelEditorView.axaml @@ -15,7 +15,7 @@ RowSpacing="8"> @@ -120,11 +120,17 @@ + + + + + + + + + + + + + Grid.Column="4" /> - @@ -189,7 +215,7 @@ - @@ -201,7 +227,7 @@ Value="{Binding DefaultScript, Mode=TwoWay}" /> - @@ -213,7 +239,7 @@ Options="{x:Static ve:VoxelEditorViewModel.FillModes}" /> - -