diff --git a/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs index a181fc9..c68d159 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Controls/FormattedTextEditor.cs @@ -46,6 +46,10 @@ public FormattedTextEditor() Padding = new Thickness(15); TextArea.TextView.LinkTextForegroundBrush = Brushes.LightBlue; TextArea.TextView.ElementGenerators.Add(new FilePathElementGenerator()); + + // Add context menu + ContextMenu = CreateContextMenu(); + // Subscribe to scroll changes this.Loaded += (_, _) => { @@ -60,6 +64,83 @@ public FormattedTextEditor() }; } + private ContextMenu CreateContextMenu() + { + var contextMenu = new ContextMenu(); + + var copySelectedItem = new MenuItem + { + Header = "Copy Selected" + }; + copySelectedItem.Click += (_, _) => + { + if (!string.IsNullOrEmpty(SelectedText)) + { + CopyToClipboard(SelectedText); + } + }; + + var copyAllItem = new MenuItem + { + Header = "Copy All" + }; + copyAllItem.Click += (_, _) => + { + if (Document != null) + { + CopyToClipboard(Document.Text); + } + }; + + var selectAllItem = new MenuItem + { + Header = "Select All" + }; + selectAllItem.Click += (_, _) => + { + if (Document != null) + { + SelectionStart = 0; + SelectionLength = Document.TextLength; + } + }; + + var searchItem = new MenuItem + { + Header = "Search (Ctrl+F)" + }; + searchItem.Click += (_, _) => + { + // Trigger the built-in search functionality + var searchPanel = AvaloniaEdit.Search.SearchPanel.Install(this); + searchPanel?.Open(); + }; + + contextMenu.Items.Add(copySelectedItem); + contextMenu.Items.Add(copyAllItem); + contextMenu.Items.Add(selectAllItem); + contextMenu.Items.Add(new Separator()); + contextMenu.Items.Add(searchItem); + + // Update menu items based on selection when opening + contextMenu.Opening += (_, _) => + { + copySelectedItem.IsEnabled = !string.IsNullOrEmpty(SelectedText); + copyAllItem.IsEnabled = Document != null && !string.IsNullOrEmpty(Document.Text); + }; + + return contextMenu; + } + + private async void CopyToClipboard(string text) + { + var clipboard = TopLevel.GetTopLevel(this)?.Clipboard; + if (clipboard != null) + { + await clipboard.SetTextAsync(text); + } + } + private void OnScrollChanged(object? sender, ScrollChangedEventArgs e) { ScrollChanged?.Invoke(this, e); diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs index 6e63972..a698562 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptConfigs/ScriptConfig.cs @@ -39,6 +39,7 @@ public class ScriptConfig public List Troubleshooting { get; set; } = new(); public List InstallTroubleshooting { get; set; } = new(); public InlineCollection CommandFormatted { get; set; } = new(); + public InlineCollection InstallCommandFormatted { get; set; } = new(); } public class TroubleshootingItem @@ -154,4 +155,3 @@ public class InteractiveInputItem public string Label { get; set; } public string Value { get; set; } } - diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ConfigLoadResult.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ConfigLoadResult.cs new file mode 100644 index 0000000..f0d0d5e --- /dev/null +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ConfigLoadResult.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using ScriptRunner.GUI.ScriptConfigs; + +namespace ScriptRunner.GUI.ScriptReader; + +public class ConfigLoadResult +{ + public List Configs { get; } = new(); + public List CorruptedFiles { get; } = new(); +} + diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs b/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs index f5be178..3267b74 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs @@ -19,6 +19,43 @@ namespace ScriptRunner.GUI.ScriptReader; public static class ScriptConfigReader { + public static ConfigLoadResult LoadWithErrorTracking(ConfigScriptEntry source, + ScriptRunnerAppSettings appSettings) + { + var result = new ConfigLoadResult(); + + if (string.IsNullOrWhiteSpace(source.Path)) + { + return result; + } + + if (source.Type == ConfigScriptType.File) + { + if (File.Exists(source.Path) == false) + { + return result; + } + + LoadFileSourceWithTracking(source.Path, appSettings, result, source.Name); + return result; + } + + if (source.Type == ConfigScriptType.Directory) + { + if (Directory.Exists(source.Path) == false) + { + return result; + } + + foreach (var file in Directory.EnumerateFiles(source.Path, "*.json", source.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly)) + { + LoadFileSourceWithTracking(file, appSettings, result, source.Name); + } + } + + return result; + } + public static IEnumerable Load(ConfigScriptEntry source, ScriptRunnerAppSettings appSettings) { @@ -103,6 +140,166 @@ private static IAutoParameterBuilder CreateBuilder(ScriptConfig scriptConfig) return EmptyAutoParameterBuilder.Instance; } + private static void LoadFileSourceWithTracking(string fileName, + ScriptRunnerAppSettings appSettings, ConfigLoadResult result, string sourceName) + { + if (!File.Exists(fileName)) return; + + try + { + var jsonString = File.ReadAllText(fileName); + + if (jsonString.Contains("ScriptRunnerSchema.json") == false) + { + return; + } + + var scriptConfig = JsonSerializer.Deserialize(jsonString, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + Converters = { new PromptTypeJsonConverter(), new ParamTypeJsonConverter(), new JsonStringEnumConverter() } + })!; + + foreach (var action in scriptConfig.Actions) + { + action.Source = fileName; + action.SourceName = sourceName; + action.Categories ??= new List(); + + var mainCategory = string.IsNullOrWhiteSpace(action.SourceName) == false + ? action.SourceName + : Path.GetFileName(fileName); + if (string.IsNullOrWhiteSpace(mainCategory) == false) + { + action.Categories.Add(mainCategory); + } + + var parameterBuilder = CreateBuilder(action); + + var actionDir = Path.GetDirectoryName(fileName); + + string ResolveAbsolutePath(string path) + { + if (string.IsNullOrWhiteSpace(path) == false) + { + if (Path.IsPathRooted(path) == false) + { + return Path.GetFullPath(Path.Combine(actionDir!, path)); + } + } + + return path; + } + + [return:NotNullIfNotNull("command")] + string? AdjustCommandPath(string? command) + { + if (string.IsNullOrWhiteSpace(command) == false && (command.StartsWith("."))) + { + var (commandPath, args) = MainWindowViewModel.SplitCommandAndArgs(command); + return (ResolveAbsolutePath(commandPath) + " " + args).Trim(); + } + + return command; + } + + action.Command = AdjustCommandPath(action.Command); + action.InstallCommand = AdjustCommandPath(action.InstallCommand); + + var autoGeneratedParameters = action.Params.Where(x=>x.SkipFromAutoParameterBuilder == false).Select(param => parameterBuilder.Build(param)).Where(paramString => string.IsNullOrWhiteSpace(paramString) == false); + action.Command += " "+string.Join(" ", autoGeneratedParameters); + + if (string.IsNullOrWhiteSpace(action.Docs) == false ) + { + var docPaths = ResolveAbsolutePath(action.Docs); + if (File.Exists(docPaths)) + { + action.HasDocs = true; + action.DocsContent = File.ReadAllText(docPaths); + action.DocAssetPath = Path.GetDirectoryName(docPaths); + } + } + + action.WorkingDirectory = string.IsNullOrWhiteSpace(action.WorkingDirectory) ? actionDir : ResolveAbsolutePath(action.WorkingDirectory); + action.InstallCommandWorkingDirectory = string.IsNullOrWhiteSpace(action.InstallCommandWorkingDirectory) ? actionDir : ResolveAbsolutePath(action.InstallCommandWorkingDirectory); + + var defaultSet = new ArgumentSet() + { + Description = MainWindowViewModel.DefaultParameterSetName + }; + + foreach (var param in action.Params) + { + param.ValueGeneratorCommand = AdjustCommandPath(param.ValueGeneratorCommand); + defaultSet.Arguments[param.Name] = param.Default; + } + + foreach (var set in action.PredefinedArgumentSets.Where(x => x.FallbackToDefault)) + { + foreach (var (key, val) in defaultSet.Arguments) + { + if (set.Arguments.ContainsKey(key) == false) + { + set.Arguments[key] = val; + } + } + } + + if (appSettings.ExtraParameterSets?.Where(x => x.ActionName == action.Name).ToList() is { } extraSets) + { + foreach (var extraSet in extraSets.Select(x => new ArgumentSet + { + Description = x.Description, + Arguments = x.Arguments + })) + { + var existing = action.PredefinedArgumentSets.FirstOrDefault(x => x.Description == extraSet.Description); + if (existing != null) + { + action.PredefinedArgumentSets[action.PredefinedArgumentSets.IndexOf(existing)] = extraSet; + } + else + { + action.PredefinedArgumentSets.Add(extraSet); + } + } + } + + switch (action.PredefinedArgumentSetsOrdering) + { + case PredefinedArgumentSetsOrdering.Ascending: + action.PredefinedArgumentSets.Sort((s1, s2) => string.CompareOrdinal(s1.Description, s2.Description)); + break; + case PredefinedArgumentSetsOrdering.Descending: + action.PredefinedArgumentSets.Sort((s1, s2) => string.CompareOrdinal(s2.Description, s1.Description)); + break; + } + + action.PredefinedArgumentSets.Insert(0, defaultSet); + + foreach (var param in action.Params.Where(x=>x.Prompt == PromptType.FileContent)) + { + foreach (var set in action.PredefinedArgumentSets) + { + if (set.Arguments.TryGetValue(param.Name, out var defaultValue)) + { + set.Arguments[param.Name] = ResolveAbsolutePath(defaultValue); + } + } + } + + + + result.Configs.Add(action); + } + } + catch (Exception ex) + { + result.CorruptedFiles.Add(fileName); + } + } + private static IEnumerable LoadFileSource(string fileName, ScriptRunnerAppSettings appSettings) { @@ -217,8 +414,6 @@ string ResolveAbsolutePath(string path) action.PredefinedArgumentSets.Add(extraSet); } } - - } switch (action.PredefinedArgumentSetsOrdering) @@ -263,6 +458,29 @@ string ResolveAbsolutePath(string path) return inline; })); + + // Format InstallCommand if it exists + if (!string.IsNullOrWhiteSpace(action.InstallCommand)) + { + var installWithMarkers = action.Params.Aggregate + ( + seed: action.InstallCommand, + func: (string accumulate, ScriptParam source) => + accumulate.Replace("{" + source.Name + "}", "[!@#]{" + source.Name + "}[!@#]") + ); + + action.InstallCommandFormatted.AddRange(installWithMarkers.Split("[!@#]").Select(x => + { + var inline = new Run(x); + if (x.StartsWith("{")) + { + inline.Foreground = Brushes.LightGreen; + inline.FontWeight = FontWeight.ExtraBold; + } + + return inline; + })); + } } return scriptConfig.Actions; diff --git a/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj b/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj index b225cb6..330434e 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj +++ b/src/ScriptRunner/ScriptRunner.GUI/ScriptRunner.GUI.csproj @@ -16,7 +16,6 @@ - diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs index ade8658..a5ff2f8 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs @@ -12,8 +12,10 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Documents; using Avalonia.Input; using Avalonia.Input.Platform; +using Avalonia.Media; using Avalonia.Threading; using CliWrap; using DynamicData; @@ -57,6 +59,55 @@ public bool IsStatisticsVisible private bool _isStatisticsVisible; + public bool IsLoadingConfig + { + get => _isLoadingConfig; + set + { + this.RaiseAndSetIfChanged(ref _isLoadingConfig, value); + UpdateIsAnyRefreshInProgress(); + } + } + + private bool _isLoadingConfig; + + public bool IsRefreshingAppUpdates + { + get => _isRefreshingAppUpdates; + set + { + this.RaiseAndSetIfChanged(ref _isRefreshingAppUpdates, value); + UpdateIsAnyRefreshInProgress(); + } + } + + private bool _isRefreshingAppUpdates; + + public bool IsRefreshingRepositories + { + get => _isRefreshingRepositories; + set + { + this.RaiseAndSetIfChanged(ref _isRefreshingRepositories, value); + UpdateIsAnyRefreshInProgress(); + } + } + + private bool _isRefreshingRepositories; + + public bool IsAnyRefreshInProgress + { + get => _isAnyRefreshInProgress; + private set => this.RaiseAndSetIfChanged(ref _isAnyRefreshInProgress, value); + } + + private bool _isAnyRefreshInProgress; + + private void UpdateIsAnyRefreshInProgress() + { + IsAnyRefreshInProgress = IsRefreshingAppUpdates || IsRefreshingRepositories || IsLoadingConfig; + } + public StatisticsViewModel Statistics { get; private set; } public bool IsSideBoxVisible => _isSideBoxVisible.Value; @@ -69,6 +120,7 @@ public bool IsStatisticsVisible public IReactiveCommand SaveAsPredefinedCommand { get; set; } + public ReactiveCommand SelectActionCommand { get; set; } private readonly ParamsPanelFactory _paramsPanelFactory; private readonly VaultProvider _vaultProvider; @@ -101,6 +153,25 @@ public string ActionFilter private string _actionFilter; + public string SelectedCategoryFilter + { + get => _selectedCategoryFilter; + set => this.RaiseAndSetIfChanged(ref _selectedCategoryFilter, value); + } + + private string _selectedCategoryFilter = "All"; + + private readonly ObservableAsPropertyHelper> _availableCategories; + public IEnumerable AvailableCategories => _availableCategories.Value; + + public bool IsTreeViewMode + { + get => _isTreeViewMode; + set => this.RaiseAndSetIfChanged(ref _isTreeViewMode, value); + } + + private bool _isTreeViewMode = false; + private readonly ObservableAsPropertyHelper> _filteredActionList; public IEnumerable FilteredActionList => _filteredActionList.Value; @@ -223,6 +294,7 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider Tile = $"Update repository", ExecutedCommand = $"{command.Command} {command.Parameters}", }; + job.ExecutedCommandFormatted.AddRange(CreateSimpleFormattedCommand($"{command.Command} {command.Parameters}")); this.RunningJobs.Add(job); SelectedRunningJob = job; @@ -236,6 +308,15 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider })); IsScriptListVisible = true; SaveAsPredefinedCommand = ReactiveCommand.Create(() => { }); + SelectActionCommand = ReactiveCommand.Create(taggedScriptConfig => + { + if (taggedScriptConfig.Config is { } scriptConfig) + { + SelectedAction = scriptConfig; + } + + return Unit.Default; + }); _paramsPanelFactory = paramsPanelFactory; _vaultProvider = vaultProvider; this.appUpdater = new GithubUpdater(); @@ -284,14 +365,50 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider Statistics.RefreshStatistics(); }); - this.WhenAnyValue(x => x.ActionFilter, x => x.Actions) + // Build available categories list + this.WhenAnyValue(x => x.Actions) + .Select(actions => + { + var categories = new List { "All" }; + var allCategories = actions + .SelectMany(a => a.Categories ?? Enumerable.Empty()) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Distinct() + .OrderBy(c => c); + categories.AddRange(allCategories); + if (actions.Any(a => a.Categories == null || a.Categories.Count == 0)) + { + categories.Add("(No Category)"); + } + return categories.AsEnumerable(); + }) + .ObserveOn(RxApp.MainThreadScheduler) + .ToProperty(this, x => x.AvailableCategories, out _availableCategories); + + this.WhenAnyValue(x => x.ActionFilter, x => x.SelectedCategoryFilter, x => x.Actions) .Throttle(TimeSpan.FromMilliseconds(200)) .DistinctUntilChanged() - .Select((pair, cancellationToken) => + .Select((tuple, cancellationToken) => { + var (textFilter, categoryFilter, actions) = tuple; - var configs = string.IsNullOrWhiteSpace(pair.Item1)? pair.Item2:pair.Item2.Where(x => x.Name.Contains(pair.Item1, StringComparison.InvariantCultureIgnoreCase)); - + // Apply text filter + var configs = string.IsNullOrWhiteSpace(textFilter) + ? actions + : actions.Where(x => x.Name.Contains(textFilter, StringComparison.InvariantCultureIgnoreCase)); + + // Apply category filter (AND operator with text filter) + if (!string.IsNullOrWhiteSpace(categoryFilter) && categoryFilter != "All") + { + if (categoryFilter == "(No Category)") + { + configs = configs.Where(x => x.Categories == null || x.Categories.Count == 0); + } + else + { + configs = configs.Where(x => x.Categories != null && x.Categories.Contains(categoryFilter)); + } + } IEnumerable scriptConfigGroupWrappers = configs.SelectMany(c => { @@ -308,8 +425,6 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider Children = x.Select(p=> new TaggedScriptConfig(x.Key, p.script.Name, p.script)).OrderBy(x=>x.Name) }); return scriptConfigGroupWrappers; - - }) .ObserveOn(RxApp.MainThreadScheduler) .ToProperty(this, x => x.FilteredActionList, out _filteredActionList); @@ -318,6 +433,7 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider .FromEventPattern( h => this.ExecutionLog.CollectionChanged += h, h => this.ExecutionLog.CollectionChanged -= h) + .Throttle(TimeSpan.FromMilliseconds(500)) .Select(_ => Unit.Default) // We don't care about the event args; we just want to know something changed. .StartWith(Unit.Default) // To ensure initial population. .CombineLatest( @@ -352,6 +468,7 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider .FromEventPattern( h => this.ExecutionLog.CollectionChanged += h, h => this.ExecutionLog.CollectionChanged -= h) + .Throttle(TimeSpan.FromMilliseconds(500)) .Select(_ => Unit.Default) .StartWith(Unit.Default) .Select(_ => @@ -383,6 +500,7 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider .FromEventPattern( h => this.ExecutionLog.CollectionChanged += h, h => this.ExecutionLog.CollectionChanged -= h) + .Throttle(TimeSpan.FromMilliseconds(500)) .Select(_ => Unit.Default) .StartWith(Unit.Default) .Select(_ => @@ -444,24 +562,40 @@ public string TermForCurrentHistoryFilter private async Task RefreshInfoAbouAppUpdates() { - var isNewerVersion = await appUpdater.CheckIsNewerVersionAvailable(); - if (isNewerVersion) + IsRefreshingAppUpdates = true; + try { - Dispatcher.UIThread.Post(() => + var isNewerVersion = await appUpdater.CheckIsNewerVersionAvailable(); + if (isNewerVersion) { - ShowNewVersionAvailable = true; - }); + Dispatcher.UIThread.Post(() => + { + ShowNewVersionAvailable = true; + }); + } + } + finally + { + IsRefreshingAppUpdates = false; } } private async Task RefreshInfoAboutRepositories() { - var outOfDateRepos = await _configRepositoryUpdater.CheckAllRepositories(); - Dispatcher.UIThread.Post(() => + IsRefreshingRepositories = true; + try { - OutOfDateConfigRepositories.Clear(); - OutOfDateConfigRepositories.AddRange(outOfDateRepos); - }); + var outOfDateRepos = await _configRepositoryUpdater.CheckAllRepositories(); + Dispatcher.UIThread.Post(() => + { + OutOfDateConfigRepositories.Clear(); + OutOfDateConfigRepositories.AddRange(outOfDateRepos); + }); + } + finally + { + IsRefreshingRepositories = false; + } } public void CheckForUpdates() @@ -493,43 +627,125 @@ public void DismissOutdatedRepositories() private void BuildUi() { - var selectedActionName = SelectedAction?.Name; - var appSettings = AppSettingsService.Load(); - var sources = appSettings.ConfigScripts == null || appSettings.ConfigScripts.Count == 0 - ? SampleScripts - : appSettings.ConfigScripts; - var actions = new List(); - foreach (var action in sources.SelectMany(x=> ScriptConfigReader.Load(x, appSettings)).OrderBy(x=>x.SourceName).ThenBy(x=>x.Name)) + IsLoadingConfig = true; + Task.Run(() => { - actions.Add(action); - } + var selectedActionName = SelectedAction?.Name; + var appSettings = AppSettingsService.Load(); + var sources = appSettings.ConfigScripts == null || appSettings.ConfigScripts.Count == 0 + ? SampleScripts + : appSettings.ConfigScripts; + var actions = new List(); + var allCorruptedFiles = new List(); + + var results = sources.Select(source => ScriptConfigReader.LoadWithErrorTracking(source, appSettings)).ToList(); + var el = AppSettingsService.LoadExecutionLog(); + Dispatcher.UIThread.Post(() => + { + foreach (var result in results) + { + + actions.AddRange(result.Configs.OrderBy(x => x.SourceName).ThenBy(x => x.Name)); + allCorruptedFiles.AddRange(result.CorruptedFiles); + } - Actions = actions; - if (string.IsNullOrWhiteSpace(selectedActionName) == false && Actions.FirstOrDefault(x => x.Name == selectedActionName) is { } previouslySelected) - { - SelectedAction = previouslySelected; - } - else if (appSettings.Recent?.OrderByDescending(x => x.Value.Timestamp).FirstOrDefault() is { } recent && Actions.FirstOrDefault(a => - a.Name == recent.Value?.ActionId.ActionName && - a.SourceName == recent.Value.ActionId.SourceName) is - { } existingRecent) - { - SelectedAction = existingRecent; - if (existingRecent.PredefinedArgumentSets.FirstOrDefault(p => - p.Description == recent.Value.ActionId.ParameterSet) is { } ps) - { - SelectedArgumentSet = ps; - } - } - else if(Actions.FirstOrDefault() is { } firstAction) + foreach (var action in actions) + { + var withMarkers = action.Params.Aggregate + ( + seed: action.Command, + func: (string accumulate, ScriptParam source) => + accumulate.Replace("{" + source.Name + "}", "[!@#]{" + source.Name + "}[!@#]") + ); + + action.CommandFormatted.AddRange(withMarkers.Split("[!@#]").Select(x => + { + var inline = new Run(x); + if (x.StartsWith("{")) + { + + inline.Foreground = Brushes.LightGreen; + inline.FontWeight = FontWeight.ExtraBold; + } + + return inline; + })); + + // Format InstallCommand if it exists + if (!string.IsNullOrWhiteSpace(action.InstallCommand)) + { + var installWithMarkers = action.Params.Aggregate + ( + seed: action.InstallCommand, + func: (string accumulate, ScriptParam source) => + accumulate.Replace("{" + source.Name + "}", "[!@#]{" + source.Name + "}[!@#]") + ); + + action.InstallCommandFormatted.AddRange(installWithMarkers.Split("[!@#]").Select(x => + { + var inline = new Run(x); + if (x.StartsWith("{")) + { + inline.Foreground = Brushes.LightGreen; + inline.FontWeight = FontWeight.ExtraBold; + } + + return inline; + })); + } + } + + Actions = actions; + + if (string.IsNullOrWhiteSpace(selectedActionName) == false && Actions.FirstOrDefault(x => x.Name == selectedActionName) is { } previouslySelected) + { + SelectedAction = previouslySelected; + } + else if (appSettings.Recent?.OrderByDescending(x => x.Value.Timestamp).FirstOrDefault() is { } recent && Actions.FirstOrDefault(a => + a.Name == recent.Value?.ActionId.ActionName && + a.SourceName == recent.Value.ActionId.SourceName) is + { } existingRecent) + { + SelectedAction = existingRecent; + if (existingRecent.PredefinedArgumentSets.FirstOrDefault(p => + p.Description == recent.Value.ActionId.ParameterSet) is { } ps) + { + SelectedArgumentSet = ps; + } + } + else if(Actions.FirstOrDefault() is { } firstAction) + { + SelectedAction = firstAction; + } + ExecutionLog.Clear(); + + ExecutionLog.AddRange(el); + IsLoadingConfig = false; + if (allCorruptedFiles.Count > 0) + { + ShowCorruptedFilesDialog(allCorruptedFiles); + } + }); + }); + } + + private async void ShowCorruptedFilesDialog(List corruptedFiles) + { + var fileList = string.Join("\n", corruptedFiles.Select(f => $"• {f}")); + var message = $"The following configuration files are corrupted and were skipped:\n\n{fileList}\n\nPlease check these files for JSON syntax errors."; + var messageBox = MessageBoxManager.GetMessageBoxStandard( + "Corrupted Configuration Files", + message, + icon: MsBox.Avalonia.Enums.Icon.Warning, + windowStartupLocation: WindowStartupLocation.CenterOwner); + + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - SelectedAction = firstAction; + await messageBox.ShowWindowDialogAsync(desktop.MainWindow); } - ExecutionLog.Clear(); - ExecutionLog.AddRange(AppSettingsService.LoadExecutionLog()); } - + private static List SampleScripts => new() { new ConfigScriptEntry @@ -645,6 +861,7 @@ public void InstallScript() ExecutedCommand = installCommand, EnvironmentVariables = new Dictionary() }; + job.ExecutedCommandFormatted.AddRange(CreateSimpleFormattedCommand(installCommand)); job.ExecutionCompleted += (sender, eventArgs) => { SelectedActionInstalled = true; @@ -689,8 +906,14 @@ public void OpenSettingsWindow() public void ForceRefresh() { - _ = RefreshInfoAbouAppUpdates(); - _ = RefreshInfoAboutRepositories(); + Task.Run(async () => + { + await RefreshInfoAbouAppUpdates(); + }); + Task.Run(async () => + { + await RefreshInfoAboutRepositories(); + }); BuildUi(); } public void RefreshSettings() => BuildUi(); @@ -955,12 +1178,22 @@ private void ExecuteCommand(string command, ScriptConfig selectedAction, bool us var (commandPath, args) = SplitCommandAndArgs(command); var envVariables = new Dictionary(selectedAction.EnvironmentVariables); var maskedArgs = args; + + // Track parameter replacements for formatting with descriptions + var parameterReplacements = new List<(string paramName, string value, bool masked, string description)>(); + foreach (var controlRecord in _controlRecords) { var controlValue = controlRecord.GetFormattedValue()?.Trim(); args = args.Replace($"{{{controlRecord.Name}}}", controlValue); commandPath = commandPath.Replace($"{{{controlRecord.Name}}}", controlValue); - maskedArgs = maskedArgs.Replace($"{{{controlRecord.Name}}}", controlRecord.MaskingRequired? "*****": controlValue); + var displayValue = controlRecord.MaskingRequired ? "*****" : controlValue; + maskedArgs = maskedArgs.Replace($"{{{controlRecord.Name}}}", displayValue); + + // Find the parameter description from the action's Params + var paramDescription = selectedAction.Params.FirstOrDefault(p => p.Name == controlRecord.Name)?.Description ?? string.Empty; + + parameterReplacements.Add((controlRecord.Name, displayValue, controlRecord.MaskingRequired, paramDescription)); foreach (var (key, val) in envVariables) { @@ -969,12 +1202,39 @@ private void ExecuteCommand(string command, ScriptConfig selectedAction, bool us } } + var executedCommand = $"{commandPath} {maskedArgs}"; + + // Create formatted version with highlighted parameter values + var formattedCommand = executedCommand; + foreach (var (paramName, value, masked, description) in parameterReplacements) + { + if (!string.IsNullOrWhiteSpace(value)) + { + formattedCommand = formattedCommand.Replace(value, $"[!@#]{value}[!@#]"); + } + } + + var executedCommandFormatted = formattedCommand.Split("[!@#]").Select(x => + { + var inline = new Run(x); + // Check if this is a parameter value (not the original text) + var matchingParam = parameterReplacements.FirstOrDefault(p => p.value == x && !string.IsNullOrWhiteSpace(x)); + if (matchingParam != default) + { + inline.Foreground = Brushes.LightGreen; + inline.FontWeight = FontWeight.ExtraBold; + } + return inline; + }).ToList(); + var job = new RunningJobViewModel { Tile = $"#{jobCounter++} {title ?? selectedAction.Name}", - ExecutedCommand = $"{commandPath} {maskedArgs}", + ExecutedCommand = executedCommand, EnvironmentVariables = envVariables }; + job.ExecutedCommandFormatted.AddRange(executedCommandFormatted); + this.RunningJobs.Add(job); SelectedRunningJob = job; @@ -1124,6 +1384,13 @@ private static string[] SplitCommand(string command) return command.Split(' ', 2); } + + private static InlineCollection CreateSimpleFormattedCommand(string command) + { + var collection = new InlineCollection(); + collection.Add(new Run(command)); + return collection; + } } public record RecentAction(ActionId ActionId, DateTime Timestamp); diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs index c14c24f..c3f0097 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/RunningJobViewModel.cs @@ -56,6 +56,7 @@ public RunningJobStatus Status } public string ExecutedCommand { get; set; } + public InlineCollection ExecutedCommandFormatted { get; set; } = new(); public void CancelExecution() { GracefulCancellation.Cancel(); diff --git a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/StatisticsViewModel.cs b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/StatisticsViewModel.cs index ef9cee6..6dcd6e1 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/ViewModels/StatisticsViewModel.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/ViewModels/StatisticsViewModel.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Windows.Input; using ReactiveUI; namespace ScriptRunner.GUI.ViewModels; @@ -9,11 +13,31 @@ namespace ScriptRunner.GUI.ViewModels; public class StatisticsViewModel : ReactiveObject { private readonly ObservableCollection _executionLog; + private List _allActions = new(); + private const int PageSize = 10; public StatisticsViewModel(ObservableCollection executionLog) { _executionLog = executionLog; - RefreshStatistics(); + + // Initialize commands + NextPageCommand = ReactiveCommand.Create(NextPage, this.WhenAnyValue(x => x.CanGoNext)); + PreviousPageCommand = ReactiveCommand.Create(PreviousPage, this.WhenAnyValue(x => x.CanGoPrevious)); + GoToPageCommand = ReactiveCommand.Create(GoToPage); + + Observable + .FromEventPattern( + h => _executionLog.CollectionChanged += h, + h => _executionLog.CollectionChanged -= h) + .Throttle(TimeSpan.FromMilliseconds(500)) + .Select(_ => Unit.Default) + .StartWith(Unit.Default) + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(unit => + { + InitializeAvailableYears(); + RefreshStatistics(); + }); } private List _heatmapDays = new(); @@ -51,6 +75,179 @@ public int MaxWeek set => this.RaiseAndSetIfChanged(ref _maxWeek, value); } + private List _availableYears = new(); + public List AvailableYears + { + get => _availableYears; + set => this.RaiseAndSetIfChanged(ref _availableYears, value); + } + + private List _yearOptions = new(); + public List YearOptions + { + get => _yearOptions; + set => this.RaiseAndSetIfChanged(ref _yearOptions, value); + } + + private YearOption? _selectedYearOption; + public YearOption? SelectedYearOption + { + get => _selectedYearOption; + set + { + this.RaiseAndSetIfChanged(ref _selectedYearOption, value); + if (value != null) + { + RefreshHeatmapForSelectedOption(); + } + } + } + + private int _selectedYear; + public int SelectedYear + { + get => _selectedYear; + set + { + this.RaiseAndSetIfChanged(ref _selectedYear, value); + } + } + + private int _currentPage = 1; + public int CurrentPage + { + get => _currentPage; + set + { + this.RaiseAndSetIfChanged(ref _currentPage, value); + this.RaisePropertyChanged(nameof(CanGoNext)); + this.RaisePropertyChanged(nameof(CanGoPrevious)); + this.RaisePropertyChanged(nameof(PageInfo)); + UpdatePagedActions(); + } + } + + private int _totalPages = 1; + public int TotalPages + { + get => _totalPages; + set + { + this.RaiseAndSetIfChanged(ref _totalPages, value); + this.RaisePropertyChanged(nameof(CanGoNext)); + this.RaisePropertyChanged(nameof(PageInfo)); + } + } + + private int _totalActions = 0; + public int TotalActions + { + get => _totalActions; + set + { + this.RaiseAndSetIfChanged(ref _totalActions, value); + this.RaisePropertyChanged(nameof(PageInfo)); + } + } + + public bool CanGoNext => CurrentPage < TotalPages; + public bool CanGoPrevious => CurrentPage > 1; + + public string PageInfo => TotalActions > 0 + ? $"Page {CurrentPage} of {TotalPages} ({TotalActions} total actions)" + : "No actions found"; + + public ICommand NextPageCommand { get; } + public ICommand PreviousPageCommand { get; } + public ICommand GoToPageCommand { get; } + + private void NextPage() + { + if (CanGoNext) + { + CurrentPage++; + } + } + + private void PreviousPage() + { + if (CanGoPrevious) + { + CurrentPage--; + } + } + + private void GoToPage(int pageNumber) + { + if (pageNumber >= 1 && pageNumber <= TotalPages) + { + CurrentPage = pageNumber; + } + } + + private void InitializeAvailableYears() + { + var yearOptions = new List(); + + // Add "Last Year" option first + yearOptions.Add(new YearOption { DisplayName = "Last Year", IsLastYear = true, Year = null }); + + System.Diagnostics.Debug.WriteLine($"=== InitializeAvailableYears called ==="); + System.Diagnostics.Debug.WriteLine($"Execution log count: {_executionLog.Count}"); + + if (_executionLog.Count == 0) + { + System.Diagnostics.Debug.WriteLine("Execution log is empty!"); + _selectedYearOption = yearOptions[0]; + YearOptions = yearOptions; + return; + } + + // Print first and last few entries for debugging + var sortedLog = _executionLog.OrderBy(x => x.Timestamp).ToList(); + System.Diagnostics.Debug.WriteLine($"First execution: {sortedLog.First().Timestamp:yyyy-MM-dd}"); + System.Diagnostics.Debug.WriteLine($"Last execution: {sortedLog.Last().Timestamp:yyyy-MM-dd}"); + + // Get all years from execution log + var years = _executionLog + .Select(x => x.Timestamp.Year) + .Distinct() + .OrderByDescending(y => y) + .ToList(); + + // Debug: Print all years found + System.Diagnostics.Debug.WriteLine($"Found {years.Count} unique years in execution log: {string.Join(", ", years)}"); + + // Add individual years (exclude current year since "Last Year" covers it) + var currentYear = DateTime.Now.Year; + System.Diagnostics.Debug.WriteLine($"Current year: {currentYear}"); + + foreach (var year in years) + { + // Only add years that are NOT the current year + if (year < currentYear) + { + System.Diagnostics.Debug.WriteLine($"Adding year option: {year}"); + yearOptions.Add(new YearOption { DisplayName = year.ToString(), IsLastYear = false, Year = year }); + } + else + { + System.Diagnostics.Debug.WriteLine($"Skipping year: {year} (current or future year)"); + } + } + + System.Diagnostics.Debug.WriteLine($"Total year options: {yearOptions.Count}"); + System.Diagnostics.Debug.WriteLine($"=== End InitializeAvailableYears ==="); + + YearOptions = yearOptions; + + // Only set selected year option if it's not already set + if (_selectedYearOption == null || !yearOptions.Contains(_selectedYearOption)) + { + SelectedYearOption = yearOptions[0]; // Select "Last Year" by default + } + } + public void RefreshStatistics() { var now = DateTime.Now; @@ -61,19 +258,64 @@ public void RefreshStatistics() .Where(x => x.Timestamp >= yearAgo) .ToList(); - // Generate heatmap data - GenerateHeatmapData(yearData, yearAgo, now); + // Generate heatmap data for selected year + RefreshHeatmapForSelectedOption(); - // Generate top 10 actions + // Generate top actions GenerateTopActions(yearData); } - private void GenerateHeatmapData(List yearData, DateTime startDate, DateTime endDate) + private void RefreshHeatmapForSelectedOption() + { + if (_selectedYearOption == null) + return; + + DateTime startDate; + DateTime endDate; + + if (_selectedYearOption.IsLastYear) + { + // Last year: from today going back 365 days + endDate = DateTime.Now.Date; + startDate = endDate.AddYears(-1).AddDays(1); // Start from 364 days ago + } + else if (_selectedYearOption.Year.HasValue) + { + // Specific year: January 1 to December 31 + startDate = new DateTime(_selectedYearOption.Year.Value, 1, 1); + endDate = new DateTime(_selectedYearOption.Year.Value, 12, 31); + } + else + { + return; + } + + var yearData = _executionLog + .Where(x => x.Timestamp >= startDate && x.Timestamp <= endDate) + .ToList(); + + GenerateHeatmapData(yearData, startDate, endDate, _selectedYearOption.IsLastYear); + } + + private void RefreshHeatmapForYear() + { + // Get data for the selected year + var startDate = new DateTime(SelectedYear, 1, 1); + var endDate = new DateTime(SelectedYear, 12, 31); + + var yearData = _executionLog + .Where(x => x.Timestamp >= startDate && x.Timestamp <= endDate) + .ToList(); + + GenerateHeatmapData(yearData, startDate, endDate, false); + } + + private void GenerateHeatmapData(List yearData, DateTime startDate, DateTime endDate, bool isLastYear) { // Group by date and count executions var executionsByDate = yearData .GroupBy(x => x.Timestamp.Date) - .ToDictionary(x => x.Key, x => x.Count()); + .ToDictionary(x => x.Key, x => x.ToList()); var heatmapDays = new List(); var monthLabels = new List(); @@ -89,9 +331,37 @@ private void GenerateHeatmapData(List yearData, DateTime sta string currentMonth = ""; int currentMonthStartWeek = 0; - while (currentDate <= endDate) + // Continue until we reach a Saturday after the end date + var actualEndDate = endDate; + while (actualEndDate.DayOfWeek != DayOfWeek.Saturday) { - var count = executionsByDate.TryGetValue(currentDate, out var c) ? c : 0; + actualEndDate = actualEndDate.AddDays(1); + } + + while (currentDate <= actualEndDate) + { + // Only count executions if date is within the actual range + var count = 0; + var isInRange = currentDate >= startDate && currentDate <= endDate; + List actionDetails = new(); + + if (isInRange && executionsByDate.TryGetValue(currentDate, out var dayExecutions)) + { + count = dayExecutions.Count; + + // Group actions by name and source, count executions + actionDetails = dayExecutions + .GroupBy(x => new { x.Source, x.Name }) + .Select(g => new DayActionDetail + { + ActionName = g.Key.Name, + Source = g.Key.Source, + ExecutionCount = g.Count() + }) + .OrderByDescending(x => x.ExecutionCount) + .ToList(); + } + var intensity = GetIntensityLevel(count); // Calculate week index based on days from start Sunday @@ -105,21 +375,26 @@ private void GenerateHeatmapData(List yearData, DateTime sta Intensity = intensity, DayOfWeek = (int)currentDate.DayOfWeek, WeekIndex = weekIndex, - ToolTip = $"{currentDate:MMM dd, yyyy}: {count} execution{(count != 1 ? "s" : "")} [Week {weekIndex}, Day {(int)currentDate.DayOfWeek}]" + ToolTip = $"{currentDate:MMM dd, yyyy}: {count} execution{(count != 1 ? "s" : "")}", + IsOutOfRange = !isInRange, + ActionDetails = actionDetails }); - // Track month changes for labels - var monthName = currentDate.ToString("MMM"); - if (monthName != currentMonth) + // Track month changes for labels (only for months in the actual range) + if (isInRange) { - // Only add the previous month if we had one - if (!string.IsNullOrEmpty(currentMonth)) + var monthName = currentDate.ToString("MMM"); + if (monthName != currentMonth) { - int weeksInMonth = weekIndex - currentMonthStartWeek; - monthLabels.Add($"{currentMonth}|{weeksInMonth}"); + // Only add the previous month if we had one + if (!string.IsNullOrEmpty(currentMonth)) + { + int weeksInMonth = weekIndex - currentMonthStartWeek; + monthLabels.Add($"{currentMonth}|{weeksInMonth}"); + } + currentMonth = monthName; + currentMonthStartWeek = weekIndex; } - currentMonth = monthName; - currentMonthStartWeek = weekIndex; } currentDate = currentDate.AddDays(1); @@ -128,7 +403,7 @@ private void GenerateHeatmapData(List yearData, DateTime sta // Add the final month if (!string.IsNullOrEmpty(currentMonth)) { - int daysSinceStart = (int)(endDate - startSunday).TotalDays; + int daysSinceStart = (int)(actualEndDate - startSunday).TotalDays; int finalWeekIndex = daysSinceStart / 7; int weeksInMonth = finalWeekIndex - currentMonthStartWeek + 1; monthLabels.Add($"{currentMonth}|{weeksInMonth}"); @@ -161,16 +436,16 @@ private int GetIntensityLevel(int count) private void GenerateTopActions(List yearData) { - var topActions = yearData + _allActions = yearData .GroupBy(x => new { x.Source, x.Name }) .Select(g => new TopActionItem { ActionName = g.Key.Name, Source = g.Key.Source, - ExecutionCount = g.Count() + ExecutionCount = g.Count(), + LastUsed = g.Max(x => x.Timestamp) }) .OrderByDescending(x => x.ExecutionCount) - .Take(10) .Select((item, index) => { item.Rank = (index + 1).ToString(); @@ -178,7 +453,16 @@ private void GenerateTopActions(List yearData) }) .ToList(); - TopActions = topActions; + TotalActions = _allActions.Count; + TotalPages = TotalActions > 0 ? (int)Math.Ceiling((double)TotalActions / PageSize) : 1; + CurrentPage = 1; + UpdatePagedActions(); + } + + private void UpdatePagedActions() + { + var skip = (CurrentPage - 1) * PageSize; + TopActions = _allActions.Skip(skip).Take(PageSize).ToList(); } } @@ -190,6 +474,22 @@ public class HeatmapDay public int DayOfWeek { get; set; } public int WeekIndex { get; set; } public string ToolTip { get; set; } = ""; + public bool IsOutOfRange { get; set; } + public List ActionDetails { get; set; } = new(); +} + +public class DayActionDetail +{ + public string ActionName { get; set; } = ""; + public string Source { get; set; } = ""; + public int ExecutionCount { get; set; } +} + +public class YearOption +{ + public string DisplayName { get; set; } = ""; + public bool IsLastYear { get; set; } + public int? Year { get; set; } } public class TopActionItem @@ -198,4 +498,6 @@ public class TopActionItem public string ActionName { get; set; } = ""; public string Source { get; set; } = ""; public int ExecutionCount { get; set; } + public DateTime LastUsed { get; set; } + public string LastUsedFormatted => LastUsed.ToString("yyyy-MM-dd"); } diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml index 12723b6..4d09e57 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionDetailsSection.axaml @@ -89,7 +89,28 @@ - + + + + Command: + + + + + + + Install Command: + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml index 8176b14..70bb807 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml @@ -5,31 +5,258 @@ xmlns:viewModels="clr-namespace:ScriptRunner.GUI.ViewModels" xmlns:scriptConfigs="clr-namespace:ScriptRunner.GUI.ScriptConfigs" xmlns:avalonia="https://github.com/projektanker/icons.avalonia" + xmlns:converters="clr-namespace:ScriptRunner.GUI.Converters" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:DataType="viewModels:MainWindowViewModel" x:Class="ScriptRunner.GUI.Views.ActionsList"> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml.cs b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml.cs index c7557e0..ffbd24b 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml.cs +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/ActionsList.axaml.cs @@ -1,18 +1,176 @@ -using Avalonia; +using System; +using System.Collections.Generic; +using System.Linq; using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Markup.Xaml; +using Avalonia.VisualTree; +using ScriptRunner.GUI.ViewModels; namespace ScriptRunner.GUI.Views; public partial class ActionsList : UserControl { + private Border? _previouslySelectedBorder; + private bool _isInternalSelection; + private readonly List _categoryBadges = new(); + public ActionsList() { InitializeComponent(); + this.DataContextChanged += OnDataContextChanged; + this.Loaded += OnLoaded; } private void InitializeComponent() { AvaloniaXamlLoader.Load(this); } + + private void OnLoaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + // Give the UI time to render, then find all category badges + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + FindAndTrackAllCategoryBadges(); + }, Avalonia.Threading.DispatcherPriority.Loaded); + } + + private void OnDataContextChanged(object? sender, EventArgs e) + { + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.PropertyChanged += (s, args) => + { + if (args.PropertyName == nameof(MainWindowViewModel.SelectedAction)) + { + // Only clear if this is an external selection change (not from our click handler) + if (!_isInternalSelection) + { + if (_previouslySelectedBorder != null) + { + _previouslySelectedBorder.Classes.Remove("selected"); + _previouslySelectedBorder = null; + } + } + } + else if (args.PropertyName == nameof(MainWindowViewModel.SelectedCategoryFilter)) + { + // Update grayed state of all category badges when selection changes + UpdateCategoryBadgesGrayedState(); + } + else if (args.PropertyName == nameof(MainWindowViewModel.AvailableCategories)) + { + // When categories change, re-scan for badges + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + _categoryBadges.Clear(); + FindAndTrackAllCategoryBadges(); + UpdateCategoryBadgesGrayedState(); + }, Avalonia.Threading.DispatcherPriority.Loaded); + } + }; + } + } + + private void ActionTile_OnTapped(object? sender, RoutedEventArgs e) + { + if (sender is Border border && border.DataContext is TaggedScriptConfig taggedScriptConfig) + { + if (DataContext is MainWindowViewModel viewModel) + { + // Set flag to prevent the PropertyChanged handler from clearing our selection + _isInternalSelection = true; + + try + { + // Remove selected class from previously selected border + if (_previouslySelectedBorder != null && _previouslySelectedBorder != border) + { + _previouslySelectedBorder.Classes.Remove("selected"); + } + + // Add selected class to clicked border immediately + border.Classes.Add("selected"); + _previouslySelectedBorder = border; + + // Set SelectedActionOrGroup to trigger the same behavior as tree view selection + viewModel.SelectedActionOrGroup = taggedScriptConfig; + } + finally + { + // Reset flag after selection is complete + _isInternalSelection = false; + } + } + } + } + + private void ClearSearch_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.ActionFilter = string.Empty; + } + } + + private void CategoryBadge_OnTapped(object? sender, RoutedEventArgs e) + { + if (sender is Border border && border.DataContext is string category) + { + // Track this badge if not already tracked + if (!_categoryBadges.Contains(border)) + { + _categoryBadges.Add(border); + } + + if (DataContext is MainWindowViewModel viewModel) + { + viewModel.SelectedCategoryFilter = category; + // Gray state will be updated by PropertyChanged handler + } + } + } + + private void UpdateCategoryBadgesGrayedState() + { + if (DataContext is not MainWindowViewModel viewModel) + return; + + var selectedCategory = viewModel.SelectedCategoryFilter; + var shouldGrayOut = !string.IsNullOrEmpty(selectedCategory) && selectedCategory != "All"; + + // Update all tracked badges + foreach (var badge in _categoryBadges.ToList()) + { + if (badge.DataContext is string category) + { + if (shouldGrayOut && category != selectedCategory) + { + badge.Classes.Add("grayed"); + } + else + { + badge.Classes.Remove("grayed"); + } + } + } + } + + private void FindAndTrackAllCategoryBadges() + { + // Find all Border elements with the categoryBadge class + var allBadges = this.GetVisualDescendants() + .OfType() + .Where(b => b.Classes.Contains("categoryBadge")) + .ToList(); + + foreach (var badge in allBadges) + { + if (!_categoryBadges.Contains(badge)) + { + _categoryBadges.Add(badge); + } + } + } } \ No newline at end of file diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml index cb7b2d8..29f3752 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/MainWindow.axaml @@ -50,7 +50,18 @@ - + + + + + + + + @@ -60,9 +71,6 @@ SelectedItem="{Binding SelectedRecentExecution, Mode=TwoWay}" ShowDatePicker="True"/> - - - diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml index b091a31..8edffcd 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/RunningJobsSection.axaml @@ -12,16 +12,16 @@ x:DataType="viewModels:MainWindowViewModel" > - - - - - - - + + + + + + + @@ -42,14 +42,16 @@ - + + + diff --git a/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml b/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml index 7b54641..7697ccd 100644 --- a/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml +++ b/src/ScriptRunner/ScriptRunner.GUI/Views/SideMenu.axaml @@ -26,7 +26,17 @@