From dc49526f7d24f57744a0fdaf51a0d82da0dd952c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Rodr=C3=ADguez=20Mart=C3=ADnez?= Date: Fri, 9 Oct 2026 18:15:59 +0200 Subject: [PATCH 01/38] game frame limiter --- editor/Engine/ToastEngine.cs | 30 +++++++++++++++++++ .../schemas/project_settings.schema.json | 7 +++++ engine/ffi/engine.h | 3 ++ engine/src/toast/engine.cpp | 4 +++ engine/src/toast/project_settings.cpp | 3 ++ engine/src/toast/project_settings.hpp | 5 ++++ tools/player/program.cs | 17 ++++++----- tools/player/toast_engine.cs | 5 ++++ 8 files changed, 67 insertions(+), 7 deletions(-) diff --git a/editor/Engine/ToastEngine.cs b/editor/Engine/ToastEngine.cs index 3a08f5cde..473398c2c 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; @@ -307,6 +308,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 @@ -322,6 +325,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 @@ -331,6 +336,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) { @@ -487,6 +514,9 @@ 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(); + public static void ReloadProjectSettings() { if (IsEngineReady) OnTickThread(toast_reload_project_settings); } diff --git a/engine/assets/schemas/project_settings.schema.json b/engine/assets/schemas/project_settings.schema.json index 4b9a4119b..79d8af4c4 100644 --- a/engine/assets/schemas/project_settings.schema.json +++ b/engine/assets/schemas/project_settings.schema.json @@ -21,6 +21,13 @@ "description": "Each entry X creates an X:// VFS scheme rooted at {ProjectPath}/X", "default": ["assets"] }, + "max_tick_rate": { + "type": "integer", + "x-toast-type": "int", + "minimum": 0, + "description": "Maximum game ticks per second in the editor and the player; 0 is uncapped", + "default": 500 + }, "gameplay": { "x-toast-type": "gameplay", "description": "Gameplay entry-point settings" diff --git a/engine/ffi/engine.h b/engine/ffi/engine.h index a26bcaafb..edf2d8233 100644 --- a/engine/ffi/engine.h +++ b/engine/ffi/engine.h @@ -112,6 +112,9 @@ TOAST_C_API int toast_viewport_get_frame(void* dst, uint32_t dst_capacity, toast TOAST_C_API void toast_reload_project_settings(void) NOEXCEPT; +/// @return Maximum ticks per second from the project settings, 0 is uncapped +TOAST_C_API uint32_t toast_get_max_tick_rate(void) NOEXCEPT; + /// @brief Tells the renderer whether the host application is focused and whether it is minimized /// @param focused 0 holds rendering at 30 fps (or the configured cap when lower), 1 restores the configured cap /// @param minimized 1 stops rendering entirely until called again with 0 diff --git a/engine/src/toast/engine.cpp b/engine/src/toast/engine.cpp index 431ee009d..d1e6262a5 100644 --- a/engine/src/toast/engine.cpp +++ b/engine/src/toast/engine.cpp @@ -1103,6 +1103,10 @@ void toast_reload_project_settings() noexcept { toast::Engine::get()->reloadSettings(); } +auto toast_get_max_tick_rate() noexcept -> uint32_t { + return toast::ProjectSettings::maxTickRate(); +} + void toast_set_window_state(int focused, int minimized) noexcept { auto* vk_renderer = renderer::VulkanRenderer::instance; if (vk_renderer == nullptr) { diff --git a/engine/src/toast/project_settings.cpp b/engine/src/toast/project_settings.cpp index d35bf5c35..47550cb42 100644 --- a/engine/src/toast/project_settings.cpp +++ b/engine/src/toast/project_settings.cpp @@ -38,6 +38,9 @@ ProjectSettings::ProjectSettings(const std::filesystem::path& path) { m_databases.emplace_back("assets"); } + m_max_tick_rate = + static_cast(std::max(0, table["max_tick_rate"].value_or(k_default_max_tick_rate))); + if (auto* gameplay = table["gameplay"].as_table()) { auto make_handle = [](std::string_view uri) -> assets::Handle { return {nullptr, toast::UID::make(), uri}; diff --git a/engine/src/toast/project_settings.hpp b/engine/src/toast/project_settings.hpp index a82f125bb..afdaad282 100644 --- a/engine/src/toast/project_settings.hpp +++ b/engine/src/toast/project_settings.hpp @@ -77,13 +77,18 @@ class TOAST_API ProjectSettings { static auto uiSettings() -> const UISettings& { return instance->m_ui_settings; } + /// 0 is uncapped + static auto maxTickRate() -> unsigned { return instance != nullptr ? instance->m_max_tick_rate : k_default_max_tick_rate; } + private: + static constexpr unsigned k_default_max_tick_rate = 500; static inline ProjectSettings* instance = nullptr; std::string m_name; std::array m_version {1, 0, 0}; std::vector m_databases; GameplaySettings m_gameplay_settings; UISettings m_ui_settings; + unsigned m_max_tick_rate = k_default_max_tick_rate; }; } diff --git a/tools/player/program.cs b/tools/player/program.cs index 4c4706abf..2a1a98604 100644 --- a/tools/player/program.cs +++ b/tools/player/program.cs @@ -4,9 +4,6 @@ namespace player; internal class Program { - // Caps the tick loop rate to 500 - private const double TargetTickHz = 500.0; - public static void Main() { var engine = new ToastEngine(); var game = new ApplicationLayer(); @@ -33,22 +30,28 @@ public static void Main() { engine.CreateSdlWindow("Toast Engine"); engine.StartGame(); - var targetInterval = TimeSpan.FromSeconds(1.0 / TargetTickHz); var stopwatch = Stopwatch.StartNew(); - var nextTick = stopwatch.Elapsed + targetInterval; + var nextTick = stopwatch.Elapsed; while (!engine.ShouldClose()) { engine.Tick(); + var maxRate = engine.MaxTickRate; + if (maxRate == 0) { + nextTick = stopwatch.Elapsed; + continue; + } + + var targetInterval = TimeSpan.FromSeconds(1.0 / maxRate); + nextTick += targetInterval; var remaining = nextTick - stopwatch.Elapsed; if (remaining > TimeSpan.Zero) { if (remaining > TimeSpan.FromMilliseconds(2)) Thread.Sleep(remaining - TimeSpan.FromMilliseconds(1)); while (stopwatch.Elapsed < nextTick) Thread.SpinWait(50); - nextTick += targetInterval; } else { // fell behind, resync instead of bursting to catch up - nextTick = stopwatch.Elapsed + targetInterval; + nextTick = stopwatch.Elapsed; } } diff --git a/tools/player/toast_engine.cs b/tools/player/toast_engine.cs index cc8216bf2..ddf631f7c 100644 --- a/tools/player/toast_engine.cs +++ b/tools/player/toast_engine.cs @@ -32,6 +32,8 @@ public void Tick() { toast_tick(m_handle); } + public uint MaxTickRate => toast_get_max_tick_rate(); + public bool ShouldClose() { return toast_should_close(m_handle) != 0; } @@ -73,6 +75,9 @@ public void StartGame() { [DllImport("__ENGINE_LIB__", CallingConvention = CallingConvention.Cdecl)] private static extern int toast_should_close(IntPtr engine); + [DllImport("__ENGINE_LIB__", CallingConvention = CallingConvention.Cdecl)] + private static extern uint toast_get_max_tick_rate(); + [DllImport("__ENGINE_LIB__", CallingConvention = CallingConvention.Cdecl)] private static extern void toast_destroy(IntPtr engine); From e8837d986021492c314c86ae57327a79347376a6 Mon Sep 17 00:00:00 2001 From: Xein Date: Sat, 10 Oct 2026 10:37:41 +0200 Subject: [PATCH 02/38] Git lock support --- editor/Assets/AssetBrowserView.axaml | 95 ++- editor/Assets/AssetBrowserViewModel.cs | 53 +- editor/Assets/AssetFile.cs | 72 ++- editor/Assets/AssetFolder.cs | 7 + editor/Components/Elements/GitChip.cs | 78 +++ editor/Components/Modals/MessageModal.axaml | 22 +- editor/Editors/CurveViewModel.cs | 21 +- editor/Editors/GenericViewModel.cs | 24 +- editor/Editors/HapticsViewModel.cs | 20 +- editor/Editors/PaletteViewModel.cs | 20 +- editor/Editors/SchemaViewModel.cs | 20 +- editor/Editors/TableViewModel.cs | 21 +- editor/Git/CommitView.axaml | 320 ++++++++++ editor/Git/CommitView.axaml.cs | 72 +++ editor/Git/CommitViewModel.cs | 164 ++++++ editor/Git/CommitWindow.cs | 17 + editor/Git/GitAttributes.cs | 152 +++++ editor/Git/GitFileRow.cs | 42 ++ editor/Git/GitLockGuard.cs | 106 ++++ editor/Git/GitModels.cs | 50 ++ editor/Git/GitParsers.cs | 211 +++++++ editor/Git/GitRunner.cs | 88 +++ editor/Git/GitService.cs | 613 ++++++++++++++++++++ editor/Git/GitStatusStyle.cs | 54 ++ editor/Git/GitViewModel.cs | 49 ++ editor/Git/GravatarCache.cs | 80 +++ editor/Git/LockArtworkView.axaml | 167 ++++++ editor/Git/LockArtworkView.axaml.cs | 9 + editor/Git/LockArtworkViewModel.cs | 229 ++++++++ editor/Git/LockArtworkWindow.cs | 17 + editor/Git/LockedFileModal.cs | 85 +++ editor/Git/LocksView.axaml | 161 +++++ editor/Git/LocksView.axaml.cs | 9 + editor/Git/LocksViewModel.cs | 139 +++++ editor/Workspace/DockFactory.cs | 12 +- editor/Workspace/MainWindowView.axaml | 63 ++ editor/Workspace/MainWindowViewModel.cs | 132 +++++ editor/Workspace/WorkspaceViewModel.cs | 19 +- engine/src/toast/world/play_workspace.hpp | 2 +- 39 files changed, 3438 insertions(+), 77 deletions(-) create mode 100644 editor/Components/Elements/GitChip.cs create mode 100644 editor/Git/CommitView.axaml create mode 100644 editor/Git/CommitView.axaml.cs create mode 100644 editor/Git/CommitViewModel.cs create mode 100644 editor/Git/CommitWindow.cs create mode 100644 editor/Git/GitAttributes.cs create mode 100644 editor/Git/GitFileRow.cs create mode 100644 editor/Git/GitLockGuard.cs create mode 100644 editor/Git/GitModels.cs create mode 100644 editor/Git/GitParsers.cs create mode 100644 editor/Git/GitRunner.cs create mode 100644 editor/Git/GitService.cs create mode 100644 editor/Git/GitStatusStyle.cs create mode 100644 editor/Git/GitViewModel.cs create mode 100644 editor/Git/GravatarCache.cs create mode 100644 editor/Git/LockArtworkView.axaml create mode 100644 editor/Git/LockArtworkView.axaml.cs create mode 100644 editor/Git/LockArtworkViewModel.cs create mode 100644 editor/Git/LockArtworkWindow.cs create mode 100644 editor/Git/LockedFileModal.cs create mode 100644 editor/Git/LocksView.axaml create mode 100644 editor/Git/LocksView.axaml.cs create mode 100644 editor/Git/LocksViewModel.cs diff --git a/editor/Assets/AssetBrowserView.axaml b/editor/Assets/AssetBrowserView.axaml index 0d3ed58d8..944a98ce7 100644 --- a/editor/Assets/AssetBrowserView.axaml +++ b/editor/Assets/AssetBrowserView.axaml @@ -38,6 +38,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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/GitRunner.cs b/editor/Git/GitRunner.cs new file mode 100644 index 000000000..dbd94f240 --- /dev/null +++ b/editor/Git/GitRunner.cs @@ -0,0 +1,88 @@ +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); + + // Background calls must never block on a credential prompt + psi.Environment["GIT_TERMINAL_PROMPT"] = "0"; + psi.Environment["GCM_INTERACTIVE"] = "never"; + psi.Environment["GIT_OPTIONAL_LOCKS"] = "0"; + psi.Environment["GIT_LFS_FORCE_PROGRESS"] = "0"; + + 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) { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(timeout); + + 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); + } + } + } +} diff --git a/editor/Git/GitService.cs b/editor/Git/GitService.cs new file mode 100644 index 000000000..bb67e84e1 --- /dev/null +++ b/editor/Git/GitService.cs @@ -0,0 +1,613 @@ +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() { + 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() { + 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) { + 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) { + 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) { + 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) { + 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/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/MainWindowView.axaml b/editor/Workspace/MainWindowView.axaml index df8e0882c..5054a50bf 100644 --- a/editor/Workspace/MainWindowView.axaml +++ b/editor/Workspace/MainWindowView.axaml @@ -41,6 +41,10 @@ Command="{Binding CompileGameReleaseCommand}" /> + + @@ -50,6 +54,45 @@ + +