2026-07-15 17:44:43 +00:00
using System.Globalization ;
using System.Net ;
using System.Text ;
using AIStudio.Components ;
using AIStudio.Tools.Rust ;
using AIStudio.Tools.Services ;
using Microsoft.AspNetCore.Components ;
// ReSharper disable NotAccessedPositionalProperty.Local
namespace AIStudio.Assistants.LogViewer ;
public partial class AssistantLogViewer : MSGComponentBase
{
private static readonly TimeSpan AUTO_REFRESH_INTERVAL = TimeSpan . FromSeconds ( 5 ) ;
private static readonly char [ ] WORD_SPLIT_CHARS = [ ' ' , '\t' , '\r' , '\n' ] ;
private static readonly Dictionary < string , int > LOG_LEVEL_ORDER = new ( StringComparer . OrdinalIgnoreCase )
{
["ERROR"] = 0 ,
["CRITICAL"] = 1 ,
["WARN"] = 2 ,
["WARNING"] = 3 ,
["INFO"] = 4 ,
["INFORMATION"] = 5 ,
["DEBUG"] = 6 ,
["TRACE"] = 7 ,
} ;
private const int DEFAULT_MAX_LINES = 5_000 ;
private const int MIN_MAX_LINES = 100 ;
private const int MAX_MAX_LINES = 100_000 ;
private const string OTHER_OPTION_VALUE = "__OTHER__" ;
[Inject]
private RustService RustService { get ; init ; } = null ! ;
[Inject]
private NavigationManager NavigationManager { get ; init ; } = null ! ;
[Inject]
private ILogger < AssistantLogViewer > Logger { get ; init ; } = null ! ;
private readonly HashSet < string > selectedLogLevels = new ( StringComparer . OrdinalIgnoreCase ) ;
private readonly HashSet < string > selectedLoggers = new ( StringComparer . OrdinalIgnoreCase ) ;
private readonly HashSet < string > selectedSourceDetails = new ( StringComparer . OrdinalIgnoreCase ) ;
private GetLogPathsResponse logPaths ;
private LogFileKind selectedLogFile = LogFileKind . APP ;
private List < LogLine > loadedLines = [ ] ;
private List < LogLine > displayLines = [ ] ;
private List < string > logLevelOptions = [ OTHER_OPTION_VALUE ] ;
private List < string > loggerOptions = [ OTHER_OPTION_VALUE ] ;
private List < string > sourceDetailOptions = [ OTHER_OPTION_VALUE ] ;
private string [ ] activeSearchTerms = [ ] ;
private CancellationTokenSource ? autoRefreshCancellationTokenSource ;
private string filterText = string . Empty ;
private string loadError = string . Empty ;
private bool isLoading ;
private bool autoRefresh ;
private bool filterOnly = true ;
private bool showTimestamps = true ;
private int maxLines = DEFAULT_MAX_LINES ;
private int totalLineCount ;
private int skippedLineCount ;
private DateTimeOffset ? lastLoadedAt ;
private string CurrentLogPath = > this . selectedLogFile is LogFileKind . APP ? this . logPaths . LogAppPath : this . logPaths . LogStartupPath ;
private bool CanOpenCurrentLogPath = > ! string . IsNullOrWhiteSpace ( this . CurrentLogPath ) ;
private bool HasDropdownFilter = > this . selectedLogLevels . Count > 0 | | this . selectedLoggers . Count > 0 | | this . selectedSourceDetails . Count > 0 ;
private bool HasActiveFilter = > ! string . IsNullOrWhiteSpace ( this . filterText ) | | this . HasDropdownFilter ;
private string FilterText
{
get = > this . filterText ;
set
{
if ( this . filterText = = value )
return ;
this . filterText = value ;
this . RefreshDisplayLines ( ) ;
}
}
private bool FilterOnly
{
get = > this . filterOnly ;
set
{
if ( this . filterOnly = = value )
return ;
this . filterOnly = value ;
this . RefreshDisplayLines ( ) ;
}
}
private bool ShowTimestamps
{
get = > this . showTimestamps ;
set
{
if ( this . showTimestamps = = value )
return ;
this . showTimestamps = value ;
this . RefreshDisplayLines ( ) ;
}
}
private string StatusText
{
get
{
if ( this . isLoading )
return T ( "Loading..." ) ;
var visibleLineCount = this . displayLines . Count . ToString ( "N0" , CultureInfo . CurrentCulture ) ;
var loadedLineCount = this . loadedLines . Count . ToString ( "N0" , CultureInfo . CurrentCulture ) ;
var totalLineCountText = this . totalLineCount . ToString ( "N0" , CultureInfo . CurrentCulture ) ;
var lastLoadedText = this . lastLoadedAt ? . LocalDateTime . ToString ( "g" , CultureInfo . CurrentCulture ) ? ? T ( "not loaded yet" ) ;
if ( this . loadedLines . Count = = 0 )
return string . Format ( T ( "Loaded {0} lines. Last refresh: {1}." ) , loadedLineCount , lastLoadedText ) ;
if ( this . skippedLineCount > 0 )
{
var skippedLineCountText = this . skippedLineCount . ToString ( "N0" , CultureInfo . CurrentCulture ) ;
return string . Format ( T ( "Showing {0} of {1} loaded lines. {2} older lines were skipped. Last refresh: {3}." ) , visibleLineCount , loadedLineCount , skippedLineCountText , lastLoadedText ) ;
}
return string . Format ( T ( "Showing {0} of {1} lines. Last refresh: {2}." ) , visibleLineCount , totalLineCountText , lastLoadedText ) ;
}
}
protected override async Task OnInitializedAsync ( )
{
await base . OnInitializedAsync ( ) ;
if ( ! this . SettingsManager . IsAssistantVisible ( Tools . Components . LOG_VIEWER_ASSISTANT , assistantName : T ( "Log Viewer" ) ) )
{
this . NavigationManager . NavigateTo ( Routes . ASSISTANTS ) ;
return ;
}
this . logPaths = await this . RustService . GetLogPaths ( ) ;
await this . RefreshLogAsync ( ) ;
}
protected override void DisposeResources ( )
{
this . StopAutoRefresh ( ) ;
}
private async Task SelectedLogFileChanged ( LogFileKind value )
{
if ( this . selectedLogFile = = value )
return ;
this . selectedLogFile = value ;
await this . RefreshLogAsync ( ) ;
}
private Task SelectedLogLevelsChanged ( IEnumerable < string? > ? selectedValues )
{
UpdateSelectedValues ( this . selectedLogLevels , selectedValues ) ;
this . RefreshDisplayLines ( ) ;
return Task . CompletedTask ;
}
private Task SelectedLoggersChanged ( IEnumerable < string? > ? selectedValues )
{
UpdateSelectedValues ( this . selectedLoggers , selectedValues ) ;
this . RefreshDisplayLines ( ) ;
return Task . CompletedTask ;
}
private Task SelectedSourceDetailsChanged ( IEnumerable < string? > ? selectedValues )
{
UpdateSelectedValues ( this . selectedSourceDetails , selectedValues ) ;
this . RefreshDisplayLines ( ) ;
return Task . CompletedTask ;
}
private async Task AutoRefreshChanged ( bool value )
{
this . autoRefresh = value ;
if ( this . autoRefresh )
this . StartAutoRefresh ( ) ;
else
this . StopAutoRefresh ( ) ;
await Task . CompletedTask ;
}
private async Task MaxLinesChanged ( int value )
{
var normalizedValue = Math . Clamp ( value , MIN_MAX_LINES , MAX_MAX_LINES ) ;
if ( this . maxLines = = normalizedValue )
return ;
this . maxLines = normalizedValue ;
await this . RefreshLogAsync ( ) ;
}
private async Task OpenCurrentLogInFileManager ( )
{
var path = this . CurrentLogPath ;
if ( string . IsNullOrWhiteSpace ( path ) )
{
2026-08-02 19:41:12 +00:00
await this . MessageBus . SendWarning ( new ( Icons . Material . Filled . Folder , T ( "The log file path is not available yet." ) ) ) ;
2026-07-15 17:44:43 +00:00
return ;
}
OpenPathResponse response ;
try
{
response = await this . RustService . TryOpenPathInRuntimeFileManager ( path ) ;
}
catch ( Exception e )
{
this . Logger . LogWarning ( e , "Could not open the log file location in the file manager." ) ;
2026-08-02 19:41:12 +00:00
await this . MessageBus . SendError ( new ( Icons . Material . Filled . Folder , T ( "Could not open the log file location." ) ) ) ;
2026-07-15 17:44:43 +00:00
return ;
}
if ( response . Success )
{
2026-08-02 19:41:12 +00:00
await this . MessageBus . SendSuccess ( new ( Icons . Material . Filled . FolderOpen , T ( "Opened the log file location." ) ) ) ;
2026-07-15 17:44:43 +00:00
return ;
}
var issue = string . IsNullOrWhiteSpace ( response . Issue ) ? T ( "Unknown error" ) : response . Issue ;
2026-08-02 19:41:12 +00:00
await this . MessageBus . SendError ( new ( Icons . Material . Filled . Folder , string . Format ( T ( "Could not open the log file location: {0}" ) , issue ) ) ) ;
2026-07-15 17:44:43 +00:00
}
private void ClearFilters ( )
{
this . filterText = string . Empty ;
this . selectedLogLevels . Clear ( ) ;
this . selectedLoggers . Clear ( ) ;
this . selectedSourceDetails . Clear ( ) ;
this . RefreshDisplayLines ( ) ;
}
private async Task RefreshLogAsync ( )
{
if ( this . isLoading )
return ;
this . isLoading = true ;
this . loadError = string . Empty ;
await this . InvokeAsync ( this . StateHasChanged ) ;
try
{
var path = this . CurrentLogPath ;
if ( string . IsNullOrWhiteSpace ( path ) )
{
this . loadedLines = [ ] ;
this . totalLineCount = 0 ;
this . skippedLineCount = 0 ;
this . lastLoadedAt = null ;
this . loadError = T ( "The log file path is not available yet." ) ;
return ;
}
if ( ! File . Exists ( path ) )
{
this . loadedLines = [ ] ;
this . totalLineCount = 0 ;
this . skippedLineCount = 0 ;
this . lastLoadedAt = null ;
this . loadError = string . Format ( T ( "The log file does not exist: {0}" ) , path ) ;
return ;
}
var snapshot = await ReadLogSnapshotAsync ( path , this . maxLines ) ;
this . loadedLines = snapshot . Lines ;
this . totalLineCount = snapshot . TotalLineCount ;
this . skippedLineCount = snapshot . SkippedLineCount ;
this . lastLoadedAt = DateTimeOffset . Now ;
}
catch ( Exception e )
{
this . Logger . LogWarning ( e , "Could not read the log file for the log viewer assistant." ) ;
this . loadedLines = [ ] ;
this . totalLineCount = 0 ;
this . skippedLineCount = 0 ;
this . lastLoadedAt = null ;
this . loadError = string . Format ( T ( "The log file could not be read: {0}" ) , e . Message ) ;
}
finally
{
this . isLoading = false ;
this . RebuildFilterOptions ( ) ;
this . RefreshDisplayLines ( ) ;
await this . InvokeAsync ( this . StateHasChanged ) ;
}
}
private static async Task < LogSnapshot > ReadLogSnapshotAsync ( string path , int maxLines )
{
var queue = new Queue < string > ( Math . Min ( maxLines , 4096 ) ) ;
var totalLineCount = 0 ;
var skippedLineCount = 0 ;
await using var stream = new FileStream ( path , FileMode . Open , FileAccess . Read , FileShare . ReadWrite | FileShare . Delete , 65536 , true ) ;
using var reader = new StreamReader ( stream , Encoding . UTF8 , true ) ;
while ( await reader . ReadLineAsync ( ) is { } line )
{
totalLineCount + + ;
queue . Enqueue ( line ) ;
if ( queue . Count < = maxLines )
continue ;
queue . Dequeue ( ) ;
skippedLineCount + + ;
}
var firstLineNumber = skippedLineCount + 1 ;
var lines = queue
. Select ( ( line , index ) = > new LogLine ( firstLineNumber + index , line , ParseLogSegments ( line ) ) )
. ToList ( ) ;
return new ( lines , totalLineCount , skippedLineCount ) ;
}
private void RebuildFilterOptions ( )
{
this . logLevelOptions = BuildFilterOptions ( this . loadedLines . Select ( line = > line . Segments . Level ) , CompareLogLevels ) ;
this . loggerOptions = BuildFilterOptions ( this . loadedLines . Select ( line = > line . Segments . Logger ) , ( left , right ) = > StringComparer . OrdinalIgnoreCase . Compare ( left , right ) ) ;
this . sourceDetailOptions = BuildFilterOptions ( this . loadedLines . Select ( line = > line . Segments . SourceDetails ) , ( left , right ) = > StringComparer . OrdinalIgnoreCase . Compare ( left , right ) ) ;
NormalizeSelectedValues ( this . selectedLogLevels , this . logLevelOptions ) ;
NormalizeSelectedValues ( this . selectedLoggers , this . loggerOptions ) ;
NormalizeSelectedValues ( this . selectedSourceDetails , this . sourceDetailOptions ) ;
}
private void RefreshDisplayLines ( )
{
this . activeSearchTerms = BuildSearchTerms ( this . filterText ) ;
this . displayLines = this . loadedLines
. Where ( this . LineMatchesFilters )
. ToList ( ) ;
}
private bool LineMatchesFilters ( LogLine line )
{
if ( ! MatchesSelection ( line . Segments . Level , this . selectedLogLevels ) )
return false ;
if ( ! MatchesSelection ( line . Segments . Logger , this . selectedLoggers ) )
return false ;
if ( ! MatchesSelection ( line . Segments . SourceDetails , this . selectedSourceDetails ) )
return false ;
if ( ! this . filterOnly | | this . activeSearchTerms . Length = = 0 )
return true ;
return MatchesSearchTerms ( this . GetPlainRenderedLine ( line ) , this . activeSearchTerms ) ;
}
private string RenderLine ( LogLine line )
{
var text = this . GetPlainRenderedLine ( line ) ;
var ranges = new List < HighlightRange > ( ) ;
AddSearchTermRanges ( text , this . activeSearchTerms , ranges ) ;
if ( ranges . Count = = 0 )
return WebUtility . HtmlEncode ( text ) ;
ranges = MergeRanges ( ranges ) ;
var sb = new StringBuilder ( ) ;
var position = 0 ;
foreach ( var range in ranges )
{
AppendEncoded ( sb , text , position , range . Start - position ) ;
sb . Append ( "" "<mark class=" log - viewer - highlight ">" "" ) ;
AppendEncoded ( sb , text , range . Start , range . Length ) ;
sb . Append ( "</mark>" ) ;
position = range . Start + range . Length ;
}
AppendEncoded ( sb , text , position , text . Length - position ) ;
return sb . ToString ( ) ;
}
private string GetPlainRenderedLine ( LogLine line )
{
var parts = new List < string > ( ) ;
var segments = line . Segments ;
if ( this . showTimestamps & & ! string . IsNullOrWhiteSpace ( segments . Timestamp ) )
parts . Add ( segments . Timestamp ) ;
if ( ! ShouldHideSelectedSegment ( segments . Level , this . selectedLogLevels ) )
AddIfNotWhiteSpace ( parts , segments . Level ) ;
if ( ! ShouldHideSelectedSegment ( segments . Logger , this . selectedLoggers ) )
AddIfNotWhiteSpace ( parts , segments . Logger ) ;
if ( ! ShouldHideSelectedSegment ( segments . SourceDetails , this . selectedSourceDetails ) )
AddIfNotWhiteSpace ( parts , segments . SourceDetails ) ;
AddIfNotWhiteSpace ( parts , segments . Message ) ;
return parts . Count = = 0 ? string . Empty : string . Join ( " " , parts ) ;
}
private static string GetLineClass ( LogLine line )
{
var level = line . Segments . Level ? ? string . Empty ;
if ( level . Contains ( "ERROR" , StringComparison . OrdinalIgnoreCase ) | | level . Contains ( "CRITICAL" , StringComparison . OrdinalIgnoreCase ) )
return "log-viewer-line log-viewer-line-error" ;
if ( level . Contains ( "WARN" , StringComparison . OrdinalIgnoreCase ) )
return "log-viewer-line log-viewer-line-warn" ;
if ( level . Equals ( "INFO" , StringComparison . OrdinalIgnoreCase ) | | level . Equals ( "INFORMATION" , StringComparison . OrdinalIgnoreCase ) )
return "log-viewer-line log-viewer-line-info" ;
if ( level . Contains ( "DEBUG" , StringComparison . OrdinalIgnoreCase ) )
return "log-viewer-line log-viewer-line-debug" ;
if ( level . Contains ( "TRACE" , StringComparison . OrdinalIgnoreCase ) )
return "log-viewer-line log-viewer-line-trace" ;
return "log-viewer-line" ;
}
private string GetFilterOptionDisplay ( string value )
{
return value = = OTHER_OPTION_VALUE ? T ( "Other" ) : value ;
}
private string GetMultiSelectionText ( List < string? > ? selectedValues )
{
if ( selectedValues is null | | selectedValues . Count = = 0 )
return T ( "All" ) ;
var selectedLabels = selectedValues
. Where ( value = > ! string . IsNullOrWhiteSpace ( value ) )
. Select ( value = > this . GetFilterOptionDisplay ( value ! ) )
. ToList ( ) ;
return selectedLabels . Count = = 0 ? T ( "All" ) : string . Join ( ", " , selectedLabels ) ;
}
private void StartAutoRefresh ( )
{
this . StopAutoRefresh ( ) ;
this . autoRefreshCancellationTokenSource = new CancellationTokenSource ( ) ;
_ = this . AutoRefreshLoopAsync ( this . autoRefreshCancellationTokenSource . Token ) ;
}
private void StopAutoRefresh ( )
{
this . autoRefreshCancellationTokenSource ? . Cancel ( ) ;
this . autoRefreshCancellationTokenSource ? . Dispose ( ) ;
this . autoRefreshCancellationTokenSource = null ;
}
private async Task AutoRefreshLoopAsync ( CancellationToken token )
{
try
{
using var timer = new PeriodicTimer ( AUTO_REFRESH_INTERVAL ) ;
while ( await timer . WaitForNextTickAsync ( token ) )
await this . InvokeAsync ( this . RefreshLogAsync ) ;
}
catch ( OperationCanceledException )
{
}
}
private static LogSegments ParseLogSegments ( string line )
{
var index = 0 ;
var parsedAnySegment = false ;
string? timestamp = null ;
string? level = null ;
string? logger = null ;
string? sourceDetails = null ;
if ( TryReadBracket ( line , index , out var bracket , out var content , out var nextIndex ) & & IsTimestamp ( content ) )
{
timestamp = bracket ;
index = nextIndex ;
parsedAnySegment = true ;
}
var candidateIndex = SkipWhiteSpace ( line , index ) ;
if ( TryReadLogLevel ( line , candidateIndex , out var detectedLevel , out nextIndex ) )
{
level = detectedLevel ;
index = nextIndex ;
parsedAnySegment = true ;
}
candidateIndex = SkipWhiteSpace ( line , index ) ;
if ( TryReadBracket ( line , candidateIndex , out bracket , out content , out nextIndex ) )
{
if ( IsSourceDetails ( content ) )
{
sourceDetails = bracket ;
index = nextIndex ;
parsedAnySegment = true ;
}
else
{
logger = bracket ;
index = nextIndex ;
parsedAnySegment = true ;
candidateIndex = SkipWhiteSpace ( line , index ) ;
if ( TryReadBracket ( line , candidateIndex , out bracket , out content , out nextIndex ) & & IsSourceDetails ( content ) )
{
sourceDetails = bracket ;
index = nextIndex ;
parsedAnySegment = true ;
}
}
}
var message = parsedAnySegment ? ReadMessage ( line , index ) : line ;
return new ( timestamp , level , logger , sourceDetails , message ) ;
}
private static bool TryReadBracket ( string text , int start , out string bracket , out string content , out int nextIndex )
{
bracket = string . Empty ;
content = string . Empty ;
nextIndex = start ;
if ( start > = text . Length | | text [ start ] ! = '[' )
return false ;
var end = text . IndexOf ( ']' , start + 1 ) ;
if ( end < 0 )
return false ;
bracket = text [ start . . ( end + 1 ) ] ;
content = text [ ( start + 1 ) . . end ] ;
nextIndex = end + 1 ;
return true ;
}
private static bool TryReadLogLevel ( string text , int start , out string level , out int nextIndex )
{
level = string . Empty ;
nextIndex = start ;
if ( start > = text . Length | | text [ start ] = = '[' )
return false ;
var end = start ;
while ( end < text . Length & & ! char . IsWhiteSpace ( text [ end ] ) )
end + + ;
if ( end = = start )
return false ;
var candidate = text [ start . . end ] ;
if ( candidate . Length > 20 | | candidate . Any ( character = > ! char . IsLetter ( character ) ) )
return false ;
var afterCandidate = SkipWhiteSpace ( text , end ) ;
if ( afterCandidate > = text . Length | | text [ afterCandidate ] ! = '[' )
return false ;
level = candidate ;
nextIndex = end ;
return true ;
}
private static bool IsTimestamp ( string content )
{
return DateTimeOffset . TryParse ( content , CultureInfo . InvariantCulture , DateTimeStyles . AssumeUniversal , out _ ) ;
}
private static bool IsSourceDetails ( string content )
{
return content . Contains ( '=' , StringComparison . Ordinal ) ;
}
private static int SkipWhiteSpace ( string text , int start )
{
var index = start ;
while ( index < text . Length & & char . IsWhiteSpace ( text [ index ] ) )
index + + ;
return index ;
}
private static string ReadMessage ( string text , int start )
{
if ( start > = text . Length )
return string . Empty ;
if ( char . IsWhiteSpace ( text [ start ] ) )
start + + ;
return start > = text . Length ? string . Empty : text [ start . . ] ;
}
private static List < string > BuildFilterOptions ( IEnumerable < string? > values , Comparison < string > comparison )
{
var options = new Dictionary < string , string > ( StringComparer . OrdinalIgnoreCase ) ;
foreach ( var value in values )
{
if ( string . IsNullOrWhiteSpace ( value ) )
continue ;
options . TryAdd ( value , value ) ;
}
var sortedOptions = options . Values . ToList ( ) ;
sortedOptions . Sort ( comparison ) ;
sortedOptions . Add ( OTHER_OPTION_VALUE ) ;
return sortedOptions ;
}
private static int CompareLogLevels ( string left , string right )
{
var leftRank = LOG_LEVEL_ORDER . GetValueOrDefault ( left , int . MaxValue ) ;
var rightRank = LOG_LEVEL_ORDER . GetValueOrDefault ( right , int . MaxValue ) ;
var rankComparison = leftRank . CompareTo ( rightRank ) ;
return rankComparison ! = 0 ? rankComparison : StringComparer . OrdinalIgnoreCase . Compare ( left , right ) ;
}
private static void NormalizeSelectedValues ( HashSet < string > selectedValues , List < string > options )
{
var validOptions = options . ToHashSet ( StringComparer . OrdinalIgnoreCase ) ;
selectedValues . RemoveWhere ( value = > ! validOptions . Contains ( value ) ) ;
}
private static void UpdateSelectedValues ( HashSet < string > target , IEnumerable < string? > ? selectedValues )
{
target . Clear ( ) ;
if ( selectedValues is null )
return ;
foreach ( var value in selectedValues )
if ( ! string . IsNullOrWhiteSpace ( value ) )
target . Add ( value ) ;
}
private static bool MatchesSelection ( string? value , HashSet < string > selectedValues )
{
if ( selectedValues . Count = = 0 )
return true ;
var normalizedValue = string . IsNullOrWhiteSpace ( value ) ? OTHER_OPTION_VALUE : value ;
return selectedValues . Contains ( normalizedValue ) ;
}
private static bool ShouldHideSelectedSegment ( string? value , HashSet < string > selectedValues )
{
return selectedValues . Count = = 1 & & ! string . IsNullOrWhiteSpace ( value ) & & selectedValues . Contains ( value ) ;
}
private static string [ ] BuildSearchTerms ( string text )
{
if ( string . IsNullOrWhiteSpace ( text ) )
return [ ] ;
return text
. Split ( WORD_SPLIT_CHARS , StringSplitOptions . RemoveEmptyEntries | StringSplitOptions . TrimEntries )
. Distinct ( StringComparer . OrdinalIgnoreCase )
. ToArray ( ) ;
}
private static bool MatchesSearchTerms ( string text , string [ ] terms )
{
return terms . Length = = 0 | | terms . Any ( term = > text . Contains ( term , StringComparison . OrdinalIgnoreCase ) ) ;
}
private static void AddSearchTermRanges ( string text , string [ ] terms , List < HighlightRange > ranges )
{
foreach ( var term in terms )
AddLiteralRanges ( text , term , ranges ) ;
}
private static void AddLiteralRanges ( string line , string value , List < HighlightRange > ranges )
{
var index = 0 ;
while ( ( index = line . IndexOf ( value , index , StringComparison . OrdinalIgnoreCase ) ) > = 0 )
{
ranges . Add ( new ( index , value . Length ) ) ;
index + = value . Length ;
}
}
private static List < HighlightRange > MergeRanges ( List < HighlightRange > ranges )
{
var mergedRanges = new List < HighlightRange > ( ) ;
foreach ( var range in ranges . OrderBy ( x = > x . Start ) . ThenByDescending ( x = > x . Length ) )
{
if ( mergedRanges . Count = = 0 )
{
mergedRanges . Add ( range ) ;
continue ;
}
var previous = mergedRanges [ ^ 1 ] ;
var previousEnd = previous . Start + previous . Length ;
var currentEnd = range . Start + range . Length ;
if ( range . Start < = previousEnd )
{
mergedRanges [ ^ 1 ] = previous with { Length = Math . Max ( previousEnd , currentEnd ) - previous . Start } ;
continue ;
}
mergedRanges . Add ( range ) ;
}
return mergedRanges ;
}
private static void AppendEncoded ( StringBuilder sb , string value , int start , int length )
{
if ( length < = 0 )
return ;
sb . Append ( WebUtility . HtmlEncode ( value . Substring ( start , length ) ) ) ;
}
private static void AddIfNotWhiteSpace ( List < string > parts , string? value )
{
if ( ! string . IsNullOrWhiteSpace ( value ) )
parts . Add ( value ) ;
}
private readonly record struct LogLine ( int Number , string Text , LogSegments Segments ) ;
private readonly record struct LogSegments ( string? Timestamp , string? Level , string? Logger , string? SourceDetails , string Message ) ;
private readonly record struct LogSnapshot ( List < LogLine > Lines , int TotalLineCount , int SkippedLineCount ) ;
private readonly record struct HighlightRange ( int Start , int Length ) ;
}