using System.Collections.ObjectModel; using System.Text; using Avalonia.Controls; using Avalonia.Media.Imaging; using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using GsaEditor.Core.Compression; using GsaEditor.Core.IO; using GsaEditor.Core.Models; using GsaEditor.Helpers; namespace GsaEditor.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private static readonly HashSet TextExtensions = new(StringComparer.OrdinalIgnoreCase) { ".txt", ".nml", ".xml", ".json", ".csv", ".ini", ".cfg", ".log", ".nut" }; private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".bmp" }; private static readonly HashSet UnsupportedImageExtensions = new(StringComparer.OrdinalIgnoreCase) { ".tga", ".dds" }; private static readonly FilePickerFileType GsaFileType = new("GSA Archives") { Patterns = new[] { "*.gsa" } }; private static readonly FilePickerFileType AllFilesType = new("All Files") { Patterns = new[] { "*" } }; private Window? _window; private GsaArchive? _archive; [ObservableProperty] [NotifyPropertyChangedFor(nameof(Title))] [NotifyPropertyChangedFor(nameof(StatusArchivePath))] [NotifyPropertyChangedFor(nameof(HasArchive))] private string? _archivePath; [ObservableProperty] [NotifyPropertyChangedFor(nameof(Title))] private bool _isDirty; [ObservableProperty] private bool _isLoading; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasSelectedFile))] [NotifyPropertyChangedFor(nameof(HasSelectedFolder))] [NotifyPropertyChangedFor(nameof(HasSelectedEntry))] private EntryTreeNodeViewModel? _selectedNode; [ObservableProperty] private EntryViewModel? _selectedEntry; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsTextPreview))] [NotifyPropertyChangedFor(nameof(IsImagePreview))] [NotifyPropertyChangedFor(nameof(IsHexPreview))] [NotifyPropertyChangedFor(nameof(HasPreview))] private PreviewMode _previewMode = PreviewMode.None; [ObservableProperty] private string? _previewText; [ObservableProperty] private Bitmap? _previewImage; [ObservableProperty] private string? _hexDumpText; [ObservableProperty] private bool _isEditingText; public ObservableCollection TreeRoots { get; } = new(); // --- Derived properties --- public string Title => ArchivePath != null ? $"GsaEditor - {Path.GetFileName(ArchivePath)}{(IsDirty ? " *" : "")}" : "GsaEditor"; public bool HasArchive => _archive != null; public bool HasSelectedEntry => SelectedNode?.Entry != null; public bool HasSelectedFile => SelectedNode != null && !SelectedNode.IsFolder; public bool HasSelectedFolder => SelectedNode != null && SelectedNode.IsFolder; public bool IsTextPreview => PreviewMode == PreviewMode.Text; public bool IsImagePreview => PreviewMode == PreviewMode.Image; public bool IsHexPreview => PreviewMode == PreviewMode.HexDump; public bool HasPreview => PreviewMode != PreviewMode.None; // --- Status bar --- public string StatusArchivePath => ArchivePath ?? "No archive loaded"; public string StatusEntryCount => _archive != null ? $"{_archive.Entries.Count} entries" : ""; public string StatusSizeInfo { get { if (_archive == null) return ""; long totalOriginal = _archive.Entries.Sum(e => (long)e.OriginalLength); long totalCompressed = _archive.Entries.Sum(e => (long)e.CompressedLength); return $"Total: {FormatSize(totalOriginal)} / Compressed: {FormatSize(totalCompressed)}"; } } /// /// Sets the parent window reference for dialog display. /// public void SetWindow(Window window) { _window = window; } // ========================================================================= // Selection changed // ========================================================================= partial void OnSelectedNodeChanged(EntryTreeNodeViewModel? value) { IsEditingText = false; if (value?.Entry != null) { var entryVm = new EntryViewModel(value.Entry); entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; SelectedEntry = entryVm; UpdatePreview(value.Entry); } else { SelectedEntry = null; ClearPreview(); } } private void UpdatePreview(GsaEntry entry) { var ext = Path.GetExtension(entry.Alias).ToLowerInvariant(); if (TextExtensions.Contains(ext)) { PreviewMode = PreviewMode.Text; var data = entry.GetDecompressedData(); PreviewText = Encoding.UTF8.GetString(data); PreviewImage?.Dispose(); PreviewImage = null; HexDumpText = null; } else if (ImageExtensions.Contains(ext)) { try { var data = entry.GetDecompressedData(); using var ms = new MemoryStream(data); PreviewImage?.Dispose(); PreviewImage = new Bitmap(ms); PreviewMode = PreviewMode.Image; } catch { PreviewImage = null; PreviewMode = PreviewMode.HexDump; HexDumpText = "Preview not available for this image."; } PreviewText = null; } else if (UnsupportedImageExtensions.Contains(ext)) { PreviewMode = PreviewMode.HexDump; HexDumpText = $"Preview not available for {ext.ToUpperInvariant()} files."; PreviewText = null; PreviewImage?.Dispose(); PreviewImage = null; } else { PreviewMode = PreviewMode.HexDump; var data = entry.GetDecompressedData(); HexDumpText = HexDumper.Dump(data, 512); PreviewText = null; PreviewImage?.Dispose(); PreviewImage = null; } } private void ClearPreview() { PreviewMode = PreviewMode.None; PreviewText = null; PreviewImage?.Dispose(); PreviewImage = null; HexDumpText = null; } // ========================================================================= // File menu commands // ========================================================================= [RelayCommand] private async Task OpenArchive() { if (_window == null) return; if (IsDirty) { var confirmed = await Dialogs.ConfirmAsync(_window, "Unsaved Changes", "You have unsaved changes. Open a new archive without saving?"); if (!confirmed) return; } var files = await _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Open GSA Archive", FileTypeFilter = new[] { GsaFileType, AllFilesType }, AllowMultiple = false }); if (files.Count == 0) return; var path = files[0].Path.LocalPath; await LoadArchiveAsync(path); } private async Task LoadArchiveAsync(string path) { IsLoading = true; ClearPreview(); SelectedEntry = null; SelectedNode = null; try { var archive = await Task.Run(() => GsaReader.Read(path)); _archive = archive; ArchivePath = path; IsDirty = false; BuildTree(); NotifyStatusChanged(); } catch (Exception ex) { if (_window != null) await Dialogs.ShowMessageAsync(_window, "Error", $"Failed to open archive:\n{ex.Message}"); } finally { IsLoading = false; } } [RelayCommand] private async Task Save() { if (_archive == null || _window == null) return; if (string.IsNullOrEmpty(ArchivePath)) { await SaveAs(); return; } try { await Task.Run(() => GsaWriter.Write(_archive, ArchivePath!)); // Generate .idx alongside var idxPath = Path.ChangeExtension(ArchivePath, ".idx"); await Task.Run(() => GsaIndexWriter.Write(_archive, idxPath)); IsDirty = false; } catch (Exception ex) { await Dialogs.ShowMessageAsync(_window, "Error", $"Failed to save archive:\n{ex.Message}"); } } [RelayCommand] private async Task SaveAs() { if (_archive == null || _window == null) return; var file = await _window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Save GSA Archive As", FileTypeChoices = new[] { GsaFileType }, DefaultExtension = "gsa", SuggestedFileName = ArchivePath != null ? Path.GetFileName(ArchivePath) : "archive.gsa" }); if (file == null) return; var path = file.Path.LocalPath; try { await Task.Run(() => GsaWriter.Write(_archive, path)); var idxPath = Path.ChangeExtension(path, ".idx"); await Task.Run(() => GsaIndexWriter.Write(_archive, idxPath)); ArchivePath = path; IsDirty = false; } catch (Exception ex) { await Dialogs.ShowMessageAsync(_window, "Error", $"Failed to save archive:\n{ex.Message}"); } } [RelayCommand] private async Task CloseArchive() { if (_window == null) return; if (IsDirty) { var confirmed = await Dialogs.ConfirmAsync(_window, "Unsaved Changes", "You have unsaved changes. Close without saving?"); if (!confirmed) return; } _archive = null; ArchivePath = null; IsDirty = false; TreeRoots.Clear(); SelectedEntry = null; SelectedNode = null; ClearPreview(); NotifyStatusChanged(); } [RelayCommand] private void Exit() { _window?.Close(); } // ========================================================================= // Edit menu commands // ========================================================================= [RelayCommand] private async Task AddFiles() { if (_archive == null || _window == null) return; var files = await _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Add Files to Archive", AllowMultiple = true, FileTypeFilter = new[] { AllFilesType } }); if (files.Count == 0) return; string prefix = ""; if (SelectedNode != null && SelectedNode.IsFolder) { prefix = SelectedNode.FullPath; if (!prefix.EndsWith("/")) prefix += "/"; } foreach (var file in files) { var filePath = file.Path.LocalPath; var fileName = Path.GetFileName(filePath); var alias = prefix + fileName; var data = await Task.Run(() => File.ReadAllBytes(filePath)); var entry = new GsaEntry(); entry.Alias = alias; entry.SetData(data, compress: true); _archive.Entries.Add(entry); } IsDirty = true; BuildTree(); NotifyStatusChanged(); } [RelayCommand] private async Task ExtractAll() { if (_archive == null || _window == null) return; var folders = await _window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select Extraction Folder" }); if (folders.Count == 0) return; var basePath = folders[0].Path.LocalPath; try { await Task.Run(() => { foreach (var entry in _archive.Entries) { var outputPath = Path.Combine(basePath, entry.Alias.Replace('/', Path.DirectorySeparatorChar)); var dir = Path.GetDirectoryName(outputPath); if (dir != null) Directory.CreateDirectory(dir); var data = entry.GetDecompressedData(); File.WriteAllBytes(outputPath, data); } }); await Dialogs.ShowMessageAsync(_window, "Extract All", $"Successfully extracted {_archive.Entries.Count} files to:\n{basePath}"); } catch (Exception ex) { await Dialogs.ShowMessageAsync(_window, "Error", $"Extraction failed:\n{ex.Message}"); } } [RelayCommand] private async Task Repack() { if (_archive == null || _window == null) return; IsLoading = true; try { await Task.Run(() => { foreach (var entry in _archive.Entries) { var decompressed = entry.GetDecompressedData(); entry.SetData(decompressed, entry.IsCompressed); } }); IsDirty = true; BuildTree(); NotifyStatusChanged(); if (SelectedNode?.Entry != null) { var entryVm = new EntryViewModel(SelectedNode.Entry); entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; SelectedEntry = entryVm; } } finally { IsLoading = false; } } // ========================================================================= // Context menu commands // ========================================================================= [RelayCommand] private async Task ExtractEntry() { if (SelectedNode?.Entry == null || _window == null) return; var entry = SelectedNode.Entry; var file = await _window.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Extract File", SuggestedFileName = Path.GetFileName(entry.Alias), FileTypeChoices = new[] { AllFilesType } }); if (file == null) return; try { var data = entry.GetDecompressedData(); var path = file.Path.LocalPath; await File.WriteAllBytesAsync(path, data); } catch (Exception ex) { await Dialogs.ShowMessageAsync(_window, "Error", $"Extraction failed:\n{ex.Message}"); } } [RelayCommand] private async Task ReplaceEntry() { if (SelectedNode?.Entry == null || _archive == null || _window == null) return; var files = await _window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Replace Entry With", AllowMultiple = false, FileTypeFilter = new[] { AllFilesType } }); if (files.Count == 0) return; var filePath = files[0].Path.LocalPath; var data = await Task.Run(() => File.ReadAllBytes(filePath)); var entry = SelectedNode.Entry; entry.SetData(data, entry.IsCompressed); IsDirty = true; NotifyStatusChanged(); // Refresh the selected entry view var entryVm = new EntryViewModel(entry); entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; SelectedEntry = entryVm; UpdatePreview(entry); } [RelayCommand] private void DeleteEntry() { if (SelectedNode?.Entry == null || _archive == null) return; _archive.Entries.Remove(SelectedNode.Entry); IsDirty = true; SelectedNode = null; SelectedEntry = null; ClearPreview(); BuildTree(); NotifyStatusChanged(); } [RelayCommand] private async Task RenameEntry() { if (SelectedNode?.Entry == null || _window == null) return; var entry = SelectedNode.Entry; var result = await Dialogs.InputAsync(_window, "Rename Entry", "New alias:", entry.Alias); if (result != null && result != entry.Alias && result.Length > 0) { entry.Alias = result; IsDirty = true; BuildTree(); NotifyStatusChanged(); if (SelectedEntry != null) { var entryVm = new EntryViewModel(entry); entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; SelectedEntry = entryVm; } } } [RelayCommand] private async Task ExtractFolder() { if (SelectedNode == null || !SelectedNode.IsFolder || _archive == null || _window == null) return; var folders = await _window.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select Extraction Folder" }); if (folders.Count == 0) return; var basePath = folders[0].Path.LocalPath; var prefix = SelectedNode.FullPath; if (!prefix.EndsWith("/")) prefix += "/"; try { var matching = _archive.Entries.Where(e => e.Alias.StartsWith(prefix, StringComparison.Ordinal)).ToList(); await Task.Run(() => { foreach (var entry in matching) { var relativePath = entry.Alias.Substring(prefix.Length); var outputPath = Path.Combine(basePath, relativePath.Replace('/', Path.DirectorySeparatorChar)); var dir = Path.GetDirectoryName(outputPath); if (dir != null) Directory.CreateDirectory(dir); var data = entry.GetDecompressedData(); File.WriteAllBytes(outputPath, data); } }); await Dialogs.ShowMessageAsync(_window, "Extract Folder", $"Successfully extracted files to:\n{basePath}"); } catch (Exception ex) { await Dialogs.ShowMessageAsync(_window, "Error", $"Extraction failed:\n{ex.Message}"); } } // ========================================================================= // Text editing commands // ========================================================================= [RelayCommand] private void StartEdit() { IsEditingText = true; } [RelayCommand] private void SaveEdit() { if (SelectedNode?.Entry == null || PreviewText == null) return; var entry = SelectedNode.Entry; var newData = Encoding.UTF8.GetBytes(PreviewText); entry.SetData(newData, entry.IsCompressed); IsDirty = true; IsEditingText = false; var entryVm = new EntryViewModel(entry); entryVm.OnModified = () => { IsDirty = true; NotifyStatusChanged(); }; SelectedEntry = entryVm; NotifyStatusChanged(); } [RelayCommand] private void CancelEdit() { IsEditingText = false; if (SelectedNode?.Entry != null) { PreviewText = Encoding.UTF8.GetString(SelectedNode.Entry.GetDecompressedData()); } } // ========================================================================= // About // ========================================================================= [RelayCommand] private async Task ShowAbout() { if (_window == null) return; await Dialogs.ShowMessageAsync(_window, "About GsaEditor", "GsaEditor v1.0\n\nGSA Archive Viewer & Editor\nBuilt with Avalonia UI"); } // ========================================================================= // Tree building // ========================================================================= private void BuildTree() { TreeRoots.Clear(); if (_archive == null) return; foreach (var entry in _archive.Entries) { var parts = entry.Alias.Split('/'); var currentLevel = TreeRoots; var currentPath = ""; for (int i = 0; i < parts.Length; i++) { var part = parts[i]; currentPath = i == 0 ? part : currentPath + "/" + part; bool isLeaf = (i == parts.Length - 1); var existing = currentLevel.FirstOrDefault(n => n.Name == part); if (existing == null) { existing = new EntryTreeNodeViewModel { Name = part, FullPath = currentPath, IsFolder = !isLeaf, Entry = isLeaf ? entry : null }; currentLevel.Add(existing); } if (!isLeaf) { currentLevel = existing.Children; } } } } // ========================================================================= // Helpers // ========================================================================= private void NotifyStatusChanged() { OnPropertyChanged(nameof(StatusEntryCount)); OnPropertyChanged(nameof(StatusSizeInfo)); OnPropertyChanged(nameof(HasArchive)); } private static string FormatSize(long bytes) { if (bytes < 1024) return $"{bytes} B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; return $"{bytes / (1024.0 * 1024.0):F1} MB"; } }