fixed GSA writing, added always-edit button and search in file and for file
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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
|
||||
// =========================================================================
|
||||
|
||||
19
GsaEditor/ViewModels/SearchResultViewModel.cs
Normal file
19
GsaEditor/ViewModels/SearchResultViewModel.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user