From 0a280670c76430b1740dfb6907f1f7314fcc6f1d Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sun, 19 Oct 2025 08:24:06 +0200 Subject: [PATCH 1/7] Add doc for launching actions from searchbox --- .../ViewModels/SearchBoxViewModel.cs | 12 ------- .../ScriptRunner.GUI/Views/SearchBox.axaml | 33 +++++++++++++++++++ .../ScriptRunner.GUI/Views/SearchBox.axaml.cs | 18 ++++++---- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/SearchBoxViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/SearchBoxViewModel.cs index 4d86847..d8ffc59 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/SearchBoxViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/SearchBoxViewModel.cs @@ -24,8 +24,6 @@ public string SearchFilter private readonly ObservableAsPropertyHelper> _filteredActionList; public IEnumerable FilteredActionList => _filteredActionList.Value; - public bool AutoLaunch { get; set; } - public SearchBoxViewModel(IReadOnlyList allActions, IReadOnlyList recent) { @@ -45,16 +43,6 @@ public SearchBoxViewModel(IReadOnlyList allActions, IReadOnlyList< .Select(text => { text = text?.Trim() ?? string.Empty; - - if (text.StartsWith(">")) - { - AutoLaunch = true; - text = text.Substring(1); - } - else - { - AutoLaunch = false; - } if (intial || string.IsNullOrWhiteSpace(text)) { diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml index 3089843..00f7752 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml @@ -32,6 +32,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml.cs index 8874bad..8865a62 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/SearchBox.axaml.cs @@ -55,9 +55,11 @@ public SearchBox(IReadOnlyList viewModelActions, IReadOnlyList viewModelActions, IReadOnlyList viewModelActions, IReadOnlyList ResultSelected?.Invoke(this, new ResultSelectedEventArgs(){Result = v, AutoLaunch = ViewModel!.AutoLaunch}); + private void OnResultSelected(ScriptConfigWithArgumentSet? v, bool autoLaunch) => ResultSelected?.Invoke(this, new ResultSelectedEventArgs(){Result = v, AutoLaunch = autoLaunch}); } } From cf00a04b80678c0a2206555a6d81506c3a5cc57f Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sun, 19 Oct 2025 08:39:54 +0200 Subject: [PATCH 2/7] Fix DatePicker on execution log --- .../Views/ActionDetailsSection.axaml | 15 +- .../Views/ExecutionLogList.axaml | 364 +++++++++++------- .../Views/ExecutionLogList.axaml.cs | 57 ++- .../ScriptRunner.GUI/Views/MainWindow.axaml | 3 - .../Views/MainWindow.axaml.cs | 13 - 5 files changed, 281 insertions(+), 171 deletions(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml index 01a542f..7a2e4e6 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml @@ -203,7 +203,7 @@ - + @@ -211,14 +211,17 @@ Compacted - + + + + - + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml index 5336ba4..8e12d2b 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml @@ -11,152 +11,226 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + Margin="0,0,0,5"/> + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs index 2f4081d..60bfee0 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs @@ -29,8 +29,11 @@ public partial class ExecutionLogList : UserControl public static readonly StyledProperty ShowDatePickerProperty = AvaloniaProperty.Register(nameof(ShowDatePicker), defaultValue: false); - // Event for when date header is clicked - public event EventHandler? DateHeaderClicked; + public static readonly StyledProperty IsDatePickerVisibleProperty = + AvaloniaProperty.Register(nameof(IsDatePickerVisible), defaultValue: false); + + public static readonly StyledProperty?> AvailableDatesProperty = + AvaloniaProperty.Register?>(nameof(AvailableDates)); private INotifyCollectionChanged? _currentCollection; @@ -64,6 +67,18 @@ public bool ShowDatePicker set => SetValue(ShowDatePickerProperty, value); } + public bool IsDatePickerVisible + { + get => GetValue(IsDatePickerVisibleProperty); + set => SetValue(IsDatePickerVisibleProperty, value); + } + + public IEnumerable? AvailableDates + { + get => GetValue(AvailableDatesProperty); + private set => SetValue(AvailableDatesProperty, value); + } + public ExecutionLogList() { InitializeComponent(); @@ -89,6 +104,7 @@ public ExecutionLogList() } RebuildGroupedList(); + RebuildAvailableDates(); }); // Watch for changes to SelectedLogItem and update SelectedItem @@ -141,9 +157,27 @@ private void RebuildGroupedList() GroupedItems = items; } + private void RebuildAvailableDates() + { + if (Items == null) + { + AvailableDates = null; + return; + } + + var dateGroups = Items + .GroupBy(a => a.Timestamp.Date) + .OrderByDescending(g => g.Key) + .Select(g => new DateGroupInfo(g.Key, g.Count())) + .ToList(); + + AvailableDates = dateGroups; + } + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { RebuildGroupedList(); + RebuildAvailableDates(); } public void OnDateHeaderClicked(object? sender, PointerPressedEventArgs e) @@ -151,8 +185,23 @@ public void OnDateHeaderClicked(object? sender, PointerPressedEventArgs e) // Show the date picker overlay when a date header is clicked (if enabled) if (ShowDatePicker) { - // Raise the event so parent can show date picker - DateHeaderClicked?.Invoke(this, EventArgs.Empty); + IsDatePickerVisible = true; + } + e.Handled = true; + } + + public void OnDatePickerOverlayClicked(object? sender, PointerPressedEventArgs e) + { + // Close the overlay when clicking on the background + IsDatePickerVisible = false; + } + + public void OnDatePickerItemClicked(object? sender, PointerPressedEventArgs e) + { + if (sender is Border border && border.DataContext is DateGroupInfo dateInfo) + { + IsDatePickerVisible = false; + _ = ScrollToDate(dateInfo.Date); } e.Handled = true; } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml index cec6890..e261db7 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml @@ -59,9 +59,6 @@ Items="{Binding ExecutionLog}" SelectedItem="{Binding SelectedRecentExecution, Mode=TwoWay}" ShowDatePicker="True"/> - - - diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs index e454731..601a848 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs @@ -41,19 +41,6 @@ public MainWindow() ViewModel.ScrollToDateAction = ScrollToDate; } - // Subscribe to date header click event from ExecutionLogList control - var executionLogList = this.FindControl("ExecutionLogListControl"); - if (executionLogList != null) - { - executionLogList.DateHeaderClicked += (sender, args) => - { - if (ViewModel != null) - { - ViewModel.IsDatePickerVisible = true; - } - }; - } - if (AppSettingsService.Load().Layout is { } layoutSettings) { From 57e271ea017691829a691584fa512ca1ed912a0e Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sun, 19 Oct 2025 10:34:50 +0200 Subject: [PATCH 3/7] Update Avalonia packages --- .../ScriptRunner.GUI/ScriptRunner.GUI.csproj | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj b/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj index 868f0d0..075acec 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj @@ -33,29 +33,29 @@ - - - - - - + + + + + + - - - - - - - + + + + + + + - + - - + + From fdc30879e20d290cbaa67b296f3c005a21fb8a31 Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sun, 19 Oct 2025 11:18:58 +0200 Subject: [PATCH 4/7] Use AvaloniaEdit to present console output --- .../Controls/FormattedTextEditor.cs | 147 +++++++++++ .../ViewModels/RunningJobViewModel.cs | 229 ++++++++++++------ .../Views/RunningJobsSection.axaml | 7 +- .../Views/RunningJobsSection.axaml.cs | 14 +- 4 files changed, 311 insertions(+), 86 deletions(-) create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs diff --git a/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs new file mode 100644 index 0000000..e9ff06c --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs @@ -0,0 +1,147 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.VisualTree; +using AvaloniaEdit; +using AvaloniaEdit.Document; +using AvaloniaEdit.Rendering; +using ScriptRunner.GUI.ViewModels; + +namespace ScriptRunner.GUI.Controls; + +public class FormattedTextEditor : TextEditor +{ + public static readonly StyledProperty ViewModelProperty = + AvaloniaProperty.Register(nameof(ViewModel)); + + public RunningJobViewModel? ViewModel + { + get => GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + protected override Type StyleKeyOverride { get; } = typeof(TextEditor); + + private FormattedTextColorizer? _colorizer; + + public event EventHandler? ScrollChanged; + + public FormattedTextEditor() + { + IsReadOnly = true; + ShowLineNumbers = false; + WordWrap = true; + Background = new SolidColorBrush(Color.FromRgb(30,30,30)); + BorderBrush = new SolidColorBrush(Color.FromRgb(62,62,54)); + BorderThickness = new Thickness(1); + FontFamily = new FontFamily("Consolas"); + Options.AllowScrollBelowDocument = false; + Options.RequireControlModifierForHyperlinkClick = false; + Padding = new Thickness(15); + TextArea.TextView.LinkTextForegroundBrush = Brushes.LightBlue; + + // Subscribe to scroll changes + this.Loaded += (_, _) => + { + var scrollViewer = this.GetVisualDescendants() + .OfType() + .FirstOrDefault(); + + if (scrollViewer != null) + { + scrollViewer.ScrollChanged += OnScrollChanged; + } + }; + } + + private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) + { + ScrollChanged?.Invoke(this, e); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ViewModelProperty) + { + if (_colorizer != null) + { + TextArea.TextView.LineTransformers.Remove(_colorizer); + } + + if (ViewModel != null) + { + Document = ViewModel.RichOutput; + _colorizer = new FormattedTextColorizer(ViewModel); + TextArea.TextView.LineTransformers.Add(_colorizer); + } + } + } +} + +public class FormattedTextColorizer : DocumentColorizingTransformer +{ + private readonly RunningJobViewModel _viewModel; + + public FormattedTextColorizer(RunningJobViewModel viewModel) + { + _viewModel = viewModel; + } + + protected override void ColorizeLine(DocumentLine line) + { + int lineStartOffset = line.Offset; + int lineEndOffset = line.EndOffset; + + var segments = _viewModel.FormattingSegments; + + foreach (var segment in segments) + { + // Skip segments completely before this line + if (segment.StartOffset + segment.Length <= lineStartOffset) + continue; + + // Stop if segment is completely after this line + if (segment.StartOffset >= lineEndOffset) + break; + + int segmentStart = Math.Max(segment.StartOffset, lineStartOffset); + int segmentEnd = Math.Min(segment.StartOffset + segment.Length, lineEndOffset); + + if (segmentStart >= segmentEnd) + continue; + + ChangeLinePart(segmentStart, segmentEnd, element => + { + if (segment.Foreground != null) + { + element.TextRunProperties.SetForegroundBrush(segment.Foreground); + } + + if (segment.Background != null && !segment.Background.Equals(Brushes.Transparent)) + { + element.TextRunProperties.SetBackgroundBrush(segment.Background); + } + + var typeface = element.TextRunProperties.Typeface; + var newTypeface = new Typeface( + typeface.FontFamily, + segment.IsItalic ? FontStyle.Italic : FontStyle.Normal, + segment.IsBold ? FontWeight.Bold : FontWeight.Normal + ); + element.TextRunProperties.SetTypeface(newTypeface); + + if (segment.IsUnderline) + { + element.TextRunProperties.SetTextDecorations(new TextDecorationCollection + { + new TextDecoration { Location = TextDecorationLocation.Underline } + }); + } + }); + } + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs index 757a456..6aff2b2 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs @@ -25,6 +25,7 @@ using DynamicData; using Microsoft.Extensions.ObjectPool; using ScriptRunner.GUI.ScriptConfigs; +using AvaloniaEdit.Document; namespace ScriptRunner.GUI.ViewModels; @@ -453,9 +454,6 @@ private void AppendToOutput(string? s, ConsoleOutputLevel level) _logForwarder.Write(s); } } - - List tmpInlinesForNewEntry = new List(); - private Regex urlPattern = new Regex(@"(https?://[^\s<>\""']+)", RegexOptions.Compiled); @@ -622,88 +620,163 @@ private void AppendToUiOutputFinal(List s) { Dispatcher.UIThread.Post(() => { - _lineBreak ??= new(); - _underlineDecoration ??= new TextDecorationCollection() + var sb = new StringBuilder(); + var newSegments = new List(); + + foreach (var part in s) { - new() + int startOffset = sb.Length; + + switch (part) { - Location = TextDecorationLocation.Underline, + case LineEnding: + sb.AppendLine(); + break; + + case Link link: + sb.Append(link.Text); + newSegments.Add(new FormattedSegment + { + StartOffset = startOffset, + Length = link.Text.Length, + Foreground = Brushes.LightBlue, + IsUnderline = true, + IsLink = true, + LinkUrl = link.Url ?? link.Text + }); + break; + + case TextSpan textSpan: + sb.Append(textSpan.Text); + + // Try to merge with the last segment if formatting is identical + var lastSegment = newSegments.Count > 0 ? newSegments[^1] : null; + if (lastSegment != null && + !lastSegment.IsLink && + lastSegment.StartOffset + lastSegment.Length == startOffset && + ReferenceEquals(lastSegment.Foreground, textSpan.Foreground) && + ReferenceEquals(lastSegment.Background, textSpan.BackGround) && + lastSegment.IsBold == textSpan.IsBold && + lastSegment.IsItalic == textSpan.IsItalic && + lastSegment.IsUnderline == textSpan.IsUnderline) + { + // Merge with previous segment by extending its length + lastSegment.Length += textSpan.Text.Length; + } + else + { + // Create new segment + newSegments.Add(new FormattedSegment + { + StartOffset = startOffset, + Length = textSpan.Text.Length, + Foreground = textSpan.Foreground, + Background = textSpan.BackGround, + IsBold = textSpan.IsBold, + IsItalic = textSpan.IsItalic, + IsUnderline = textSpan.IsUnderline, + IsLink = false + }); + } + break; + + default: + throw new ArgumentOutOfRangeException(nameof(part)); } - }; - tmpInlinesForNewEntry.Clear(); - foreach (var part in s) + } + + var newText = sb.ToString(); + + // Handle buffer size limit + if (RichOutput.TextLength + newText.Length > OutputBufferSize * 100) { - Inline transformed = part switch + var excessLength = (RichOutput.TextLength + newText.Length) - (OutputBufferSize * 100); + if (excessLength < RichOutput.TextLength) { - LineEnding => _lineBreak, - Link link => CreateLink(link.Text), - TextSpan textSpan => new Run(textSpan.Text) + RichOutput.Remove(0, excessLength); + // Adjust existing segments + for (int i = _formattingSegments.Count - 1; i >= 0; i--) { - Foreground = textSpan.Foreground, - Background = textSpan.BackGround, - FontStyle = textSpan switch + var seg = _formattingSegments[i]; + if (seg.StartOffset + seg.Length <= excessLength) { - { IsBold: true } => FontStyle.Oblique, - { IsItalic: true } => FontStyle.Italic, - _ => FontStyle.Normal - }, - TextDecorations = textSpan.IsUnderline ? _underlineDecoration : null - }, - _ => throw new ArgumentOutOfRangeException(nameof(part)) - }; - tmpInlinesForNewEntry.Add(transformed); - + _formattingSegments.RemoveAt(i); + } + else if (seg.StartOffset < excessLength) + { + seg.Length -= (excessLength - seg.StartOffset); + seg.StartOffset = 0; + } + else + { + seg.StartOffset -= excessLength; + } + } + } + else + { + RichOutput.Text = string.Empty; + _formattingSegments.Clear(); + } } - - if (RichOutput.Count + tmpInlinesForNewEntry.Count > OutputBufferSize) + + int baseOffset = RichOutput.TextLength; + RichOutput.Insert(RichOutput.TextLength, newText); + + // Add new segments with adjusted offsets and merge with last existing segment if possible + foreach (var segment in newSegments) { - var tmpNewLines = RichOutput.Concat(tmpInlinesForNewEntry).TakeLast(OutputBufferSize).ToArray(); - RichOutput.Clear(); - RichOutput.AddRange(tmpNewLines); + segment.StartOffset += baseOffset; + + // Try to merge with the very last segment in the existing list + var lastExistingSegment = _formattingSegments.Count > 0 ? _formattingSegments[^1] : null; + if (lastExistingSegment != null && + !lastExistingSegment.IsLink && + !segment.IsLink && + lastExistingSegment.StartOffset + lastExistingSegment.Length == segment.StartOffset && + ReferenceEquals(lastExistingSegment.Foreground, segment.Foreground) && + ReferenceEquals(lastExistingSegment.Background, segment.Background) && + lastExistingSegment.IsBold == segment.IsBold && + lastExistingSegment.IsItalic == segment.IsItalic && + lastExistingSegment.IsUnderline == segment.IsUnderline) + { + // Merge by extending the last segment + lastExistingSegment.Length += segment.Length; + } + else + { + _formattingSegments.Add(segment); + } + } + + // Limit total number of segments to prevent performance issues + const int maxSegments = 10000; + if (_formattingSegments.Count > maxSegments) + { + int toRemove = _formattingSegments.Count - maxSegments; + _formattingSegments.RemoveRange(0, toRemove); } - else RichOutput.AddRange(tmpInlinesForNewEntry); s.Clear(); _outputElementListPool.Return(s); - } ); + }); } - private InlineUIContainer CreateLink(string chunk) + private static void OpenUrl(string url) { - var link = new TextBlock + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + Process.Start(new ProcessStartInfo(url) + { + UseShellExecute = true, + Verb = "open" + }); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - Text = chunk, - Cursor = _linkCursor??= new Cursor(StandardCursorType.Hand), - Foreground = Brushes.LightBlue, - TextDecorations = _underlineDecoration - }; - link.PointerPressed += OnLinkOnPointerPressed; - return new InlineUIContainer(link); - } - - private static void OnLinkOnPointerPressed(object? sender, PointerPressedEventArgs args) - { - if(args.GetCurrentPoint(null).Properties.IsLeftButtonPressed == false) - return; - - if (sender is TextBlock { Text: { } url }) + Process.Start("xdg-open", url); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Process.Start(new ProcessStartInfo(url) - { - UseShellExecute = true, - Verb = "open" - }); - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Process.Start("xdg-open", url); - } - else - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - return; - Process.Start("open", url); - } + Process.Start("open", url); } } @@ -792,16 +865,15 @@ public bool KillAvailable private int _numberOfLines; private readonly IDisposable outputSub; private IReadOnlyList _troubleshooting = Array.Empty(); - private LineBreak? _lineBreak; + private List _formattingSegments = new(); private readonly LogForwarder _logForwarder; - private TextDecorationCollection? _underlineDecoration; - private Cursor? _linkCursor; public CancellationTokenSource GracefulCancellation { get; set; } public CancellationTokenSource KillCancellation { get; set; } public Dictionary EnvironmentVariables { get; set; } - public InlineCollection RichOutput { get; set; } = new(); + public TextDocument RichOutput { get; set; } = new(); + public List FormattingSegments => _formattingSegments; private bool _followOutput = true; public bool FollowOutput @@ -811,6 +883,19 @@ public bool FollowOutput } } +public class FormattedSegment +{ + public int StartOffset { get; set; } + public int Length { get; set; } + public IBrush? Foreground { get; set; } + public IBrush? Background { get; set; } + public bool IsBold { get; set; } + public bool IsItalic { get; set; } + public bool IsUnderline { get; set; } + public bool IsLink { get; set; } + public string? LinkUrl { get; set; } +} + public enum TroubleShootingSeverity { Error, @@ -823,4 +908,4 @@ class TroubleShootingElement { public TroubleShootingSeverity Severity { get; set; } public string Message { get; set; } -} \ No newline at end of file +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml index d49d0f9..b091a31 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml @@ -6,6 +6,7 @@ xmlns:gui="clr-namespace:ScriptRunner.GUI" xmlns:avalonia="https://github.com/projektanker/icons.avalonia" xmlns:scriptConfigs="clr-namespace:ScriptRunner.GUI.ScriptConfigs" + xmlns:controls="clr-namespace:ScriptRunner.GUI.Controls" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="ScriptRunner.GUI.Views.RunningJobsSection" x:DataType="viewModels:MainWindowViewModel" @@ -57,9 +58,7 @@ - - - + @@ -80,7 +79,6 @@ - Follow output @@ -91,4 +89,3 @@ - diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs index 6a580ad..3f43e98 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using ScriptRunner.GUI.ViewModels; +using ScriptRunner.GUI.Controls; namespace ScriptRunner.GUI.Views; @@ -24,8 +25,11 @@ private void InitializeComponent() private void ScrollChangedHandler(object? sender, ScrollChangedEventArgs e) { - if (sender is ScrollViewer sc && sc.DataContext is RunningJobViewModel viewModel) + if ( e is {Source: ScrollViewer sc} && sender is Control{ DataContext: RunningJobViewModel viewModel } ) { + // Find the FormattedTextEditor inside the ScrollViewer + var textEditor = sc.Content as FormattedTextEditor; + // If content was added (extent changed), auto-scroll if follow output is enabled if (e.ExtentDelta.Y > 0 && viewModel.FollowOutput) { @@ -64,17 +68,9 @@ public void AcceptCommand(object? sender, KeyEventArgs e) private void InputElement_OnGotFocus(object? sender, GotFocusEventArgs e) { - if (sender is TextBox textBox) - { - textBox.Height = Double.NaN; - } } private void InputElement_OnLostFocus(object? sender, RoutedEventArgs e) { - if (sender is TextBox textBox) - { - textBox.Height = 30; - } } } \ No newline at end of file From 6a6c789fc5ea570df70bbb449386bfc6a9dfef99 Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sun, 19 Oct 2025 15:05:50 +0200 Subject: [PATCH 5/7] Add links for folders and files --- .../Controls/FormattedTextEditor.cs | 180 +++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs index e9ff06c..850fcdd 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs @@ -1,8 +1,12 @@ using System; +using System.IO; using System.Linq; +using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Media; +using Avalonia.Media.TextFormatting; using Avalonia.VisualTree; using AvaloniaEdit; using AvaloniaEdit.Document; @@ -41,7 +45,7 @@ public FormattedTextEditor() Options.RequireControlModifierForHyperlinkClick = false; Padding = new Thickness(15); TextArea.TextView.LinkTextForegroundBrush = Brushes.LightBlue; - + TextArea.TextView.ElementGenerators.Add(new FilePathElementGenerator()); // Subscribe to scroll changes this.Loaded += (_, _) => { @@ -145,3 +149,177 @@ protected override void ColorizeLine(DocumentLine line) } } } + + + +/// +/// Detects file and directory paths and makes them clickable. +/// +public class FilePathElementGenerator : VisualLineElementGenerator +{ + // Windows paths: + // - Starts with drive letter (C:\) or UNC path (\\server\) + // - Can contain spaces and most characters except: < > : " | ? * [ ] and control chars + // - Terminates at: whitespace, quotes, brackets, or line break + // - For files: must end with extension (.txt, .cs, etc.) + // - For dirs: can end with \ or directory name + private static readonly Regex WindowsPathRegex = new Regex( + @"(?:[a-zA-Z]:\\|\\\\[^\\]+\\[^\\]+\\)" + // Drive (C:\) or UNC (\\server\share\) + @"(?:[^<>:""|?*\[\]\r\n]+\\)*" + // Intermediate directories (can have spaces) + @"(?:[^<>:""|?*\[\]\r\n\\]+(?:\.[a-zA-Z0-9]+)?|[^<>:""|?*\[\]\r\n\\]+\\)", // Final file with extension or directory + RegexOptions.Compiled); + + // Unix paths: + // - Starts with / or ~/ + // - Can contain spaces in directory/file names + // - Terminates at: whitespace, quotes, brackets, or special chars + private static readonly Regex UnixPathRegex = new Regex( + @"(?:~/|/)" + // Root or home + @"(?:[^<>:""\\\|?*\[\]\s\n]+/)*" + // Directories (terminated by /) + @"[^<>:""\\\|?*\[\]\s\n]+", // Final file or directory name + RegexOptions.Compiled); + public bool RequireControlModifierForClick { get; set; } + + public FilePathElementGenerator() + { + RequireControlModifierForClick = false; + } + + private Match GetMatch(int startOffset, out int matchOffset) + { + var endOffset = CurrentContext.VisualLine.LastDocumentLine.EndOffset; + var relevantText = CurrentContext.GetText(startOffset, endOffset - startOffset); + + // Try Windows paths first + var match = WindowsPathRegex.Match(relevantText.Text, relevantText.Offset, relevantText.Count); + + // If no Windows path found, try Unix paths + if (!match.Success) + { + match = UnixPathRegex.Match(relevantText.Text, relevantText.Offset, relevantText.Count); + } + + matchOffset = match.Success ? match.Index - relevantText.Offset + startOffset : -1; + return match; + } + + public override int GetFirstInterestedOffset(int startOffset) + { + GetMatch(startOffset, out var matchOffset); + return matchOffset; + } + + public override VisualLineElement ConstructElement(int offset) + { + var match = GetMatch(offset, out var matchOffset); + if (match.Success && matchOffset == offset) + { + var path = match.Value; + + // Validate that the path exists + if (File.Exists(path) || Directory.Exists(path)) + { + return new FilePathLinkText(CurrentContext.VisualLine, match.Length) + { + Path = path, + RequireControlModifierForClick = RequireControlModifierForClick + }; + } + } + return null; + } +} + +/// +/// Visual line element representing a clickable file path. +/// +public class FilePathLinkText : VisualLineText +{ + public string Path { get; set; } + public bool RequireControlModifierForClick { get; set; } + + public FilePathLinkText(VisualLine parentVisualLine, int length) + : base(parentVisualLine, length) + { + RequireControlModifierForClick = true; + } + + public override TextRun CreateTextRun(int startVisualColumn, ITextRunConstructionContext context) + { + // Apply link styling + this.TextRunProperties.SetForegroundBrush(context.TextView.LinkTextForegroundBrush); + this.TextRunProperties.SetBackgroundBrush(context.TextView.LinkTextBackgroundBrush); + if (context.TextView.LinkTextUnderline) + this.TextRunProperties.SetTextDecorations(TextDecorations.Underline); + return base.CreateTextRun(startVisualColumn, context); + } + + protected virtual bool LinkIsClickable(KeyModifiers modifiers) + { + if (string.IsNullOrEmpty(Path)) + return false; + if (RequireControlModifierForClick) + return modifiers.HasFlag(KeyModifiers.Control); + return true; + } + + protected override void OnQueryCursor(PointerEventArgs e) + { + if (LinkIsClickable(e.KeyModifiers)) + { + if (e.Source is InputElement inputElement) + { + inputElement.Cursor = new Cursor(StandardCursorType.Hand); + } + e.Handled = true; + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + if (!e.Handled && LinkIsClickable(e.KeyModifiers)) + { + OpenPath(Path); + e.Handled = true; + } + } + + private static void OpenPath(string path) + { + try + { + if (File.Exists(path)) + { + // Open file with default application + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = path, + UseShellExecute = true + }); + } + else if (Directory.Exists(path)) + { + // Open directory in file explorer + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = path, + UseShellExecute = true + }); + } + } + catch (Exception ex) + { + // Handle errors (log or show notification) + System.Diagnostics.Debug.WriteLine($"Failed to open path: {ex.Message}"); + } + } + + protected override VisualLineText CreateInstance(int length) + { + return new FilePathLinkText(ParentVisualLine, length) + { + Path = Path, + RequireControlModifierForClick = RequireControlModifierForClick + }; + } +} \ No newline at end of file From 9ada2da956a0e0bdb22f63afc2b54da7550275bf Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sun, 19 Oct 2025 15:30:14 +0200 Subject: [PATCH 6/7] Fix console colors --- .../ViewModels/RunningJobViewModel.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs index 6aff2b2..28a3ea1 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs @@ -496,7 +496,7 @@ private void AppendToUiOutput(IList s) "\u001b[32m" => Brushes.Green, "\u001b[33m" => Brushes.Yellow, "\u001b[34m" => Brushes.Blue, - "\u001b[35m," => Brushes.DarkMagenta, + "\u001b[35m" => Brushes.DarkMagenta, "\u001b[36m" => Brushes.DarkCyan, "\u001b[37m" => Brushes.White, "\u001b[90m" => ConsoleColors.BrightBlack, @@ -504,7 +504,7 @@ private void AppendToUiOutput(IList s) "\u001b[92m" => ConsoleColors.BrightGreen, "\u001b[93m" => ConsoleColors.BrightYellow, "\u001b[94m" => ConsoleColors.BrightBlue, - "\u001b[95m," =>ConsoleColors.BrightMagenta, + "\u001b[95m" =>ConsoleColors.BrightMagenta, "\u001b[96m" => ConsoleColors.BrightCyan, "\u001b[97m" => ConsoleColors.BrightWhite, "\u001b[30;1m" => Brushes.Gray, @@ -512,7 +512,7 @@ private void AppendToUiOutput(IList s) "\u001b[32;1m" => Brushes.LightGreen, "\u001b[33;1m" => Brushes.LightYellow, "\u001b[34;1m" => Brushes.LightBlue, - "\u001b[35;1m," => Brushes.Magenta, + "\u001b[35;1m" => Brushes.Magenta, "\u001b[36;1m" => Brushes.Cyan, "\u001b[37;1m" => Brushes.White, "\u001b[0m" => Brushes.White, @@ -531,7 +531,7 @@ private void AppendToUiOutput(IList s) "\u001b[42m" => Brushes.DarkGreen, "\u001b[43m" => Brushes.Yellow, "\u001b[44m" => Brushes.DarkBlue, - "\u001b[45m," => Brushes.DarkMagenta, + "\u001b[45m" => Brushes.DarkMagenta, "\u001b[46m" => Brushes.DarkCyan, "\u001b[47m" => Brushes.White, "\u001b[100m" => ConsoleColors.BrightBlack, @@ -539,7 +539,7 @@ private void AppendToUiOutput(IList s) "\u001b[102m" => ConsoleColors.BrightGreen, "\u001b[103m" => ConsoleColors.BrightYellow, "\u001b[104m" => ConsoleColors.BrightBlue, - "\u001b[105m," =>ConsoleColors.BrightMagenta, + "\u001b[105m" =>ConsoleColors.BrightMagenta, "\u001b[106m" => ConsoleColors.BrightCyan, "\u001b[107m" => ConsoleColors.BrightWhite, "\u001b[40;1m" => Brushes.Gray, @@ -547,7 +547,7 @@ private void AppendToUiOutput(IList s) "\u001b[42;1m" => Brushes.Green, "\u001b[43;1m" => Brushes.LightYellow, "\u001b[44;1m" => Brushes.Blue, - "\u001b[45;1m," => Brushes.Magenta, + "\u001b[45;1m" => Brushes.Magenta, "\u001b[46;1m" => Brushes.Cyan, "\u001b[47;1m" => Brushes.White, "\u001b[0m" => Brushes.Transparent, From 826ea2a4811a24dc762a34d840158b473f6a33ed Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sun, 19 Oct 2025 15:51:46 +0200 Subject: [PATCH 7/7] Improve console text formatting --- .../Controls/FormattedTextEditor.cs | 18 +++++++--- .../ViewModels/RunningJobViewModel.cs | 35 ++++++++++++++++--- .../ViewModels/TextElements.cs | 2 +- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs index 850fcdd..a181fc9 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs @@ -138,12 +138,22 @@ protected override void ColorizeLine(DocumentLine line) ); element.TextRunProperties.SetTypeface(newTypeface); - if (segment.IsUnderline) + // Apply text decorations (underline and/or strikethrough) + if (segment.IsUnderline || segment.IsStrikethrough) { - element.TextRunProperties.SetTextDecorations(new TextDecorationCollection + var decorations = new TextDecorationCollection(); + + if (segment.IsUnderline) { - new TextDecoration { Location = TextDecorationLocation.Underline } - }); + decorations.Add(new TextDecoration { Location = TextDecorationLocation.Underline }); + } + + if (segment.IsStrikethrough) + { + decorations.Add(new TextDecoration { Location = TextDecorationLocation.Strikethrough }); + } + + element.TextRunProperties.SetTextDecorations(decorations); } }); } diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs index 28a3ea1..abb646d 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs @@ -377,6 +377,7 @@ private void TryPopNextAlert() private bool underline = false; private bool bold = false; private bool italic = false; + private bool strikethrough = false; public ObservableCollection CurrentInteractiveInputs { get; set; } = new(); @@ -581,19 +582,40 @@ private void AppendToUiOutput(IList s) { bold = true; } - else if (subPart == "\u003b[1m") + else if (subPart == "\u001b[22m") + { + bold = false; + } + else if (subPart == "\u001b[3m") { italic = true; } + else if (subPart == "\u001b[23m") + { + italic = false; + } else if (subPart == "\u001b[4m") { underline = true; } + else if (subPart == "\u001b[24m") + { + underline = false; + } + else if (subPart == "\u001b[9m") + { + strikethrough = true; + } + else if (subPart == "\u001b[29m") + { + strikethrough = false; + } else if (subPart == "\u001b[0m") { bold = false; underline = false; italic = false; + strikethrough = false; } @@ -603,8 +625,9 @@ private void AppendToUiOutput(IList s) _outputElements.Add(new TextSpan( Text: subPart, IsBold: bold, - IsItalic: bold == false && italic, + IsItalic: italic, IsUnderline: underline, + IsStrikethrough: strikethrough, Foreground:currentConsoleTextColor, BackGround: currentConsoleBackgroundColor )); @@ -658,7 +681,8 @@ private void AppendToUiOutputFinal(List s) ReferenceEquals(lastSegment.Background, textSpan.BackGround) && lastSegment.IsBold == textSpan.IsBold && lastSegment.IsItalic == textSpan.IsItalic && - lastSegment.IsUnderline == textSpan.IsUnderline) + lastSegment.IsUnderline == textSpan.IsUnderline && + lastSegment.IsStrikethrough == textSpan.IsStrikethrough) { // Merge with previous segment by extending its length lastSegment.Length += textSpan.Text.Length; @@ -675,6 +699,7 @@ private void AppendToUiOutputFinal(List s) IsBold = textSpan.IsBold, IsItalic = textSpan.IsItalic, IsUnderline = textSpan.IsUnderline, + IsStrikethrough = textSpan.IsStrikethrough, IsLink = false }); } @@ -738,7 +763,8 @@ private void AppendToUiOutputFinal(List s) ReferenceEquals(lastExistingSegment.Background, segment.Background) && lastExistingSegment.IsBold == segment.IsBold && lastExistingSegment.IsItalic == segment.IsItalic && - lastExistingSegment.IsUnderline == segment.IsUnderline) + lastExistingSegment.IsUnderline == segment.IsUnderline && + lastExistingSegment.IsStrikethrough == segment.IsStrikethrough) { // Merge by extending the last segment lastExistingSegment.Length += segment.Length; @@ -892,6 +918,7 @@ public class FormattedSegment public bool IsBold { get; set; } public bool IsItalic { get; set; } public bool IsUnderline { get; set; } + public bool IsStrikethrough { get; set; } public bool IsLink { get; set; } public string? LinkUrl { get; set; } } diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/TextElements.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/TextElements.cs index 3a9a5df..0f9a1ea 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/TextElements.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/TextElements.cs @@ -8,5 +8,5 @@ public record LineEnding : OutputElement { public static readonly LineEnding Instance = new LineEnding(); } -public record TextSpan(string Text, IBrush Foreground, IBrush BackGround, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false):OutputElement; +public record TextSpan(string Text, IBrush Foreground, IBrush BackGround, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false, bool IsStrikethrough = false):OutputElement; public record Link(string Text, string? Url = null):OutputElement; \ No newline at end of file