1 Commits
1.1.0 ... main

42 changed files with 538 additions and 61 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
**/bin/**
**/obj/**

View File

@ -3,7 +3,9 @@
<component name="MaterialThemeProjectNewConfig"> <component name="MaterialThemeProjectNewConfig">
<option name="metadata"> <option name="metadata">
<MTProjectMetadataState> <MTProjectMetadataState>
<option name="userId" value="24a2abbf:19d72afb14b:-7ffd" /> <option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="4ce26237:199b9d43f46:-7ffe" />
</MTProjectMetadataState> </MTProjectMetadataState>
</option> </option>
</component> </component>

View File

@ -1,4 +1,4 @@
using System.Xml.Linq; using System.Text.RegularExpressions;
namespace GsaEditor.Core.IO; namespace GsaEditor.Core.IO;
@ -34,10 +34,20 @@ public class GsaIndexEntry
} }
/// <summary> /// <summary>
/// Parses .idx NML index files (XML-compatible format) into a list of <see cref="GsaIndexEntry"/>. /// Parses .idx NML index files into a list of <see cref="GsaIndexEntry"/>.
/// NML is the engine's own tag syntax (&lt;Name=value&gt; blocks, see <see cref="GsaIndexWriter"/>),
/// not standard XML.
/// </summary> /// </summary>
public static class GsaIndexReader public static class GsaIndexReader
{ {
private static readonly Regex EntryRegex = new(
@"<Entry=(?<body>.*?)\n\s*>",
RegexOptions.Singleline | RegexOptions.Compiled);
private static readonly Regex TagRegex = new(
@"<(?<name>\w+)=""?(?<value>[^"">]*)""?>",
RegexOptions.Compiled);
/// <summary> /// <summary>
/// Reads index entries from the specified .idx file. /// Reads index entries from the specified .idx file.
/// </summary> /// </summary>
@ -45,23 +55,36 @@ public static class GsaIndexReader
/// <returns>A list of parsed index entries.</returns> /// <returns>A list of parsed index entries.</returns>
public static List<GsaIndexEntry> Read(string filePath) public static List<GsaIndexEntry> Read(string filePath)
{ {
var doc = XDocument.Load(filePath); var text = File.ReadAllText(filePath);
var entries = new List<GsaIndexEntry>(); var entries = new List<GsaIndexEntry>();
var root = doc.Root; foreach (Match entryMatch in EntryRegex.Matches(text))
if (root == null || root.Name.LocalName != "Index") {
return entries; var entry = new GsaIndexEntry();
foreach (var entryEl in root.Elements("Entry")) foreach (Match tag in TagRegex.Matches(entryMatch.Groups["body"].Value))
{ {
var entry = new GsaIndexEntry var value = tag.Groups["value"].Value;
switch (tag.Groups["name"].Value)
{ {
Id = entryEl.Element("Id")?.Value ?? string.Empty, case "ID":
Method = int.TryParse(entryEl.Element("Method")?.Value, out var m) ? m : 0, entry.Id = value;
Length = uint.TryParse(entryEl.Element("Len")?.Value, out var l) ? l : 0, break;
CompressedLength = uint.TryParse(entryEl.Element("CompLen")?.Value, out var cl) ? cl : 0, case "Method":
Offset = long.TryParse(entryEl.Element("Offset")?.Value, out var o) ? o : 0 entry.Method = value.Equals("Zlib", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
}; break;
case "Len":
entry.Length = uint.TryParse(value, out var l) ? l : 0;
break;
case "CompLen":
entry.CompressedLength = uint.TryParse(value, out var cl) ? cl : 0;
break;
case "Offset":
entry.Offset = long.TryParse(value, out var o) ? o : 0;
break;
}
}
entries.Add(entry); entries.Add(entry);
} }

View File

@ -1,36 +1,62 @@
using System.Xml.Linq; using System.Text;
using GsaEditor.Core.Models; using GsaEditor.Core.Models;
namespace GsaEditor.Core.IO; namespace GsaEditor.Core.IO;
/// <summary> /// <summary>
/// Writes a .idx NML index file from a <see cref="GsaArchive"/>. /// Writes a .idx NML index file from a <see cref="GsaArchive"/>.
/// The index file is XML-compatible and lists each entry with its alias, compression method, /// The NML format is the engine's own tag syntax (NOT standard XML):
/// sizes, and data offset for fast lookup without a full sequential scan. /// <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> /// </summary>
public static class GsaIndexWriter public static class GsaIndexWriter
{ {
/// <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>
private const string BootstrapAlias = "bootstrap.txt";
/// <summary> /// <summary>
/// Writes the index file for the given archive to the specified path. /// 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="GsaWriter.Write(GsaArchive, string)"/>).
/// </summary> /// </summary>
/// <param name="archive">The archive whose entries will be indexed.</param> /// <param name="archive">The archive whose entries will be indexed.</param>
/// <param name="filePath">The output .idx file path.</param> /// <param name="filePath">The output .idx file path.</param>
public static void Write(GsaArchive archive, string filePath) public static void Write(GsaArchive archive, string filePath)
{ {
var doc = new XDocument( var sb = new StringBuilder();
new XElement("Index", sb.Append("<Version=1.0>\n");
archive.Entries.Select(e => sb.Append("<Index=\n");
new XElement("Entry",
new XElement("Id", e.Alias),
new XElement("Method", e.IsCompressed ? 1 : 0),
new XElement("Len", e.OriginalLength),
new XElement("CompLen", e.CompressedLength),
new XElement("Offset", e.DataOffset)
)
)
)
);
doc.Save(filePath); foreach (var e in archive.Entries)
{
if (string.Equals(e.Alias, BootstrapAlias, StringComparison.OrdinalIgnoreCase))
continue;
sb.Append("\t<Entry=\n");
sb.Append("\t\t<ID=\"").Append(e.Alias).Append("\">\n");
sb.Append("\t\t<CompLen=").Append(e.CompressedLength).Append(">\n");
sb.Append("\t\t<Len=").Append(e.OriginalLength).Append(">\n");
sb.Append("\t\t<Offset=").Append(e.DataOffset).Append(">\n");
sb.Append("\t\t<Method=\"").Append(e.IsCompressed ? "Zlib" : "Raw").Append("\">\n");
sb.Append("\t>\n");
}
sb.Append(">\n");
File.WriteAllText(filePath, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
} }
} }

View File

@ -15,6 +15,12 @@ public static class GsaWriter
/// <summary>Magic word for the NARD (enhanced) format.</summary> /// <summary>Magic word for the NARD (enhanced) format.</summary>
private const uint MAGIC_NARD = 0x4E415244; private const uint MAGIC_NARD = 0x4E415244;
/// <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>
private const uint TERMINATOR = 0xFFFFFFFF;
/// <summary> /// <summary>
/// Writes the archive to the specified file path. /// Writes the archive to the specified file path.
/// </summary> /// </summary>
@ -53,6 +59,11 @@ public static class GsaWriter
{ {
WriteEntry(writer, entry, archive); WriteEntry(writer, entry, archive);
} }
// Write the end-of-archive terminator, aligned like an entry header
if (archive.Format == GsaFormat.NARD && archive.OffsetPadding > 0)
WritePadding(writer, archive.OffsetPadding);
writer.Write(TERMINATOR);
} }
/// <summary> /// <summary>

View File

@ -67,7 +67,9 @@
</member> </member>
<member name="T:GsaEditor.Core.IO.GsaIndexReader"> <member name="T:GsaEditor.Core.IO.GsaIndexReader">
<summary> <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> </summary>
</member> </member>
<member name="M:GsaEditor.Core.IO.GsaIndexReader.Read(System.String)"> <member name="M:GsaEditor.Core.IO.GsaIndexReader.Read(System.String)">
@ -80,13 +82,33 @@
<member name="T:GsaEditor.Core.IO.GsaIndexWriter"> <member name="T:GsaEditor.Core.IO.GsaIndexWriter">
<summary> <summary>
Writes a .idx NML index file from a <see cref="T:GsaEditor.Core.Models.GsaArchive"/>. 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, The NML format is the engine's own tag syntax (NOT standard XML):
sizes, and data offset for fast lookup without a full sequential scan. <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> </summary>
</member> </member>
<member name="M:GsaEditor.Core.IO.GsaIndexWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)"> <member name="M:GsaEditor.Core.IO.GsaIndexWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary> <summary>
Writes the index file for the given archive to the specified path. 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> </summary>
<param name="archive">The archive whose entries will be indexed.</param> <param name="archive">The archive whose entries will be indexed.</param>
<param name="filePath">The output .idx file path.</param> <param name="filePath">The output .idx file path.</param>
@ -149,6 +171,12 @@
<member name="F:GsaEditor.Core.IO.GsaWriter.MAGIC_NARD"> <member name="F:GsaEditor.Core.IO.GsaWriter.MAGIC_NARD">
<summary>Magic word for the NARD (enhanced) format.</summary> <summary>Magic word for the NARD (enhanced) format.</summary>
</member> </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)"> <member name="M:GsaEditor.Core.IO.GsaWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary> <summary>
Writes the archive to the specified file path. Writes the archive to the specified file path.

View File

@ -13,10 +13,10 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("GsaEditor.Core")] [assembly: System.Reflection.AssemblyCompanyAttribute("GsaEditor.Core")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [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.Core")] [assembly: System.Reflection.AssemblyProductAttribute("GsaEditor.Core")]
[assembly: System.Reflection.AssemblyTitleAttribute("GsaEditor.Core")] [assembly: System.Reflection.AssemblyTitleAttribute("GsaEditor.Core")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [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 @@
27416b735d6b5e6056e4f76966d345ebebb5a18e412e4170cdbcf381f9ab7092 24bad01704fb232d5dd4f84bfcb34e9e1b11dc1475b9ee697c24bd2eb1122246

View File

@ -67,7 +67,9 @@
</member> </member>
<member name="T:GsaEditor.Core.IO.GsaIndexReader"> <member name="T:GsaEditor.Core.IO.GsaIndexReader">
<summary> <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> </summary>
</member> </member>
<member name="M:GsaEditor.Core.IO.GsaIndexReader.Read(System.String)"> <member name="M:GsaEditor.Core.IO.GsaIndexReader.Read(System.String)">
@ -80,13 +82,33 @@
<member name="T:GsaEditor.Core.IO.GsaIndexWriter"> <member name="T:GsaEditor.Core.IO.GsaIndexWriter">
<summary> <summary>
Writes a .idx NML index file from a <see cref="T:GsaEditor.Core.Models.GsaArchive"/>. 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, The NML format is the engine's own tag syntax (NOT standard XML):
sizes, and data offset for fast lookup without a full sequential scan. <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> </summary>
</member> </member>
<member name="M:GsaEditor.Core.IO.GsaIndexWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)"> <member name="M:GsaEditor.Core.IO.GsaIndexWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary> <summary>
Writes the index file for the given archive to the specified path. 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> </summary>
<param name="archive">The archive whose entries will be indexed.</param> <param name="archive">The archive whose entries will be indexed.</param>
<param name="filePath">The output .idx file path.</param> <param name="filePath">The output .idx file path.</param>
@ -149,6 +171,12 @@
<member name="F:GsaEditor.Core.IO.GsaWriter.MAGIC_NARD"> <member name="F:GsaEditor.Core.IO.GsaWriter.MAGIC_NARD">
<summary>Magic word for the NARD (enhanced) format.</summary> <summary>Magic word for the NARD (enhanced) format.</summary>
</member> </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)"> <member name="M:GsaEditor.Core.IO.GsaWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary> <summary>
Writes the archive to the specified file path. Writes the archive to the specified file path.

View File

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

View File

@ -1,6 +1,7 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
@ -84,8 +85,24 @@ public partial class MainWindowViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isEditingText; 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<EntryTreeNodeViewModel> TreeRoots { get; } = new();
public ObservableCollection<SearchResultViewModel> SearchResults { get; } = new();
public bool HasSearchResults => SearchResults.Count > 0;
// --- Derived properties --- // --- Derived properties ---
public string Title => ArchivePath != null public string Title => ArchivePath != null
@ -255,6 +272,8 @@ public partial class MainWindowViewModel : ViewModelBase
_archive = archive; _archive = archive;
ArchivePath = path; ArchivePath = path;
IsDirty = false; IsDirty = false;
SearchQuery = null;
ClearSearchResults();
BuildTree(); BuildTree();
NotifyStatusChanged(); NotifyStatusChanged();
} }
@ -347,6 +366,8 @@ public partial class MainWindowViewModel : ViewModelBase
TreeRoots.Clear(); TreeRoots.Clear();
SelectedEntry = null; SelectedEntry = null;
SelectedNode = null; SelectedNode = null;
SearchQuery = null;
ClearSearchResults();
ClearPreview(); ClearPreview();
NotifyStatusChanged(); 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 // 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" <TextBlock DockPanel.Dock="Top" Text="Archive Contents"
FontWeight="SemiBold" Padding="8,6" FontSize="13" FontWeight="SemiBold" Padding="8,6" FontSize="13"
Background="#EEEEEE"/> 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" <TreeView Name="ArchiveTree"
ItemsSource="{Binding TreeRoots}" ItemsSource="{Binding TreeRoots}"
SelectionChanged="TreeView_SelectionChanged"> SelectionChanged="TreeView_SelectionChanged">
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
</Style>
</TreeView.Styles>
<TreeView.ContextMenu> <TreeView.ContextMenu>
<ContextMenu> <ContextMenu>
<!-- File node operations --> <!-- File node operations -->
@ -221,14 +272,21 @@
</ScrollViewer> </ScrollViewer>
<!-- Hex Dump Preview --> <!-- Hex Dump Preview -->
<TextBox IsVisible="{Binding IsHexPreview}" <DockPanel IsVisible="{Binding IsHexPreview}">
Text="{Binding HexDumpText}" <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" IsReadOnly="True"
FontFamily="Consolas, Courier New, monospace" FontFamily="Consolas, Courier New, monospace"
FontSize="13" FontSize="13"
AcceptsReturn="True" AcceptsReturn="True"
TextWrapping="NoWrap" TextWrapping="NoWrap"
Margin="10"/> Margin="10,0,10,10"/>
</DockPanel>
</Panel> </Panel>
</Grid> </Grid>

View File

@ -59,6 +59,19 @@ public partial class MainWindow : Window
{ {
ApplySyntaxHighlighting(vm); 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) private void ApplySyntaxHighlighting(MainWindowViewModel vm)

View File

@ -67,7 +67,9 @@
</member> </member>
<member name="T:GsaEditor.Core.IO.GsaIndexReader"> <member name="T:GsaEditor.Core.IO.GsaIndexReader">
<summary> <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> </summary>
</member> </member>
<member name="M:GsaEditor.Core.IO.GsaIndexReader.Read(System.String)"> <member name="M:GsaEditor.Core.IO.GsaIndexReader.Read(System.String)">
@ -80,13 +82,33 @@
<member name="T:GsaEditor.Core.IO.GsaIndexWriter"> <member name="T:GsaEditor.Core.IO.GsaIndexWriter">
<summary> <summary>
Writes a .idx NML index file from a <see cref="T:GsaEditor.Core.Models.GsaArchive"/>. 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, The NML format is the engine's own tag syntax (NOT standard XML):
sizes, and data offset for fast lookup without a full sequential scan. <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> </summary>
</member> </member>
<member name="M:GsaEditor.Core.IO.GsaIndexWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)"> <member name="M:GsaEditor.Core.IO.GsaIndexWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary> <summary>
Writes the index file for the given archive to the specified path. 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> </summary>
<param name="archive">The archive whose entries will be indexed.</param> <param name="archive">The archive whose entries will be indexed.</param>
<param name="filePath">The output .idx file path.</param> <param name="filePath">The output .idx file path.</param>
@ -149,6 +171,12 @@
<member name="F:GsaEditor.Core.IO.GsaWriter.MAGIC_NARD"> <member name="F:GsaEditor.Core.IO.GsaWriter.MAGIC_NARD">
<summary>Magic word for the NARD (enhanced) format.</summary> <summary>Magic word for the NARD (enhanced) format.</summary>
</member> </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)"> <member name="M:GsaEditor.Core.IO.GsaWriter.Write(GsaEditor.Core.Models.GsaArchive,System.String)">
<summary> <summary>
Writes the archive to the specified file path. 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.AssemblyCompanyAttribute("GsaEditor")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [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.AssemblyProductAttribute("GsaEditor")]
[assembly: System.Reflection.AssemblyTitleAttribute("GsaEditor")] [assembly: System.Reflection.AssemblyTitleAttribute("GsaEditor")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [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

View File

@ -1 +1 @@
7e7dc0454609f380d65d4e6792d0f069435ad58b608973047eb829ce8ac3f4b4 889fc292feef8bf02dfc3026d9915bb9baa08741fc71839c03938c2e64645e14

Binary file not shown.

View File

@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("GsaViewer")] [assembly: System.Reflection.AssemblyCompanyAttribute("GsaViewer")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+cbf0891ba467ebec2dd40e9b34eb1f2d6379d7dd")]
[assembly: System.Reflection.AssemblyProductAttribute("GsaViewer")] [assembly: System.Reflection.AssemblyProductAttribute("GsaViewer")]
[assembly: System.Reflection.AssemblyTitleAttribute("GsaViewer")] [assembly: System.Reflection.AssemblyTitleAttribute("GsaViewer")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@ -1 +1 @@
02bce5f0f4908bf808f9b59b3bc005e20eed825237a3bf3d65ea07bda1696b95 4a85afe2a84876aeeaf24b10d0721cd3b7f5dba258044299d3123346f674505b

View File

@ -24,5 +24,11 @@ build_property.EnableCodeStyleSeverity =
[C:/Users/simulateur/Desktop/GsaViewer/App.axaml] [C:/Users/simulateur/Desktop/GsaViewer/App.axaml]
build_metadata.AdditionalFiles.SourceItemGroup = AvaloniaXaml build_metadata.AdditionalFiles.SourceItemGroup = AvaloniaXaml
[C:/Users/simulateur/Desktop/GsaViewer/GsaEditor/App.axaml]
build_metadata.AdditionalFiles.SourceItemGroup = AvaloniaXaml
[C:/Users/simulateur/Desktop/GsaViewer/GsaEditor/Views/MainWindow.axaml]
build_metadata.AdditionalFiles.SourceItemGroup = AvaloniaXaml
[C:/Users/simulateur/Desktop/GsaViewer/Views/MainWindow.axaml] [C:/Users/simulateur/Desktop/GsaViewer/Views/MainWindow.axaml]
build_metadata.AdditionalFiles.SourceItemGroup = AvaloniaXaml build_metadata.AdditionalFiles.SourceItemGroup = AvaloniaXaml