diff --git a/docs/examples/async_compute/debris.slang b/docs/examples/async_compute/debris.slang new file mode 100644 index 000000000..ad2ce26c5 --- /dev/null +++ b/docs/examples/async_compute/debris.slang @@ -0,0 +1,48 @@ +// Mirrors DebrisSimulation::Params +struct Params +{ + float dt; + uint count; +}; + +[[vk::push_constant]] +Params gParams; + +// xyz position w radius +[[vk::binding(0, 0)]] +StructuredBuffer gPrevious; + +[[vk::binding(1, 0)]] +RWStructuredBuffer gCurrent; + +// xyz velocity w unused +[[vk::binding(2, 0)]] +RWStructuredBuffer gVelocities; + +static const float3 kGravity = float3(0.0, -9.81, 0.0); + +[shader("compute")] +[numthreads(64, 1, 1)] +void integrateMain(uint3 id : SV_DispatchThreadID) +{ + const uint i = id.x; + if (i >= gParams.count) + { + return; + } + + float4 particle = gPrevious[i]; + float3 velocity = gVelocities[i].xyz + kGravity * gParams.dt; + particle.xyz += velocity * gParams.dt; + + // Ground plane bounce so the example stays on screen + if (particle.y < particle.w) + { + particle.y = particle.w; + velocity.y = -velocity.y * 0.4; + velocity.xz *= 0.8; + } + + gCurrent[i] = particle; + gVelocities[i] = float4(velocity, 0.0); +} diff --git a/docs/examples/async_compute/debris_simulation.cpp b/docs/examples/async_compute/debris_simulation.cpp new file mode 100644 index 000000000..14551ebe9 --- /dev/null +++ b/docs/examples/async_compute/debris_simulation.cpp @@ -0,0 +1,42 @@ +/// @file debris_simulation.cpp +/// @author dario +/// @date 10/10/2026 + +#include "debris_simulation.hpp" + +#include + +namespace example { + +namespace { +constexpr vk::DeviceSize k_particle_bytes = sizeof(glm::vec4) * DebrisSimulation::k_max_particles; +constexpr uint32_t k_workgroup_size = 64; +} + +DebrisSimulation::DebrisSimulation() + : AsyncComputeTask({.shader_uri = "core://shaders/debris.slang", .entries = {"integrateMain"}, .debug_name = "Debris"}) { + // The renderer draws positions and gameplay reads them back. Velocities never leave the task + m_positions = addRingBuffer(k_particle_bytes, true); + m_velocities = addPersistentBuffer(k_particle_bytes); + + // Mirrors the bindings in debris.slang + bind(0, m_positions, Copy::previous); + bind(1, m_positions, Copy::current); + bind(2, m_velocities); +} + +void DebrisSimulation::spawn(std::span positions, std::span velocities) { + m_count = static_cast(std::min({positions.size(), velocities.size(), size_t {k_max_particles}})); + upload(m_positions, positions.first(m_count)); + upload(m_velocities, velocities.first(m_count)); +} + +void DebrisSimulation::step(float dt) { + if (m_count == 0) { + return; + } + const Params params {.dt = dt, .count = m_count}; + AsyncComputeTask::step([&](StepContext& ctx) { ctx.dispatch((m_count + k_workgroup_size - 1) / k_workgroup_size, params); }); +} + +} diff --git a/docs/examples/async_compute/debris_simulation.hpp b/docs/examples/async_compute/debris_simulation.hpp new file mode 100644 index 000000000..be0b1b036 --- /dev/null +++ b/docs/examples/async_compute/debris_simulation.hpp @@ -0,0 +1,49 @@ +/// @file debris_simulation.hpp +/// @author dario +/// @date 10/10/2026 + +#pragma once + +#include +#include +#include + +namespace example { + +/// Debris particles integrated on the async compute queue +class DebrisSimulation : public renderer::AsyncComputeTask { +public: + static constexpr uint32_t k_max_particles = 65536; + + DebrisSimulation(); + + /// Physics thread before the first step + void spawn(std::span positions, std::span velocities); + + /// Physics thread. Returns right away + void step(float dt); + + /// Physics thread. Newest finished positions and false until one exists + auto readPositions(std::span out) -> bool { + return read(m_positions, out); + } + + /// For the render pass. xyz position w radius + [[nodiscard]] + auto positionsId() const -> BufferId { + return m_positions; + } + +private: + /// Mirrors debris.slang Params + struct Params { + float dt; + uint32_t count; + }; + + BufferId m_positions = 0; + BufferId m_velocities = 0; + uint32_t m_count = 0; +}; + +} diff --git a/editor/App.axaml b/editor/App.axaml index 1a82bd324..3aed34e6d 100644 --- a/editor/App.axaml +++ b/editor/App.axaml @@ -20,6 +20,7 @@ + diff --git a/editor/Assets/AssetBrowserView.axaml b/editor/Assets/AssetBrowserView.axaml index e8cdea9d6..80de4e38b 100644 --- a/editor/Assets/AssetBrowserView.axaml +++ b/editor/Assets/AssetBrowserView.axaml @@ -38,6 +38,10 @@ + - - + + - - + - @@ -130,12 +149,60 @@ - + + + + @@ -167,23 +234,40 @@ Classes="section" /> - + + + + + + - + - - + - + Text="Max emissive" /> + + - - - + @@ -274,11 +361,10 @@ ColumnSpacing="8"> - + - - - - - - - + + - - - - - - - - - - - - - - - - + + + + + + + + + + - - + + diff --git a/editor/Editors/PaletteViewModel.cs b/editor/Editors/PaletteViewModel.cs index edb9bae1d..baad97f45 100644 --- a/editor/Editors/PaletteViewModel.cs +++ b/editor/Editors/PaletteViewModel.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Media; @@ -14,6 +15,7 @@ using CommunityToolkit.Mvvm.Input; using Dock.Model.Mvvm.Controls; using editor.Assets; +using editor.Git; using editor.Assets.Types; using editor.Components.Modals; using editor.Engine; @@ -29,12 +31,25 @@ public partial class PaletteSwatchVM : ObservableObject { /// False for an index the palette does not author so it draws as a hole not black [ObservableProperty] private bool m_isUsed; + [ObservableProperty] private bool m_showChecker; + [NotifyPropertyChangedFor(nameof(HasSlotText))] + [ObservableProperty] private string? m_slotText; + [ObservableProperty] private bool m_lightBackground; + public bool HasSlotText => SlotText is not null; + [ObservableProperty] private string m_title = ""; + [ObservableProperty] private IBrush? m_albedoDot; + [ObservableProperty] private string m_albedoText = ""; + [ObservableProperty] private IBrush? m_rmsDot; + [ObservableProperty] private string m_rmsText = ""; + [ObservableProperty] private string m_emissiveText = ""; + [ObservableProperty] private string m_alphaText = ""; public PaletteSwatchVM(Action select, int index, IBrush brush, bool used) { m_select = select; Index = index; m_brush = brush; m_isUsed = used; + m_title = $"{index}: empty"; } public int Index { get; } @@ -46,7 +61,7 @@ private void Select() { } public partial class PaletteSlotVM : ObservableObject { - private readonly Action m_touch; + private readonly Action m_touch; private readonly VoxelMaterialSlot m_slot; private bool m_loading; @@ -54,7 +69,7 @@ public partial class PaletteSlotVM : ObservableObject { [ObservableProperty] private string m_name; [ObservableProperty] private string? m_physicsUid; - public PaletteSlotVM(int index, VoxelMaterialSlot slot, Action touch) { + public PaletteSlotVM(int index, VoxelMaterialSlot slot, Action touch) { Index = index; m_slot = slot; m_touch = touch; @@ -68,32 +83,36 @@ public PaletteSlotVM(int index, VoxelMaterialSlot slot, Action touch) { public int Index { get; } - public string Label => string.IsNullOrWhiteSpace(Name) ? $"{Index}:" : $"{Index}: {Name}"; + public bool IsDefault => Index == 0; + + public string Label => IsDefault ? "0: Default" + : string.IsNullOrWhiteSpace(Name) ? $"{Index}:" + : $"{Index}: {Name}"; partial void OnNameChanged(string value) { OnPropertyChanged(nameof(Label)); if (m_loading) return; m_slot.Name = value ?? ""; - m_touch(); + m_touch(Index); } partial void OnPhysicsUidChanged(string? value) { if (m_loading) return; m_slot.PhysicsUid = value ?? ""; - m_touch(); + m_touch(Index); } partial void OnDestructionUidChanged(string? value) { if (m_loading) return; m_slot.DestructionUid = value ?? ""; - m_touch(); + m_touch(Index); } } /// Assigns entry materials which a reimport of the source .vox cannot re-derive public partial class PaletteViewModel : Tool, IToastZoneEditor, IAutosavable { private const string BaseTitle = "Palette Editor"; - private static readonly IBrush s_emptyBrush = new SolidColorBrush(Color.FromArgb(40, 255, 255, 255)); + private static readonly IBrush s_emptyBrush = Brushes.Transparent; [ObservableProperty] private float m_albedoB; [ObservableProperty] private float m_albedoG; @@ -116,7 +135,7 @@ public partial class PaletteViewModel : Tool, IToastZoneEditor, IAutosavable { [ObservableProperty] private float m_roughness; [ObservableProperty] private int m_selectedIndex = 1; [ObservableProperty] private float m_alpha = 1f; - [ObservableProperty] private int m_materialSlot; + [ObservableProperty] private PaletteSlotVM? m_selectedSlot; [ObservableProperty] private int m_transformsTo; [ObservableProperty] private bool m_transparent; @@ -129,8 +148,11 @@ public PaletteViewModel() { public ObservableCollection Swatches { get; } = []; - /// The palette's eight material slots, rebuilt whenever a file is opened - public ObservableCollection Slots { get; } = []; + private readonly List m_slots = []; + private readonly List m_slotOptions = []; + [ObservableProperty] private IReadOnlyList m_slotLabels = []; + [ObservableProperty] private string? m_selectedSlotLabel; + public PaletteSlotVM? EditableSlot => SelectedSlot is { IsDefault: false } slot ? slot : null; public bool HasContent => m_palette is not null; @@ -190,8 +212,12 @@ public async Task ConfirmCloseCurrentAsync() { OkLabel: "Save" )).ShowDialog(owner); if (result is null) return false; - if (result is true) await Save(); - else AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); + if (result is true) { + await Save(); + return !IsDirty; + } + + AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); return true; } @@ -242,25 +268,93 @@ private void CloseCurrent() { foreach (var swatch in Swatches) { swatch.Brush = s_emptyBrush; swatch.IsUsed = false; + swatch.ShowChecker = false; + swatch.SlotText = null; + swatch.Title = $"{swatch.Index}: empty"; } + ClearSlots(); + UpdateTitle(); OnPropertyChanged(nameof(HasContent)); } private void RebuildSlots() { - Slots.Clear(); + ClearSlots(); if (m_palette is not { } palette) return; for (var i = 0; i < VoxelPaletteFile.SlotCount; i++) { - Slots.Add(new PaletteSlotVM(i, palette.Slots[i], MarkSlotDirty)); + m_slots.Add(new PaletteSlotVM(i, palette.Slots[i], OnSlotEdited)); } } + private void ClearSlots() { + m_slotOptions.Clear(); + m_slots.Clear(); + SlotLabels = []; + SelectedSlot = null; + } + + private static int SlotOf(VoxelPaletteEntry entry) { + return VoxelPaletteTip.SlotOf(entry); + } + + private void RefreshSlotOptions() { + if (m_palette is not { } palette || m_slots.Count == 0) { + m_slotOptions.Clear(); + SlotLabels = []; + return; + } + + var wanted = new bool[VoxelPaletteFile.SlotCount]; + wanted[0] = true; + for (var i = 1; i < VoxelPaletteFile.SlotCount; i++) + if (!palette.Slots[i].IsEmpty) + wanted[i] = true; + + + for (var i = 1; i < VoxelPaletteFile.Size; i++) + if (palette.Entries[i].InUse) + wanted[SlotOf(palette.Entries[i])] = true; + + if (Selected is { } selected) wanted[SlotOf(selected)] = true; + + var hasEmpty = false; + for (var i = 1; i < VoxelPaletteFile.SlotCount; i++) + if (wanted[i] && palette.Slots[i].IsEmpty) { + hasEmpty = true; + break; + } + + if (!hasEmpty) + for (var i = 1; i < VoxelPaletteFile.SlotCount; i++) { + if (wanted[i]) continue; + wanted[i] = true; + break; + } + + m_slotOptions.Clear(); + for (var i = 0; i < wanted.Length; i++) + if (wanted[i]) + m_slotOptions.Add(m_slots[i]); + + SlotLabels = m_slotOptions.Select(slot => slot.Label).ToList(); + + // rename changes the slot label + if (SelectedSlot is { } current) SelectedSlotLabel = current.Label; + } + /// A slot edit dirties the palette but authors no entry - slots exist without any voxel using them - private void MarkSlotDirty() { + private void OnSlotEdited(int slot) { if (m_loading || m_palette is null) return; IsDirty = true; + + // tooltip + for (var i = 1; i < VoxelPaletteFile.Size; i++) + if (m_palette.Entries[i].InUse && SlotOf(m_palette.Entries[i]) == slot) + RefreshSwatch(i); + + RefreshSlotOptions(); } private void RefreshSwatch(int index) { @@ -269,9 +363,43 @@ private void RefreshSwatch(int index) { var entry = palette.Entries[index]; var swatch = Swatches[index - 1]; swatch.IsUsed = entry.InUse; + swatch.ShowChecker = entry.InUse && entry.Transparent; swatch.Brush = entry.InUse - ? new SolidColorBrush(Color.FromRgb(entry.R, entry.G, entry.B)) + ? new SolidColorBrush(Color.FromRgb(entry.R, entry.G, entry.B), entry.Transparent ? Math.Clamp(entry.Alpha, 0f, 1f) : 1.0) : s_emptyBrush; + + RefreshSlotBadge(swatch, entry); + + if (!entry.InUse) { + swatch.Title = $"{index}: empty"; + return; + } + + var tip = VoxelPaletteTip.Describe(index, palette); + swatch.Title = tip.Title; + swatch.AlbedoDot = new SolidColorBrush(tip.Albedo); + swatch.AlbedoText = tip.AlbedoText; + swatch.RmsDot = new SolidColorBrush(tip.Rms); + swatch.RmsText = tip.RmsText; + swatch.EmissiveText = tip.EmissiveText; + swatch.AlphaText = tip.AlphaText; + } + + private void RefreshSlotBadge(PaletteSwatchVM swatch, VoxelPaletteEntry entry) { + var slot = SelectedSlot?.Index ?? 0; + if (slot == 0 || !entry.InUse || SlotOf(entry) != slot) { + swatch.SlotText = null; + return; + } + + const float backdrop = 40f; + var opacity = entry.Transparent ? Math.Clamp(entry.Alpha, 0f, 1f) : 1f; + var red = entry.R * opacity + backdrop * (1f - opacity); + var green = entry.G * opacity + backdrop * (1f - opacity); + var blue = entry.B * opacity + backdrop * (1f - opacity); + + swatch.LightBackground = (0.299f * red + 0.587f * green + 0.114f * blue) / 255f > 0.5f; + swatch.SlotText = slot.ToString(); } private void SelectIndex(int index) { @@ -307,7 +435,8 @@ private void LoadSelectedMaterial() { var wasLoading = m_loading; m_loading = true; - MaterialSlot = entry.HasMaterial ? Math.Clamp(entry.Material, 0, VoxelPaletteFile.SlotCount - 1) : 0; + RefreshSlotOptions(); + SelectedSlot = m_slots[SlotOf(entry)]; m_loading = wasLoading; } @@ -332,7 +461,7 @@ private void TouchEntry() { private void SeedNewEntryDefaults(VoxelPaletteEntry entry) { if (entry.Roughness == 0f && Roughness == 0f) entry.Roughness = 0.5f; if (entry.Metallic == 0f && Metallic == 0f) entry.Metallic = 0.1f; - if (entry.Reflectivity == 0f && Reflectivity == 0f) entry.Reflectivity = 0.5f; + if (entry.Reflectivity == 0f && Reflectivity == 0f) entry.Reflectivity = 0.04f; var wasLoading = m_loading; m_loading = true; @@ -345,11 +474,12 @@ private void SeedNewEntryDefaults(VoxelPaletteEntry entry) { [RelayCommand] private async Task Save() { if (m_palette is not { } palette || string.IsNullOrEmpty(CurrentPath)) return; - var realPath = ProjectContext.Resolve(CurrentPath); - await Task.Run(() => palette.Save(realPath)); - MetaFile.Touch(CurrentPath); - AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); - IsDirty = false; + if (await GitLockGuard.SaveAsync(CurrentUid, CurrentPath, path => Task.Run(() => palette.Save(path)), ReopenFile)) + IsDirty = false; + } + + private void ReopenFile(string uid, string virtualPath) { + OpenFile(uid, virtualPath, GitLockGuard.DefinitionOf(virtualPath)!); } /// Unauthors the entry so the file drops it @@ -357,14 +487,7 @@ private async Task Save() { private void ClearEntry() { if (Selected is not { } entry) return; - entry.InUse = false; - entry.HasMaterial = false; - entry.Material = 0; - entry.R = entry.G = entry.B = 0; - entry.Roughness = entry.Metallic = entry.Reflectivity = entry.Emissive = 0f; - entry.Alpha = 1f; - entry.TransformsTo = 0; - entry.Transparent = false; + entry.Clear(); RefreshSwatch(SelectedIndex); LoadSelectedEntry(); @@ -439,13 +562,26 @@ partial void OnTransparentChanged(bool value) { TouchEntry(); } - partial void OnMaterialSlotChanged(int value) { - if (Ignore || Selected is not { } entry) return; + partial void OnSelectedSlotLabelChanged(string? value) { + if (value is null || SelectedSlot?.Label == value) return; + if (m_slotOptions.Find(slot => slot.Label == value) is { } slot) SelectedSlot = slot; + } + + partial void OnSelectedSlotChanged(PaletteSlotVM? value) { + OnPropertyChanged(nameof(EditableSlot)); + SelectedSlotLabel = value?.Label; + + if (m_palette is { } palette) + for (var i = 1; i < VoxelPaletteFile.Size; i++) + RefreshSlotBadge(Swatches[i - 1], palette.Entries[i]); + + if (Ignore || value is null || Selected is not { } entry) return; // Picking a slot authors the material, so the entry stops being one the engine warns about entry.HasMaterial = true; - entry.Material = Math.Clamp(value, 0, VoxelPaletteFile.SlotCount - 1); + entry.Material = value.Index; TouchEntry(); + RefreshSlotOptions(); } partial void OnAlphaChanged(float value) { diff --git a/editor/Editors/SchemaViewModel.cs b/editor/Editors/SchemaViewModel.cs index d854bcae0..bac304a87 100644 --- a/editor/Editors/SchemaViewModel.cs +++ b/editor/Editors/SchemaViewModel.cs @@ -12,6 +12,7 @@ using CommunityToolkit.Mvvm.Input; using Dock.Model.Mvvm.Controls; using editor.Assets; +using editor.Git; using editor.Assets.Types; using editor.Components.Modals; using editor.Workspace; @@ -316,12 +317,13 @@ private void AddStructType() { private async Task Save() { if (string.IsNullOrEmpty(CurrentPath)) return; - var realPath = ProjectContext.Resolve(CurrentPath); - await File.WriteAllTextAsync(realPath, SerializeDocument()); - MetaFile.Touch(CurrentPath); - AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); + var text = SerializeDocument(); + if (!await GitLockGuard.SaveAsync(CurrentUid, CurrentPath, path => File.WriteAllTextAsync(path, text), + (uid, virtualPath) => OpenFile(uid, virtualPath))) + return; + IsDirty = false; - SchemaSaved?.Invoke(realPath); + SchemaSaved?.Invoke(ProjectContext.Resolve(CurrentPath)); } private string SerializeDocument() { @@ -367,8 +369,12 @@ public async Task ConfirmCloseCurrentAsync() { OkLabel: "Save" )).ShowDialog(owner); if (result is null) return false; - if (result is true) await Save(); - else AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); + if (result is true) { + await Save(); + return !IsDirty; + } + + AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); return true; } diff --git a/editor/Editors/TableViewModel.cs b/editor/Editors/TableViewModel.cs index a29def51a..29d8cddc8 100644 --- a/editor/Editors/TableViewModel.cs +++ b/editor/Editors/TableViewModel.cs @@ -15,6 +15,7 @@ using CommunityToolkit.Mvvm.Input; using Dock.Model.Mvvm.Controls; using editor.Assets; +using editor.Git; using editor.Assets.Types; using editor.Components.Modals; using editor.Engine; @@ -96,8 +97,12 @@ public async Task ConfirmCloseCurrentAsync() { OkLabel: "Save" )).ShowDialog(owner); if (result is null) return false; - if (result is true) await Save(); - else AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); + if (result is true) { + await Save(); + return !IsDirty; + } + + AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); return true; } @@ -237,11 +242,13 @@ private void CloseCurrent() { private async Task Save() { if (!HasContent || string.IsNullOrEmpty(CurrentPath)) return; var csv = Serialize(); - var realPath = ProjectContext.Resolve(CurrentPath); - await Task.Run(() => File.WriteAllText(realPath, csv)); - MetaFile.Touch(CurrentPath); - AutosaveService.Delete(CurrentUid, AssetTypeRegistry.GetExtension(CurrentPath)); - IsDirty = false; + if (await GitLockGuard.SaveAsync(CurrentUid, CurrentPath, path => Task.Run(() => File.WriteAllText(path, csv)), + ReopenFile)) + IsDirty = false; + } + + private void ReopenFile(string uid, string virtualPath) { + OpenFile(uid, virtualPath, GitLockGuard.DefinitionOf(virtualPath)!); } [RelayCommand] diff --git a/editor/Engine/PhysicsLayers.cs b/editor/Engine/PhysicsLayers.cs new file mode 100644 index 000000000..8fcf37edc --- /dev/null +++ b/editor/Engine/PhysicsLayers.cs @@ -0,0 +1,26 @@ +// +// PhysicsLayers.cs by Xein +// 12 Oct 2026 +// + +namespace editor.Engine; + +public static class PhysicsLayers { + public const int Count = 32; + public const int MaxAbbreviationLength = 3; + + public static string NameKey(int index) => $"physics.layers.{index}.name"; + public static string AbbreviationKey(int index) => $"physics.layers.{index}.abbr"; + + public static string Name(int index) { + if (index == 0) return "Default"; + var name = ToastSettings.GetString(NameKey(index)); + return string.IsNullOrEmpty(name) ? $"Layer {index}" : name; + } + + public static string Abbreviation(int index) { + if (index == 0) return "0"; + var abbreviation = ToastSettings.GetString(AbbreviationKey(index)); + return string.IsNullOrEmpty(abbreviation) ? index.ToString() : abbreviation; + } +} diff --git a/editor/Engine/ToastEngine.cs b/editor/Engine/ToastEngine.cs index bd1aabdde..604c74eb5 100644 --- a/editor/Engine/ToastEngine.cs +++ b/editor/Engine/ToastEngine.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Runtime.ExceptionServices; @@ -145,6 +146,12 @@ public static void SetWindowState(bool focused, bool minimized) { toast_set_window_state(focused ? 1 : 0, minimized ? 1 : 0); } + /// The ImGui overlays drawn over the viewport, such as the performance window + public static void SetShowDebugWindows(bool visible) { + if (!IsEngineReady) return; + toast_set_show_debug_windows(visible ? 1 : 0); + } + public string ProjectPath { get; } public string CorePath { get; } @@ -359,6 +366,8 @@ private static string EngineDllPath() { private void TickLoop(CancellationToken token) { m_tickThreadId = Environment.CurrentManagedThreadId; m_ticking = true; + var stopwatch = Stopwatch.StartNew(); + var nextTick = stopwatch.Elapsed; try { while (!token.IsCancellationRequested && toast_should_close() != 1) { // Wait until the gate is open @@ -374,6 +383,8 @@ private void TickLoop(CancellationToken token) { } finally { m_tickIdle.Set(); } + + PaceTick(stopwatch, ref nextTick, token); } } catch (OperationCanceledException) { // Dispose() cancels the token while the loop is on m_tickGate @@ -383,6 +394,28 @@ private void TickLoop(CancellationToken token) { } } + // Max tick rate comes from the project settings, which only change on the tick thread + private static void PaceTick(Stopwatch stopwatch, ref TimeSpan nextTick, CancellationToken token) { + var maxRate = toast_get_max_tick_rate(); + if (maxRate == 0) { + nextTick = stopwatch.Elapsed; + return; + } + + var interval = TimeSpan.FromSeconds(1.0 / maxRate); + nextTick += interval; + var remaining = nextTick - stopwatch.Elapsed; + if (remaining <= TimeSpan.Zero) { + // fell behind, resync instead of bursting to catch up + nextTick = stopwatch.Elapsed; + return; + } + + if (remaining > TimeSpan.FromMilliseconds(2)) + token.WaitHandle.WaitOne(remaining - TimeSpan.FromMilliseconds(1)); + while (stopwatch.Elapsed < nextTick && !token.IsCancellationRequested) Thread.SpinWait(50); + } + private void CreateMainWindow() { var desktop = (IClassicDesktopStyleApplicationLifetime)Application.Current!.ApplicationLifetime!; desktop.MainWindow = new MainWindowView(this) { @@ -561,6 +594,12 @@ public static void ReloadManifest() { [LibraryImport(EngineLib)] private static partial void toast_reload_project_settings(); + [LibraryImport(EngineLib)] + private static partial uint toast_get_max_tick_rate(); + + [LibraryImport(EngineLib)] + private static partial void toast_set_show_debug_windows(int visible); + public static void ReloadProjectSettings() { if (IsEngineReady) OnTickThread(toast_reload_project_settings); } diff --git a/editor/Git/CommitView.axaml b/editor/Git/CommitView.axaml new file mode 100644 index 000000000..884a2cea6 --- /dev/null +++ b/editor/Git/CommitView.axaml @@ -0,0 +1,320 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/Git/CommitView.axaml.cs b/editor/Git/CommitView.axaml.cs new file mode 100644 index 000000000..624f44d86 --- /dev/null +++ b/editor/Git/CommitView.axaml.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace editor.Git; + +public partial class CommitView : UserControl { + public CommitView() { + InitializeComponent(); + } + + private CommitViewModel? Vm => DataContext as CommitViewModel; + + private List SelectedUnstaged => UnstagedList.SelectedItems?.OfType().ToList() ?? []; + private List SelectedStaged => StagedList.SelectedItems?.OfType().ToList() ?? []; + + private void OnUnstagedDoubleTapped(object? sender, TappedEventArgs e) { + StageSelected(); + } + + private void OnStagedDoubleTapped(object? sender, TappedEventArgs e) { + UnstageSelected(); + } + + private void OnUnstagedKeyDown(object? sender, KeyEventArgs e) { + switch (e.Key) { + case Key.Enter: + StageSelected(); + e.Handled = true; + break; + case Key.Delete: + DiscardSelected(); + e.Handled = true; + break; + } + } + + private void OnStagedKeyDown(object? sender, KeyEventArgs e) { + if (e.Key != Key.Enter) return; + UnstageSelected(); + e.Handled = true; + } + + private void OnStageClick(object? sender, RoutedEventArgs e) { + StageSelected(); + } + + private void OnDiscardClick(object? sender, RoutedEventArgs e) { + DiscardSelected(); + } + + private void OnUnstageClick(object? sender, RoutedEventArgs e) { + UnstageSelected(); + } + + private void StageSelected() { + var rows = SelectedUnstaged; + if (rows.Count > 0) _ = Vm?.StageAsync(rows); + } + + private void UnstageSelected() { + var rows = SelectedStaged; + if (rows.Count > 0) _ = Vm?.UnstageAsync(rows); + } + + private void DiscardSelected() { + var rows = SelectedUnstaged; + if (rows.Count > 0) _ = Vm?.DiscardAsync(rows); + } +} diff --git a/editor/Git/CommitViewModel.cs b/editor/Git/CommitViewModel.cs new file mode 100644 index 000000000..1864f0500 --- /dev/null +++ b/editor/Git/CommitViewModel.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace editor.Git; + +public partial class CommitViewModel : ObservableObject, IDisposable { + private readonly GitService m_git; + + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(CommitCommand))] [NotifyCanExecuteChangedFor(nameof(CommitAndPushCommand))] + private string m_message = ""; + + [ObservableProperty] private string m_description = ""; + [ObservableProperty] private string m_error = ""; + [ObservableProperty] private string m_status = ""; + [ObservableProperty] private string m_branchText = ""; + + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(CommitCommand))] [NotifyCanExecuteChangedFor(nameof(CommitAndPushCommand))] + private bool m_isBusy; + + public CommitViewModel(GitService git) { + m_git = git; + m_git.StatusChanged += Rebuild; + Rebuild(); + } + + public ObservableCollection Unstaged { get; } = []; + public ObservableCollection Staged { get; } = []; + + public void Dispose() { + m_git.StatusChanged -= Rebuild; + GC.SuppressFinalize(this); + } + + private void Rebuild() { + var byKey = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var change in m_git.Changes) { + var key = GitFileRow.KeyOf(change.Path); + if (!byKey.TryGetValue(key, out var list)) byKey[key] = list = []; + list.Add(change); + } + + var unstaged = new List(); + var staged = new List(); + foreach (var (key, changes) in byKey) { + var unstagedChanges = changes.Where(c => c.HasUnstaged).ToList(); + if (unstagedChanges.Count > 0) { + var status = unstagedChanges.Aggregate(GitFileStatus.None, (acc, c) => acc | c.Unstaged); + var row = new GitFileRow(key, m_git.RepoRoot, GitChange.Strongest(status)); + row.Members.AddRange(unstagedChanges); + unstaged.Add(row); + } + + var stagedChanges = changes.Where(c => c.HasStaged).ToList(); + if (stagedChanges.Count > 0) { + var status = stagedChanges.Aggregate(GitFileStatus.None, (acc, c) => acc | c.Staged); + var row = new GitFileRow(key, m_git.RepoRoot, GitChange.Strongest(status)); + row.Members.AddRange(stagedChanges); + staged.Add(row); + } + } + + Fill(Unstaged, unstaged); + Fill(Staged, staged); + + var branch = m_git.Branch; + BranchText = branch.HasUpstream ? $"{branch.Name} ↑{branch.Ahead} ↓{branch.Behind}" : branch.Name; + CommitCommand.NotifyCanExecuteChanged(); + CommitAndPushCommand.NotifyCanExecuteChanged(); + } + + private static void Fill(ObservableCollection target, List rows) { + target.Clear(); + foreach (var row in rows.OrderBy(r => r.RelPath, StringComparer.OrdinalIgnoreCase)) target.Add(row); + } + + public Task StageAsync(IEnumerable rows) { + var paths = rows.SelectMany(r => r.Members).Where(m => m.HasUnstaged).Select(m => m.Path).ToList(); + return Run(() => m_git.StageAsync(paths)); + } + + public Task UnstageAsync(IEnumerable rows) { + // A staged rename is two paths + var paths = rows.SelectMany(r => r.Members).Where(m => m.HasStaged) + .SelectMany(m => m.OldPath is null ? [m.Path] : new[] { m.Path, m.OldPath }).ToList(); + return Run(() => m_git.UnstageAsync(paths)); + } + + [RelayCommand] + private Task StageAll() { + return StageAsync(Unstaged.ToList()); + } + + [RelayCommand] + private Task UnstageAll() { + return UnstageAsync(Staged.ToList()); + } + + public async Task DiscardAsync(IReadOnlyCollection rows) { + if (rows.Count == 0 || IsBusy) return; + + var created = rows.Count(r => r.Members.Any(m => (m.Unstaged & (GitFileStatus.Untracked | GitFileStatus.Moved)) != 0)); + var message = $"Discard the changes to {rows.Count} file{(rows.Count == 1 ? "" : "s")}? This can't be undone."; + if (created > 0) + message += $"\n\n{created} new file{(created == 1 ? " is" : "s are")} not in git yet and will be deleted."; + if (!await App.Modals.ShowConfirm("Discard changes", message)) return; + + var members = rows.SelectMany(r => r.Members).ToList(); + await Run(() => m_git.DiscardAsync(members)); + } + + [RelayCommand] + private Task DiscardAll() { + return DiscardAsync(Unstaged.ToList()); + } + + private bool CanCommit() { + return !IsBusy && Staged.Count > 0 && Message.Trim().Length > 0; + } + + [RelayCommand(CanExecute = nameof(CanCommit))] + private async Task Commit() { + await Run(async () => { + var result = await m_git.CommitAsync(Message, Description); + if (result.Ok) { + Message = ""; + Description = ""; + } + + return result; + }); + } + + [RelayCommand(CanExecute = nameof(CanCommit))] + private async Task CommitAndPush() { + await Run(async () => { + var commit = await m_git.CommitAsync(Message, Description); + if (!commit.Ok) return commit; + + Message = ""; + Description = ""; + Status = "Pushing..."; + var push = await m_git.PushAsync(); + return push.Ok ? push : new GitResult(push.ExitCode, "", "Committed, but the push failed:\n" + push.Message); + }); + } + + private async Task Run(Func> action) { + if (IsBusy) return; + IsBusy = true; + Error = ""; + try { + var result = await action(); + if (!result.Ok) Error = result.Message; + } finally { + Status = ""; + IsBusy = false; + } + } +} diff --git a/editor/Git/CommitWindow.cs b/editor/Git/CommitWindow.cs new file mode 100644 index 000000000..db15d086b --- /dev/null +++ b/editor/Git/CommitWindow.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; + +namespace editor.Git; + +public sealed class CommitWindow : Window { + public CommitWindow(CommitViewModel viewModel) { + Title = "Commit"; + Width = 1000; + Height = 640; + MinWidth = 720; + MinHeight = 440; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + DataContext = viewModel; + Content = new CommitView { DataContext = viewModel }; + Closed += (_, _) => viewModel.Dispose(); + } +} diff --git a/editor/Git/GitAskpass.cs b/editor/Git/GitAskpass.cs new file mode 100644 index 000000000..b1f072dcf --- /dev/null +++ b/editor/Git/GitAskpass.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace editor.Git; + +/// +/// Editor modal for git and ssh prompts with the editor executable as the askpass program over a named pipe +/// +public static class GitAskpass { + private const string k_pipeVariable = "TOAST_ASKPASS_PIPE"; + private const string k_runVariable = "TOAST_ASKPASS_RUN"; + + private static readonly UTF8Encoding s_utf8 = new(false); + private static readonly string s_pipeName = $"toast-askpass-{Environment.ProcessId}-{Guid.NewGuid():N}"; + private static readonly Lazy s_helper = new(ResolveHelper); + + // Everything below is guarded by s_lock + private static readonly object s_lock = new(); + private static readonly Dictionary s_answers = new(); + private static readonly Dictionary> s_pending = new(); + private static readonly HashSet s_declined = new(); + private static readonly Dictionary s_runs = new(); + private static bool s_serving; + private static long s_nextRun; + + private sealed class Run(CancellationTokenSource timeout, TimeSpan duration) { + public readonly Dictionary Asked = new(); + private int m_waiting; + + // The git timeout must not run out while the user types + public void Pause() { + if (m_waiting++ == 0) timeout.CancelAfter(Timeout.Infinite); + } + + public void Resume() { + if (--m_waiting == 0) timeout.CancelAfter(duration); + } + } + + public static bool IsHelperInvocation => Environment.GetEnvironmentVariable(k_pipeVariable) is { Length: > 0 }; + + /// + /// The run id for EndRun or null when askpass is unavailable + /// + public static string? BeginRun(ProcessStartInfo psi, CancellationTokenSource timeout, TimeSpan duration) { + if (s_helper.Value is not { } helper) return null; + + string id; + lock (s_lock) { + if (!s_serving) { + s_serving = true; + _ = Task.Run(ServeAsync); + } + + id = (++s_nextRun).ToString(); + s_runs[id] = new Run(timeout, duration); + } + + psi.Environment["SSH_ASKPASS"] = helper; + psi.Environment["SSH_ASKPASS_REQUIRE"] = "force"; + psi.Environment["GIT_ASKPASS"] = helper; + psi.Environment[k_pipeVariable] = s_pipeName; + psi.Environment[k_runVariable] = id; + return id; + } + + public static void EndRun(string? id) { + if (id is null) return; + lock (s_lock) s_runs.Remove(id); + } + + /// + /// User initiated operations only so background refreshes never nag after a cancel + /// + public static void AllowDeclinedPrompts() { + lock (s_lock) s_declined.Clear(); + } + + public static int RunHelper(string[] args) { + // ssh only wants an acknowledgement for informational prompts + if (Environment.GetEnvironmentVariable("SSH_ASKPASS_PROMPT") == "none") return 0; + + var prompt = string.Join(' ', args).ReplaceLineEndings(" ").Trim(); + try { + using var pipe = new NamedPipeClientStream(".", Environment.GetEnvironmentVariable(k_pipeVariable)!, + PipeDirection.InOut, PipeOptions.CurrentUserOnly); + pipe.Connect(5000); + + using var writer = new StreamWriter(pipe, s_utf8, leaveOpen: true) { NewLine = "\n", AutoFlush = true }; + writer.WriteLine(Environment.GetEnvironmentVariable(k_runVariable) ?? ""); + writer.WriteLine(FindSshProcess()); + writer.WriteLine(prompt); + + using var reader = new StreamReader(pipe, s_utf8, leaveOpen: true); + var status = reader.ReadLine(); + if (status != "1") return Fail(status is null ? "the editor closed the pipe without answering" : "cancelled"); + + using var stdout = Console.OpenStandardOutput(); + stdout.Write(s_utf8.GetBytes((reader.ReadLine() ?? "") + "\n")); + stdout.Flush(); + return 0; + } catch (Exception e) { + return Fail(e.Message); + } + } + + // MSYS ssh forks before launching askpass so the ssh owning the connection is one process further up + private static int FindSshProcess() { + try { + var tree = OperatingSystem.IsWindows() ? WindowsProcessTree() : ProcProcessTree(); + var ssh = 0; + if (!tree.TryGetValue(Environment.ProcessId, out var self)) return 0; + + var pid = self.Parent; + for (var depth = 0; depth < 8 && tree.TryGetValue(pid, out var entry) && entry.Name == "ssh"; depth++) { + ssh = pid; + pid = entry.Parent; + } + + return ssh; + } catch { + return 0; + } + } + + private static Dictionary ProcProcessTree() { + var tree = new Dictionary(); + foreach (var dir in Directory.EnumerateDirectories("/proc")) { + if (!int.TryParse(Path.GetFileName(dir), out var pid)) continue; + try { + // pid (comm) state ppid + var stat = File.ReadAllText(Path.Combine(dir, "stat")); + var open = stat.IndexOf('('); + var close = stat.LastIndexOf(')'); + var rest = stat[(close + 2)..].Split(' '); + tree[pid] = (int.Parse(rest[1]), stat[(open + 1)..close]); + } catch (IOException) { + // exited while listing + } + } + + return tree; + } + + private static Dictionary WindowsProcessTree() { + var tree = new Dictionary(); + var snapshot = CreateToolhelp32Snapshot(k_snapProcess, 0); + if (snapshot == new IntPtr(-1)) return tree; + + try { + var entry = new ProcessEntry32 { Size = (uint)Marshal.SizeOf() }; + for (var ok = Process32FirstW(snapshot, ref entry); ok; ok = Process32NextW(snapshot, ref entry)) { + var name = Path.GetFileNameWithoutExtension(entry.ExeFile).ToLowerInvariant(); + tree[(int)entry.ProcessId] = ((int)entry.ParentProcessId, name); + } + } finally { + CloseHandle(snapshot); + } + + return tree; + } + + private const uint k_snapProcess = 0x2; + + // Mirrors PROCESSENTRY32W + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry32 { + public uint Size; + public uint Usage; + public uint ProcessId; + public IntPtr DefaultHeapId; + public uint ModuleId; + public uint Threads; + public uint ParentProcessId; + public int PriorityClassBase; + public uint Flags; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string ExeFile; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Process32FirstW(IntPtr snapshot, ref ProcessEntry32 entry); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool Process32NextW(IntPtr snapshot, ref ProcessEntry32 entry); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + + // ssh forwards askpass stderr into the git error the editor logs + private static int Fail(string reason) { + try { + using var stderr = Console.OpenStandardError(); + stderr.Write(s_utf8.GetBytes($"toast askpass {reason}\n")); + } catch (IOException) { + } + + return 1; + } + + private static string? ResolveHelper() { + var path = Environment.ProcessPath; + if (path is null) return null; + + // Under dotnet exec the apphost next to the assembly is the program to launch + if (Path.GetFileNameWithoutExtension(path).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) { + path = Path.ChangeExtension(typeof(GitAskpass).Assembly.Location, OperatingSystem.IsWindows() ? ".exe" : null); + if (!File.Exists(path)) return null; + } + + // Both MSYS ssh and native programs take forward slashes + return path.Replace('\\', '/'); + } + + private static async Task ServeAsync() { + while (true) { + var pipe = new NamedPipeServerStream(s_pipeName, PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + try { + await pipe.WaitForConnectionAsync().ConfigureAwait(false); + } catch (Exception) { + await pipe.DisposeAsync().ConfigureAwait(false); + await Task.Delay(1000).ConfigureAwait(false); + continue; + } + + _ = HandleAsync(pipe); + } + } + + private static async Task HandleAsync(NamedPipeServerStream pipe) { + await using (pipe) { + try { + using var reader = new StreamReader(pipe, s_utf8, leaveOpen: true); + var run = await reader.ReadLineAsync().ConfigureAwait(false) ?? ""; + var ssh = await reader.ReadLineAsync().ConfigureAwait(false) ?? "0"; + var prompt = await reader.ReadLineAsync().ConfigureAwait(false) ?? ""; + var answer = await AnswerAsync(run, ssh, prompt).ConfigureAwait(false); + + await using var writer = new StreamWriter(pipe, s_utf8, leaveOpen: true) { NewLine = "\n" }; + await writer.WriteLineAsync(answer is null ? "0" : "1").ConfigureAwait(false); + if (answer is not null) await writer.WriteLineAsync(answer).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); + + // Closing the server end first can drop the reply before the helper reads it on Windows + using var drain = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await reader.ReadLineAsync(drain.Token).ConfigureAwait(false); + } catch (OperationCanceledException) { + } catch (IOException) { + // git was killed while the modal was open + } + } + } + + private static async Task AnswerAsync(string runId, string ssh, string prompt) { + Run? run; + Task pending; + lock (s_lock) { + run = s_runs.GetValueOrDefault(runId); + var asked = 0; + if (run is not null && ssh != "0") { + var key = ssh + "\n" + prompt; + asked = run.Asked.GetValueOrDefault(key); + run.Asked[key] = asked + 1; + } + + // Per ssh process since one git call may open several connections that each ask once + var rejected = asked > 0; + if (rejected) s_answers.Remove(prompt); + else if (s_answers.TryGetValue(prompt, out var cached)) return cached; + else if (s_declined.Contains(prompt)) return null; + + if (!s_pending.TryGetValue(prompt, out var existing)) { + existing = Dispatcher.UIThread.InvokeAsync(() => GitPassphraseModal.ShowAsync(prompt, rejected)); + s_pending[prompt] = existing; + } + + pending = existing; + run?.Pause(); + } + + string? answer = null; + try { + answer = await pending.ConfigureAwait(false); + } finally { + lock (s_lock) { + if (s_pending.GetValueOrDefault(prompt) == pending) { + s_pending.Remove(prompt); + if (answer is null) s_declined.Add(prompt); + else s_answers[prompt] = answer; + } + + if (run is not null && s_runs.ContainsKey(runId)) run.Resume(); + } + } + + return answer; + } +} diff --git a/editor/Git/GitAttributes.cs b/editor/Git/GitAttributes.cs new file mode 100644 index 000000000..370a1dde6 --- /dev/null +++ b/editor/Git/GitAttributes.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +namespace editor.Git; + +public sealed class GitAttributes { + private sealed record Rule(string BaseDir, Regex Pattern, bool Anchored, bool? Lockable, bool? Lfs); + + private readonly List m_rules = []; + private readonly bool m_ignoreCase = OperatingSystem.IsWindows(); + + public static GitAttributes Load(string repoRoot, string projectPath) { + var attributes = new GitAttributes(); + var dirs = new List { repoRoot }; + var project = Path.GetFullPath(projectPath); + if (!string.Equals(project.TrimEnd(Path.DirectorySeparatorChar), repoRoot.TrimEnd(Path.DirectorySeparatorChar), + StringComparison.OrdinalIgnoreCase)) + dirs.Add(project); + + attributes.LoadFile(Path.Combine(repoRoot, ".git", "info", "attributes"), repoRoot); + foreach (var dir in dirs) attributes.LoadFile(Path.Combine(dir, ".gitattributes"), dir); + return attributes; + } + + public static GitAttributes FromText(string text, string baseDir) { + var attributes = new GitAttributes(); + attributes.Parse(text, baseDir); + return attributes; + } + + private void LoadFile(string path, string baseDir) { + try { + if (File.Exists(path)) Parse(File.ReadAllText(path, Encoding.UTF8), baseDir); + } catch { + // unreadable attributes just mean nothing is lockable + } + } + + private void Parse(string text, string baseDir) { + var normalizedBase = Path.GetFullPath(baseDir).TrimEnd(Path.DirectorySeparatorChar); + foreach (var raw in text.Split('\n')) { + var line = raw.Trim(); + if (line.Length == 0 || line[0] == '#' || line.StartsWith("[attr]", StringComparison.Ordinal)) continue; + + var parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) continue; + + var pattern = parts[0]; + if (pattern.EndsWith('/')) continue; // directory patterns don't set attributes on the files inside + + bool? lockable = null; + bool? lfs = null; + for (var i = 1; i < parts.Length; i++) { + switch (parts[i]) { + case "lockable": lockable = true; break; + case "-lockable": lockable = false; break; + case "filter=lfs": lfs = true; break; + case "-filter": lfs = false; break; + default: + if (parts[i].StartsWith("filter=", StringComparison.Ordinal)) lfs = false; + break; + } + } + + if (lockable is null && lfs is null) continue; + + var anchored = pattern.TrimStart('/').Contains('/'); + var options = RegexOptions.Compiled | (m_ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None); + m_rules.Add(new Rule(normalizedBase, new Regex("^" + GlobToRegex(pattern.TrimStart('/')) + "$", options), + anchored, lockable, lfs)); + } + } + + public bool IsLockable(string absolutePath) { + bool? lockable = null; + bool? lfs = null; + var full = Path.GetFullPath(absolutePath); + var name = Path.GetFileName(full); + + foreach (var rule in m_rules) { + string subject; + if (rule.Anchored) { + if (!full.StartsWith(rule.BaseDir + Path.DirectorySeparatorChar, + m_ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + continue; + subject = full[(rule.BaseDir.Length + 1)..].Replace(Path.DirectorySeparatorChar, '/'); + } else { + if (!full.StartsWith(rule.BaseDir + Path.DirectorySeparatorChar, + m_ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + continue; + subject = name; + } + + if (!rule.Pattern.IsMatch(subject)) continue; + if (rule.Lockable is not null) lockable = rule.Lockable; + if (rule.Lfs is not null) lfs = rule.Lfs; + } + + return lockable ?? lfs ?? false; + } + + private static string GlobToRegex(string glob) { + var sb = new StringBuilder(); + for (var i = 0; i < glob.Length; i++) { + var c = glob[i]; + switch (c) { + case '*': + if (i + 1 < glob.Length && glob[i + 1] == '*') { + // "**/" matches zero or more directories, a bare "**" matches anything + if (i + 2 < glob.Length && glob[i + 2] == '/') { + sb.Append("(?:.*/)?"); + i += 2; + } else { + sb.Append(".*"); + i++; + } + } else { + sb.Append("[^/]*"); + } + + break; + case '?': + sb.Append("[^/]"); + break; + case '[': { + var end = glob.IndexOf(']', i + 1); + if (end < 0) { + sb.Append("\\["); + } else { + var body = glob[(i + 1)..end]; + if (body.StartsWith('!')) body = "^" + body[1..]; + sb.Append('[').Append(body.Replace("\\", "\\\\")).Append(']'); + i = end; + } + + break; + } + case '\\' when i + 1 < glob.Length: + sb.Append(Regex.Escape(glob[++i].ToString())); + break; + default: + sb.Append(Regex.Escape(c.ToString())); + break; + } + } + + return sb.ToString(); + } +} diff --git a/editor/Git/GitFileRow.cs b/editor/Git/GitFileRow.cs new file mode 100644 index 000000000..257394091 --- /dev/null +++ b/editor/Git/GitFileRow.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using editor.Assets; +using editor.Assets.Types; + +namespace editor.Git; + +public partial class GitFileRow : ObservableObject { + [ObservableProperty] private GitFileStatus m_status; + + public GitFileRow(string key, string repoRoot, GitFileStatus status = GitFileStatus.None) { + Key = key; + Status = status; + Name = Path.GetFileName(key); + RelPath = ProjectContext.ToVirtual(key) ?? GitParsers.Rel(repoRoot, key); + + var ext = AssetTypeRegistry.GetExtension(Name); + var definition = AssetTypeRegistry.ByExtension(ext); + if (definition is not null) { + TypeLabel = definition.ChipText; + TypeBrush = GitStatusStyle.Brush(definition.ChipColor); + } else { + var plain = Path.GetExtension(Name).TrimStart('.'); + TypeLabel = plain.Length == 0 ? "FILE" : plain.ToUpperInvariant(); + TypeBrush = GitStatusStyle.Brush("TextMuted"); + } + } + + public string Key { get; } + public string Name { get; } + public string RelPath { get; } + public string TypeLabel { get; } + public IBrush TypeBrush { get; } + public List Members { get; } = []; + + public static string KeyOf(string path) { + return path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) ? path[..^".meta".Length] : path; + } +} diff --git a/editor/Git/GitLockGuard.cs b/editor/Git/GitLockGuard.cs new file mode 100644 index 000000000..37d64c69e --- /dev/null +++ b/editor/Git/GitLockGuard.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using editor.Assets; +using editor.Assets.Types; +using editor.Engine; +using editor.Workspace; + +namespace editor.Git; + +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; + + var result = await git.LockAsync(realPath); + if (!result.Ok) Log.Warn($"Git: could not lock {Path.GetFileName(realPath)} on open ({result.Message})"); + } + + public static async Task CheckSaveAsync(string realPath) { + if (Usable is not { } git) return LockedSaveChoice.Proceed; + + var held = git.GetLock(realPath); + if (held is { OwnedByMe: true }) return LockedSaveChoice.Proceed; + + if (held is null) { + if (!git.IsLockable(realPath)) return LockedSaveChoice.Proceed; + + var result = await git.LockAsync(realPath); + if (result.Ok) return LockedSaveChoice.Proceed; + + // The cached locks may be old + held = git.GetLock(realPath); + if (held is null) { + // Offline or server trouble must not stop people from saving their work + Log.Warn($"Git: could not lock {Path.GetFileName(realPath)} on save ({result.Message})"); + return LockedSaveChoice.Proceed; + } + + if (held.OwnedByMe) return LockedSaveChoice.Proceed; + } else { + // It may have been released since the last poll + await git.RefreshLocksAsync(); + held = git.GetLock(realPath); + if (held is null) return await CheckSaveAsync(realPath); + if (held.OwnedByMe) return LockedSaveChoice.Proceed; + } + + return await LockedFileModal.ShowAsync(Path.GetFileName(realPath), held.Owner); + } + + public static BaseAsset? DefinitionOf(string virtualPath) { + return AssetTypeRegistry.ByExtension(AssetTypeRegistry.GetExtension(virtualPath)); + } + + public static async Task SaveAsync(string uid, string virtualPath, Func write, + Action reopen) { + var ext = AssetTypeRegistry.GetExtension(virtualPath); + var realPath = ProjectContext.Resolve(virtualPath); + + switch (await CheckSaveAsync(realPath)) { + case LockedSaveChoice.Cancel: + return false; + case LockedSaveChoice.Discard: + AutosaveService.Delete(uid, ext); + reopen(uid, virtualPath); + return true; + case LockedSaveChoice.SaveAs: + return await SaveCopyAsync(uid, virtualPath, write, reopen); + } + + await write(realPath); + MetaFile.Touch(virtualPath); + AutosaveService.Delete(uid, ext); + return true; + } + + private static async Task SaveCopyAsync(string uid, string virtualPath, Func write, + Action reopen) { + var ext = AssetTypeRegistry.GetExtension(virtualPath); + var target = await App.Modals.ShowSaveFile(Path.GetFileName(virtualPath), ext); + if (target is null) return false; + + var realPath = ProjectContext.Resolve(target); + if (File.Exists(realPath)) { + await App.Modals.ShowError("Save As", $"{target} already exists, pick another name."); + return false; + } + + Directory.CreateDirectory(Path.GetDirectoryName(realPath)!); + await write(realPath); + + var newUid = UidGenerator.Generate(); + MetaFile.Write(realPath, new MetaHeader { Uid = newUid, Type = DefinitionOf(virtualPath)?.Type ?? "" }); + AssetDatabase.RebuildAssetDatabase(); + ProjectContext.RaiseAssetsChanged(); + AutosaveService.Delete(uid, ext); + + // Saving takes the lock on the copy + await LockOnOpenAsync(realPath); + reopen(newUid, target); + return true; + } +} diff --git a/editor/Git/GitModels.cs b/editor/Git/GitModels.cs new file mode 100644 index 000000000..5c35217b2 --- /dev/null +++ b/editor/Git/GitModels.cs @@ -0,0 +1,50 @@ +using System; + +namespace editor.Git; + +[Flags] +public enum GitFileStatus { + None = 0, + Added = 1, + Modified = 2, + Moved = 4, + Untracked = 8, + Deleted = 16 +} + +public sealed record GitChange(string Path, GitFileStatus Staged, GitFileStatus Unstaged, string? OldPath = null) { + public bool HasStaged => Staged != GitFileStatus.None; + public bool HasUnstaged => Unstaged != GitFileStatus.None; + + public GitFileStatus Display => Strongest(Staged | Unstaged); + + public static GitFileStatus Strongest(GitFileStatus status) { + if ((status & GitFileStatus.Moved) != 0) return GitFileStatus.Moved; + if ((status & GitFileStatus.Added) != 0) return GitFileStatus.Added; + if ((status & GitFileStatus.Modified) != 0) return GitFileStatus.Modified; + if ((status & GitFileStatus.Untracked) != 0) return GitFileStatus.Untracked; + if ((status & GitFileStatus.Deleted) != 0) return GitFileStatus.Deleted; + return GitFileStatus.None; + } +} + +public sealed record GitLock(string Id, string Path, string Owner, bool OwnedByMe, DateTime LockedAt); + +public sealed record GitBranchInfo(string Name, int Ahead, int Behind, bool HasUpstream) { + public static readonly GitBranchInfo None = new("", 0, 0, false); +} + +public sealed record LockManyResult(int Locked, int Failed, string? FirstError) { + public int Unlocked => Locked; +} + +public readonly record struct GitResult(int ExitCode, string Output, string Error) { + public bool Ok => ExitCode == 0; + + public string Message { + get { + var text = string.IsNullOrWhiteSpace(Error) ? Output : Error; + return text.Trim(); + } + } +} diff --git a/editor/Git/GitParsers.cs b/editor/Git/GitParsers.cs new file mode 100644 index 000000000..952fa362e --- /dev/null +++ b/editor/Git/GitParsers.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace editor.Git; + +public sealed record GitStatusSnapshot(GitBranchInfo Branch, IReadOnlyList Changes); + +public static class GitParsers { + public static GitStatusSnapshot ParseStatus(string output, string repoRoot) { + var changes = new List(); + var head = ""; + var ahead = 0; + var behind = 0; + var hasUpstream = false; + + var tokens = output.Split('\0'); + for (var i = 0; i < tokens.Length; i++) { + var token = tokens[i]; + if (token.Length < 2) continue; + + switch (token[0]) { + case '#': + ParseHeader(token, ref head, ref ahead, ref behind, ref hasUpstream); + break; + case '1': { + // 1 XY sub mH mI mW hH hI path + var parts = token.Split(' ', 9); + if (parts.Length < 9) break; + changes.Add(new GitChange(Abs(repoRoot, parts[8]), MapCode(parts[1][0]), MapCode(parts[1][1]))); + break; + } + case '2': { + // 2 XY sub mH mI mW hH hI Xscore path \0 origPath + var parts = token.Split(' ', 10); + var orig = i + 1 < tokens.Length ? tokens[++i] : null; + if (parts.Length < 10) break; + changes.Add(new GitChange(Abs(repoRoot, parts[9]), MapCode(parts[1][0]), MapCode(parts[1][1]), + orig is null ? null : Abs(repoRoot, orig))); + break; + } + case 'u': { + // u XY sub m1 m2 m3 mW h1 h2 h3 path + var parts = token.Split(' ', 11); + if (parts.Length < 11) break; + changes.Add(new GitChange(Abs(repoRoot, parts[10]), GitFileStatus.None, GitFileStatus.Modified)); + break; + } + case '?': + changes.Add(new GitChange(Abs(repoRoot, token[2..]), GitFileStatus.None, GitFileStatus.Untracked)); + break; + } + } + + return new GitStatusSnapshot(new GitBranchInfo(head, ahead, behind, hasUpstream), changes); + } + + private static void ParseHeader(string token, ref string head, ref int ahead, ref int behind, + ref bool hasUpstream) { + if (token.StartsWith("# branch.head ", StringComparison.Ordinal)) { + head = token["# branch.head ".Length..]; + if (head == "(detached)") head = "HEAD (detached)"; + } else if (token.StartsWith("# branch.upstream ", StringComparison.Ordinal)) { + hasUpstream = true; + } else if (token.StartsWith("# branch.ab ", StringComparison.Ordinal)) { + // # branch.ab +1 -2 + var parts = token.Split(' '); + if (parts.Length >= 4) { + int.TryParse(parts[2].TrimStart('+'), out ahead); + int.TryParse(parts[3].TrimStart('-'), out behind); + } + } + } + + private static GitFileStatus MapCode(char c) { + return c switch { + 'A' => GitFileStatus.Added, + 'M' or 'T' or 'U' => GitFileStatus.Modified, + 'D' => GitFileStatus.Deleted, + 'R' or 'C' => GitFileStatus.Moved, + _ => GitFileStatus.None + }; + } + + public static string Abs(string repoRoot, string relative) { + return Path.GetFullPath(Path.Combine(repoRoot, relative.Replace('/', Path.DirectorySeparatorChar))); + } + + public static string Rel(string repoRoot, string absolute) { + return Path.GetRelativePath(repoRoot, absolute).Replace(Path.DirectorySeparatorChar, '/'); + } + + public static List ParseLocks(string json, string repoRoot, string? currentUser) { + var result = new List(); + if (string.IsNullOrWhiteSpace(json)) return result; + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (root.ValueKind == JsonValueKind.Array) { + foreach (var item in root.EnumerateArray()) { + var owner = OwnerName(item); + AddLock(result, item, repoRoot, owner, currentUser is not null && owner == currentUser); + } + } else if (root.ValueKind == JsonValueKind.Object) { + if (root.TryGetProperty("ours", out var ours) && ours.ValueKind == JsonValueKind.Array) + foreach (var item in ours.EnumerateArray()) + AddLock(result, item, repoRoot, OwnerName(item), true); + if (root.TryGetProperty("theirs", out var theirs) && theirs.ValueKind == JsonValueKind.Array) + foreach (var item in theirs.EnumerateArray()) + AddLock(result, item, repoRoot, OwnerName(item), false); + } + + return result; + } + + private static string OwnerName(JsonElement item) { + return item.TryGetProperty("owner", out var owner) && owner.TryGetProperty("name", out var name) + ? name.GetString() ?? "" + : ""; + } + + private static void AddLock(List list, JsonElement item, string repoRoot, string owner, bool mine) { + if (!item.TryGetProperty("path", out var path) || path.GetString() is not { Length: > 0 } rel) return; + var id = item.TryGetProperty("id", out var idElement) ? idElement.ToString() : ""; + var lockedAt = DateTime.MinValue; + if (item.TryGetProperty("locked_at", out var at) && at.GetString() is { } text) + DateTime.TryParse(text, null, System.Globalization.DateTimeStyles.AdjustToUniversal, out lockedAt); + list.Add(new GitLock(id, Abs(repoRoot, rel), owner, mine, lockedAt)); + } + + public static Dictionary ParseAuthors(string output) { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { + var parts = line.TrimEnd('\r').Split('\t'); + if (parts.Length < 2 || parts[0].Length == 0 || parts[1].Length == 0) continue; + map.TryAdd(parts[0], parts[1]); + map.TryAdd(parts[1], parts[1]); + } + + return map; + } + + public static string? FindEmail(IReadOnlyDictionary authors, string owner) { + if (owner.Length == 0) return null; + if (authors.TryGetValue(owner, out var byName)) return byName; + + foreach (var email in authors.Values) { + var local = email.Split('@')[0]; + var plus = local.IndexOf('+'); + if (plus >= 0) local = local[(plus + 1)..]; + if (string.Equals(local, owner, StringComparison.OrdinalIgnoreCase)) return email; + } + + return null; + } + + private static readonly Regex s_grepUid = new(@"^HEAD:(?.+?):\d+:uid\s*=\s*""?(?[^""\s]+)""?\s*$", + RegexOptions.Compiled); + + public static Dictionary ParseGrepUids(string output) { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { + var match = s_grepUid.Match(line.TrimEnd('\r')); + if (match.Success) map[match.Groups["path"].Value] = match.Groups["uid"].Value; + } + + return map; + } + + public static List PairMoves(IReadOnlyList changes, IReadOnlyDictionary oldUids, + Func readNewUid) { + var byOldUid = new Dictionary(); + foreach (var kv in oldUids) byOldUid[kv.Value] = kv.Key; + + var result = new List(changes); + var index = result.Select((c, i) => (c.Path, i)).ToDictionary(t => t.Path, t => t.i); + var movedMetas = new HashSet(); + + for (var i = 0; i < result.Count; i++) { + var change = result[i]; + if (!change.Path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) continue; + var isNew = change.Unstaged == GitFileStatus.Untracked || (change.Staged & GitFileStatus.Added) != 0; + if (!isNew) continue; + var uid = readNewUid(change.Path); + if (uid is null || !byOldUid.ContainsKey(uid)) continue; + + result[i] = Promote(change); + movedMetas.Add(change.Path); + } + + // The asset next to a moved .meta moved with it + foreach (var meta in movedMetas) { + var asset = meta[..^".meta".Length]; + if (!index.TryGetValue(asset, out var i)) continue; + var change = result[i]; + if (change.Unstaged == GitFileStatus.Untracked || (change.Staged & GitFileStatus.Added) != 0) + result[i] = Promote(change); + } + + return result; + } + + private static GitChange Promote(GitChange change) { + var staged = (change.Staged & GitFileStatus.Added) != 0 ? GitFileStatus.Moved : change.Staged; + var unstaged = change.Unstaged == GitFileStatus.Untracked ? GitFileStatus.Moved : change.Unstaged; + return change with { Staged = staged, Unstaged = unstaged }; + } +} diff --git a/editor/Git/GitPassphraseModal.cs b/editor/Git/GitPassphraseModal.cs new file mode 100644 index 000000000..736ab283b --- /dev/null +++ b/editor/Git/GitPassphraseModal.cs @@ -0,0 +1,109 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using editor.Components.Modals; +using Lucide.Avalonia; + +namespace editor.Git; + +public sealed class GitPassphraseModal : Window { + private readonly TextBox m_input; + + private GitPassphraseModal(string prompt, bool rejected) { + Title = "Git authentication"; + 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.KeyRound, Size = 28, StrokeWidth = 2.5, + Foreground = GitStatusStyle.Orange, VerticalAlignment = VerticalAlignment.Center + }); + header.Children.Add(new TextBlock { + Text = "Git needs your credentials", + FontSize = 16, + FontWeight = FontWeight.Bold, + VerticalAlignment = VerticalAlignment.Center + }); + + var root = new StackPanel { Spacing = 12, Margin = new Thickness(20) }; + root.Children.Add(header); + root.Children.Add(new TextBlock { + Text = prompt, + Foreground = GitStatusStyle.Brush("TextMuted"), + TextWrapping = TextWrapping.Wrap + }); + + m_input = new TextBox(); + if (IsSecret(prompt)) m_input.PasswordChar = '•'; + m_input.KeyDown += (_, e) => { + if (e.Key != Key.Enter) return; + e.Handled = true; + Close(m_input.Text ?? ""); + }; + root.Children.Add(m_input); + + if (rejected) { + root.Children.Add(new TextBlock { + Text = "That answer was rejected, try again", + Foreground = GitStatusStyle.Brush("Red"), + TextWrapping = TextWrapping.Wrap + }); + } + + root.Children.Add(new TextBlock { + Text = "Kept in memory until the editor closes", + FontSize = 11, + Foreground = GitStatusStyle.Brush("TextMuted") + }); + + var buttons = new StackPanel { + Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right + }; + buttons.Children.Add(MakeButton("Cancel", null, isCancel: true)); + buttons.Children.Add(MakeButton("OK", LucideIconKind.Check, isCancel: false)); + root.Children.Add(buttons); + Content = root; + } + + public static Task ShowAsync(string prompt, bool rejected) { + var parent = ModalService.FindActiveWindow(); + if (parent is null) return Task.FromResult(null); + return new GitPassphraseModal(prompt, rejected).ShowDialog(parent); + } + + protected override void OnOpened(EventArgs e) { + base.OnOpened(e); + m_input.Focus(); + } + + // Usernames and host key confirmations stay readable + private static bool IsSecret(string prompt) { + return prompt.Contains("pass", StringComparison.OrdinalIgnoreCase) || + prompt.Contains("PIN", StringComparison.Ordinal); + } + + private Button MakeButton(string text, LucideIconKind? icon, bool isCancel) { + 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 }); + content.Children.Add(new TextBlock { Text = text }); + + var button = new Button { + Content = content, + IsCancel = isCancel, + Background = GitStatusStyle.Brush("Bg4"), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(14, 6) + }; + button.Click += (_, _) => Close(isCancel ? null : m_input.Text ?? ""); + return button; + } +} diff --git a/editor/Git/GitRunner.cs b/editor/Git/GitRunner.cs new file mode 100644 index 000000000..7a6bc0c0c --- /dev/null +++ b/editor/Git/GitRunner.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace editor.Git; + +public static class GitRunner { + // Anything that touches the index goes through this gate + private static readonly SemaphoreSlim s_indexGate = new(1, 1); + + public static async Task RunAsync( + string workingDirectory, + IEnumerable args, + string? stdin = null, + bool touchesIndex = true, + TimeSpan? timeout = null, + CancellationToken ct = default) { + if (touchesIndex) await s_indexGate.WaitAsync(ct).ConfigureAwait(false); + try { + return await RunCore(workingDirectory, args, stdin, timeout ?? TimeSpan.FromMinutes(2), ct) + .ConfigureAwait(false); + } finally { + if (touchesIndex) s_indexGate.Release(); + } + } + + private static async Task RunCore(string workingDirectory, IEnumerable args, string? stdin, + TimeSpan timeout, CancellationToken ct) { + var psi = new ProcessStartInfo { + FileName = "git", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdin is not null, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + // Only valid when stdin is redirected + if (stdin is not null) psi.StandardInputEncoding = new UTF8Encoding(false); + foreach (var arg in args) psi.ArgumentList.Add(arg); + + // Credential prompts go to the editor through askpass and never to a terminal + psi.Environment["GIT_TERMINAL_PROMPT"] = "0"; + psi.Environment["GCM_INTERACTIVE"] = "never"; + psi.Environment["GIT_OPTIONAL_LOCKS"] = "0"; + psi.Environment["GIT_LFS_FORCE_PROGRESS"] = "0"; + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + var askpass = GitAskpass.BeginRun(psi, cts, timeout); + try { + Process process; + try { + process = Process.Start(psi) ?? throw new InvalidOperationException("git did not start"); + } catch (Exception e) { + return new GitResult(-1, "", e.Message); + } + + using (process) { + try { + if (stdin is not null) { + await process.StandardInput.WriteAsync(stdin).ConfigureAwait(false); + process.StandardInput.Close(); + } + + var stdout = process.StandardOutput.ReadToEndAsync(cts.Token); + var stderr = process.StandardError.ReadToEndAsync(cts.Token); + await process.WaitForExitAsync(cts.Token).ConfigureAwait(false); + return new GitResult(process.ExitCode, await stdout.ConfigureAwait(false), + await stderr.ConfigureAwait(false)); + } catch (OperationCanceledException) { + try { + process.Kill(true); + } catch { + // already gone + } + + return new GitResult(-1, "", ct.IsCancellationRequested ? "cancelled" : "git timed out"); + } catch (Exception e) { + return new GitResult(-1, "", e.Message); + } + } + } finally { + GitAskpass.EndRun(askpass); + } + } +} diff --git a/editor/Git/GitService.cs b/editor/Git/GitService.cs new file mode 100644 index 000000000..1436cec38 --- /dev/null +++ b/editor/Git/GitService.cs @@ -0,0 +1,619 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using editor.Engine; + +namespace editor.Git; + +public sealed class GitService : IDisposable { + private static readonly TimeSpan s_fetchInterval = TimeSpan.FromMinutes(5); + private static readonly TimeSpan s_locksInterval = TimeSpan.FromMinutes(1); + + private sealed record StatusSnapshot( + GitBranchInfo Branch, + IReadOnlyDictionary Changes, + IReadOnlyDictionary Folders) { + public static readonly StatusSnapshot Empty = new( + GitBranchInfo.None, + new Dictionary(), + new Dictionary()); + } + + private sealed record LockSnapshot(IReadOnlyDictionary ByPath, IReadOnlyList All) { + public static readonly LockSnapshot Empty = new(new Dictionary(), []); + } + + private readonly string m_projectPath; + private readonly CancellationTokenSource m_cts = new(); + private readonly object m_watchLock = new(); + private readonly List m_watchers = []; + + private DispatcherTimer? m_fetchTimer; + private DispatcherTimer? m_locksTimer; + private Timer? m_debounce; + + private volatile StatusSnapshot m_status = StatusSnapshot.Empty; + private volatile LockSnapshot m_locks = LockSnapshot.Empty; + private volatile GitAttributes? m_attributes; + private volatile Dictionary m_authors = new(StringComparer.OrdinalIgnoreCase); + + private int m_statusRunning; + private int m_statusPending; + private int m_locksRunning; + private int m_fetchRunning; + private bool m_disposed; + private DateTime m_authorsLoadedAt = DateTime.MinValue; + private bool m_locksFailing; + private bool m_fetchFailing; + private string m_userName = ""; + private string m_userEmail = ""; + + private GitService(string projectPath) { + m_projectPath = projectPath; + } + + public static GitService? Current { get; private set; } + public bool IsAvailable { get; private set; } + public bool HasLfs { get; private set; } + public string RepoRoot { get; private set; } = ""; + public DateTime? LastFetch { get; private set; } + public GitBranchInfo Branch => m_status.Branch; + public IReadOnlyCollection Changes => m_status.Changes.Values.ToList(); + public IReadOnlyList Locks => m_locks.All; + public string UserName => m_userName; + public event Action? StatusChanged; + public event Action? LocksChanged; + + public static GitService Start(string projectPath) { + Current?.Dispose(); + var service = new GitService(projectPath); + Current = service; + _ = service.InitializeAsync(); + return service; + } + + public void Dispose() { + if (m_disposed) return; + m_disposed = true; + if (ReferenceEquals(Current, this)) Current = null; + m_cts.Cancel(); + m_fetchTimer?.Stop(); + m_locksTimer?.Stop(); + m_debounce?.Dispose(); + lock (m_watchLock) { + foreach (var watcher in m_watchers) watcher.Dispose(); + m_watchers.Clear(); + } + + m_cts.Dispose(); + } + + public GitChange? GetChange(string absolutePath) { + return m_status.Changes.GetValueOrDefault(Normalize(absolutePath)); + } + + public GitFileStatus GetAssetStatus(string assetPath) { + var asset = GetChange(assetPath)?.Display ?? GitFileStatus.None; + var meta = GetChange(assetPath + ".meta")?.Display ?? GitFileStatus.None; + return GitChange.Strongest(Visible(asset) | Visible(meta)); + } + + public GitFileStatus GetFolderStatus(string folderPath) { + return m_status.Folders.GetValueOrDefault(Normalize(folderPath)); + } + + public GitLock? GetLock(string absolutePath) { + return m_locks.ByPath.GetValueOrDefault(Normalize(absolutePath)); + } + + public bool IsLockable(string absolutePath) { + return HasLfs && (m_attributes?.IsLockable(absolutePath) == true || GetLock(absolutePath) is not null); + } + + public bool IsGitHub { get; private set; } + + public string? GetEmail(GitLock held) { + if (held.OwnedByMe && m_userEmail.Length > 0) return m_userEmail; + return GitParsers.FindEmail(m_authors, held.Owner); + } + + private static GitFileStatus Visible(GitFileStatus status) { + // Deleted files have no card + return status == GitFileStatus.Deleted ? GitFileStatus.None : status; + } + + private static string Normalize(string path) { + return Path.GetFullPath(path); + } + + private async Task InitializeAsync() { + try { + var top = await RunAsync(["rev-parse", "--show-toplevel"], serialize: false); + if (!top.Ok || string.IsNullOrWhiteSpace(top.Output)) { + Log.Info("Git: project is not in a git repository, git features are disabled"); + return; + } + + RepoRoot = Path.GetFullPath(top.Output.Trim()); + var lfs = await RunAsync(["lfs", "version"], serialize: false); + HasLfs = lfs.Ok; + if (!HasLfs) Log.Warn("Git: git-lfs is not installed, lock features are disabled"); + + m_userName = (await RunAsync(["config", "user.name"], serialize: false)).Output.Trim(); + m_userEmail = (await RunAsync(["config", "user.email"], serialize: false)).Output.Trim(); + var origin = (await RunAsync(["remote", "get-url", "origin"], serialize: false)).Output; + IsGitHub = origin.Contains("github.com", StringComparison.OrdinalIgnoreCase); + m_attributes = GitAttributes.Load(RepoRoot, m_projectPath); + IsAvailable = true; + + Dispatcher.UIThread.Post(StartBackgroundWork); + await RefreshStatusAsync(); + if (HasLfs) await RefreshLocksAsync(); + // The first refresh may find nothing to report + Dispatcher.UIThread.Post(() => { + StatusChanged?.Invoke(); + LocksChanged?.Invoke(); + }); + _ = FetchAsync(); + } catch (Exception e) { + Log.Warn($"Git: failed to initialize ({e.Message})"); + } + } + + private void StartBackgroundWork() { + if (m_disposed) return; + + m_fetchTimer = new DispatcherTimer { Interval = s_fetchInterval }; + m_fetchTimer.Tick += (_, _) => _ = FetchAsync(); + m_fetchTimer.Start(); + + if (HasLfs) { + m_locksTimer = new DispatcherTimer { Interval = s_locksInterval }; + m_locksTimer.Tick += (_, _) => _ = RefreshLocksAsync(); + m_locksTimer.Start(); + } + + StartWatching(); + } + + private void StartWatching() { + m_debounce = new Timer(_ => { + m_attributes = GitAttributes.Load(RepoRoot, m_projectPath); + _ = RefreshStatusAsync(); + }); + + try { + var project = new FileSystemWatcher(m_projectPath) { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.LastWrite + }; + project.Changed += OnWatchEvent; + project.Created += OnWatchEvent; + project.Deleted += OnWatchEvent; + project.Renamed += OnWatchEvent; + project.EnableRaisingEvents = true; + + // Staging, committing or switching branches from outside the editor only touches the .git folder + var gitDir = Path.Combine(RepoRoot, ".git"); + var index = new FileSystemWatcher(gitDir) { NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite }; + index.Changed += OnGitDirEvent; + index.Created += OnGitDirEvent; + index.Renamed += OnGitDirEvent; + index.EnableRaisingEvents = true; + + lock (m_watchLock) { + m_watchers.Add(project); + m_watchers.Add(index); + } + } catch (Exception e) { + Log.Warn($"Git: could not watch the project folder ({e.Message})"); + } + } + + private void OnWatchEvent(object sender, FileSystemEventArgs e) { + var path = e.FullPath; + var sep = Path.DirectorySeparatorChar; + if (path.Contains($"{sep}.git{sep}") || path.Contains($"{sep}.toast{sep}")) return; + Debounce(); + } + + private void OnGitDirEvent(object sender, FileSystemEventArgs e) { + var name = Path.GetFileName(e.FullPath); + // Ignore our own fetch/lock noise + if (name is "index" or "HEAD" or "MERGE_HEAD" or "ORIG_HEAD") Debounce(); + } + + private void Debounce() { + if (m_disposed) return; + try { + m_debounce?.Change(300, Timeout.Infinite); + } catch (ObjectDisposedException) { + // shutting down + } + } + + public async Task RefreshStatusAsync() { + if (!IsAvailable || m_disposed) return; + + // Coalesce overlapping refreshes into one extra pass + if (Interlocked.Exchange(ref m_statusRunning, 1) == 1) { + Interlocked.Exchange(ref m_statusPending, 1); + return; + } + + try { + do { + Interlocked.Exchange(ref m_statusPending, 0); + try { + await RefreshStatusOnce(); + } catch (Exception e) { + // Callers fire and forget + Log.Warn($"Git: status refresh failed ({e.Message})"); + } + } while (Interlocked.CompareExchange(ref m_statusPending, 0, 1) == 1 && !m_disposed); + } finally { + Interlocked.Exchange(ref m_statusRunning, 0); + } + } + + private async Task RefreshStatusOnce() { + var result = await RunAsync(["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all"]); + if (!result.Ok) { + Log.Warn($"Git: status failed ({result.Message})"); + return; + } + + var snapshot = GitParsers.ParseStatus(result.Output, RepoRoot); + var changes = await PairMoves(snapshot.Changes); + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var change in changes) map[change.Path] = change; + + var next = new StatusSnapshot(snapshot.Branch, map, BuildFolderStatus(map.Values)); + var previous = m_status; + m_status = next; + if (!Equivalent(previous, next)) Dispatcher.UIThread.Post(() => StatusChanged?.Invoke()); + } + + private static bool Equivalent(StatusSnapshot a, StatusSnapshot b) { + if (a.Branch != b.Branch || a.Changes.Count != b.Changes.Count) return false; + foreach (var (path, change) in a.Changes) + if (!b.Changes.TryGetValue(path, out var other) || other != change) + return false; + return true; + } + + private async Task> PairMoves(IReadOnlyList changes) { + var deletedMetas = changes + .Where(c => c.Path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) && + ((c.Staged | c.Unstaged) & GitFileStatus.Deleted) != 0) + .Select(c => GitParsers.Rel(RepoRoot, c.Path)) + .ToList(); + var hasNewMetas = changes.Any(c => c.Path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) && + (c.Unstaged == GitFileStatus.Untracked || + (c.Staged & GitFileStatus.Added) != 0)); + if (deletedMetas.Count == 0 || !hasNewMetas) return changes; + + var oldUids = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var chunk in deletedMetas.Chunk(100)) { + var args = new List { "--literal-pathspecs", "grep", "--no-color", "-n", "-e", "^uid", "HEAD", "--" }; + args.AddRange(chunk); + var grep = await RunAsync(args); + if (!grep.Ok) continue; + foreach (var kv in GitParsers.ParseGrepUids(grep.Output)) oldUids[kv.Key] = kv.Value; + } + + if (oldUids.Count == 0) return changes; + return GitParsers.PairMoves(changes, oldUids, ReadUid); + } + + private static string? ReadUid(string metaPath) { + try { + foreach (var line in File.ReadLines(metaPath)) { + var trimmed = line.Trim(); + if (!trimmed.StartsWith("uid", StringComparison.Ordinal)) continue; + var eq = trimmed.IndexOf('='); + if (eq < 0) continue; + return trimmed[(eq + 1)..].Trim().Trim('"'); + } + } catch { + // unreadable + } + + return null; + } + + private Dictionary BuildFolderStatus(IEnumerable changes) { + var folders = new Dictionary(StringComparer.OrdinalIgnoreCase); + var root = RepoRoot.TrimEnd(Path.DirectorySeparatorChar); + foreach (var change in changes) { + var status = Visible(change.Display); + if (status == GitFileStatus.None) continue; + + var dir = Path.GetDirectoryName(change.Path); + while (!string.IsNullOrEmpty(dir) && dir.Length >= root.Length) { + folders[dir] = GitChange.Strongest(folders.GetValueOrDefault(dir) | status); + if (dir.Length == root.Length) break; + dir = Path.GetDirectoryName(dir); + } + } + + return folders; + } + + public async Task RefreshLocksAsync() { + if (!IsAvailable || !HasLfs || m_disposed) return; + if (Interlocked.Exchange(ref m_locksRunning, 1) == 1) return; + + try { + var result = await RunAsync(["lfs", "locks", "--verify", "--json"], serialize: false, + timeout: TimeSpan.FromSeconds(30)); + if (!result.Ok) + result = await RunAsync(["lfs", "locks", "--json"], serialize: false, timeout: TimeSpan.FromSeconds(30)); + if (!result.Ok) { + if (!m_locksFailing) Log.Warn($"Git: could not fetch LFS locks ({result.Message})"); + m_locksFailing = true; + return; + } + + m_locksFailing = false; + var locks = GitParsers.ParseLocks(result.Output, RepoRoot, m_userName); + await LoadAuthors(locks); + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var l in locks) map[l.Path] = l; + var previous = m_locks; + m_locks = new LockSnapshot(map, locks.OrderByDescending(l => l.OwnedByMe).ThenBy(l => l.Path).ToList()); + + var changed = previous.All.Count != locks.Count || + locks.Any(l => !previous.ByPath.TryGetValue(l.Path, out var old) || old != l); + if (changed) Dispatcher.UIThread.Post(() => LocksChanged?.Invoke()); + } catch (Exception e) { + Log.Warn($"Git: lock refresh failed ({e.Message})"); + } finally { + Interlocked.Exchange(ref m_locksRunning, 0); + } + } + + private async Task LoadAuthors(IReadOnlyCollection locks) { + // Authors only change when people commit + if (locks.Count == 0 || DateTime.UtcNow - m_authorsLoadedAt < TimeSpan.FromMinutes(10)) return; + m_authorsLoadedAt = DateTime.UtcNow; + + var log = await RunAsync(["log", "--all", "--max-count=5000", "--format=%an%x09%ae"], serialize: false, + timeout: TimeSpan.FromSeconds(20)); + if (log.Ok) m_authors = GitParsers.ParseAuthors(log.Output); + } + + public async Task FetchAsync() { + if (!IsAvailable || m_disposed) return new GitResult(-1, "", "git is not available"); + if (Interlocked.Exchange(ref m_fetchRunning, 1) == 1) return new GitResult(0, "", ""); + + try { + var result = await RunAsync(["fetch", "--prune"], serialize: false, timeout: TimeSpan.FromMinutes(5)); + if (result.Ok) { + m_fetchFailing = false; + LastFetch = DateTime.Now; + await RefreshStatusAsync(); + } else { + if (!m_fetchFailing) Log.Warn($"Git: fetch failed ({result.Message})"); + m_fetchFailing = true; + } + + return result; + } finally { + Interlocked.Exchange(ref m_fetchRunning, 0); + } + } + + public async Task PullAsync() { + GitAskpass.AllowDeclinedPrompts(); + var result = await RunAsync(["pull", "--ff-only"], timeout: TimeSpan.FromMinutes(30)); + if (result.Ok) LastFetch = DateTime.Now; + await RefreshStatusAsync(); + if (HasLfs) _ = RefreshLocksAsync(); + return result; + } + + public async Task PushAsync() { + GitAskpass.AllowDeclinedPrompts(); + var result = await RunAsync(["push"], timeout: TimeSpan.FromMinutes(30)); + if (!result.Ok && !Branch.HasUpstream && Branch.Name.Length > 0 && !Branch.Name.StartsWith("HEAD")) + result = await RunAsync(["push", "--set-upstream", "origin", Branch.Name], timeout: TimeSpan.FromMinutes(30)); + await RefreshStatusAsync(); + return result; + } + + public async Task StageAsync(IEnumerable absolutePaths) { + var result = await RunPathspecAsync(["add"], absolutePaths); + await RefreshStatusAsync(); + return result; + } + + public async Task UnstageAsync(IEnumerable absolutePaths) { + var result = await RunPathspecAsync(["reset", "-q"], absolutePaths); + await RefreshStatusAsync(); + return result; + } + + public async Task DiscardAsync(IEnumerable changes) { + var restore = new List(); + var created = new List(); + foreach (var change in changes) { + if (!change.HasUnstaged) continue; + // A move paired by UID is a new file as far as git is concerned + if ((change.Unstaged & (GitFileStatus.Untracked | GitFileStatus.Moved)) != 0) created.Add(change.Path); + else restore.Add(change.Path); + } + + var result = new GitResult(0, "", ""); + foreach (var path in created) { + try { + File.SetAttributes(path, FileAttributes.Normal); + File.Delete(path); + } catch (Exception e) { + result = new GitResult(-1, "", $"Could not delete {Path.GetFileName(path)}: {e.Message}"); + } + } + + if (restore.Count > 0) { + var restored = await RunPathspecAsync(["restore"], restore); + if (!restored.Ok) result = restored; + } + + await RefreshStatusAsync(); + return result; + } + + public async Task CommitAsync(string message, string description) { + var text = string.IsNullOrWhiteSpace(description) + ? message.Trim() + : message.Trim() + "\n\n" + description.Trim(); + var result = await RunAsync(["commit", "-F", "-"], stdin: text); + await RefreshStatusAsync(); + return result; + } + + public async Task LockAsync(string absolutePath) { + GitAskpass.AllowDeclinedPrompts(); + var result = await RunAsync(["lfs", "lock", "--", GitParsers.Rel(RepoRoot, absolutePath)], serialize: false, + timeout: TimeSpan.FromSeconds(60)); + await RefreshLocksAsync(); + return result; + } + + public List FindLockableFiles(string folder) { + var result = new List(); + if (!HasLfs || !Directory.Exists(folder)) return result; + + var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; + foreach (var file in Directory.EnumerateFiles(folder, "*", options)) { + if (file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) continue; + if (IsLockable(file) && GetLock(file) is null) result.Add(file); + } + + return result; + } + + public async Task LockManyAsync(IReadOnlyList absolutePaths, Action? progress = null) { + GitAskpass.AllowDeclinedPrompts(); + var locked = 0; + var failed = 0; + var done = 0; + string? firstError = null; + + using var gate = new SemaphoreSlim(4); + await Task.WhenAll(absolutePaths.Select(async path => { + await gate.WaitAsync(); + try { + var result = await RunAsync(["lfs", "lock", "--", GitParsers.Rel(RepoRoot, path)], serialize: false, + timeout: TimeSpan.FromSeconds(60)); + if (result.Ok) { + Interlocked.Increment(ref locked); + } else { + Interlocked.Increment(ref failed); + Interlocked.CompareExchange(ref firstError, result.Message, null); + } + } finally { + gate.Release(); + progress?.Invoke(Interlocked.Increment(ref done), absolutePaths.Count); + } + })); + + await RefreshLocksAsync(); + return new LockManyResult(locked, failed, firstError); + } + + public async Task UnlockManyAsync(IReadOnlyList locks) { + GitAskpass.AllowDeclinedPrompts(); + var unlocked = 0; + var failed = 0; + string? firstError = null; + + using var gate = new SemaphoreSlim(4); + await Task.WhenAll(locks.Select(async held => { + await gate.WaitAsync(); + try { + // By id + var args = held.Id.Length > 0 + ? new List { "lfs", "unlock", "--id=" + held.Id } + : ["lfs", "unlock", "--", GitParsers.Rel(RepoRoot, held.Path)]; + var result = await RunUnlock(args, held.Id); + if (result.Ok) { + Interlocked.Increment(ref unlocked); + } else { + Interlocked.Increment(ref failed); + Interlocked.CompareExchange(ref firstError, result.Message, null); + } + } finally { + gate.Release(); + } + })); + + await RefreshLocksAsync(); + return new LockManyResult(unlocked, failed, firstError); + } + + public async Task UnlockAsync(string absolutePath, bool force) { + GitAskpass.AllowDeclinedPrompts(); + var existing = GetLock(absolutePath); + var args = new List { "lfs", "unlock" }; + if (force) args.Add("--force"); + if (existing is { Id.Length: > 0 }) args.Add("--id=" + existing.Id); + else args.AddRange(["--", GitParsers.Rel(RepoRoot, absolutePath)]); + + var result = await RunUnlock(args, existing?.Id); + await RefreshLocksAsync(); + return result; + } + + // git-lfs releases the lock on the server first and only then looks at the file + private async Task RunUnlock(List args, string? id) { + var result = await RunAsync(args, serialize: false, timeout: TimeSpan.FromSeconds(60)); + if (result.Ok || !IsMissingFileError(result.Message)) return result; + + if (!string.IsNullOrEmpty(id)) { + var check = await RunAsync(["lfs", "locks", "--id=" + id, "--json"], serialize: false, + timeout: TimeSpan.FromSeconds(30)); + if (check.Ok && check.Output.Trim() is "[]" or "") return new GitResult(0, "Unlocked", ""); + } + + if (args.Contains("--force")) return result; + var forced = new List(args) { "--force" }; + return await RunAsync(forced, serialize: false, timeout: TimeSpan.FromSeconds(60)); + } + + private static bool IsMissingFileError(string message) { + return message.Contains("cannot find the file", StringComparison.OrdinalIgnoreCase) || + message.Contains("no such file", StringComparison.OrdinalIgnoreCase) || + message.Contains("GetFileAttributesEx", StringComparison.OrdinalIgnoreCase); + } + + private Task RunAsync(IEnumerable args, string? stdin = null, bool serialize = true, + TimeSpan? timeout = null) { + if (m_disposed) return Task.FromResult(new GitResult(-1, "", "cancelled")); + var cwd = RepoRoot.Length > 0 ? RepoRoot : m_projectPath; + try { + return GitRunner.RunAsync(cwd, args, stdin, serialize, timeout, m_cts.Token); + } catch (ObjectDisposedException) { + return Task.FromResult(new GitResult(-1, "", "cancelled")); + } + } + + private Task RunPathspecAsync(IEnumerable command, IEnumerable absolutePaths) { + var paths = absolutePaths.Select(p => GitParsers.Rel(RepoRoot, p)).Distinct().ToList(); + if (paths.Count == 0) return Task.FromResult(new GitResult(0, "", "")); + + var args = new List { "--literal-pathspecs" }; + args.AddRange(command); + args.AddRange(["--pathspec-from-file=-", "--pathspec-file-nul"]); + var stdin = new StringBuilder(); + foreach (var path in paths) stdin.Append(path).Append('\0'); + return RunAsync(args, stdin.ToString()); + } +} diff --git a/editor/Git/GitStatusStyle.cs b/editor/Git/GitStatusStyle.cs new file mode 100644 index 000000000..4b9c04ed8 --- /dev/null +++ b/editor/Git/GitStatusStyle.cs @@ -0,0 +1,54 @@ +using Avalonia; +using Avalonia.Media; +using Avalonia.Styling; +using Lucide.Avalonia; + +namespace editor.Git; + +public static class GitStatusStyle { + private static readonly IBrush s_fallback = new SolidColorBrush(Color.Parse("#a0a0a0")); + + public static LucideIconKind Icon(GitFileStatus status) { + return GitChange.Strongest(status) switch { + GitFileStatus.Added or GitFileStatus.Untracked => LucideIconKind.Plus, + GitFileStatus.Modified => LucideIconKind.Pencil, + GitFileStatus.Moved => LucideIconKind.ArrowRightLeft, + GitFileStatus.Deleted => LucideIconKind.Minus, + _ => LucideIconKind.Plus + }; + } + + public static string BrushKey(GitFileStatus status) { + return GitChange.Strongest(status) switch { + GitFileStatus.Added or GitFileStatus.Untracked => "Green", + GitFileStatus.Modified => "Orange", + GitFileStatus.Moved => "Blue", + GitFileStatus.Deleted => "Red", + _ => "TextMuted" + }; + } + + public static string Label(GitFileStatus status) { + return GitChange.Strongest(status) switch { + GitFileStatus.Added => "Added", + GitFileStatus.Modified => "Modified", + GitFileStatus.Moved => "Moved", + GitFileStatus.Deleted => "Deleted", + GitFileStatus.Untracked => "Untracked", + _ => "" + }; + } + + public static IBrush Brush(string key) { + if (Application.Current?.TryGetResource(key, ThemeVariant.Default, out var res) == true && res is IBrush brush) + return brush; + return s_fallback; + } + + public static IBrush Brush(GitFileStatus status) { + return Brush(BrushKey(status)); + } + + public static IBrush Green => Brush("Green"); + public static IBrush Orange => Brush("Orange"); +} diff --git a/editor/Git/GitViewModel.cs b/editor/Git/GitViewModel.cs new file mode 100644 index 000000000..b9616b60b --- /dev/null +++ b/editor/Git/GitViewModel.cs @@ -0,0 +1,49 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace editor.Git; + +public partial class GitViewModel : ObservableObject, IDisposable { + [ObservableProperty] private int m_ahead; + [ObservableProperty] private int m_behind; + [ObservableProperty] private string m_branchName = ""; + [ObservableProperty] private bool m_isAvailable; + [ObservableProperty] private string m_toolTip = ""; + + public GitViewModel(GitService service) { + Service = service; + Service.StatusChanged += Refresh; + Refresh(); + } + + public GitService Service { get; } + + public bool HasAhead => Ahead > 0; + public bool HasBehind => Behind > 0; + + public void Dispose() { + Service.StatusChanged -= Refresh; + GC.SuppressFinalize(this); + } + + partial void OnAheadChanged(int value) { + OnPropertyChanged(nameof(HasAhead)); + } + + partial void OnBehindChanged(int value) { + OnPropertyChanged(nameof(HasBehind)); + } + + private void Refresh() { + var branch = Service.Branch; + IsAvailable = Service.IsAvailable; + BranchName = branch.Name; + Ahead = branch.Ahead; + Behind = branch.Behind; + + var fetched = Service.LastFetch is { } at ? $"Last fetched {at:HH:mm}" : "Not fetched yet"; + ToolTip = branch.HasUpstream + ? $"{branch.Name}: {branch.Ahead} to push, {branch.Behind} to pull\n{fetched}\nClick to open the commit window" + : $"{branch.Name}: no upstream branch\nClick to open the commit window"; + } +} diff --git a/editor/Git/GravatarCache.cs b/editor/Git/GravatarCache.cs new file mode 100644 index 000000000..3aa71b357 --- /dev/null +++ b/editor/Git/GravatarCache.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using editor.Assets; + +namespace editor.Git; + +public static class GravatarCache { + private static readonly TimeSpan s_diskLifetime = TimeSpan.FromDays(7); + private static readonly HttpClient s_http = CreateClient(); + private static readonly ConcurrentDictionary> s_cache = new(); + + /// Looked up on gravatar.com + /// Looked up on github.com when the gravatar is missing + public static Task GetAsync(string? email, string? githubLogin = null) { + var login = githubLogin is not null && IsValidLogin(githubLogin) ? githubLogin : null; + if (string.IsNullOrWhiteSpace(email) && login is null) return Task.FromResult(null); + + var key = (string.IsNullOrWhiteSpace(email) ? "" : Hash(email)) + "|" + login?.ToLowerInvariant(); + return s_cache.GetOrAdd(key, _ => Load(email, login)); + } + + public static string Hash(string email) { + var bytes = MD5.HashData(Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant())); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static HttpClient CreateClient() { + var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("ToastEditor/1.0"); + return client; + } + + private static bool IsValidLogin(string login) { + return login.Length is > 0 and <= 39 && login.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_'); + } + + private static async Task Load(string? email, string? githubLogin) { + if (!string.IsNullOrWhiteSpace(email)) { + var hash = Hash(email); + // d=404 so an address without a gravatar answers 404 + var bitmap = await Fetch("gravatar_" + hash, $"https://www.gravatar.com/avatar/{hash}?s=64&d=404"); + if (bitmap is not null) return bitmap; + } + + return githubLogin is null + ? null + : await Fetch("github_" + githubLogin.ToLowerInvariant(), $"https://github.com/{githubLogin}.png?size=64"); + } + + private static async Task Fetch(string name, string url) { + try { + var path = ProjectContext.IsInitialized ? Path.Combine(ProjectContext.CachePath, "avatars", name + ".img") : null; + + if (path is not null && File.Exists(path) && DateTime.UtcNow - File.GetLastWriteTimeUtc(path) < s_diskLifetime) + return new Bitmap(path); + + using var response = await s_http.GetAsync(url); + if (!response.IsSuccessStatusCode) return null; + + var data = await response.Content.ReadAsByteArrayAsync(); + var bitmap = new Bitmap(new MemoryStream(data)); + if (path is not null) { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllBytesAsync(path, data); + } + + return bitmap; + } catch { + // initials are shown instead + return null; + } + } +} diff --git a/editor/Git/LockArtworkView.axaml b/editor/Git/LockArtworkView.axaml new file mode 100644 index 000000000..6e11ea3b5 --- /dev/null +++ b/editor/Git/LockArtworkView.axaml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/Git/LockArtworkView.axaml.cs b/editor/Git/LockArtworkView.axaml.cs new file mode 100644 index 000000000..ac58f1d1f --- /dev/null +++ b/editor/Git/LockArtworkView.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace editor.Git; + +public partial class LockArtworkView : UserControl { + public LockArtworkView() { + InitializeComponent(); + } +} diff --git a/editor/Git/LockArtworkViewModel.cs b/editor/Git/LockArtworkViewModel.cs new file mode 100644 index 000000000..9005ce2a3 --- /dev/null +++ b/editor/Git/LockArtworkViewModel.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using editor.Assets; + +namespace editor.Git; + +public partial class ArtworkRow : GitFileRow { + [ObservableProperty] private bool m_isChecked; + + public ArtworkRow(string path, GitService git) : base(path, git.RepoRoot, git.GetAssetStatus(path)) { } + public bool IsExpanded { get; set; } +} + +public partial class ArtworkFolder : ObservableObject { + private readonly Action m_expandedChanged; + [ObservableProperty] private bool m_isExpanded; + + public ArtworkFolder(string name, string key, Action expandedChanged) { + Name = name; + Key = key; + m_expandedChanged = expandedChanged; + } + + public string Name { get; } + public string Key { get; } + public ObservableCollection Children { get; } = []; + public List Files { get; } = []; + public string Count => $"({Files.Count})"; + + public bool? IsChecked { + get { + var ticked = Files.Count(f => f.IsChecked); + return ticked == 0 ? false : ticked == Files.Count ? true : null; + } + set { + var target = IsChecked is null ? true : value ?? false; + foreach (var file in Files) file.IsChecked = target; + } + } + + public void RaiseCheckedChanged() { + OnPropertyChanged(nameof(IsChecked)); + } + + partial void OnIsExpandedChanged(bool value) { + m_expandedChanged(this); + } +} + +public partial class LockArtworkViewModel : ObservableObject { + private readonly GitService m_git; + private readonly HashSet m_expanded = new(StringComparer.OrdinalIgnoreCase); + private List m_all = []; + private Dictionary> m_ancestors = new(); + private bool m_building; + private List m_folders = []; + + [ObservableProperty] private string m_error = ""; + [ObservableProperty] private bool m_isBusy; + [ObservableProperty] private bool m_isLoading = true; + [ObservableProperty] private double m_progress; + [ObservableProperty] private string m_searchText = ""; + [ObservableProperty] private object? m_selectedNode; + + public LockArtworkViewModel(GitService git) { + m_git = git; + _ = LoadAsync(); + } + + public ObservableCollection Roots { get; } = []; + + public int CheckedCount => m_all.Count(r => r.IsChecked); + public bool IsEmpty => !IsLoading && m_all.Count == 0; + public string LockLabel => CheckedCount == 0 ? "Lock" : $"Lock {CheckedCount} selected"; + + public event Action? Done; + + partial void OnSearchTextChanged(string value) { + BuildTree(); + } + + partial void OnIsBusyChanged(bool value) { + LockSelectedCommand.NotifyCanExecuteChanged(); + } + + private async Task LoadAsync() { + IsLoading = true; + var files = await Task.Run(() => m_git.FindLockableFiles(ProjectContext.ArtworkPath)); + + foreach (var row in m_all) row.PropertyChanged -= OnRowChanged; + m_all = files.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).Select(f => new ArtworkRow(f, m_git)).ToList(); + foreach (var row in m_all) row.PropertyChanged += OnRowChanged; + + IsLoading = false; + BuildTree(); + NotifySelectionChanged(); + OnPropertyChanged(nameof(IsEmpty)); + } + + private void OnRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { + if (e.PropertyName != nameof(ArtworkRow.IsChecked) || sender is not ArtworkRow row) return; + + // Every folder above the file shows ticked, unticked or partly + if (m_ancestors.TryGetValue(row, out var folders)) + foreach (var folder in folders) folder.RaiseCheckedChanged(); + NotifySelectionChanged(); + } + + private void NotifySelectionChanged() { + OnPropertyChanged(nameof(CheckedCount)); + OnPropertyChanged(nameof(LockLabel)); + LockSelectedCommand.NotifyCanExecuteChanged(); + } + + private void OnFolderExpanded(ArtworkFolder folder) { + // A search opens everything so matches are visible + if (m_building || SearchText.Length > 0) return; + if (folder.IsExpanded) m_expanded.Add(folder.Key); + else m_expanded.Remove(folder.Key); + } + + private void BuildTree() { + m_building = true; + var searching = SearchText.Length > 0; + var terms = SearchText.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var root = new ArtworkFolder("", "", OnFolderExpanded); + var folders = new Dictionary(StringComparer.OrdinalIgnoreCase) { [""] = root }; + var ancestors = new Dictionary>(); + + foreach (var row in m_all) { + if (!terms.All(t => row.RelPath.Contains(t, StringComparison.OrdinalIgnoreCase))) continue; + + var relative = Path.GetRelativePath(ProjectContext.ArtworkPath, row.Key); + var parts = relative.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var chain = new List { root }; + var parent = root; + var key = ""; + for (var i = 0; i < parts.Length - 1; i++) { + key = key.Length == 0 ? parts[i] : key + "/" + parts[i]; + if (!folders.TryGetValue(key, out var folder)) { + folder = new ArtworkFolder(parts[i], key, OnFolderExpanded) { + IsExpanded = searching || m_expanded.Contains(key) + }; + folders[key] = folder; + parent.Children.Add(folder); + } + + chain.Add(folder); + parent = folder; + } + + parent.Children.Add(row); + foreach (var folder in chain) folder.Files.Add(row); + ancestors[row] = chain; + } + + m_ancestors = ancestors; + m_folders = folders.Values.Where(f => !ReferenceEquals(f, root)).ToList(); + foreach (var folder in folders.Values) SortChildren(folder); + + Roots.Clear(); + foreach (var child in root.Children) Roots.Add(child); + m_building = false; + } + + // Folders first, then files + private static void SortChildren(ArtworkFolder folder) { + var sorted = folder.Children + .OrderBy(c => c is ArtworkFolder ? 0 : 1) + .ThenBy(c => c is ArtworkFolder f ? f.Name : ((ArtworkRow)c).Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + folder.Children.Clear(); + foreach (var child in sorted) folder.Children.Add(child); + } + + [RelayCommand] + private void ExpandAll() { + foreach (var folder in m_folders) folder.IsExpanded = true; + } + + [RelayCommand] + private void CollapseAll() { + foreach (var folder in m_folders) folder.IsExpanded = IsOnPathTo(folder, SelectedNode); + } + + private bool IsOnPathTo(ArtworkFolder folder, object? selected) { + return selected switch { + ArtworkRow row => m_ancestors.TryGetValue(row, out var chain) && chain.Contains(folder), + ArtworkFolder other => ReferenceEquals(folder, other) || other.Key.StartsWith(folder.Key + "/", StringComparison.OrdinalIgnoreCase), + _ => false + }; + } + + private bool CanLock() { + return !IsBusy && CheckedCount > 0; + } + + [RelayCommand(CanExecute = nameof(CanLock))] + private async Task LockSelected() { + var chosen = m_all.Where(r => r.IsChecked).Select(r => r.Key).ToList(); + if (chosen.Count == 0) return; + + IsBusy = true; + Error = ""; + Progress = 0; + try { + var outcome = await m_git.LockManyAsync(chosen, + (done, total) => Dispatcher.UIThread.Post(() => Progress = (double)done / total * 100)); + + if (outcome.Failed == 0) { + Done?.Invoke(); + return; + } + + Error = $"Locked {outcome.Locked} of {chosen.Count}. {outcome.Failed} couldn't be locked, most likely " + + $"because someone else locked them first:\n{outcome.FirstError}"; + await LoadAsync(); // what got locked drops off the list, what failed stays + } finally { + IsBusy = false; + } + } +} diff --git a/editor/Git/LockArtworkWindow.cs b/editor/Git/LockArtworkWindow.cs new file mode 100644 index 000000000..30b24426c --- /dev/null +++ b/editor/Git/LockArtworkWindow.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; + +namespace editor.Git; + +public sealed class LockArtworkWindow : Window { + public LockArtworkWindow(LockArtworkViewModel viewModel) { + Title = "Lock Artwork"; + Width = 760; + Height = 600; + MinWidth = 520; + MinHeight = 360; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + DataContext = viewModel; + Content = new LockArtworkView { DataContext = viewModel }; + viewModel.Done += Close; + } +} diff --git a/editor/Git/LockedFileModal.cs b/editor/Git/LockedFileModal.cs new file mode 100644 index 000000000..6a18ff316 --- /dev/null +++ b/editor/Git/LockedFileModal.cs @@ -0,0 +1,85 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using editor.Components.Modals; +using Lucide.Avalonia; + +namespace editor.Git; + +public enum LockedSaveChoice { + Cancel, + Proceed, + Discard, + 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 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); + } + + 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 }); + + var button = new Button { + Content = content, + Background = GitStatusStyle.Brush("Bg4"), + CornerRadius = new CornerRadius(6), + Padding = new Thickness(14, 6) + }; + button.Click += (_, _) => Close(choice); + return button; + } +} diff --git a/editor/Git/LocksView.axaml b/editor/Git/LocksView.axaml new file mode 100644 index 000000000..a61ad28db --- /dev/null +++ b/editor/Git/LocksView.axaml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/Git/LocksView.axaml.cs b/editor/Git/LocksView.axaml.cs new file mode 100644 index 000000000..f0a85228a --- /dev/null +++ b/editor/Git/LocksView.axaml.cs @@ -0,0 +1,9 @@ +using Avalonia.Controls; + +namespace editor.Git; + +public partial class LocksView : UserControl { + public LocksView() { + InitializeComponent(); + } +} diff --git a/editor/Git/LocksViewModel.cs b/editor/Git/LocksViewModel.cs new file mode 100644 index 000000000..77bf2f6bc --- /dev/null +++ b/editor/Git/LocksViewModel.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Dock.Model.Mvvm.Controls; +using editor.Components.Modals; +using editor.Workspace; + +namespace editor.Git; + +public partial class LockRow : GitFileRow { + private static readonly string[] s_avatarColors = ["Red", "Green", "Blue", "Magenta", "Orange", "Cyan", "Beige"]; + + private readonly LocksViewModel m_owner; + [ObservableProperty] private Bitmap? m_avatar; + + public LockRow(GitLock heldLock, GitService git, LocksViewModel owner) + : base(heldLock.Path, git.RepoRoot, git.GetAssetStatus(heldLock.Path)) { + m_owner = owner; + Lock = heldLock; + Email = git.GetEmail(heldLock); + Initials = MakeInitials(heldLock.Owner); + InitialsBrush = GitStatusStyle.Brush(s_avatarColors[Math.Abs(heldLock.Owner.GetHashCode()) % s_avatarColors.Length]); + UnlockCommand = new AsyncRelayCommand(() => m_owner.UnlockAsync(this)); + + _ = LoadAvatar(Email, git.IsGitHub ? heldLock.Owner : null); + } + + public GitLock Lock { get; } + public string? Email { get; } + public string Initials { get; } + public IBrush InitialsBrush { get; } + public bool IsMine => Lock.OwnedByMe; + public bool HasAvatar => Avatar is not null; + public string OwnerDetails => Email is null ? Lock.Owner : $"{Lock.Owner}\n{Email}"; + public IRelayCommand UnlockCommand { get; } + + partial void OnAvatarChanged(Bitmap? value) { + OnPropertyChanged(nameof(HasAvatar)); + } + + private async Task LoadAvatar(string? email, string? githubLogin) { + var bitmap = await GravatarCache.GetAsync(email, githubLogin); + if (bitmap is null) return; + Avalonia.Threading.Dispatcher.UIThread.Post(() => Avatar = bitmap); + } + + private static string MakeInitials(string name) { + var words = name.Split([' ', '.', '-', '_'], StringSplitOptions.RemoveEmptyEntries); + return words.Length switch { + 0 => "?", + 1 => words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant(), + _ => (words[0][..1] + words[1][..1]).ToUpperInvariant() + }; + } +} + +public partial class LocksViewModel : Tool, IDisposable { + private readonly GitService? m_git = GitService.Current; + [ObservableProperty] private string m_summary = "No locks"; + + public LocksViewModel() { + if (m_git is null) return; + m_git.LocksChanged += Rebuild; + m_git.StatusChanged += Rebuild; + Rebuild(); + } + + public ObservableCollection Rows { get; } = []; + public bool HasRows => Rows.Count > 0; + public string EmptyText => m_git is { IsAvailable: false } + ? "Git is not available for this project" + : m_git is { HasLfs: false } + ? "git-lfs is not installed" + : "Nothing is locked"; + + public void Dispose() { + if (m_git is not null) { + m_git.LocksChanged -= Rebuild; + m_git.StatusChanged -= Rebuild; + } + + GC.SuppressFinalize(this); + } + + [RelayCommand] + private void LockArtwork() { + MainWindowViewModel.Current?.LockArtworkCommand.Execute(null); + } + + [RelayCommand] + private async Task Refresh() { + if (m_git is not null) await m_git.RefreshLocksAsync(); + } + + private bool CanUnlockAll() { + return m_git is not null && Rows.Any(r => r.IsMine); + } + + [RelayCommand(CanExecute = nameof(CanUnlockAll))] + private async Task UnlockAll() { + if (m_git is null) return; + + var mine = Rows.Where(r => r.IsMine).Select(r => r.Lock).ToList(); + if (mine.Count == 0) return; + + var confirmed = await App.Modals.ShowConfirm("Unlock all", + $"Release your {mine.Count} lock{(mine.Count == 1 ? "" : "s")}? Anyone will be able to edit these files again."); + if (!confirmed) return; + + var outcome = await m_git.UnlockManyAsync(mine); + if (outcome.Failed > 0) + await App.Modals.ShowError("Unlock all", + $"Unlocked {outcome.Unlocked} of {mine.Count}. {outcome.Failed} could not be unlocked:\n{outcome.FirstError}"); + } + + private void Rebuild() { + if (m_git is null) return; + Rows.Clear(); + foreach (var held in m_git.Locks) Rows.Add(new LockRow(held, m_git, this)); + + var mine = Rows.Count(r => r.IsMine); + Summary = Rows.Count == 0 ? "No locks" : $"{Rows.Count} locked, {mine} by you"; + OnPropertyChanged(nameof(HasRows)); + OnPropertyChanged(nameof(EmptyText)); + UnlockAllCommand.NotifyCanExecuteChanged(); + } + + internal async Task UnlockAsync(LockRow row) { + if (m_git is null) return; + + var result = await m_git.UnlockAsync(row.Key, false); + if (!result.Ok) await App.Modals.ShowError("Cannot unlock " + row.Name, result.Message); + } +} diff --git a/editor/Program.cs b/editor/Program.cs index 8ba6779cb..4589c2fee 100644 --- a/editor/Program.cs +++ b/editor/Program.cs @@ -6,13 +6,17 @@ using System; using Avalonia; using editor.Engine; +using editor.Git; namespace editor; internal sealed class Program { [STAThread] - public static void Main(string[] args) { - BuildAvaloniaApp() + public static int Main(string[] args) { + // Launched by git or ssh as the askpass program + if (GitAskpass.IsHelperInvocation) return GitAskpass.RunHelper(args); + + return BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); } diff --git a/editor/Resources/TagBox.axaml b/editor/Resources/TagBox.axaml new file mode 100644 index 000000000..7279b207f --- /dev/null +++ b/editor/Resources/TagBox.axaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/editor/Resources/files/lib/CMakeLists.txt b/editor/Resources/files/lib/CMakeLists.txt index c9dd19de7..ea44952cb 100644 --- a/editor/Resources/files/lib/CMakeLists.txt +++ b/editor/Resources/files/lib/CMakeLists.txt @@ -43,6 +43,16 @@ elseif (CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") target_compile_options(toast_engine INTERFACE $<$:-march=x86-64-v3>) endif () +if (WIN32) + set(_toast_profiled "$") + target_compile_definitions(toast_engine INTERFACE + $<${_toast_profiled}:TRACY_ENABLE> + $<${_toast_profiled}:TRACY_ON_DEMAND> + $<${_toast_profiled}:TRACY_IMPORTS> + ) + target_link_libraries(toast_engine INTERFACE $<${_toast_profiled}:${TOAST_PATH}/lib/TracyClient.lib>) +endif () + file(GLOB_RECURSE SOURCE "src/*.cpp" "generated/*.cpp") add_library(my_game SHARED ${SOURCE}) target_include_directories(my_game PUBLIC diff --git a/editor/StartWindow/StartWindowViewModel.cs b/editor/StartWindow/StartWindowViewModel.cs index 5ed04522f..f6177984c 100644 --- a/editor/StartWindow/StartWindowViewModel.cs +++ b/editor/StartWindow/StartWindowViewModel.cs @@ -148,6 +148,8 @@ private void LaunchProject(string projectPath, Window parentWindow) { tasks.Add(LoaderTask.Run("cmake --build .toast/cmake_cache", "cmake", #if DEBUG "--build .toast/cmake_cache --config Debug --parallel")); +#elif RELWITHDEBINFO + "--build .toast/cmake_cache --config RelWithDebInfo --parallel")); #else "--build .toast/cmake_cache --config Release --parallel")); #endif diff --git a/editor/VoxelEditor/VoxelPalettePicker.cs b/editor/VoxelEditor/VoxelPalettePicker.cs index 364e74478..aa6a9d6eb 100644 --- a/editor/VoxelEditor/VoxelPalettePicker.cs +++ b/editor/VoxelEditor/VoxelPalettePicker.cs @@ -3,27 +3,37 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; using Avalonia.Data; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.Styling; using Avalonia.Threading; using Avalonia.VisualTree; using CommunityToolkit.Mvvm.ComponentModel; using editor.Assets; using editor.Components.Elements; using editor.Engine; +using Lucide.Avalonia; namespace editor.VoxelEditor; public partial class PaletteEntryVM : ObservableObject { private readonly Action m_changed; private readonly VoxelPaletteEntry m_entry; + private readonly VoxelPaletteFile m_palette; - public PaletteEntryVM(VoxelPaletteEntry entry, Action changed) { - m_entry = entry; + public PaletteEntryVM(VoxelPaletteFile palette, int id, Action changed) { + m_palette = palette; + m_entry = palette.Entries[id]; m_changed = changed; } + public bool IsEmpty => !m_entry.InUse; + + public bool InUse => m_entry.InUse; + public float AlbedoR { get => m_entry.R / 255f; set => Set(() => m_entry.R = ToByte(value)); @@ -59,6 +69,15 @@ public float Emissive { set => Set(() => m_entry.Emissive = value); } + public float MaxEmissive { + get => m_palette.MaxEmissive; + set { + m_palette.MaxEmissive = Math.Max(value, 0f); + OnPropertyChanged(string.Empty); + m_changed(); + } + } + public bool Transparent { get => m_entry.Transparent; set => Set(() => m_entry.Transparent = value); @@ -90,6 +109,7 @@ public sealed class VoxelPalettePicker : UserControl { private readonly Flyout m_flyout = new() { Placement = PlacementMode.BottomEdgeAlignedLeft }; private VoxelEditorViewModel? m_editor; + private UniformGrid? m_grid; private VoxelPaletteFile? m_palette; private string? m_palettePath; private DispatcherTimer? m_saveTimer; @@ -141,48 +161,126 @@ private void LoadPalette() { } } - private Color ColorOf(int id) { - if (m_palette is null) return Color.FromArgb(255, 160, 160, 160); // the engine default palette + private static IBrush Resource(string key) { + return Application.Current?.Resources.TryGetResource(key, ThemeVariant.Dark, out var found) == true && found is IBrush brush + ? brush + : Brushes.Gray; + } + + private IBrush BrushOf(int id) { + if (m_palette is null) return new SolidColorBrush(Color.FromRgb(160, 160, 160)); // the engine default palette + var entry = m_palette.Entries[id]; - return Color.FromArgb((byte)Math.Clamp((int)Math.Round(entry.Alpha * 255), 0, 255), entry.R, entry.G, entry.B); + if (!entry.InUse) return Resource("Bg2"); + + return new SolidColorBrush(Color.FromRgb(entry.R, entry.G, entry.B), entry.Transparent ? Math.Clamp(entry.Alpha, 0f, 1f) : 1.0); } private void Refresh() { var id = m_editor?.PaletteId ?? 1; - m_swatch.Background = new SolidColorBrush(ColorOf(id)); + m_swatch.Background = BrushOf(id); m_label.Text = id.ToString(); } - private Control BuildPopup() { - var grid = new UniformGrid { Columns = Columns, Width = Columns * CellSize }; - for (var id = 0; id < VoxelPaletteFile.Size; ++id) { - if (id == 0) { - // 0 is empty and never pickable - grid.Children.Add(new Border()); - continue; - } - var chosen = id; - var cell = new Button { + private Control BuildCell(int id) { + var entry = m_palette?.Entries[id]; + var empty = entry is { InUse: false }; + var selected = id == m_editor?.PaletteId; + + var cell = new Button { + Width = CellSize - 2, + Height = CellSize - 2, + Padding = new Thickness(0), + Margin = new Thickness(1), + CornerRadius = new CornerRadius(3), + BorderThickness = new Thickness(selected ? 2 : 0), + BorderBrush = Brushes.White, + Background = BrushOf(id) + }; + ToolTip.SetTip(cell, BuildTip(id)); + cell.Click += (_, _) => { + if (m_editor is not null) m_editor.PaletteId = id; + m_flyout.Content = BuildPopup(); + }; + + var panel = new Panel(); + + // behind the translucent brush so alpha reads against it + if (entry is { InUse: true, Transparent: true }) + panel.Children.Add(new Border { Width = CellSize - 2, Height = CellSize - 2, - Padding = new Thickness(0), Margin = new Thickness(1), CornerRadius = new CornerRadius(3), - BorderThickness = new Thickness(chosen == m_editor?.PaletteId ? 2 : 0), - BorderBrush = Brushes.White, - Background = new SolidColorBrush(ColorOf(chosen)) - }; - ToolTip.SetTip(cell, chosen.ToString()); - cell.Click += (_, _) => { - if (m_editor is not null) m_editor.PaletteId = chosen; - m_flyout.Content = BuildPopup(); - }; - grid.Children.Add(cell); - } + IsHitTestVisible = false, + Background = CheckerBrush.Instance + }); + + panel.Children.Add(cell); + + // marks an id as empty + if (empty) + panel.Children.Add(new Path { + Data = Geometry.Parse("M0,0.5 L1,0.5 M0.5,0 L0.5,1"), + Stretch = Stretch.Fill, + Width = 8, + Height = 8, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Stroke = Resource("Bg3"), + StrokeThickness = 2, + IsHitTestVisible = false + }); + + return panel; + } + + private object BuildTip(int id) { + if (m_palette is null) return id.ToString(); + + var tip = VoxelPaletteTip.Describe(id, m_palette); + var panel = new StackPanel { Spacing = 4 }; + panel.Children.Add(new TextBlock { Text = tip.Title, FontWeight = FontWeight.SemiBold }); + if (!tip.InUse) return panel; + + panel.Children.Add(TipRow(tip.Albedo, tip.AlbedoText)); + panel.Children.Add(TipRow(tip.Rms, tip.RmsText)); + panel.Children.Add(new TextBlock { Text = tip.EmissiveText }); + panel.Children.Add(new TextBlock { Text = tip.AlphaText }); + return panel; + } + + private static Control TipRow(Color dot, string text) { + var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + row.Children.Add(new Ellipse { + Width = 10, Height = 10, VerticalAlignment = VerticalAlignment.Center, Fill = new SolidColorBrush(dot) + }); + row.Children.Add(new TextBlock { Text = text }); + return row; + } + + private void RefreshCell(int id) { + if (m_grid is null || id < 1 || id > m_grid.Children.Count) return; + m_grid.Children[id - 1] = BuildCell(id); + } + + private Control BuildPopup() { + var grid = new UniformGrid { Columns = Columns, Width = Columns * CellSize }; + m_grid = grid; + + // 1 to 255 since 0 is empty + for (var id = 1; id < VoxelPaletteFile.Size; ++id) grid.Children.Add(BuildCell(id)); var properties = new StackPanel { Spacing = 8, Width = 320 }; var selected = m_editor?.PaletteId ?? 1; - properties.Children.Add(new TextBlock { Text = $"Palette ID {selected}", FontWeight = FontWeight.SemiBold }); + + var header = new DockPanel(); + var title = new TextBlock { + Text = $"Palette ID {selected}", FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center + }; + header.Children.Add(title); + properties.Children.Add(header); + if (m_palette is null) { properties.Children.Add(new TextBlock { Text = "The shape uses the default palette. Set a Palette Override on it to edit the colours", @@ -190,7 +288,30 @@ private Control BuildPopup() { Foreground = Brushes.Gray }); } else { - properties.DataContext = new PaletteEntryVM(m_palette.Entries[selected], QueueSave); + var entry = new PaletteEntryVM(m_palette, selected, QueueSave); + properties.DataContext = entry; + + var clear = new Button { + Content = new LucideIcon { Kind = LucideIconKind.Trash2, Size = 16, StrokeWidth = 2.5 }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(5), + CornerRadius = new CornerRadius(6), + Cursor = new Cursor(StandardCursorType.Hand), + VerticalAlignment = VerticalAlignment.Center + }; + ToolTip.SetTip(clear, "Remove this entry from the palette"); + clear.Bind(IsEnabledProperty, new Binding(nameof(PaletteEntryVM.InUse))); + clear.Click += (_, _) => { + m_palette.Entries[selected].Clear(); + m_flyout.Content = BuildPopup(); + QueueSave(); + }; + DockPanel.SetDock(clear, Avalonia.Controls.Dock.Right); + header.Children.Insert(0, clear); + + properties.Children.Add(EmptyBanner()); + var albedo = new Color3Box(); albedo.Bind(ColorBoxBase.RProperty, new Binding(nameof(PaletteEntryVM.AlbedoR)) { Mode = BindingMode.TwoWay }); albedo.Bind(ColorBoxBase.GProperty, new Binding(nameof(PaletteEntryVM.AlbedoG)) { Mode = BindingMode.TwoWay }); @@ -200,6 +321,7 @@ private Control BuildPopup() { properties.Children.Add(Row("Metallic", Number(nameof(PaletteEntryVM.Metallic)))); properties.Children.Add(Row("Reflectivity", Number(nameof(PaletteEntryVM.Reflectivity)))); properties.Children.Add(Row("Emissive", Number(nameof(PaletteEntryVM.Emissive)))); + properties.Children.Add(Row("Max emissive", Number(nameof(PaletteEntryVM.MaxEmissive), false))); var transparent = new BoolBox(); transparent.Bind(BoolBox.ValueProperty, new Binding(nameof(PaletteEntryVM.Transparent)) { Mode = BindingMode.TwoWay }); properties.Children.Add(Row("Transparent", transparent)); @@ -222,8 +344,30 @@ private Control BuildPopup() { return content; } - private static DragFloatBox Number(string property) { - var box = new DragFloatBox { Minimum = 0, Maximum = 1 }; + private static Control EmptyBanner() { + var banner = new Border { + Background = Resource("Orange"), + Padding = new Thickness(4), + CornerRadius = new CornerRadius(8), + Child = new StackPanel { + Orientation = Orientation.Horizontal, + Spacing = 4, + HorizontalAlignment = HorizontalAlignment.Center, + Children = { + new LucideIcon { + Kind = LucideIconKind.SquareDashedMousePointer, Size = 18, StrokeWidth = 2.5, Foreground = Resource("Bg1") + }, + new TextBlock { Text = "This ID is empty", FontWeight = FontWeight.Bold, Foreground = Resource("Bg1") } + } + } + }; + banner.Bind(IsVisibleProperty, new Binding(nameof(PaletteEntryVM.IsEmpty))); + return banner; + } + + private static DragFloatBox Number(string property, bool unit = true) { + var box = new DragFloatBox { Minimum = 0 }; + if (unit) box.Maximum = 1; box.Bind(DragFloatBox.ValueProperty, new Binding(property) { Mode = BindingMode.TwoWay }); return box; } @@ -244,6 +388,7 @@ private void QueueSave() { m_saveTimer.Stop(); m_saveTimer.Start(); Refresh(); + RefreshCell(m_editor?.PaletteId ?? 1); } private void OnSaveTick(object? sender, EventArgs e) { diff --git a/editor/Workspace/DockFactory.cs b/editor/Workspace/DockFactory.cs index b95898e58..519b33426 100644 --- a/editor/Workspace/DockFactory.cs +++ b/editor/Workspace/DockFactory.cs @@ -8,6 +8,7 @@ using Dock.Model.Mvvm.Controls; using editor.Assets.Types; using editor.Editors; +using editor.Git; namespace editor.Workspace; @@ -23,6 +24,7 @@ public class DockFactory : Factory { public HierarchyViewModel? Hierarchy { get; private set; } public HistoryViewModel? History { get; private set; } + public LocksViewModel? Locks { get; private set; } public InspectorViewModel? Inspector { get; private set; } public ProjectSettingsViewModel? ProjectSettingsVm { get; private set; } public SignalsViewModel? Signals { get; private set; } @@ -34,6 +36,7 @@ public class DockFactory : Factory { public override IRootDock CreateLayout() { var hierarchy = new HierarchyViewModel { Id = "Hierarchy", Title = "Hierarchy" }; var history = new HistoryViewModel { Id = "History", Title = "History" }; + var locks = new LocksViewModel { Id = "Locks", Title = "Locks" }; var inspector = new InspectorViewModel { Id = "Inspector", Title = "Inspector" }; var signals = new SignalsViewModel { Id = "Signals", Title = "Signals" }; var generic = new GenericViewModel { Id = "GenericEditor", Title = "Data Editor" }; @@ -44,6 +47,7 @@ public override IRootDock CreateLayout() { Hierarchy = hierarchy; History = history; + Locks = locks; Inspector = inspector; ProjectSettingsVm = projectSettings; Signals = signals; @@ -122,6 +126,7 @@ public override void InitLayout(IDockable layout) { ["Workspace"] = () => layout, ["Hierarchy"] = () => layout, ["History"] = () => layout, + ["Locks"] = () => layout, ["Inspector"] = () => layout, ["Signals"] = () => layout, ["GenericEditor"] = () => layout, @@ -132,6 +137,7 @@ public override void InitLayout(IDockable layout) { ["Root"] = () => m_rootDock, ["Documents"] = () => m_documentDock, ["History"] = () => History, + ["Locks"] = () => Locks, ["GenericEditor"] = () => GenericEditorVm, ["SchemaEditor"] = () => SchemaEditorVm, ["ProjectSettings"] = () => ProjectSettingsVm @@ -295,6 +301,7 @@ private void EnsureDocumentDock(IRootDock root) { return id switch { "Hierarchy" => Hierarchy, "History" => History, + "Locks" => Locks, "Inspector" => Inspector, "Signals" => Signals, "GenericEditor" => GenericEditorVm, @@ -307,6 +314,7 @@ private void EnsureDocumentDock(IRootDock root) { private IEnumerable AllTools() { yield return Hierarchy; yield return History; + yield return Locks; yield return Inspector; yield return Signals; yield return GenericEditorVm; @@ -315,7 +323,9 @@ private void EnsureDocumentDock(IRootDock root) { } private IToolDock? PreferredDockFor(Tool tool) { - return ReferenceEquals(tool, Hierarchy) || ReferenceEquals(tool, History) ? m_leftToolDock : m_rightToolDock; + return ReferenceEquals(tool, Hierarchy) || ReferenceEquals(tool, History) || ReferenceEquals(tool, Locks) + ? m_leftToolDock + : m_rightToolDock; } private void ShowTool(Tool tool, IToolDock? preferred) { diff --git a/editor/Workspace/InspectorModels.cs b/editor/Workspace/InspectorModels.cs index e36f4c511..731fe2f63 100644 --- a/editor/Workspace/InspectorModels.cs +++ b/editor/Workspace/InspectorModels.cs @@ -30,7 +30,8 @@ public enum WidgetKind { ReadOnly, Color3, Color4, - Array + Array, + Layers } public static class InspectorFormat { @@ -133,6 +134,16 @@ public static bool TryInt(string s, out int v) { return int.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out v); } + // layer masks are uint32 + // honestly i have no clue if ill receive them as -1 or 4294967295 + public static bool TryMask(string s, out uint v) { + v = 0; + if (!long.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var wide) || + wide < int.MinValue || wide > uint.MaxValue) return false; + v = unchecked((uint)wide); + return true; + } + public static float[] Floats(string s) { var cleaned = s.Replace('[', ' ').Replace(']', ' ').Replace(',', ' ').Replace(';', ' '); return cleaned.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) @@ -168,6 +179,7 @@ public static float[] Floats(string s) { switch (kind) { case WidgetKind.Float: case WidgetKind.Int: + case WidgetKind.Layers: case WidgetKind.Enum: { var m = NumberToken.Match(raw); return m.Success ? m.Value : null; @@ -233,7 +245,7 @@ public static float[] Floats(string s) { public static string? TypeZero(WidgetKind kind) { return kind switch { - WidgetKind.Float or WidgetKind.Int => "0", + WidgetKind.Float or WidgetKind.Int or WidgetKind.Layers => "0", WidgetKind.Bool => "false", WidgetKind.String => "", WidgetKind.Vec2 => "0 0", @@ -271,6 +283,8 @@ public static bool ValuesEqual(WidgetKind kind, string a, string b) { } case WidgetKind.Array: return string.Equals(a.Trim(), b.Trim(), StringComparison.Ordinal); + case WidgetKind.Layers: + return TryMask(a, out var ma) && TryMask(b, out var mb) ? ma == mb : string.Equals(a.Trim(), b.Trim(), StringComparison.Ordinal); default: return string.Equals(a.Trim(), b.Trim(), StringComparison.Ordinal); } @@ -292,6 +306,10 @@ public partial class FieldVM : ObservableObject { [ObservableProperty] private float m_float; [ObservableProperty] private int m_int; + [ObservableProperty] private uint m_mask; + [ObservableProperty] private int m_layerRows = 2; + [ObservableProperty] private FieldVM? m_layersPartner; + private FieldVM? m_layersOwner; [ObservableProperty] private bool m_isDefault = true; [ObservableProperty] private string m_readOnlyText = ""; @@ -324,6 +342,16 @@ public FieldVM(FieldInfo info) { else if (info.FieldType == "vec4_t") Kind = WidgetKind.Color4; } + if (!info.IsArray && info.FieldType == "int_t") { + if (ReflectionDatabase.HasAttr(info.Attributes, "Layers")) { + Kind = WidgetKind.Layers; + LayersPartnerName = ReflectionDatabase.GetAttr(info.Attributes, "Layers"); + } else if (ReflectionDatabase.HasAttr(info.Attributes, "LayersExcluded")) { + Kind = WidgetKind.Layers; + IsLayersPartner = true; + } + } + var nameOverride = ReflectionDatabase.GetAttr(info.Attributes, "Name"); DisplayName = string.IsNullOrEmpty(nameOverride) ? InspectorFormat.DisplayName(info.Name) : nameOverride; @@ -405,7 +433,7 @@ internal FieldVM( public DateTime LastEdit { get; private set; } = DateTime.MinValue; - public bool ShowReset => Editable && !IsDefault; + public bool ShowReset => Editable && !(IsDefault && (LayersPartner?.IsDefault ?? true)); public ObservableCollection Segments { get; } = []; @@ -424,7 +452,14 @@ internal FieldVM( public bool IsAssetRef => Kind == WidgetKind.AssetRef; public bool IsNodeRef => Kind == WidgetKind.NodeRef; public bool IsReadOnlyText => Kind == WidgetKind.ReadOnly; - public bool IsVectorLayout => Kind is WidgetKind.Vec2 or WidgetKind.Vec3 or WidgetKind.Vec4; + public bool IsLayers => Kind == WidgetKind.Layers; + public bool IsVectorLayout => Kind is WidgetKind.Vec2 or WidgetKind.Vec3 or WidgetKind.Vec4 or WidgetKind.Layers; + public string? LayersPartnerName { get; } + public bool IsLayersPartner { get; } + + public event Action? LayersEdited; + + internal string? DefaultEngineValue => m_default; public bool Editable => !ReadOnly && Kind != WidgetKind.ReadOnly; @@ -454,6 +489,23 @@ internal FieldVM( partial void OnIsDefaultChanged(bool value) { OnPropertyChanged(nameof(ShowReset)); + m_layersOwner?.OnPropertyChanged(nameof(ShowReset)); + } + + partial void OnLayersPartnerChanged(FieldVM? oldValue, FieldVM? newValue) { + if (oldValue != null) oldValue.m_layersOwner = null; + if (newValue != null) newValue.m_layersOwner = this; + OnPropertyChanged(nameof(ShowReset)); + } + + partial void OnMaskChanged(uint value) { + OnUserEdited(); + } + + [RelayCommand] + private void SetLayers(LayerMasks masks) { + if (!Editable) return; + LayersEdited?.Invoke(this, masks); } partial void OnFloatChanged(float value) { @@ -631,6 +683,8 @@ private string ToEngineString() { return Kind switch { WidgetKind.Float => InspectorFormat.Float(Float), WidgetKind.Int => Int.ToString(CultureInfo.InvariantCulture), + // the prefab keeps the mask as an int + WidgetKind.Layers => unchecked((int)Mask).ToString(CultureInfo.InvariantCulture), WidgetKind.Enum => EnumValue is not null && m_enumLabelToValue.TryGetValue(EnumValue, out var enumValue) ? enumValue : EnumValue ?? "", @@ -657,6 +711,9 @@ public void ApplyEngineString(string s) { case WidgetKind.Int: if (InspectorFormat.TryInt(s, out var i)) Int = i; break; + case WidgetKind.Layers: + if (InspectorFormat.TryMask(s, out var mask)) Mask = mask; + break; case WidgetKind.Enum: { var value = s.Trim(); EnumValue = m_enumValueToLabel.GetValueOrDefault(value, value); @@ -779,6 +836,13 @@ private void UpdateIsDefault(string current) { [RelayCommand] private void Reset() { if (m_default is null) return; + if (Kind == WidgetKind.Layers && LayersPartner is { } partner) { + InspectorFormat.TryMask(m_default, out var included); + InspectorFormat.TryMask(partner.m_default ?? "0", out var excluded); + LayersEdited?.Invoke(this, new LayerMasks(included, excluded)); + return; + } + Edited?.Invoke(this, m_default); ApplyEngineString(m_default); } diff --git a/editor/Workspace/InspectorState.cs b/editor/Workspace/InspectorState.cs index 521fc3f5b..c558b14b7 100644 --- a/editor/Workspace/InspectorState.cs +++ b/editor/Workspace/InspectorState.cs @@ -7,28 +7,36 @@ namespace editor.Workspace; public sealed class InspectorState { private readonly Dictionary m_collapsed; + private readonly Dictionary m_numbers; private readonly string? m_path; - private InspectorState(string? path, Dictionary collapsed) { + private InspectorState(string? path, Dictionary collapsed, Dictionary numbers) { m_path = path; m_collapsed = collapsed; + m_numbers = numbers; } public static InspectorState Load(string uid) { var path = ProjectContext.Resolve($"cache://tools/inspector/{uid}.json"); - Dictionary? data = null; + var collapsed = new Dictionary(); + var numbers = new Dictionary(); try { - if (File.Exists(path)) - data = JsonSerializer.Deserialize>(File.ReadAllText(path)); + if (File.Exists(path)) { + var data = JsonSerializer.Deserialize>(File.ReadAllText(path)); + foreach (var (key, value) in data ?? []) { + if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) collapsed[key] = value.GetBoolean(); + else if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) numbers[key] = number; + } + } } catch { // corrupt cache -> fall back to defaults } - return new InspectorState(path, data ?? new Dictionary()); + return new InspectorState(path, collapsed, numbers); } public static InspectorState CreateTransient() { - return new InspectorState(null, new Dictionary()); + return new InspectorState(null, new Dictionary(), new Dictionary()); } public bool Get(string key, bool defaultCollapsed) { @@ -40,11 +48,23 @@ public void Set(string key, bool collapsed) { Save(); } + public int GetInt(string key, int fallback) { + return m_numbers.TryGetValue(key, out var v) ? v : fallback; + } + + public void SetInt(string key, int value) { + m_numbers[key] = value; + Save(); + } + private void Save() { if (m_path is null) return; try { + var all = new Dictionary(); + foreach (var (key, value) in m_collapsed) all[key] = value; + foreach (var (key, value) in m_numbers) all[key] = value; Directory.CreateDirectory(Path.GetDirectoryName(m_path)!); - File.WriteAllText(m_path, JsonSerializer.Serialize(m_collapsed)); + File.WriteAllText(m_path, JsonSerializer.Serialize(all)); } catch { // best-effort persistence; ignore IO errors } diff --git a/editor/Workspace/InspectorView.axaml b/editor/Workspace/InspectorView.axaml index fde282510..7d80eeefe 100644 --- a/editor/Workspace/InspectorView.axaml +++ b/editor/Workspace/InspectorView.axaml @@ -477,6 +477,12 @@ PasteCommand="{Binding PasteCommand}" IsEnabled="{Binding Editable}" IsVisible="{Binding IsVec4}" /> + diff --git a/editor/Workspace/InspectorViewModel.cs b/editor/Workspace/InspectorViewModel.cs index 6d2794706..fd016c0fc 100644 --- a/editor/Workspace/InspectorViewModel.cs +++ b/editor/Workspace/InspectorViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Threading.Tasks; @@ -11,6 +12,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Dock.Model.Mvvm.Controls; +using editor.Components.Elements; using editor.Engine; using Proto.Events; @@ -520,8 +522,35 @@ private void AddField(ObservableCollection target, FieldInfo info) { var vm = new FieldVM(info); vm.AttachClipboardHost(this); vm.Edited += OnFieldEdited; - target.Add(vm); m_fieldByParam[vm.ParameterName] = vm; + + if (vm.IsLayers) { + LinkLayerFields(vm); + if (vm.IsLayersPartner) return; // the excluded mask receives engine values + var rowsKey = $"layers:{info.Name}"; + vm.LayerRows = m_state!.GetInt(rowsKey, vm.LayerRows); + vm.PropertyChanged += (_, e) => { + if (e.PropertyName == nameof(FieldVM.LayerRows)) m_state?.SetInt(rowsKey, vm.LayerRows); + }; + vm.LayersEdited += OnLayersEdited; + } + + target.Add(vm); + } + + private void LinkLayerFields(FieldVM field) { + foreach (var candidate in m_fieldByParam.Values) { + if (!candidate.IsLayers || ReferenceEquals(candidate, field)) continue; + if (field.IsLayersPartner && candidate.LayersPartnerName == field.ParameterName) candidate.LayersPartner = field; + else if (candidate.IsLayersPartner && field.LayersPartnerName == candidate.ParameterName) field.LayersPartner = candidate; + } + } + + private void OnLayersEdited(FieldVM field, LayerMasks masks) { + var edits = new List<(FieldVM, string)> { (field, unchecked((int)masks.Included).ToString(CultureInfo.InvariantCulture)) }; + if (field.LayersPartner is { } partner) + edits.Add((partner, unchecked((int)masks.Excluded).ToString(CultureInfo.InvariantCulture))); + ApplyPasteBatch(edits, $"{field.DisplayName} changed"); } private void OnFieldEdited(FieldVM field, string value) { diff --git a/editor/Workspace/MainWindowView.axaml b/editor/Workspace/MainWindowView.axaml index df8e0882c..c8ba582b2 100644 --- a/editor/Workspace/MainWindowView.axaml +++ b/editor/Workspace/MainWindowView.axaml @@ -41,6 +41,10 @@ Command="{Binding CompileGameReleaseCommand}" /> + + @@ -50,6 +54,45 @@ + +