Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Avalonia.Data.Converters;
using Avalonia.Media;

Expand All @@ -9,30 +12,84 @@ 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
// Blues & Cyans (8 colors)
Color.FromRgb(25, 95, 145), // Dark Steel Blue
Color.FromRgb(0, 105, 200), // Strong Blue
Color.FromRgb(0, 130, 200), // Deep Blue
Color.FromRgb(0, 140, 140), // Deep Teal
Color.FromRgb(0, 150, 136), // Dark Turquoise
Color.FromRgb(30, 80, 180), // Royal Blue
Color.FromRgb(0, 85, 140), // Navy Blue
Color.FromRgb(0, 120, 150), // Ocean Blue

// Greens (8 colors)
Color.FromRgb(30, 130, 76), // Dark Sea Green
Color.FromRgb(22, 160, 80), // Deep Emerald
Color.FromRgb(65, 165, 65), // Forest Green
Color.FromRgb(50, 120, 50), // Dark Green
Color.FromRgb(100, 140, 30), // Olive Green
Color.FromRgb(0, 135, 60), // Green
Color.FromRgb(40, 105, 40), // Hunter Green
Color.FromRgb(85, 150, 0), // Lime Green

// Purples & Magentas (8 colors)
Color.FromRgb(100, 65, 165), // Deep Purple
Color.FromRgb(90, 24, 154), // Dark Violet
Color.FromRgb(130, 50, 150), // Dark Orchid
Color.FromRgb(155, 65, 155), // Dark Magenta
Color.FromRgb(160, 15, 100), // Deep Pink
Color.FromRgb(75, 0, 130), // Indigo
Color.FromRgb(120, 40, 140), // Purple
Color.FromRgb(145, 30, 180), // Violet

// Reds & Pinks (8 colors)
Color.FromRgb(180, 15, 45), // Deep Crimson
Color.FromRgb(200, 50, 35), // Dark Red
Color.FromRgb(200, 50, 120), // Dark Rose
Color.FromRgb(190, 70, 70), // Brick Red
Color.FromRgb(165, 50, 50), // Dark Coral
Color.FromRgb(170, 0, 60), // Maroon
Color.FromRgb(220, 40, 85), // Ruby Red
Color.FromRgb(185, 25, 100), // Deep Rose

// Oranges & Yellows (8 colors)
Color.FromRgb(210, 105, 0), // Deep Orange
Color.FromRgb(230, 120, 0), // Vivid Orange
Color.FromRgb(200, 160, 0), // Dark Gold
Color.FromRgb(165, 125, 20), // Dark Goldenrod
Color.FromRgb(180, 160, 50), // Dark Khaki
Color.FromRgb(190, 90, 0), // Burnt Orange
Color.FromRgb(170, 140, 0), // Mustard
Color.FromRgb(200, 140, 30), // Amber

// Browns & Earth Tones (10 colors)
Color.FromRgb(160, 75, 20), // Dark Chocolate
Color.FromRgb(155, 95, 40), // Burnt Sienna
Color.FromRgb(140, 90, 90), // Dark Rose Brown
Color.FromRgb(120, 60, 30), // Dark Sienna
Color.FromRgb(100, 50, 15), // Deep Brown
Color.FromRgb(130, 70, 25), // Saddle Brown
Color.FromRgb(145, 85, 50), // Copper
Color.FromRgb(115, 80, 65), // Coffee Brown
Color.FromRgb(140, 100, 60), // Bronze
Color.FromRgb(105, 65, 40), // Dark Tan
};

// Cache to ensure same category always gets same color
private static readonly Dictionary<string, int> CategoryColorCache = new();

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;
// Check cache first
if (!CategoryColorCache.TryGetValue(category, out int colorIndex))
{
// Generate deterministic hash from category name using SHA256
colorIndex = GetStableColorIndex(category);
CategoryColorCache[category] = colorIndex;
}

return new SolidColorBrush(PredefinedColors[colorIndex]);
}

Expand All @@ -44,22 +101,16 @@ public object Convert(object? value, Type targetType, object? parameter, Culture
throw new NotImplementedException();
}

private static int GetDeterministicHashCode(string str)
private static int GetStableColorIndex(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);
}
// Use SHA256 for better distribution and fewer collisions
using var sha256 = SHA256.Create();
byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(str));

// Use first 4 bytes to create an integer
int hash = BitConverter.ToInt32(hashBytes, 0);

// Map to color index with better distribution
return Math.Abs(hash % PredefinedColors.Length);
}
}
110 changes: 31 additions & 79 deletions src/ScriptRunner/ScriptRunner.GUI/ScriptReader/ScriptConfigReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public static ConfigLoadResult LoadWithErrorTracking(ConfigScriptEntry source,
return result;
}

LoadFileSourceWithTracking(source.Path, appSettings, result, source.Name);
LoadFileSourceWithTracking(source.Path, appSettings, result, source.Name, source.Path);
return result;
}

Expand All @@ -49,82 +49,13 @@ public static ConfigLoadResult LoadWithErrorTracking(ConfigScriptEntry source,

foreach (var file in Directory.EnumerateFiles(source.Path, "*.json", source.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly))
{
LoadFileSourceWithTracking(file, appSettings, result, source.Name);
LoadFileSourceWithTracking(file, appSettings, result, source.Name, source.Path);
}
}

return result;
}

public static IEnumerable<ScriptConfig> Load(ConfigScriptEntry source,
ScriptRunnerAppSettings appSettings)
{
if (string.IsNullOrWhiteSpace(source.Path))
{
yield break;
}

if (source.Type == ConfigScriptType.File)
{
if (File.Exists(source.Path) == false)
{
yield break;
}

foreach (var scriptConfig in LoadFileSource(source.Path, appSettings))
{
scriptConfig.SourceName = source.Name;
scriptConfig.Categories ??= new List<string>();

var mainCategory = string.IsNullOrWhiteSpace(scriptConfig.SourceName) == false
? scriptConfig.SourceName
: Path.GetFileName(source.Path);
if (string.IsNullOrWhiteSpace(mainCategory) == false)
{
scriptConfig.Categories.Add(mainCategory);

if (mainCategory != source.Name)
{
source.Name = mainCategory;
}
}
yield return scriptConfig;
}
yield break;
}

if (source.Type == ConfigScriptType.Directory)
{
if (Directory.Exists(source.Path) == false)
{
yield break;
}

foreach (var file in Directory.EnumerateFiles(source.Path, "*.json", source.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly))
{
foreach (var scriptConfig in LoadFileSource(file, appSettings))
{
scriptConfig.SourceName = source.Name;
scriptConfig.Categories ??= new List<string>();

var mainCategory = string.IsNullOrWhiteSpace(scriptConfig.SourceName) == false
? scriptConfig.SourceName
: Path.GetFileName(source.Path);
if (string.IsNullOrWhiteSpace(mainCategory) == false)
{
scriptConfig.Categories.Add(mainCategory);

if (mainCategory != source.Name)
{
source.Name = mainCategory;
}
}
yield return scriptConfig;
}
}
}
}

private static IAutoParameterBuilder CreateBuilder(ScriptConfig scriptConfig)
{
if (scriptConfig.AutoParameterBuilderStyle == "powershell")
Expand All @@ -140,8 +71,7 @@ private static IAutoParameterBuilder CreateBuilder(ScriptConfig scriptConfig)
return EmptyAutoParameterBuilder.Instance;
}

private static void LoadFileSourceWithTracking(string fileName,
ScriptRunnerAppSettings appSettings, ConfigLoadResult result, string sourceName)
private static void LoadFileSourceWithTracking(string fileName, ScriptRunnerAppSettings appSettings, ConfigLoadResult result, string sourceName, string sourcePath)
{
if (!File.Exists(fileName)) return;

Expand All @@ -166,14 +96,19 @@ private static void LoadFileSourceWithTracking(string fileName,
action.Source = fileName;
action.SourceName = sourceName;
action.Categories ??= new List<string>();

var mainCategory = string.IsNullOrWhiteSpace(action.SourceName) == false
? action.SourceName
: Path.GetFileName(fileName);
if (string.IsNullOrWhiteSpace(mainCategory) == false)
if (string.IsNullOrWhiteSpace(action.SourceName) == false)
{
action.Categories.Add(mainCategory);
action.Categories.Add(action.SourceName);
}
else
{
var dir = Path.GetDirectoryName(sourcePath)?.Split(new[]{'\\','/'}).Last();
if (string.IsNullOrWhiteSpace(dir) == false)
{
action.Categories.Add(dir);
}
}
NormalizeCategories(action.Categories);

var parameterBuilder = CreateBuilder(action);

Expand Down Expand Up @@ -300,6 +235,23 @@ string ResolveAbsolutePath(string path)
}
}

private static readonly Dictionary<string, string> NormalizeCategoriesCache = new();
private static void NormalizeCategories(List<string> actionCategories)
{
var normalize = actionCategories.Select(cat =>
{
var key = cat.Trim().ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", "");
if (NormalizeCategoriesCache.TryGetValue(key, out var normalized))
{
return normalized;
}
return NormalizeCategoriesCache[key] = cat.Trim();

}).Distinct().ToList();
actionCategories.Clear();
actionCategories.AddRange(normalize);
}

private static IEnumerable<ScriptConfig> LoadFileSource(string fileName,
ScriptRunnerAppSettings appSettings)
{
Expand Down
39 changes: 29 additions & 10 deletions src/ScriptRunner/ScriptRunner.GUI/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -410,20 +410,39 @@ public MainWindowViewModel(ParamsPanelFactory paramsPanelFactory, VaultProvider
}
}

IEnumerable<ScriptConfigGroupWrapper> scriptConfigGroupWrappers = configs.SelectMany(c =>
IEnumerable<ScriptConfigGroupWrapper> scriptConfigGroupWrappers;

// When a specific category is filtered, show each action only once under that category
if (!string.IsNullOrWhiteSpace(categoryFilter) && categoryFilter != "All")
{
scriptConfigGroupWrappers = new[]
{
if (c.Categories is {Count: > 0})
new ScriptConfigGroupWrapper
{
return c.Categories.DistinctBy(x=>x).Select((cat) => (category: cat, script: c));
Name = categoryFilter,
Children = configs.Select(c => new TaggedScriptConfig(categoryFilter, c.Name, c)).OrderBy(x => x.Name)
}
};
}
else
{
// When no filter or "All" is selected, show actions grouped by all their categories
scriptConfigGroupWrappers = configs.SelectMany(c =>
{
if (c.Categories is {Count: > 0})
{
return c.Categories.DistinctBy(x=>x).Select((cat) => (category: cat, script: c));
}

return new[] {(category: "(No Category)", script: c)};
}).GroupBy(x => x.category).OrderBy(x=>x.Key)
.Select(x=> new ScriptConfigGroupWrapper
{
Name = x.Key,
Children = x.Select(p=> new TaggedScriptConfig(x.Key, p.script.Name, p.script)).OrderBy(x=>x.Name)
});
return new[] {(category: "(No Category)", script: c)};
}).GroupBy(x => x.category).OrderBy(x=>x.Key)
.Select(x=> new ScriptConfigGroupWrapper
{
Name = x.Key,
Children = x.Select(p=> new TaggedScriptConfig(x.Key, p.script.Name, p.script)).OrderBy(x=>x.Name)
});
}

return scriptConfigGroupWrappers;
})
.ObserveOn(RxApp.MainThreadScheduler)
Expand Down
Loading