fixed GSA writing, added always-edit button and search in file and for file

This commit is contained in:
2026-07-21 15:50:52 +02:00
parent cbf0891ba4
commit d171c34236
42 changed files with 538 additions and 61 deletions

View File

@ -13,6 +13,9 @@ public partial class EntryTreeNodeViewModel : ViewModelBase
[ObservableProperty]
private string _name = string.Empty;
[ObservableProperty]
private bool _isExpanded;
/// <summary>
/// Full relative path of this node within the archive (using '/' separator).
/// </summary>

View File

@ -1,6 +1,7 @@
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;
@ -84,8 +85,24 @@ public partial class MainWindowViewModel : ViewModelBase
[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
@ -255,6 +272,8 @@ public partial class MainWindowViewModel : ViewModelBase
_archive = archive;
ArchivePath = path;
IsDirty = false;
SearchQuery = null;
ClearSearchResults();
BuildTree();
NotifyStatusChanged();
}
@ -347,6 +366,8 @@ public partial class MainWindowViewModel : ViewModelBase
TreeRoots.Clear();
SelectedEntry = null;
SelectedNode = null;
SearchQuery = null;
ClearSearchResults();
ClearPreview();
NotifyStatusChanged();
}
@ -663,6 +684,215 @@ public partial class MainWindowViewModel : ViewModelBase
}
}
[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
// =========================================================================

View File

@ -0,0 +1,19 @@
namespace GsaEditor.ViewModels;
/// <summary>
/// A single search hit: a file name match, or a matching line inside a file.
/// </summary>
public class SearchResultViewModel
{
/// <summary>
/// Alias of the matching archive entry.
/// </summary>
public string Alias { get; init; } = string.Empty;
/// <summary>
/// Match context for content searches (e.g. "line 12: ..."). Empty for name matches.
/// </summary>
public string Detail { get; init; } = string.Empty;
public bool HasDetail => Detail.Length > 0;
}

View File

@ -93,10 +93,61 @@
<TextBlock DockPanel.Dock="Top" Text="Archive Contents"
FontWeight="SemiBold" Padding="8,6" FontSize="13"
Background="#EEEEEE"/>
<!-- Search area -->
<StackPanel DockPanel.Dock="Top" Margin="6,6,6,0" Spacing="4"
IsEnabled="{Binding HasArchive}">
<TextBox Text="{Binding SearchQuery, Mode=TwoWay}"
Watermark="Search...">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SearchCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<DockPanel>
<Button DockPanel.Dock="Right" Content="Search"
Command="{Binding SearchCommand}"
FontSize="12" Padding="8,3"
IsVisible="{Binding SearchInContents}"/>
<CheckBox Content="In contents (regex)"
IsChecked="{Binding SearchInContents, Mode=TwoWay}"
FontSize="12"/>
</DockPanel>
<TextBlock Text="{Binding SearchStatus}"
FontSize="11" Foreground="#777777"
IsVisible="{Binding SearchStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<!-- Search results -->
<ListBox DockPanel.Dock="Top"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult, Mode=TwoWay}"
IsVisible="{Binding HasSearchResults}"
MaxHeight="220" Margin="6,4,6,4"
BorderThickness="1" BorderBrush="#DDDDDD">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,1">
<TextBlock Text="{Binding Alias}" FontSize="12"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Detail}" FontSize="11"
Foreground="#777777"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding HasDetail}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TreeView Name="ArchiveTree"
ItemsSource="{Binding TreeRoots}"
SelectionChanged="TreeView_SelectionChanged">
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
</Style>
</TreeView.Styles>
<TreeView.ContextMenu>
<ContextMenu>
<!-- File node operations -->
@ -221,14 +272,21 @@
</ScrollViewer>
<!-- Hex Dump Preview -->
<TextBox IsVisible="{Binding IsHexPreview}"
Text="{Binding HexDumpText}"
IsReadOnly="True"
FontFamily="Consolas, Courier New, monospace"
FontSize="13"
AcceptsReturn="True"
TextWrapping="NoWrap"
Margin="10"/>
<DockPanel IsVisible="{Binding IsHexPreview}">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
Spacing="5" Margin="10,6">
<Button Content="Edit as Text"
Command="{Binding OpenAsTextCommand}"
IsVisible="{Binding HasSelectedEntry}"/>
</StackPanel>
<TextBox Text="{Binding HexDumpText}"
IsReadOnly="True"
FontFamily="Consolas, Courier New, monospace"
FontSize="13"
AcceptsReturn="True"
TextWrapping="NoWrap"
Margin="10,0,10,10"/>
</DockPanel>
</Panel>
</Grid>

View File

@ -59,6 +59,19 @@ public partial class MainWindow : Window
{
ApplySyntaxHighlighting(vm);
}
if (e.PropertyName == nameof(MainWindowViewModel.SelectedNode))
{
// Reflect programmatic selection (e.g. from search results) in the tree.
// Posted so newly expanded parent containers are realized first.
var tree = this.FindControl<TreeView>("ArchiveTree");
if (tree != null && vm.SelectedNode != null && !ReferenceEquals(tree.SelectedItem, vm.SelectedNode))
{
Avalonia.Threading.Dispatcher.UIThread.Post(
() => tree.SelectedItem = vm.SelectedNode,
Avalonia.Threading.DispatcherPriority.Background);
}
}
}
private void ApplySyntaxHighlighting(MainWindowViewModel vm)

View File

@ -67,7 +67,9 @@
</member>
<member name="T:GsaEditor.Core.IO.GsaIndexReader">
<summary>
Parses .idx NML index files (XML-compatible format) into a list of <see cref="T:GsaEditor.Core.IO.GsaIndexEntry"/>.
Parses .idx NML index files into a list of <see cref="T:GsaEditor.Core.IO.GsaIndexEntry"/>.
NML is the engine's own tag syntax (&lt;Name=value&gt; blocks, see <see cref="T:GsaEditor.Core.IO.GsaIndexWriter"/>),
not standard XML.
</summary>
</member>
<member name="M:GsaEditor.Core.IO.GsaIndexReader.Read(System.String)">
@ -80,13 +82,33 @@
<member name="T:GsaEditor.Core.IO.GsaIndexWriter">
<summary>
Writes a .idx NML index file from a <see cref="T:GsaEditor.Core.Models.GsaArchive"/>.
The index file is XML-compatible and lists each entry with its alias, compression method,
sizes, and data offset for fast lookup without a full sequential scan.
The NML format is the engine's own tag syntax (NOT standard XML):
<code>
&lt;Version=1.0&gt;
&lt;Index=
&lt;Entry=
&lt;ID="path/in/archive"&gt;
&lt;CompLen=123&gt;
&lt;Len=456&gt;
&lt;Offset=789&gt;
&lt;Method="Zlib"&gt;
&gt;
&gt;
</code>
Tab indentation, LF line endings, no BOM. Method is "Zlib" or "Raw".
The engine parses this file to locate entries, so the format must match exactly.
</summary>
</member>
<member name="F:GsaEditor.Core.IO.GsaIndexWriter.BootstrapAlias">
<summary>
The engine's bootstrap script is loaded by sequential archive scan and is
deliberately absent from the shipped index files, so it is skipped here too.
</summary>
</member>
<member name="M:GsaEditor.Core.IO.GsaIndexWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary>
Writes the index file for the given archive to the specified path.
Entry offsets must be up to date (i.e. call after <see cref="M:GsaEditor.Core.IO.GsaWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)"/>).
</summary>
<param name="archive">The archive whose entries will be indexed.</param>
<param name="filePath">The output .idx file path.</param>
@ -149,6 +171,12 @@
<member name="F:GsaEditor.Core.IO.GsaWriter.MAGIC_NARD">
<summary>Magic word for the NARD (enhanced) format.</summary>
</member>
<member name="F:GsaEditor.Core.IO.GsaWriter.TERMINATOR">
<summary>
End-of-archive terminator written where the next entry's alias length would be.
The engine's sequential reader stops when it hits this invalid length.
</summary>
</member>
<member name="M:GsaEditor.Core.IO.GsaWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary>
Writes the archive to the specified file path.

View File

@ -1 +1 @@
aada552c8fd51710edc7ad0d0c49003781116f6ee0520c030a54982aadcd9736
eae8f0d208f1a483cabdff404aa7aa142ad05798b023a7ebd7aceb317939a4cf

View File

@ -13,10 +13,10 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("GsaEditor")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+d6d621dc92b3083d8e47827baa0ccf59d5b0a4c4")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+cbf0891ba467ebec2dd40e9b34eb1f2d6379d7dd")]
[assembly: System.Reflection.AssemblyProductAttribute("GsaEditor")]
[assembly: System.Reflection.AssemblyTitleAttribute("GsaEditor")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Généré par la classe MSBuild WriteCodeFragment.
// Generated by the MSBuild WriteCodeFragment class.

View File

@ -1 +1 @@
2ff94264e42d1924e3c6fd4243d279b8ea5cccc75f6ef146a562319dedcab0d3
27365fc2eec4d91990ba8ea7d96835a6e6291b2f5002237551bfa5d30ea5cac6

View File

@ -1 +1 @@
39a488a720409bb8a448fa3ac77a4fee498f2436a8299f5fec82e7f76321352a
9aadcb9c3b29da493ac1af6d86cd2bccb6af7d19576833358fa0b759709d9ce1