Files
GsaViewer/GsaEditor/ViewModels/MainWindowViewModel.cs

977 lines
29 KiB
C#

using System.Collections.ObjectModel;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
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<string> TextExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".txt", ".nml", ".xml", ".json", ".csv", ".ini", ".cfg", ".log", ".nut"
};
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".bmp"
};
private static readonly HashSet<string> 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;
[ObservableProperty]
private string? _searchQuery;
[ObservableProperty]
private bool _searchInContents;
[ObservableProperty]
private string? _searchStatus;
[ObservableProperty]
private SearchResultViewModel? _selectedSearchResult;
public ObservableCollection<EntryTreeNodeViewModel> TreeRoots { get; } = new();
public ObservableCollection<SearchResultViewModel> SearchResults { get; } = new();
public bool HasSearchResults => SearchResults.Count > 0;
// --- 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)}";
}
}
/// <summary>
/// Sets the parent window reference for dialog display.
/// </summary>
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;
SearchQuery = null;
ClearSearchResults();
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;
SearchQuery = null;
ClearSearchResults();
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) return;
// Get the current text from the AvaloniaEdit editor via the window
string? editorText = null;
if (_window is GsaEditor.Views.MainWindow mainWindow)
{
editorText = mainWindow.GetEditorText();
}
if (editorText == null) editorText = PreviewText ?? string.Empty;
var entry = SelectedNode.Entry;
var newData = Encoding.UTF8.GetBytes(editorText);
entry.SetData(newData, entry.IsCompressed);
IsDirty = true;
IsEditingText = false;
PreviewText = editorText;
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());
}
}
[RelayCommand]
private async Task OpenAsText()
{
if (SelectedNode?.Entry == null) return;
var data = SelectedNode.Entry.GetDecompressedData();
try
{
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true).GetString(data);
}
catch (DecoderFallbackException)
{
if (_window != null)
{
var confirmed = await Dialogs.ConfirmAsync(_window, "Not a Text File",
"This file is not valid UTF-8 text. Editing and saving it as text will corrupt its content.\n\nOpen as text anyway?");
if (!confirmed) return;
}
}
PreviewMode = PreviewMode.Text;
PreviewText = Encoding.UTF8.GetString(data);
PreviewImage?.Dispose();
PreviewImage = null;
HexDumpText = null;
}
// =========================================================================
// Search
// =========================================================================
private const int MaxNameResults = 200;
private const int MaxContentResults = 1000;
partial void OnSearchQueryChanged(string? value)
{
// Name search is live; content search only runs on Enter / the Search button
if (!SearchInContents)
RunNameSearch();
else if (string.IsNullOrWhiteSpace(value))
ClearSearchResults();
}
partial void OnSearchInContentsChanged(bool value)
{
if (!value)
RunNameSearch();
else
ClearSearchResults();
}
partial void OnSelectedSearchResultChanged(SearchResultViewModel? value)
{
if (value != null && value.Alias.Length > 0)
SelectEntryNode(value.Alias);
}
[RelayCommand]
private async Task Search()
{
if (_archive == null) return;
if (!SearchInContents)
{
RunNameSearch();
return;
}
if (string.IsNullOrEmpty(SearchQuery)) return;
Regex regex;
try
{
regex = new Regex(SearchQuery, RegexOptions.None, TimeSpan.FromSeconds(5));
}
catch (ArgumentException ex)
{
if (_window != null)
await Dialogs.ShowMessageAsync(_window, "Invalid Regex", $"Invalid regular expression:\n{ex.Message}");
return;
}
IsLoading = true;
try
{
var entries = _archive.Entries.ToList();
var results = await Task.Run(() =>
{
var found = new List<SearchResultViewModel>();
foreach (var entry in entries)
{
string text;
try
{
text = Encoding.UTF8.GetString(entry.GetDecompressedData());
}
catch
{
continue;
}
int lineNo = 0;
foreach (var rawLine in text.Split('\n'))
{
lineNo++;
var line = rawLine.TrimEnd('\r');
bool isMatch;
try
{
isMatch = regex.IsMatch(line);
}
catch (RegexMatchTimeoutException)
{
break;
}
if (!isMatch) continue;
var snippet = line.Trim();
if (snippet.Length > 120) snippet = snippet.Substring(0, 120) + "…";
found.Add(new SearchResultViewModel
{
Alias = entry.Alias,
Detail = $"line {lineNo}: {snippet}"
});
if (found.Count >= MaxContentResults)
return found;
}
}
return found;
});
SearchResults.Clear();
foreach (var r in results)
SearchResults.Add(r);
OnPropertyChanged(nameof(HasSearchResults));
SearchStatus = results.Count >= MaxContentResults
? $"{results.Count} matches (stopped at {MaxContentResults})"
: $"{results.Count} match(es)";
}
finally
{
IsLoading = false;
}
}
private void RunNameSearch()
{
SearchResults.Clear();
var query = SearchQuery;
if (_archive == null || string.IsNullOrWhiteSpace(query))
{
SearchStatus = null;
OnPropertyChanged(nameof(HasSearchResults));
return;
}
int total = 0;
foreach (var entry in _archive.Entries)
{
if (!entry.Alias.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
total++;
if (SearchResults.Count < MaxNameResults)
SearchResults.Add(new SearchResultViewModel { Alias = entry.Alias });
}
SearchStatus = total > MaxNameResults
? $"{total} file(s) (showing first {MaxNameResults})"
: $"{total} file(s)";
OnPropertyChanged(nameof(HasSearchResults));
}
private void ClearSearchResults()
{
SearchResults.Clear();
SearchStatus = null;
SelectedSearchResult = null;
OnPropertyChanged(nameof(HasSearchResults));
}
/// <summary>
/// Selects the tree node matching the given alias, expanding parent folders along the way.
/// </summary>
private void SelectEntryNode(string alias)
{
var parts = alias.Split('/');
var level = TreeRoots;
EntryTreeNodeViewModel? node = null;
foreach (var part in parts)
{
node = level.FirstOrDefault(n => n.Name == part);
if (node == null) return;
if (node.IsFolder)
{
node.IsExpanded = true;
level = node.Children;
}
}
if (node != null && !node.IsFolder)
SelectedNode = node;
}
// =========================================================================
// About
// =========================================================================
[RelayCommand]
private async Task ShowAbout()
{
if (_window == null) return;
var appVersion = Assembly.GetExecutingAssembly().GetName().Version;
var versionStr = appVersion != null ? $"{appVersion.Major}.{appVersion.Minor}.{appVersion.Build}" : "1.0.0";
var avaloniaVersion = typeof(Avalonia.Application).Assembly.GetName().Version;
var avaloniaStr = avaloniaVersion != null ? $"{avaloniaVersion.Major}.{avaloniaVersion.Minor}.{avaloniaVersion.Build}" : "unknown";
await Dialogs.ShowMessageAsync(_window, "About GsaEditor",
$"GsaEditor v{versionStr}\n\n" +
$"GSA Archive Viewer & Editor\n" +
$"By Develter Innovation (Nicolas RACOT)\n\n" +
$"Built with Avalonia UI v{avaloniaStr}");
}
// =========================================================================
// 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";
}
}