From aff1313ebc6a14d19ff408c3c23ea8ee28880bba Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Fri, 17 Oct 2025 19:07:03 +0200 Subject: [PATCH 01/12] Add option to define dropdown items as array --- schema/v1/ScriptRunnerSchema.json | 54 +++- .../Parameters/DropdownControl.cs | 31 ++- .../Parameters/MultiSelectControl.cs | 7 +- .../Parameters/ParamsPanelFactory.cs | 111 +++++--- .../ScriptConfigs/DropdownOption.cs | 25 ++ .../ScriptConfigs/ScriptConfig.cs | 57 ++++- .../Scripts/DropdownOptionsExample.json | 66 +++++ .../Scripts/ScriptRunnerSchema.json | 239 ------------------ 8 files changed, 313 insertions(+), 277 deletions(-) create mode 100644 src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/DropdownOption.cs create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Scripts/DropdownOptionsExample.json delete mode 100644 src/ScriptRunner/ScriptRunner.GUI/Scripts/ScriptRunnerSchema.json diff --git a/schema/v1/ScriptRunnerSchema.json b/schema/v1/ScriptRunnerSchema.json index 6fad487..8a78869 100644 --- a/schema/v1/ScriptRunnerSchema.json +++ b/schema/v1/ScriptRunnerSchema.json @@ -185,7 +185,32 @@ "type": "object", "properties": { "options": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + ] }, "searchable": { "type": "boolean" @@ -212,7 +237,32 @@ "type": "object", "properties": { "options": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "array", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + ] }, "delimiter": { "type": "string" diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs index d089fdd..c94aee3 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/DropdownControl.cs @@ -1,4 +1,7 @@ -using Avalonia.Controls; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Controls; +using ScriptRunner.GUI.ScriptConfigs; using ScriptRunner.GUI.Views; namespace ScriptRunner.GUI; @@ -7,15 +10,33 @@ public class DropdownControl : IControlRecord { public Control Control { get; set; } public Control InputControl { get; set; } + public ObservableCollection DropdownOptions { get; set; } public string GetFormattedValue() { - return InputControl switch + var selectedItem = InputControl switch { - ComboBox cb => cb.SelectedItem?.ToString(), + ComboBox cb => cb.SelectedItem, SearchableComboBox acb => acb.SelectedItem, - _ => "" - } ?? string.Empty; + _ => null + }; + + if (selectedItem is DropdownOption option) + { + return option.Value; + } + + // For SearchableComboBox, we need to map the label back to value + if (selectedItem is string label && DropdownOptions != null) + { + var matchingOption = DropdownOptions.FirstOrDefault(opt => opt.Label == label); + if (matchingOption != null) + { + return matchingOption.Value; + } + } + + return selectedItem?.ToString() ?? string.Empty; } public string Name { get; set; } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs index cf9a196..d688808 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/MultiSelectControl.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Avalonia.Controls; +using ScriptRunner.GUI.ScriptConfigs; namespace ScriptRunner.GUI; @@ -13,7 +14,11 @@ public string GetFormattedValue() var copy = new List(); foreach (var item in selectedItems) { - if (item.ToString() is { } nonNullItem) + if (item is DropdownOption option) + { + copy.Add(option.Value); + } + else if (item?.ToString() is { } nonNullItem) { copy.Add(nonNullItem); } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs index c8834c3..0c1f716 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs @@ -241,32 +241,60 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind }; case PromptType.Dropdown: var delimiterForOptions = p.GetPromptSettings("delimiter", x => x, ","); - var initialOptions = p.GetPromptSettings("options", out var options) ? options.Split(delimiterForOptions):Array.Empty(); - var observableOptions = new ObservableCollection(initialOptions); + var dropdownOptions = p.GetDropdownOptions(delimiterForOptions); + var observableDropdownOptions = new ObservableCollection(dropdownOptions); var searchable = p.GetPromptSettings("searchable", bool.Parse, false); var optionsGeneratorCommand = p.GetPromptSettings("optionsGeneratorCommand", out var optionsGeneratorCommandText) ? optionsGeneratorCommandText : null; - - if (observableOptions.Count == 0 && string.IsNullOrWhiteSpace(value) == false && string.IsNullOrWhiteSpace(optionsGeneratorCommand) == false) + // Find selected item by matching value + DropdownOption? selectedOption = null; + if (!string.IsNullOrWhiteSpace(value)) { - observableOptions.Add(value); + selectedOption = observableDropdownOptions.FirstOrDefault(opt => opt.Value == value); + if (selectedOption == null && string.IsNullOrWhiteSpace(optionsGeneratorCommand) == false) + { + // Add the value as a temporary option if not found and generator is available + selectedOption = new DropdownOption(value); + observableDropdownOptions.Add(selectedOption); + } } - Control inputControl = searchable ? new SearchableComboBox() + Control inputControl; + + if (searchable) { - Items = observableOptions, - SelectedItem = value, - TabIndex = index, - IsTabStop = true, - Width = 500 - }: new ComboBox - { - ItemsSource = observableOptions, - SelectedItem = value, - TabIndex = index, - IsTabStop = true, - Width = 500 - }; + // For searchable, convert to strings (SearchableComboBox only supports strings) + var stringOptions = new ObservableCollection(dropdownOptions.Select(o => o.Label)); + var selectedString = selectedOption?.Label; + + var searchBox = new SearchableComboBox() + { + Items = stringOptions, + TabIndex = index, + IsTabStop = true, + Width = 500 + }; + + // Set selected item after Items collection is set + if (!string.IsNullOrWhiteSpace(selectedString) && stringOptions.Contains(selectedString)) + { + searchBox.SelectedItem = selectedString; + } + + inputControl = searchBox; + } + else + { + inputControl = new ComboBox + { + ItemsSource = observableDropdownOptions, + SelectedItem = selectedOption, + TabIndex = index, + IsTabStop = true, + Width = 500 + }; + } + var actionPanel = new StackPanel() { Orientation = Orientation.Horizontal, @@ -293,24 +321,42 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind var result = await commandExecutor($"Generate options for '{p.Name}'", optionsGeneratorCommand) ?? ""; Dispatcher.UIThread.Post(() => { - observableOptions.Clear(); - foreach (var option in result.Split(new[]{"\r", "\n",delimiterForOptions}, StringSplitOptions.RemoveEmptyEntries).Distinct().OrderBy(x=>x)) + var newOptions = result.Split(new[]{"\r", "\n",delimiterForOptions}, StringSplitOptions.RemoveEmptyEntries) + .Distinct() + .OrderBy(x=>x) + .Select(opt => new DropdownOption(opt.Trim())) + .ToList(); + + if (searchable && inputControl is SearchableComboBox searchBox) { - observableOptions.Add(option); + searchBox.Items.Clear(); + foreach (var option in newOptions) + { + searchBox.Items.Add(option.Label); + } } + else if (inputControl is ComboBox comboBox) + { + observableDropdownOptions.Clear(); + foreach (var option in newOptions) + { + observableDropdownOptions.Add(option); + } + } + generateButton.Classes.Remove("spinning"); generateButton.IsEnabled = true; wasGenerated = true; - if (inputControl is SearchableComboBox scb) + if (inputControl is SearchableComboBox scb2) { - scb.ShowAll(); + scb2.ShowAll(); } }); }; generateButton.Click += generate; - if(inputControl is SearchableComboBox scb) + if(inputControl is SearchableComboBox searchableBox) { - scb.GotFocus += (sender, args) => + searchableBox.GotFocus += (sender, args) => { if (wasGenerated == false) { @@ -326,17 +372,24 @@ private IControlRecord CreateControlRecord(ScriptParam p, string? value, int ind return new DropdownControl { Control = actionPanel, - InputControl = inputControl + InputControl = inputControl, + DropdownOptions = observableDropdownOptions }; case PromptType.Multiselect: var delimiter = p.GetPromptSettings("delimiter", s => s, ","); + var multiSelectOptions = p.GetDropdownOptions(delimiter); + + // Parse selected values + var selectedValues = (value ?? string.Empty).Split(new[] { delimiter }, StringSplitOptions.RemoveEmptyEntries).Select(v => v.Trim()).ToList(); + var selectedDropdownOptions = multiSelectOptions.Where(opt => selectedValues.Contains(opt.Value)).ToList(); + return new MultiSelectControl { Control = new CheckBoxListBox { SelectionMode = SelectionMode.Multiple, - ItemsSource = p.GetPromptSettings("options", out var multiSelectOptions) ? multiSelectOptions.Split(delimiter) : Array.Empty(), - SelectedItems = new AvaloniaList((value ?? string.Empty).Split(delimiter)), + ItemsSource = multiSelectOptions, + SelectedItems = new AvaloniaList(selectedDropdownOptions), TabIndex = index, IsTabStop = true, BorderBrush = new SolidColorBrush(Color.Parse("#99ffffff")), diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/DropdownOption.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/DropdownOption.cs new file mode 100644 index 0000000..7430fa7 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/DropdownOption.cs @@ -0,0 +1,25 @@ +namespace ScriptRunner.GUI.ScriptConfigs; + +/// +/// Represents a dropdown or multiselect option with a display label and value +/// +public class DropdownOption +{ + public string Label { get; set; } + public string Value { get; set; } + + public DropdownOption(string label, string value) + { + Label = label; + Value = value; + } + + public DropdownOption(string value) + { + Label = value; + Value = value; + } + + public override string ToString() => Label; +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs index fce31e5..6e63972 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; +using System.Text.Json; using ScriptRunner.GUI.ViewModels; @@ -87,6 +89,58 @@ public T GetPromptSettings(string name, Func convert, T @default) return @default; } + + public List GetDropdownOptions(string delimiter = ",") + { + if (!PromptSettings.TryGetValue("options", out var optionsValue)) + { + return new List(); + } + + // Case 1: String with comma-separated values + if (optionsValue is string optionsString) + { + return optionsString + .Split(new[] { delimiter }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => new DropdownOption(x.Trim())) + .ToList(); + } + + // Case 2: JsonElement (from deserialization) + if (optionsValue is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.String) + { + return jsonElement.GetString()! + .Split(new[] { delimiter }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => new DropdownOption(x.Trim())) + .ToList(); + } + + if (jsonElement.ValueKind == JsonValueKind.Array) + { + var result = new List(); + foreach (var item in jsonElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + // Array of strings + result.Add(new DropdownOption(item.GetString()!)); + } + else if (item.ValueKind == JsonValueKind.Object) + { + // Array of objects with label and value + var label = item.GetProperty("label").GetString()!; + var value = item.GetProperty("value").GetString()!; + result.Add(new DropdownOption(label, value)); + } + } + return result; + } + } + + return new List(); + } } public class InteractiveInputDescription @@ -99,4 +153,5 @@ public class InteractiveInputItem { public string Label { get; set; } public string Value { get; set; } -} \ No newline at end of file +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Scripts/DropdownOptionsExample.json b/src/ScriptRunner/ScriptRunner.GUI/Scripts/DropdownOptionsExample.json new file mode 100644 index 0000000..cb627fe --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Scripts/DropdownOptionsExample.json @@ -0,0 +1,66 @@ +{ + "$schema": "../../../../schema/v1/ScriptRunnerSchema.json", + "actions": [ + { + "name": "Coma separated", + "description": "Demonstrates different formats for dropdown options", + "command": "echo", + "autoParameterBuilderStyle": "powershell", + "params": [ + { + "name": "Environment", + "description": "Coma separated string", + "prompt": "dropdown", + "promptSettings": { + "options": "Development,Staging,Production" + } + }, + { + "name": "List", + "description": "List of strings", + "prompt": "dropdown", + "promptSettings": { + "options": ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"] + } + }, + { + "name": "Size", + "description": "List of label/value objects", + "prompt": "dropdown", + "promptSettings": { + "options": [ + {"label": "Small (1 CPU, 2GB RAM)", "value": "t2.small"}, + {"label": "Medium (2 CPU, 4GB RAM)", "value": "t2.medium"}, + {"label": "Large (4 CPU, 8GB RAM)", "value": "t2.large"}, + {"label": "X-Large (8 CPU, 16GB RAM)", "value": "t2.xlarge"} + ] + } + }, + { + "name": "Features", + "description": "Select features (multiSelect with array of strings)", + "prompt": "multiSelect", + "promptSettings": { + "options": ["Monitoring", "Backups", "Auto-scaling", "High Availability"], + "delimiter": "," + } + }, + { + "name": "Services", + "description": "Select services (multiSelect with label/value objects)", + "prompt": "multiSelect", + "promptSettings": { + "options": [ + {"label": "Web Server (HTTPS)", "value": "web-https"}, + {"label": "Database (PostgreSQL)", "value": "db-postgres"}, + {"label": "Cache (Redis)", "value": "cache-redis"}, + {"label": "Queue (RabbitMQ)", "value": "queue-rabbitmq"} + ], + "delimiter": "," + } + } + ] + } + ] +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Scripts/ScriptRunnerSchema.json b/src/ScriptRunner/ScriptRunner.GUI/Scripts/ScriptRunnerSchema.json deleted file mode 100644 index df4c822..0000000 --- a/src/ScriptRunner/ScriptRunner.GUI/Scripts/ScriptRunnerSchema.json +++ /dev/null @@ -1,239 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ScriptRunnerSchema", - "title": "Product", - "description": "ScriptRunnerSchema", - "type": "object", - "properties": { - "actions": { - "description": "A list of available actions", - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name", "command"], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "command": { - "type": "string" - }, - "workingDirectory": { - "type": "string" - }, - "installCommand":{ - "type": "string" - }, - "installCommandWorkingDirectory":{ - "type": "string" - }, - "predefinedArgumentSets":{ - "type": "array", - "items": { - "type": "object", - "properties": { - "description":{ - "type":"string" - }, - "fallbackToDefault":{ - "type":"boolean" - }, - "arguments":{ - "type":"object" - } - } - } - }, - "predefinedArgumentSetsOrdering": { - "type": "string", - "enum": [ - "ascending", - "descending" - ] - }, - "environmentVariables":{ - "type":"object", - "additionalProperties": true - }, - "params": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "default": { - "type": "string" - }, - "prompt": { - "type":"string", - "enum": [ - "text", - "multilineText", - "password", - "checkbox", - "dropdown", - "multiSelect", - "filePicker", - "directoryPicker", - "datePicker", - "numeric", - "timePicker" - ] - } - }, - "required": ["name", "prompt"], - "anyOf": [ - { - "properties": { - "prompt": { - "const": "datePicker" - }, - "promptSettings": { - "type": "object", - "additionalProperties": false, - "properties": { - "format": { - "type": "string" - }, - "yearVisible": { - "type": "string" - }, - "monthVisible": { - "type": "string" - }, - "dayVisible": { - "type": "string" - }, - "todayAsDefault": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "timePicker" - }, - "promptSettings": { - "type": "object", - "additionalProperties": false, - "properties": { - "format": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "dropdown" - }, - "promptSettings": { - "type": "object", - "properties": { - "options": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "multiSelect" - }, - "promptSettings": { - "type": "object", - "properties": { - "options": { - "type": "string" - }, - "delimiter": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "checkbox" - }, - "promptSettings": { - "type": "object", - "properties": { - "checkedValue": { - "type": "string" - }, - "uncheckedValue": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "const": "numeric" - }, - "promptSettings": { - "type": "object", - "properties": { - "min": { - "type": "string" - }, - "max": { - "type": "string" - }, - "step": { - "type": "string" - } - } - } - }, - "required": ["prompt"] - }, - { - "properties": { - "prompt": { - "enum": [ - "text", - "multilineText", - "password", - "filePicker", - "directoryPicker" - ] - } - }, - "required": ["prompt"] - } - ] - } - } - } - } - } - } -} \ No newline at end of file From 6af0c6edbd7657bf75bb133857c5d00cee4d909d Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Fri, 17 Oct 2025 19:28:44 +0200 Subject: [PATCH 02/12] Improve displaying forms with long labels --- .../ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs | 8 ++++++-- .../ScriptRunner.GUI/Themes/StyleClasses.axaml | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs index 0c1f716..440a10e 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Parameters/ParamsPanelFactory.cs @@ -58,8 +58,12 @@ public ParamsPanel Create(ScriptConfig action, Dictionary values var label = new Label { - Content = string.IsNullOrWhiteSpace(param.Description)? param.Name: param.Description, - + Content = new TextBlock + { + Text = string.IsNullOrWhiteSpace(param.Description) ? param.Name : param.Description, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 300 + } }; ToolTip.SetTip(label, param.Name); diff --git a/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml b/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml index 2d28ef0..eb498fb 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Themes/StyleClasses.axaml @@ -118,6 +118,7 @@ + + + + + + + + + + + + + + + - + + + + - + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml index 67862a5..d16a253 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ContentWithSidebar.axaml @@ -9,11 +9,11 @@ - - - + - - - + - - - + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs index b838245..80a6244 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml.cs @@ -65,4 +65,12 @@ private void SplitButton_OnClick(object? sender, RoutedEventArgs e) else sp.Flyout.ShowAt(sp); } } + + private void OnActionPanelScrollChange(object? sender, ScrollChangedEventArgs e) + { + if (sender is ScrollViewer sc && e.ExtentDelta.Y > 0) + { + sc.ScrollToHome(); + } + } } \ No newline at end of file From 927dcb28961083115b689a2c9524253997ea7bce Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sat, 18 Oct 2025 10:56:08 +0200 Subject: [PATCH 08/12] Improve execution log layout --- .../StringEmptyToVisibilityConverter.cs | 23 +++ .../ViewModels/DateGroupInfo.cs | 36 ++++ .../ViewModels/ExecutionLogAction.cs | 8 + .../ViewModels/ExecutionLogItemBase.cs | 56 +++++ .../ViewModels/MainWindowViewModel.cs | 104 +++++++++- .../ViewModels/ParameterTag.cs | 67 ++++++ .../Views/ActionDetailsSection.axaml | 15 +- .../Views/DatePickerOverlay.axaml | 83 ++++++++ .../Views/DatePickerOverlay.axaml.cs | 43 ++++ .../Views/ExecutionLogList.axaml | 162 +++++++++++++++ .../Views/ExecutionLogList.axaml.cs | 191 ++++++++++++++++++ .../ScriptRunner.GUI/Views/MainWindow.axaml | 20 +- .../Views/MainWindow.axaml.cs | 30 +++ 13 files changed, 815 insertions(+), 23 deletions(-) create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Converters/StringEmptyToVisibilityConverter.cs create mode 100644 src/ScriptRunner/ScriptRunner.GUI/ViewModels/DateGroupInfo.cs create mode 100644 src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogItemBase.cs create mode 100644 src/ScriptRunner/ScriptRunner.GUI/ViewModels/ParameterTag.cs create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml.cs create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs diff --git a/src/ScriptRunner/ScriptRunner.GUI/Converters/StringEmptyToVisibilityConverter.cs b/src/ScriptRunner/ScriptRunner.GUI/Converters/StringEmptyToVisibilityConverter.cs new file mode 100644 index 0000000..93152bf --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Converters/StringEmptyToVisibilityConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace ScriptRunner.GUI.Converters; + +public class StringEmptyToVisibilityConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string str) + { + return !string.IsNullOrEmpty(str); + } + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/DateGroupInfo.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/DateGroupInfo.cs new file mode 100644 index 0000000..6e8adc5 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/DateGroupInfo.cs @@ -0,0 +1,36 @@ +using System; + +namespace ScriptRunner.GUI.ViewModels; + +/// +/// Represents a date group in the date picker with count of items +/// +public class DateGroupInfo +{ + public DateTime Date { get; } + public string DateDisplay { get; } + public int Count { get; set; } + + public DateGroupInfo(DateTime date, int count) + { + Date = date.Date; + Count = count; + DateDisplay = GetDateDisplay(date); + } + + private string GetDateDisplay(DateTime date) + { + var today = DateTime.Today; + var yesterday = today.AddDays(-1); + + if (date.Date == today) + return "Today"; + else if (date.Date == yesterday) + return "Yesterday"; + else if (date.Date > today.AddDays(-7)) + return date.ToString("dddd, MMMM dd"); // Day of week with date + else + return date.ToString("MMMM dd, yyyy"); + } +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogAction.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogAction.cs index 7dea5b7..982c588 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogAction.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogAction.cs @@ -31,5 +31,13 @@ public record ExecutionLogAction(DateTime Timestamp, string Source, string Name, new Run("]"), }; + [JsonIgnore] + public IEnumerable ParameterTags => Parameters + .OrderBy(x => x.Key) + .Select(x => new ParameterTag( + x.Key, + x.Value ?? string.Empty, + isMasked: x.Value?.StartsWith("!!vault:") == true)); + public string ParametersDescriptionString() => string.Join(", ", Parameters.OrderBy(x=>x.Key).Select(x => $"{x.Key} = {x.Value}")); }; \ No newline at end of file diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogItemBase.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogItemBase.cs new file mode 100644 index 0000000..2769405 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ExecutionLogItemBase.cs @@ -0,0 +1,56 @@ +using System; + +namespace ScriptRunner.GUI.ViewModels; + +/// +/// Base class for items in the execution log list (both actions and date headers) +/// +public abstract class ExecutionLogItemBase +{ +} + +/// +/// Represents a date header divider in the execution log +/// +public class ExecutionLogDateHeader : ExecutionLogItemBase +{ + public DateTime Date { get; } + public string DateDisplay { get; } + + // Property to control highlight animation + public bool IsHighlighted { get; set; } + + public ExecutionLogDateHeader(DateTime date) + { + Date = date.Date; + DateDisplay = GetDateDisplay(date); + } + + private string GetDateDisplay(DateTime date) + { + var today = DateTime.Today; + var yesterday = today.AddDays(-1); + + if (date.Date == today) + return "Today"; + else if (date.Date == yesterday) + return "Yesterday"; + else if (date.Date > today.AddDays(-7)) + return date.ToString("dddd"); // Day of week + else + return date.ToString("MMMM dd, yyyy"); + } +} + +/// +/// Wrapper for ExecutionLogAction to fit in the grouped list +/// +public class ExecutionLogItemAction : ExecutionLogItemBase +{ + public ExecutionLogAction Action { get; } + + public ExecutionLogItemAction(ExecutionLogAction action) + { + Action = action; + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs index 6c452b9..51285ca 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs @@ -323,6 +323,58 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider .ObserveOn(RxApp.MainThreadScheduler) .ToProperty(this, x => x.ExecutionLogForCurrent, out _executionLogForCurrent); + // Create grouped execution log with date dividers + Observable + .FromEventPattern( + h => this.ExecutionLog.CollectionChanged += h, + h => this.ExecutionLog.CollectionChanged -= h) + .Select(_ => Unit.Default) + .StartWith(Unit.Default) + .Select(_ => + { + var items = new List(); + DateTime? lastDate = null; + + foreach (var action in ExecutionLog) + { + var actionDate = action.Timestamp.Date; + + // Add date header if the date changed + if (lastDate == null || lastDate != actionDate) + { + items.Add(new ExecutionLogDateHeader(actionDate)); + lastDate = actionDate; + } + + items.Add(new ExecutionLogItemAction(action)); + } + + return items.AsEnumerable(); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.ExecutionLogGrouped, out _executionLogGrouped); + + // Create available dates list for date picker + Observable + .FromEventPattern( + h => this.ExecutionLog.CollectionChanged += h, + h => this.ExecutionLog.CollectionChanged -= h) + .Select(_ => Unit.Default) + .StartWith(Unit.Default) + .Select(_ => + { + // Group by date and count items + var dateGroups = ExecutionLog + .GroupBy(x => x.Timestamp.Date) + .OrderByDescending(g => g.Key) + .Select(g => new DateGroupInfo(g.Key, g.Count())) + .ToList(); + + return dateGroups.AsEnumerable(); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.AvailableDates, out _availableDates); + this.WhenAnyValue(x => x.SelectedAction) .ObserveOn(RxApp.MainThreadScheduler) .Subscribe(s => @@ -908,6 +960,24 @@ private void ExecuteCommand(string command, ScriptConfig selectedAction, bool us public ObservableCollection ExecutionLog { get; set; } = new (); + // Grouped execution log with date dividers + private readonly ObservableAsPropertyHelper> _executionLogGrouped; + public IEnumerable ExecutionLogGrouped => _executionLogGrouped.Value; + + // Available dates for date picker + private readonly ObservableAsPropertyHelper> _availableDates; + public IEnumerable AvailableDates => _availableDates.Value; + + // Date picker visibility + private bool _isDatePickerVisible; + public bool IsDatePickerVisible + { + get => _isDatePickerVisible; + set => this.RaiseAndSetIfChanged(ref _isDatePickerVisible, value); + } + + // Store reference to the ListBox for scrolling + public Action? ScrollToDateAction { get; set; } private readonly ObservableAsPropertyHelper> _executionLogForCurrent; public IEnumerable ExecutionLogForCurrent => _executionLogForCurrent.Value; @@ -921,6 +991,30 @@ public ExecutionLogAction SelectedRecentExecution } private ExecutionLogAction _selectedRecentExecution; + + public ExecutionLogItemBase? SelectedExecutionLogItem + { + get => _selectedExecutionLogItem; + set + { + // Ignore selection of date headers - only allow action items to be selected + if (value is ExecutionLogDateHeader) + { + // Reset selection to null for date headers + this.RaiseAndSetIfChanged(ref _selectedExecutionLogItem, null); + return; + } + + this.RaiseAndSetIfChanged(ref _selectedExecutionLogItem, value); + // When an ExecutionLogItemAction is selected, set the underlying ExecutionLogAction + if (value is ExecutionLogItemAction itemAction) + { + SelectedRecentExecution = itemAction.Action; + } + } + } + + private ExecutionLogItemBase? _selectedExecutionLogItem; private readonly ConfigRepositoryUpdater _configRepositoryUpdater; @@ -933,6 +1027,14 @@ private void AddExecutionAudit(ScriptConfig selectedAction) }); } + /// + /// Scrolls to the specified date in the execution log + /// + public void ScrollToDate(DateTime date) + { + IsDatePickerVisible = false; + ScrollToDateAction?.Invoke(date); + } public void CloseJob(object arg) { @@ -976,4 +1078,4 @@ public class ScriptConfigGroupWrapper public IEnumerable Children { get; set; } } -public record TaggedScriptConfig(string Tag, string Name, ScriptConfig Config); \ No newline at end of file +public record TaggedScriptConfig(string Tag, string Name, ScriptConfig Config); diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ParameterTag.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ParameterTag.cs new file mode 100644 index 0000000..2840fb2 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/ParameterTag.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using Avalonia.Media; + +namespace ScriptRunner.GUI.ViewModels; + +/// +/// Represents a single parameter as a tag for display +/// +public class ParameterTag +{ + public string Name { get; } + public string Value { get; } + public IBrush BackgroundBrush { get; } + public bool IsMasked { get; } + + private static readonly List TagColors = new() + { + Color.FromRgb(52, 152, 219), // Blue + Color.FromRgb(155, 89, 182), // Purple + Color.FromRgb(46, 204, 113), // Green + Color.FromRgb(230, 126, 34), // Orange + Color.FromRgb(231, 76, 60), // Red + Color.FromRgb(26, 188, 156), // Teal + Color.FromRgb(241, 196, 15), // Yellow + Color.FromRgb(149, 165, 166), // Gray + Color.FromRgb(192, 57, 43), // Dark Red + Color.FromRgb(39, 174, 96), // Dark Green + Color.FromRgb(142, 68, 173), // Dark Purple + Color.FromRgb(41, 128, 185), // Dark Blue + }; + + public ParameterTag(string name, string value, bool isMasked = false) + { + Name = name; + Value = isMasked ? "*****" : value; + IsMasked = isMasked; + + // Generate deterministic color based on parameter name + var hash = GetStableHashCode(name); + var colorIndex = Math.Abs(hash) % TagColors.Count; + var color = TagColors[colorIndex]; + + // Use semi-transparent color for background + BackgroundBrush = new SolidColorBrush(Color.FromArgb(180, color.R, color.G, color.B)); + } + + private static int GetStableHashCode(string str) + { + unchecked + { + int hash1 = 5381; + int hash2 = hash1; + + for (int i = 0; i < str.Length && str[i] != '\0'; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ str[i]; + if (i == str.Length - 1 || str[i + 1] == '\0') + break; + hash2 = ((hash2 << 5) + hash2) ^ str[i + 1]; + } + + return hash1 + (hash2 * 1566083941); + } + } +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml index fd2bb28..39e0d9a 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml @@ -186,17 +186,10 @@ Compacted - - - - - - - - - - - + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml new file mode 100644 index 0000000..f51ac0c --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml.cs new file mode 100644 index 0000000..8d5694e --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/DatePickerOverlay.axaml.cs @@ -0,0 +1,43 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using ScriptRunner.GUI.ViewModels; + +namespace ScriptRunner.GUI.Views; + +public partial class DatePickerOverlay : UserControl +{ + public DatePickerOverlay() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void OnOverlayClicked(object? sender, PointerPressedEventArgs e) + { + // Close the overlay when clicking on the background + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.IsDatePickerVisible = false; + } + } + + private void OnDateItemClicked(object? sender, PointerPressedEventArgs e) + { + if (sender is Border border && border.DataContext is DateGroupInfo dateInfo) + { + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.ScrollToDate(dateInfo.Date); + } + } + e.Handled = true; + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml new file mode 100644 index 0000000..5336ba4 --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs new file mode 100644 index 0000000..2f4081d --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ExecutionLogList.axaml.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.VisualTree; +using ScriptRunner.GUI.ViewModels; + +namespace ScriptRunner.GUI.Views; + +public partial class ExecutionLogList : UserControl +{ + public static readonly StyledProperty?> ItemsProperty = + AvaloniaProperty.Register?>(nameof(Items)); + + public static readonly StyledProperty?> GroupedItemsProperty = + AvaloniaProperty.Register?>(nameof(GroupedItems)); + + public static readonly StyledProperty SelectedItemProperty = + AvaloniaProperty.Register(nameof(SelectedItem), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + public static readonly StyledProperty SelectedLogItemProperty = + AvaloniaProperty.Register(nameof(SelectedLogItem), defaultBindingMode: Avalonia.Data.BindingMode.TwoWay); + + public static readonly StyledProperty ShowDatePickerProperty = + AvaloniaProperty.Register(nameof(ShowDatePicker), defaultValue: false); + + // Event for when date header is clicked + public event EventHandler? DateHeaderClicked; + + private INotifyCollectionChanged? _currentCollection; + + public IEnumerable? Items + { + get => GetValue(ItemsProperty); + set => SetValue(ItemsProperty, value); + } + + public IEnumerable? GroupedItems + { + get => GetValue(GroupedItemsProperty); + private set => SetValue(GroupedItemsProperty, value); + } + + public ExecutionLogAction? SelectedItem + { + get => GetValue(SelectedItemProperty); + set => SetValue(SelectedItemProperty, value); + } + + public ExecutionLogItemBase? SelectedLogItem + { + get => GetValue(SelectedLogItemProperty); + set => SetValue(SelectedLogItemProperty, value); + } + + public bool ShowDatePicker + { + get => GetValue(ShowDatePickerProperty); + set => SetValue(ShowDatePickerProperty, value); + } + + public ExecutionLogList() + { + InitializeComponent(); + + // Watch for changes to Items and rebuild grouped list + this.GetObservable(ItemsProperty).Subscribe(items => + { + // Unsubscribe from old collection + if (_currentCollection != null) + { + _currentCollection.CollectionChanged -= OnCollectionChanged; + } + + // Subscribe to new collection if it's observable + if (items is INotifyCollectionChanged observable) + { + _currentCollection = observable; + observable.CollectionChanged += OnCollectionChanged; + } + else + { + _currentCollection = null; + } + + RebuildGroupedList(); + }); + + // Watch for changes to SelectedLogItem and update SelectedItem + this.GetObservable(SelectedLogItemProperty).Subscribe(item => + { + if (item is ExecutionLogDateHeader) + { + // Ignore date header selections + SelectedLogItem = null; + return; + } + + if (item is ExecutionLogItemAction actionItem) + { + SelectedItem = actionItem.Action; + } + }); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void RebuildGroupedList() + { + if (Items == null) + { + GroupedItems = null; + return; + } + + var items = new List(); + DateTime? lastDate = null; + + foreach (var action in Items) + { + var actionDate = action.Timestamp.Date; + + // Add date header if the date changed + if (lastDate == null || lastDate != actionDate) + { + items.Add(new ExecutionLogDateHeader(actionDate)); + lastDate = actionDate; + } + + items.Add(new ExecutionLogItemAction(action)); + } + + GroupedItems = items; + } + + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + RebuildGroupedList(); + } + + 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); + } + e.Handled = true; + } + + public async Task ScrollToDate(DateTime date) + { + var listBox = this.FindControl("ExecutionLogListBox"); + if (listBox == null || GroupedItems == null) return; + + var items = GroupedItems.ToList(); + var targetItem = items.FirstOrDefault(item => + item is ExecutionLogDateHeader header && header.Date == date.Date); + + if (targetItem != null) + { + listBox.ScrollIntoView(targetItem); + + await Task.Delay(200); + + var itemContainer = listBox.ContainerFromItem(targetItem); + if (itemContainer != null) + { + var border = itemContainer.GetVisualDescendants() + .OfType() + .FirstOrDefault(b => b.Name == "DateHeaderBorder"); + + if (border != null) + { + border.Classes.Add("highlight"); + await Task.Delay(2000); + border.Classes.Remove("highlight"); + } + } + } + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml index 5de3b6c..cec6890 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml @@ -54,17 +54,15 @@ - - - - - - - - - - - + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs index 984c910..e454731 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml.cs @@ -34,6 +34,26 @@ public MainWindow() InitializeComponent(); ViewModel = Locator.Current.GetService(); Title = $"ScriptRunner {this.GetType().Assembly.GetName().Version}"; + + // Set up scroll action for date navigation + if (ViewModel != null) + { + 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) { @@ -69,4 +89,14 @@ public MainWindow() }); } } + + private async void ScrollToDate(DateTime date) + { + // Find the ExecutionLogList control + var executionLogList = this.FindControl("ExecutionLogListControl"); + if (executionLogList != null) + { + await executionLogList.ScrollToDate(date); + } + } } \ No newline at end of file From f7a35f270682bd422a400356ed2819a0fa4a5bbd Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sat, 18 Oct 2025 16:22:19 +0200 Subject: [PATCH 09/12] Add option for controling logs follows --- .../ViewModels/RunningJobViewModel.cs | 6 +++++ .../Views/RunningJobsSection.axaml | 2 ++ .../Views/RunningJobsSection.axaml.cs | 27 +++++++++++++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs index d6444a3..757a456 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs @@ -803,6 +803,12 @@ public bool KillAvailable public InlineCollection RichOutput { get; set; } = new(); + private bool _followOutput = true; + public bool FollowOutput + { + get => _followOutput; + set => this.RaiseAndSetIfChanged(ref _followOutput, value); + } } public enum TroubleShootingSeverity diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml index 098e40c..57f4e94 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml @@ -73,6 +73,7 @@ + Follow output @@ -83,3 +84,4 @@ + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs index 8be5aa9..6a580ad 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml.cs @@ -10,6 +10,8 @@ namespace ScriptRunner.GUI.Views; public partial class RunningJobsSection : UserControl { + private bool _isUserScrolling = false; + public RunningJobsSection() { InitializeComponent(); @@ -22,9 +24,30 @@ private void InitializeComponent() private void ScrollChangedHandler(object? sender, ScrollChangedEventArgs e) { - if (sender is ScrollViewer sc && e.ExtentDelta.Y > 0) + if (sender is ScrollViewer sc && sc.DataContext is RunningJobViewModel viewModel) { - sc.ScrollToEnd(); + // If content was added (extent changed), auto-scroll if follow output is enabled + if (e.ExtentDelta.Y > 0 && viewModel.FollowOutput) + { + _isUserScrolling = false; + sc.ScrollToEnd(); + } + // If user manually scrolled (offset changed without extent change) + else if (e.OffsetDelta.Y != 0 && e.ExtentDelta.Y == 0) + { + _isUserScrolling = true; + // Check if user scrolled away from bottom + var isAtBottom = Math.Abs(sc.Offset.Y - sc.ScrollBarMaximum.Y) < 1.0; + if (!isAtBottom && viewModel.FollowOutput) + { + viewModel.FollowOutput = false; + } + // If user scrolled back to bottom, re-enable follow output + else if (isAtBottom && !viewModel.FollowOutput) + { + viewModel.FollowOutput = true; + } + } } } From d2946ac6062b49a5abf40f23cd1ba749c636882c Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sat, 18 Oct 2025 16:34:53 +0200 Subject: [PATCH 10/12] Add batch closing for running job's tabs --- .../ViewModels/MainWindowViewModel.cs | 30 +++++++++++++++++++ .../Views/RunningJobsSection.axaml | 7 +++++ 2 files changed, 37 insertions(+) diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs index 51285ca..c97dbc9 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs @@ -1045,6 +1045,36 @@ public void CloseJob(object arg) } } + public void CloseAllFinished() + { + var finishedJobs = RunningJobs.Where(job => job.Status != RunningJobStatus.Running).ToList(); + foreach (var job in finishedJobs) + { + job.CancelExecution(); + RunningJobs.Remove(job); + } + } + + public void CloseAllFailed() + { + var failedJobs = RunningJobs.Where(job => job.Status == RunningJobStatus.Failed).ToList(); + foreach (var job in failedJobs) + { + job.CancelExecution(); + RunningJobs.Remove(job); + } + } + + public void CloseAllCancelled() + { + var cancelledJobs = RunningJobs.Where(job => job.Status == RunningJobStatus.Cancelled).ToList(); + foreach (var job in cancelledJobs) + { + job.CancelExecution(); + RunningJobs.Remove(job); + } + } + private static string[] SplitCommand(string command) { command = command.Trim(); diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml index 57f4e94..d49d0f9 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml @@ -11,6 +11,13 @@ x:DataType="viewModels:MainWindowViewModel" > + + + + + + + From a59d5d81278dfaab5821699054cfb353c308a830 Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sat, 18 Oct 2025 17:31:39 +0200 Subject: [PATCH 11/12] Improve styles of checkbox list --- src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs b/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs index c5baa2f..51faaf2 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/CheckBoxListBox.cs @@ -55,5 +55,15 @@ public CheckBoxListBox() } }; this.Styles.Add(style); + + // Style for selected items + var selectedStyle = new Style(x => x.OfType().Class(":selected").Template().OfType()) + { + Setters = + { + new Setter(ContentPresenter.BackgroundProperty, Avalonia.Media.Brushes.Transparent) + } + }; + this.Styles.Add(selectedStyle); } } From 763422b18b10de56b57e01bc2e381bfa9f5220cc Mon Sep 17 00:00:00 2001 From: Cezary Piatek Date: Sat, 18 Oct 2025 18:42:37 +0200 Subject: [PATCH 12/12] Display categories in action header --- .../Converters/CategoryToColorConverter.cs | 65 +++++++++++++++++++ .../Scripts/TextInputScript.json | 4 +- .../Views/ActionDetailsSection.axaml | 27 +++++++- 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs diff --git a/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs b/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs new file mode 100644 index 0000000..b1cc00a --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/Converters/CategoryToColorConverter.cs @@ -0,0 +1,65 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ScriptRunner.GUI.Converters; + +public class CategoryToColorConverter : IValueConverter +{ + private static readonly Color[] PredefinedColors = new[] + { + Color.FromRgb(70, 100, 180), // Darker Blue + Color.FromRgb(200, 90, 70), // Darker Coral + Color.FromRgb(80, 150, 80), // Darker Green + Color.FromRgb(150, 100, 150), // Darker Plum + Color.FromRgb(180, 140, 0), // Darker Gold + Color.FromRgb(85, 130, 160), // Darker Sky Blue + Color.FromRgb(180, 100, 120), // Darker Pink + Color.FromRgb(90, 160, 90), // Darker Pale Green + Color.FromRgb(180, 100, 80), // Darker Salmon + Color.FromRgb(110, 130, 150), // Darker Steel Blue + Color.FromRgb(160, 80, 160), // Darker Violet + Color.FromRgb(180, 130, 100), // Darker Peach + Color.FromRgb(100, 140, 170), // Darker Light Blue + Color.FromRgb(170, 80, 80), // Darker Coral Red + Color.FromRgb(120, 140, 70), // Olive Green + }; + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string category) + { + // Generate deterministic hash from category name + int hash = GetDeterministicHashCode(category); + int colorIndex = Math.Abs(hash) % PredefinedColors.Length; + return new SolidColorBrush(PredefinedColors[colorIndex]); + } + + return new SolidColorBrush(Colors.Gray); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + + private static int GetDeterministicHashCode(string str) + { + unchecked + { + int hash1 = 5381; + int hash2 = hash1; + + for (int i = 0; i < str.Length && str[i] != '\0'; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ str[i]; + if (i == str.Length - 1 || str[i + 1] == '\0') + break; + hash2 = ((hash2 << 5) + hash2) ^ str[i + 1]; + } + + return hash1 + (hash2 * 1566083941); + } + } +} diff --git a/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json b/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json index da5df1f..a67b688 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json +++ b/src/ScriptRunner/ScriptRunner.GUI/Scripts/TextInputScript.json @@ -55,7 +55,7 @@ }, { "name": "p22", - "description": "Fil Ccontent", + "description": "Fil Content", "default": "using System;\r\n\r\nclass Program {\r\n static void Main() {\r\n Console.WriteLine(\"Hello, World!\");\r\n }\r\n}", "prompt": "fileContent", "promptSettings": { @@ -71,7 +71,7 @@ "promptSettings":{ "checkedValue": "checked", "uncheckedValue": "unchecked" - } + } }, { "name": "p4", diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml index 39e0d9a..01a542f 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml @@ -8,9 +8,14 @@ xmlns:views="clr-namespace:ScriptRunner.GUI.Views" xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia.Tight" xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia" + xmlns:converters="clr-namespace:ScriptRunner.GUI.Converters" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="450" x:DataType="viewModels:MainWindowViewModel" x:Class="ScriptRunner.GUI.Views.ActionDetailsSection"> + + + + @@ -23,7 +28,7 @@ - +