diff --git a/Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs b/Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs index f2e722d..96721f8 100644 --- a/Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs +++ b/Localizer.Desktop/ViewModels/MainViewModel.CatalogLifecycle.cs @@ -52,24 +52,12 @@ await RunIoAsync(async token => [RelayCommand] private async Task SaveCatalogAsync(CancellationToken cancellationToken) { - if (_catalog is null) + if (_catalog is null || string.IsNullOrWhiteSpace(_catalogPath) || !IsDirty) { return; } - var path = _catalogPath; - if (string.IsNullOrWhiteSpace(path)) - { - path = await _dialogs.PickSaveCatalogPathAsync( - suggestedFileName: $"{_catalog.CatalogId.Value}.json", - cancellationToken).ConfigureAwait(true); - if (path is null) - { - return; - } - } - - await SaveToPathAsync(path, cancellationToken).ConfigureAwait(true); + await SaveToPathAsync(_catalogPath, cancellationToken).ConfigureAwait(true); } [RelayCommand] @@ -112,6 +100,7 @@ await RunIoAsync(async token => _catalogPath = path; IsDirty = false; + RefreshSaveCommandState(); UpdateWindowTitle(); StatusText = $"Saved {path}"; }, cancellationToken).ConfigureAwait(true); diff --git a/Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs b/Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs index dd2f71d..e58d2ab 100644 --- a/Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs +++ b/Localizer.Desktop/ViewModels/MainViewModel.EntryEditing.cs @@ -30,6 +30,7 @@ private async Task HandleSelectedEntryChangedAsync(EntryRowViewModel? value) IsDirty = true; StatusText = $"Saved pending edits for '{previousKey}'."; + // Grid locale statuses already synced inside TryApplyPendingEditorChangesAsync. } LoadSelectedEntry(value); @@ -188,9 +189,22 @@ private async Task TryApplyPendingEditorChangesAsync(bool refreshUi) } IsDirty = true; + SyncEntriesFromCatalog(); if (refreshUi) { - RefreshFromCatalog(selectKey: key); + var row = Entries.FirstOrDefault(entry => entry.Key == key); + if (row is not null) + { + if (!ReferenceEquals(SelectedEntry, row)) + { + _suppressSelectionLoad = true; + SelectedEntry = row; + _suppressSelectionLoad = false; + } + + LoadSelectedEntry(row); + } + RunValidation(); } diff --git a/Localizer.Desktop/ViewModels/MainViewModel.cs b/Localizer.Desktop/ViewModels/MainViewModel.cs index 69ef359..dbf7d1b 100644 --- a/Localizer.Desktop/ViewModels/MainViewModel.cs +++ b/Localizer.Desktop/ViewModels/MainViewModel.cs @@ -102,6 +102,12 @@ public MainViewModel( [ObservableProperty] public partial bool IsDirty { get; set; } + [ObservableProperty] + public partial bool CanSave { get; set; } + + [ObservableProperty] + public partial bool CanSaveAs { get; set; } + [ObservableProperty] public partial string EditorKey { get; set; } = string.Empty; @@ -134,6 +140,13 @@ partial void OnIsBusyChanged(bool value) partial void OnHasCatalogChanged(bool value) { OnPropertyChanged(nameof(CanRunCatalogCommands)); + RefreshSaveCommandState(); + } + + partial void OnIsDirtyChanged(bool value) + { + RefreshSaveCommandState(); + UpdateWindowTitle(); } partial void OnHasProjectFolderChanged(bool value) @@ -141,6 +154,12 @@ partial void OnHasProjectFolderChanged(bool value) OnPropertyChanged(nameof(CanRunProjectCommands)); } + private void RefreshSaveCommandState() + { + CanSaveAs = HasCatalog; + CanSave = HasCatalog && IsDirty && !string.IsNullOrWhiteSpace(_catalogPath); + } + private void UpdateBusyStatusAnimation(bool isBusy) { if (isBusy) @@ -219,12 +238,13 @@ private void SetCatalog(Catalog catalog, string? path, bool dirty) _catalogPath = path; HasCatalog = true; IsDirty = dirty; + RefreshSaveCommandState(); RefreshFromCatalog(selectKey: catalog.Entries.Keys.Select(key => key.Value).OrderBy(key => key, StringComparer.Ordinal).FirstOrDefault()); RunValidation(); UpdateWindowTitle(); } - private void RefreshFromCatalog(string? selectKey) + private void RefreshFromCatalog(string? selectKey, bool reloadEditor = true) { if (_catalog is null) { @@ -240,31 +260,97 @@ private void RefreshFromCatalog(string? selectKey) SummaryText = $"Entries {summary.EntryCount} | Missing {summary.MissingCount} | Stale {summary.StaleCount} | Draft {summary.DraftCount} | Approved {summary.ApprovedCount}"; + SyncEntriesFromCatalog(); + var previousKey = selectKey ?? SelectedEntry?.Key; - _suppressSelectionLoad = true; - Entries.Clear(); + var target = Entries.FirstOrDefault(entry => entry.Key == previousKey) ?? Entries.FirstOrDefault(); - foreach (var entry in _catalog.Entries.Values.OrderBy(entry => entry.Key.Value, StringComparer.Ordinal)) + if (!ReferenceEquals(SelectedEntry, target)) { - var statuses = _catalog.RequiredLocales - .OrderBy(locale => locale.Value, StringComparer.Ordinal) - .Select(locale => $"{locale.Value}:{_catalog.GetEffectiveStatus(entry, locale)}") - .ToArray(); - - Entries.Add(new EntryRowViewModel( - entry.Key.Value, - entry.SourceText, - string.Join(" | ", statuses))); + _suppressSelectionLoad = true; + SelectedEntry = target; + _suppressSelectionLoad = false; } - _suppressSelectionLoad = false; - SelectedEntry = Entries.FirstOrDefault(entry => entry.Key == previousKey) ?? Entries.FirstOrDefault(); - if (SelectedEntry is null) + if (target is null) { ClearEditor(); + return; + } + + if (reloadEditor) + { + LoadSelectedEntry(target); } } + // Updates existing row view-models in place so the entry grid keeps scroll position and selection. + private void SyncEntriesFromCatalog() + { + if (_catalog is null) + { + Entries.Clear(); + return; + } + + var ordered = _catalog.Entries.Values + .OrderBy(entry => entry.Key.Value, StringComparer.Ordinal) + .ToList(); + + var desiredKeys = new HashSet( + ordered.Select(entry => entry.Key.Value), + StringComparer.Ordinal); + + for (var index = Entries.Count - 1; index >= 0; index--) + { + if (!desiredKeys.Contains(Entries[index].Key)) + { + Entries.RemoveAt(index); + } + } + + var rowsByKey = Entries.ToDictionary(row => row.Key, StringComparer.Ordinal); + + for (var index = 0; index < ordered.Count; index++) + { + var entry = ordered[index]; + var key = entry.Key.Value; + var localeStatuses = FormatLocaleStatuses(entry); + + if (rowsByKey.TryGetValue(key, out var existing)) + { + existing.SourceText = entry.SourceText; + existing.LocaleStatuses = localeStatuses; + + var currentIndex = Entries.IndexOf(existing); + if (currentIndex != index && currentIndex >= 0) + { + Entries.Move(currentIndex, index); + } + } + else + { + var row = new EntryRowViewModel(key, entry.SourceText, localeStatuses); + Entries.Insert(index, row); + rowsByKey[key] = row; + } + } + } + + private string FormatLocaleStatuses(CatalogEntry entry) + { + if (_catalog is null) + { + return string.Empty; + } + + var statuses = _catalog.RequiredLocales + .OrderBy(locale => locale.Value, StringComparer.Ordinal) + .Select(locale => $"{locale.Value}:{_catalog.GetEffectiveStatus(entry, locale)}"); + + return string.Join(" | ", statuses); + } + private void UpdateWindowTitle() { if (_catalog is null) diff --git a/Localizer.Desktop/Views/MainWindow.axaml b/Localizer.Desktop/Views/MainWindow.axaml index ac020e0..5351f8f 100644 --- a/Localizer.Desktop/Views/MainWindow.axaml +++ b/Localizer.Desktop/Views/MainWindow.axaml @@ -15,8 +15,8 @@