diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18c8666..58534b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+## [0.4.0] - 2026-08-20
+
+### Added
+
+- Resource table column filters, row sort, widths, and search are stored per cluster in `appsettings.json` (`Configuration:Layout:Tables`).
+
+### Fixed
+
+- Switching clusters no longer resets table column filters or sort order.
+
## [0.3.0] - 2026-08-20
### Changed
diff --git a/README.md b/README.md
index bf37962..b12351e 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Capabilities that are first-class in ClusterConsole, not afterthoughts:
- **Contexts** — catalog of kubeconfig contexts; radio selects kubectl current-context
- **Navigator** — Cluster, Nodes, Applications, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Dapr, Access Control, Custom Resources
-- **Resource tables** — list and refresh any catalogued type; per-column filters; namespace scope persisted per context
+- **Resource tables** — list and refresh any catalogued type; per-column filters and row sort persisted per cluster
- **Inspect and apply** — YAML view, apply, create, delete; force-delete (grace period 0 and strip finalizers), including namespaces whose objects are already gone
- **Workloads** — scale, restart, CronJob trigger; node cordon and drain
- **Pods** — follow logs, exec
@@ -68,7 +68,7 @@ Defaults live in `src/MaksIT.ClusterConsole.Shared/appsettings.json` (copied nex
| `OllamaEndpoint` | Chat API, default `http://127.0.0.1:11434` |
| `OllamaModel` | Chat model, default `qwen3:8b` |
| `PortForwards` | Enabled localhost forwards; restored when the cluster reconnects |
-| `Layout` | Window and pane sizes, last navigator item, column state |
+| `Layout` | Window and pane sizes, last navigator item, per-cluster table layout (`Tables`) |
Port-forwards are saved when you start them in the UI. Chat cannot apply, restart, or delete.
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 9f243f7..8f53ae0 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
latest
enable
enable
- 0.3.0
+ 0.4.0
MaksIT.ClusterConsole
MaksIT.ClusterConsole
diff --git a/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs b/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs
index e8f3df8..9598303 100644
--- a/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs
+++ b/src/MaksIT.ClusterConsole.Shared/CollectionSync.cs
@@ -5,7 +5,8 @@ public static class CollectionSync {
IList target,
IReadOnlyList source,
Func keySelector,
- Action? copy = null)
+ Action? copy = null,
+ bool matchSourceOrder = false)
where TKey : notnull {
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(source);
@@ -48,5 +49,22 @@ public static class CollectionSync {
if (index >= 0)
target[index] = item;
}
+
+ if (!matchSourceOrder)
+ return;
+
+ var byKey = new Dictionary();
+ foreach (var item in target)
+ byKey[keySelector(item)] = item;
+
+ for (var i = 0; i < nextItems.Count; i++) {
+ if (!byKey.TryGetValue(nextItems[i].Key, out var item))
+ continue;
+ var at = target.IndexOf(item);
+ if (at < 0 || at == i)
+ continue;
+ target.RemoveAt(at);
+ target.Insert(i, item);
+ }
}
}
diff --git a/src/MaksIT.ClusterConsole.Shared/Configuration.cs b/src/MaksIT.ClusterConsole.Shared/Configuration.cs
index fdee2e7..c9f7135 100644
--- a/src/MaksIT.ClusterConsole.Shared/Configuration.cs
+++ b/src/MaksIT.ClusterConsole.Shared/Configuration.cs
@@ -28,9 +28,7 @@ public sealed class Configuration {
NamespacesByContext ??= new Dictionary(StringComparer.Ordinal);
NavigatorExpanded ??= new Dictionary(StringComparer.Ordinal);
Layout ??= new LayoutSettings();
- Layout.ColumnWidths ??= new Dictionary>(StringComparer.Ordinal);
- Layout.ColumnFilters ??= new Dictionary>(StringComparer.Ordinal);
- Layout.SearchByResource ??= new Dictionary(StringComparer.Ordinal);
+ Layout.Normalize();
PortForwards ??= [];
if (string.IsNullOrWhiteSpace(OllamaEndpoint))
OllamaEndpoint = ClusterChatService.DefaultEndpoint;
diff --git a/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs b/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs
index 9814b83..fa58632 100644
--- a/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs
+++ b/src/MaksIT.ClusterConsole.Shared/LayoutSettings.cs
@@ -1,3 +1,6 @@
+using System.Text.Json.Serialization;
+
+
namespace MaksIT.ClusterConsole.Shared;
public sealed class SavedColumnFilter {
@@ -6,6 +9,22 @@ public sealed class SavedColumnFilter {
public List Excluded { get; set; } = [];
}
+public sealed class SavedColumnSort {
+ public string Header { get; set; } = "";
+
+ public string Direction { get; set; } = "Ascending";
+}
+
+public sealed class SavedTableLayout {
+ public Dictionary Widths { get; set; } = new(StringComparer.Ordinal);
+
+ public Dictionary Filters { get; set; } = new(StringComparer.Ordinal);
+
+ public SavedColumnSort? Sort { get; set; }
+
+ public string Search { get; set; } = "";
+}
+
public sealed class LayoutSettings {
public const string OverviewWarningsTable = "overview-warnings";
public const string OverviewErrorsTable = "overview-errors";
@@ -30,48 +49,166 @@ public sealed class LayoutSettings {
public string? SelectedNavId { get; set; }
- public Dictionary> ColumnWidths { get; set; } = new(StringComparer.Ordinal);
+ public Dictionary Tables { get; set; } = new(StringComparer.Ordinal);
- public Dictionary> ColumnFilters { get; set; } = new(StringComparer.Ordinal);
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public Dictionary>? ColumnWidths { get; set; }
- public Dictionary SearchByResource { get; set; } = new(StringComparer.Ordinal);
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public Dictionary>? ColumnFilters { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public Dictionary? ColumnSorts { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public Dictionary? SearchByResource { get; set; }
public static string ResourceTable(string? resourceId) =>
string.IsNullOrWhiteSpace(resourceId) ? "resources" : $"resources/{resourceId}";
- public IReadOnlyDictionary? ColumnsFor(string tableKey) {
- if (ColumnWidths is not null && ColumnWidths.TryGetValue(tableKey, out var widths) && widths.Count > 0)
- return widths;
- return null;
+ public static string ContextTable(string? context, string tableKey) =>
+ string.IsNullOrWhiteSpace(context) ? tableKey : $"{context}/{tableKey}";
+
+ public void Normalize() {
+ Tables ??= new Dictionary(StringComparer.Ordinal);
+ foreach (var table in Tables.Values)
+ NormalizeTable(table);
+
+ MergeLegacy();
+ ColumnWidths = null;
+ ColumnFilters = null;
+ ColumnSorts = null;
+ SearchByResource = null;
}
- public void SetColumns(string tableKey, Dictionary widths) {
- ColumnWidths ??= new Dictionary>(StringComparer.Ordinal);
- ColumnWidths[tableKey] = widths;
+ public IReadOnlyDictionary? ColumnsFor(string tableKey) =>
+ ColumnsFor(null, tableKey);
+
+ public IReadOnlyDictionary? ColumnsFor(string? context, string tableKey) {
+ var widths = Find(context, tableKey)?.Widths;
+ return widths is { Count: > 0 } ? widths : null;
}
- public SavedColumnFilter? FilterFor(string tableKey, string header) {
- if (ColumnFilters is not null
- && ColumnFilters.TryGetValue(tableKey, out var filters)
- && filters.TryGetValue(header, out var filter))
+ public void SetColumns(string tableKey, Dictionary widths) =>
+ SetColumns(null, tableKey, widths);
+
+ public void SetColumns(string? context, string tableKey, Dictionary widths) =>
+ GetOrAdd(context, tableKey).Widths = new Dictionary(widths, StringComparer.Ordinal);
+
+ public SavedColumnFilter? FilterFor(string tableKey, string header) =>
+ FilterFor(null, tableKey, header);
+
+ public SavedColumnFilter? FilterFor(string? context, string tableKey, string header) {
+ var filters = Find(context, tableKey)?.Filters;
+ if (filters is not null && filters.TryGetValue(header, out var filter))
return filter;
return null;
}
- public void SetFilters(string tableKey, Dictionary filters) {
- ColumnFilters ??= new Dictionary>(StringComparer.Ordinal);
- ColumnFilters[tableKey] = filters;
+ public void SetFilters(string tableKey, Dictionary filters) =>
+ SetFilters(null, tableKey, filters);
+
+ public void SetFilters(string? context, string tableKey, Dictionary filters) =>
+ GetOrAdd(context, tableKey).Filters = new Dictionary(filters, StringComparer.Ordinal);
+
+ public SavedColumnSort? SortFor(string tableKey) =>
+ SortFor(null, tableKey);
+
+ public SavedColumnSort? SortFor(string? context, string tableKey) {
+ var sort = Find(context, tableKey)?.Sort;
+ return sort is null || string.IsNullOrWhiteSpace(sort.Header) ? null : sort;
}
- public string SearchFor(string? resourceId) {
- var key = ResourceTable(resourceId);
- if (SearchByResource is not null && SearchByResource.TryGetValue(key, out var text))
- return text ?? "";
- return "";
+ public void SetSort(string tableKey, SavedColumnSort? sort) =>
+ SetSort(null, tableKey, sort);
+
+ public void SetSort(string? context, string tableKey, SavedColumnSort? sort) {
+ var table = GetOrAdd(context, tableKey);
+ table.Sort = sort is null || string.IsNullOrWhiteSpace(sort.Header) ? null : sort;
}
- public void SetSearch(string? resourceId, string text) {
- SearchByResource ??= new Dictionary(StringComparer.Ordinal);
- SearchByResource[ResourceTable(resourceId)] = text ?? "";
+ public string SearchFor(string? resourceId) =>
+ SearchFor(null, resourceId);
+
+ public string SearchFor(string? context, string? resourceId) =>
+ Find(context, ResourceTable(resourceId))?.Search ?? "";
+
+ public void SetSearch(string? resourceId, string text) =>
+ SetSearch(null, resourceId, text);
+
+ public void SetSearch(string? context, string? resourceId, string text) =>
+ GetOrAdd(context, ResourceTable(resourceId)).Search = text ?? "";
+
+ private SavedTableLayout GetOrAdd(string? context, string tableKey) {
+ Tables ??= new Dictionary(StringComparer.Ordinal);
+ var key = ContextTable(context, tableKey);
+ if (!Tables.TryGetValue(key, out var table)) {
+ table = new SavedTableLayout();
+ Tables[key] = table;
+ }
+
+ NormalizeTable(table);
+ return table;
+ }
+
+ private SavedTableLayout? Find(string? context, string tableKey) {
+ if (Tables is null)
+ return null;
+
+ if (!string.IsNullOrWhiteSpace(context)
+ && Tables.TryGetValue(ContextTable(context, tableKey), out var keyed))
+ return keyed;
+
+ if (Tables.TryGetValue(tableKey, out var shared))
+ return shared;
+
+ return null;
+ }
+
+ private void MergeLegacy() {
+ if (ColumnWidths is not null) {
+ foreach (var (key, widths) in ColumnWidths) {
+ if (widths is not { Count: > 0 })
+ continue;
+ var table = GetOrAdd(null, key);
+ if (table.Widths.Count == 0)
+ table.Widths = new Dictionary(widths, StringComparer.Ordinal);
+ }
+ }
+
+ if (ColumnFilters is not null) {
+ foreach (var (key, filters) in ColumnFilters) {
+ if (filters is not { Count: > 0 })
+ continue;
+ var table = GetOrAdd(null, key);
+ if (table.Filters.Count == 0)
+ table.Filters = new Dictionary(filters, StringComparer.Ordinal);
+ }
+ }
+
+ if (ColumnSorts is not null) {
+ foreach (var (key, sort) in ColumnSorts) {
+ if (sort is null || string.IsNullOrWhiteSpace(sort.Header))
+ continue;
+ var table = GetOrAdd(null, key);
+ table.Sort ??= sort;
+ }
+ }
+
+ if (SearchByResource is not null) {
+ foreach (var (key, text) in SearchByResource) {
+ if (string.IsNullOrEmpty(text))
+ continue;
+ var table = GetOrAdd(null, key);
+ if (string.IsNullOrEmpty(table.Search))
+ table.Search = text;
+ }
+ }
+ }
+
+ private static void NormalizeTable(SavedTableLayout table) {
+ table.Widths ??= new Dictionary(StringComparer.Ordinal);
+ table.Filters ??= new Dictionary(StringComparer.Ordinal);
+ table.Search ??= "";
}
}
diff --git a/src/MaksIT.ClusterConsole.Shared/appsettings.json b/src/MaksIT.ClusterConsole.Shared/appsettings.json
index 018d752..70ee22d 100644
--- a/src/MaksIT.ClusterConsole.Shared/appsettings.json
+++ b/src/MaksIT.ClusterConsole.Shared/appsettings.json
@@ -19,9 +19,7 @@
"NavigatorWidth": 228,
"DetailsWidth": 380,
"SelectedNavId": "pods",
- "ColumnWidths": {},
- "ColumnFilters": {},
- "SearchByResource": {}
+ "Tables": {}
},
"PortForwards": []
}
diff --git a/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs b/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs
index 65a4248..5eb9d49 100644
--- a/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/CollectionSyncTests.cs
@@ -32,6 +32,24 @@ public class CollectionSyncTests {
Assert.Equal("c", target[1].Key);
}
+ [Fact]
+ public void MergeByKey_can_reorder_to_match_source() {
+ var first = Item("a", 1);
+ var second = Item("b", 1);
+ var third = Item("c", 1);
+ var target = new ObservableCollection { first, second, third };
+
+ CollectionSync.MergeByKey(
+ target,
+ [third, first, second],
+ item => item.Key,
+ matchSourceOrder: true);
+
+ Assert.Equal(["c", "a", "b"], target.Select(item => item.Key).ToList());
+ Assert.Same(third, target[0]);
+ Assert.Same(first, target[1]);
+ }
+
[Fact]
public void MergeByKey_replaces_records_without_reset() {
var target = new ObservableCollection {
diff --git a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs
index 641b4a2..d157023 100644
--- a/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/ConfigurationFileServiceTests.cs
@@ -289,4 +289,114 @@ public class ConfigurationFileServiceTests {
File.Delete(path);
}
}
+
+ [Fact]
+ public void Table_filters_and_sort_are_stored_per_context() {
+ var layout = new LayoutSettings();
+ layout.SetFilters("resources/pods", new Dictionary {
+ ["Status"] = new() { Text = "legacy" }
+ });
+ layout.SetFilters("prod", "resources/pods", new Dictionary {
+ ["Status"] = new() { Text = "Crash" }
+ });
+ layout.SetSort("prod", "resources/pods", new SavedColumnSort {
+ Header = "Age",
+ Direction = "Descending"
+ });
+ layout.SetColumns("prod", "resources/pods", new Dictionary { ["Name"] = 240 });
+
+ Assert.Equal("Crash", layout.FilterFor("prod", "resources/pods", "Status")?.Text);
+ Assert.Equal("legacy", layout.FilterFor("dev", "resources/pods", "Status")?.Text);
+ Assert.Equal("Age", layout.SortFor("prod", "resources/pods")?.Header);
+ Assert.Equal("Descending", layout.SortFor("prod", "resources/pods")?.Direction);
+ Assert.Null(layout.SortFor("dev", "resources/pods"));
+ Assert.Equal(240, layout.ColumnsFor("prod", "resources/pods")!["Name"]);
+ Assert.Null(layout.ColumnsFor("dev", "resources/pods"));
+ Assert.Equal("prod/resources/pods", LayoutSettings.ContextTable("prod", "resources/pods"));
+ }
+
+ [Fact]
+ public void Save_round_trips_per_context_sort_and_filters() {
+ var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json");
+ File.WriteAllText(path, """
+ {
+ "Logging": { "LogLevel": { "Default": "Information" } },
+ "Configuration": { "SelectedNamespace": "all" }
+ }
+ """);
+
+ try {
+ var service = new ConfigurationFileService(path);
+ var cfg = service.Current;
+ cfg.Layout.SetFilters("homelab", "resources/pods", new Dictionary {
+ ["Namespace"] = new() { Excluded = ["default"] }
+ });
+ cfg.Layout.SetSort("homelab", "resources/pods", new SavedColumnSort {
+ Header = "Age",
+ Direction = "Descending"
+ });
+ service.Save(cfg);
+
+ var reloaded = new ConfigurationFileService(path);
+ Assert.Equal(["default"], reloaded.Current.Layout.FilterFor("homelab", "resources/pods", "Namespace")?.Excluded);
+ Assert.Null(reloaded.Current.Layout.FilterFor("dev", "resources/pods", "Namespace"));
+ var sort = reloaded.Current.Layout.SortFor("homelab", "resources/pods");
+ Assert.NotNull(sort);
+ Assert.Equal("Age", sort.Header);
+ Assert.Equal("Descending", sort.Direction);
+ Assert.Contains("\"Tables\"", File.ReadAllText(path), StringComparison.Ordinal);
+ Assert.DoesNotContain("\"ColumnSorts\"", File.ReadAllText(path), StringComparison.Ordinal);
+ }
+ finally {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Migrates_legacy_column_maps_into_tables() {
+ var path = Path.Combine(Path.GetTempPath(), $"maksit-cluster-console-{Guid.NewGuid():N}.json");
+ File.WriteAllText(path, """
+ {
+ "Logging": { "LogLevel": { "Default": "Information" } },
+ "Configuration": {
+ "Layout": {
+ "ColumnWidths": { "resources/pods": { "Name": 220 } },
+ "ColumnFilters": {
+ "homelab/resources/pods": {
+ "Status": { "Text": "Crash", "Excluded": [] }
+ }
+ },
+ "ColumnSorts": {
+ "homelab/resources/pods": { "Header": "Age", "Direction": "Descending" }
+ },
+ "SearchByResource": { "resources/pods": "coredns" }
+ }
+ }
+ }
+ """);
+
+ try {
+ var service = new ConfigurationFileService(path);
+ var layout = service.Current.Layout;
+ Assert.Equal(220, layout.ColumnsFor("resources/pods")!["Name"]);
+ Assert.Equal("Crash", layout.FilterFor("homelab", "resources/pods", "Status")?.Text);
+ Assert.Equal("Age", layout.SortFor("homelab", "resources/pods")?.Header);
+ Assert.Equal("coredns", layout.SearchFor("pods"));
+
+ service.Save(service.Current);
+ var json = File.ReadAllText(path);
+ Assert.Contains("\"Tables\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"ColumnWidths\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"ColumnFilters\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"ColumnSorts\"", json, StringComparison.Ordinal);
+ Assert.DoesNotContain("\"SearchByResource\"", json, StringComparison.Ordinal);
+
+ var reloaded = new ConfigurationFileService(path);
+ Assert.Equal("Crash", reloaded.Current.Layout.FilterFor("homelab", "resources/pods", "Status")?.Text);
+ Assert.Equal("coredns", reloaded.Current.Layout.SearchFor("pods"));
+ }
+ finally {
+ File.Delete(path);
+ }
+ }
}
diff --git a/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs b/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs
index ce49a6d..ede6c91 100644
--- a/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs
+++ b/src/MaksIT.ClusterConsole.UI/LayoutPersistence.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
@@ -9,16 +10,23 @@ namespace MaksIT.ClusterConsole.UI;
internal sealed class LayoutPersistence {
private readonly Window _window;
private readonly ConfigurationFileService _configuration;
+ private readonly Func _contextName;
private readonly Func _resourceTableId;
private readonly DispatcherTimer _saveTimer;
private readonly Dictionary> _tables = [];
- private bool _applying;
+ private int _applyDepth;
+ private int _restoreSortPending;
private bool _attached;
private string? _lastSaved;
- public LayoutPersistence(Window window, ConfigurationFileService configuration, Func resourceTableId) {
+ public LayoutPersistence(
+ Window window,
+ ConfigurationFileService configuration,
+ Func contextName,
+ Func resourceTableId) {
_window = window;
_configuration = configuration;
+ _contextName = contextName;
_resourceTableId = resourceTableId;
_saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_saveTimer.Tick += (_, _) => {
@@ -53,29 +61,40 @@ internal sealed class LayoutPersistence {
};
}
- public void ApplyResourceColumns(DataGrid grid) =>
- ApplyColumnWidths(grid, LayoutSettings.ResourceTable(_resourceTableId()));
+ public IDisposable SuspendSave() {
+ _applyDepth++;
+ return new ApplyScope(this);
+ }
+
+ public void RestoreTables() {
+ using (SuspendSave()) {
+ foreach (var (grid, key) in _tables)
+ ApplyColumnState(grid, key());
+ }
+ }
public void ScheduleSave() {
- if (_applying)
+ if (_applyDepth > 0 || _restoreSortPending > 0)
return;
_saveTimer.Stop();
_saveTimer.Start();
}
public void SaveNow() {
- if (_applying)
+ if (_applyDepth > 0)
return;
var cfg = _configuration.Current;
cfg.EnsureDefaults();
var layout = cfg.Layout;
+ var context = _contextName();
CaptureWindow(layout);
CapturePanes(layout);
foreach (var (grid, key) in _tables) {
+ var tableKey = key();
var widths = ReadColumnWidths(grid);
if (widths.Count > 0)
- layout.SetColumns(key(), widths);
+ layout.SetColumns(context, tableKey, widths);
}
var snapshot = JsonSnapshot(layout);
@@ -87,16 +106,12 @@ internal sealed class LayoutPersistence {
}
private void Apply() {
- _applying = true;
- try {
+ using (SuspendSave()) {
var layout = _configuration.Current.Layout;
ApplyWindow(layout);
ApplyPanes(layout);
foreach (var (grid, key) in _tables)
- ApplyColumnWidths(grid, key());
- }
- finally {
- _applying = false;
+ ApplyColumnState(grid, key());
}
}
@@ -111,6 +126,7 @@ internal sealed class LayoutPersistence {
return;
_tables[grid] = key;
grid.LayoutUpdated += (_, _) => ScheduleSave();
+ grid.Sorting += (_, e) => PersistSort(grid, e.Column);
}
private void ApplyWindow(LayoutSettings layout) {
@@ -179,8 +195,13 @@ internal sealed class LayoutPersistence {
grid.ColumnDefinitions[index].Width = new GridLength(width);
}
+ private void ApplyColumnState(DataGrid grid, string tableKey) {
+ ApplyColumnWidths(grid, tableKey);
+ ApplyColumnSort(grid, tableKey);
+ }
+
private void ApplyColumnWidths(DataGrid grid, string tableKey) {
- var saved = _configuration.Current.Layout.ColumnsFor(tableKey);
+ var saved = _configuration.Current.Layout.ColumnsFor(_contextName(), tableKey);
if (saved is null)
return;
@@ -192,6 +213,61 @@ internal sealed class LayoutPersistence {
}
}
+ private void ApplyColumnSort(DataGrid grid, string tableKey) {
+ var saved = _configuration.Current.Layout.SortFor(_contextName(), tableKey);
+ if (saved is null)
+ return;
+ if (!Enum.TryParse(saved.Direction, true, out var direction))
+ direction = ListSortDirection.Ascending;
+
+ DataGridColumn? column = null;
+ foreach (var candidate in grid.Columns) {
+ if (!string.Equals(ColumnKey(candidate), saved.Header, StringComparison.Ordinal))
+ continue;
+ column = candidate;
+ break;
+ }
+
+ if (column is null)
+ return;
+
+ _restoreSortPending++;
+ Dispatcher.UIThread.Post(() => {
+ column.Sort(direction);
+ Dispatcher.UIThread.Post(() => {
+ if (_restoreSortPending > 0)
+ _restoreSortPending--;
+ }, DispatcherPriority.Background);
+ }, DispatcherPriority.Loaded);
+ }
+
+ private void PersistSort(DataGrid grid, DataGridColumn column) {
+ if (_applyDepth > 0 || _restoreSortPending > 0)
+ return;
+ if (!_tables.TryGetValue(grid, out var key))
+ return;
+ var header = ColumnKey(column);
+ if (header is null)
+ return;
+
+ var context = _contextName();
+ var tableKey = key();
+ var previous = _configuration.Current.Layout.SortFor(context, tableKey);
+ var direction = ListSortDirection.Ascending;
+ if (previous is not null
+ && string.Equals(previous.Header, header, StringComparison.Ordinal)
+ && string.Equals(previous.Direction, nameof(ListSortDirection.Ascending), StringComparison.OrdinalIgnoreCase))
+ direction = ListSortDirection.Descending;
+
+ var cfg = _configuration.Current;
+ cfg.EnsureDefaults();
+ cfg.Layout.SetSort(context, tableKey, new SavedColumnSort {
+ Header = header,
+ Direction = direction.ToString()
+ });
+ ScheduleSave();
+ }
+
private static Dictionary ReadColumnWidths(DataGrid grid) {
var widths = new Dictionary(StringComparer.Ordinal);
foreach (var column in grid.Columns) {
@@ -220,4 +296,13 @@ internal sealed class LayoutPersistence {
private static string JsonSnapshot(LayoutSettings layout) =>
System.Text.Json.JsonSerializer.Serialize(layout);
+
+ private void ReleaseApply() {
+ if (_applyDepth > 0)
+ _applyDepth--;
+ }
+
+ private sealed class ApplyScope(LayoutPersistence owner) : IDisposable {
+ public void Dispose() => owner.ReleaseApply();
+ }
}
diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
index 7d96262..60d349a 100644
--- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
+++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml.cs
@@ -25,7 +25,11 @@ public partial class MainWindow : Window {
public MainWindow(MainViewModel viewModel, ConfigurationFileService configuration) : this() {
DataContext = viewModel;
- _layout = new LayoutPersistence(this, configuration, () => viewModel.SelectedDescriptor?.Id);
+ _layout = new LayoutPersistence(
+ this,
+ configuration,
+ () => viewModel.ActivePage?.Name,
+ () => viewModel.SelectedDescriptor?.Id);
Opened += (_, _) => {
_layout.Attach();
RebuildColumns(viewModel);
@@ -70,16 +74,19 @@ public partial class MainWindow : Window {
if (grid is null)
return;
- grid.Columns.Clear();
- var descriptor = viewModel.SelectedDescriptor;
- var headers = descriptor?.Columns.Select(c => c.Header).ToList()
- ?? ["Name", "Namespace", "Age"];
+ using (_layout?.SuspendSave()) {
+ viewModel.ActivePage?.ReloadColumnFilters();
+ grid.Columns.Clear();
+ var descriptor = viewModel.SelectedDescriptor;
+ var headers = descriptor?.Columns.Select(c => c.Header).ToList()
+ ?? ["Name", "Namespace", "Age"];
- foreach (var header in headers) {
- grid.Columns.Add(CreateColumn(header, viewModel.ActivePage));
+ foreach (var header in headers) {
+ grid.Columns.Add(CreateColumn(header, viewModel.ActivePage));
+ }
+
+ _layout?.RestoreTables();
}
-
- _layout?.ApplyResourceColumns(grid);
}
private DataGridColumn CreateColumn(string header, ClusterPageViewModel? page) {
diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
index 578f719..fc1de17 100644
--- a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
+++ b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
@@ -376,7 +376,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
var nav = items.FirstOrDefault(i => i.Id == savedId)
?? items.FirstOrDefault(i => i.Id == "pods")
?? items.FirstOrDefault();
- Filter = _configuration.Current.Layout.SearchFor(nav?.Id);
+ Filter = _configuration.Current.Layout.SearchFor(Name, nav?.Id);
SelectedNavItem = nav;
}
finally {
@@ -408,15 +408,23 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public ColumnFilterViewModel FilterFor(string header) {
if (!_columnFilters.TryGetValue(header, out var filter)) {
filter = new ColumnFilterViewModel(header, () => OnColumnFilterChanged(header));
- var saved = _configuration.Current.Layout.FilterFor(TableKey(), header);
- if (saved is not null)
- filter.Restore(saved);
_columnFilters[header] = filter;
+ RestoreColumnFilter(filter);
}
return filter;
}
+ public void ReloadColumnFilters() {
+ foreach (var filter in _columnFilters.Values)
+ RestoreColumnFilter(filter);
+ }
+
+ private void RestoreColumnFilter(ColumnFilterViewModel filter) {
+ var saved = _configuration.Current.Layout.FilterFor(Name, TableKey(), filter.Header);
+ filter.Restore(saved ?? new SavedColumnFilter());
+ }
+
private void OnColumnFilterChanged(string header) {
ApplyColumnFilters();
if (header == "Namespace")
@@ -435,7 +443,14 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
desired.Add(row);
}
- CollectionSync.MergeByKey(Rows, desired, row => row.Uid, static (current, incoming) => current.CopyFrom(incoming));
+ SortRows(desired);
+
+ CollectionSync.MergeByKey(
+ Rows,
+ desired,
+ row => row.Uid,
+ static (current, incoming) => current.CopyFrom(incoming),
+ matchSourceOrder: true);
if (SelectedRow is null || !Rows.Contains(SelectedRow))
SelectedRow = keepUid is null ? null : Rows.FirstOrDefault(row => row.Uid == keepUid);
@@ -466,7 +481,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
var cfg = _configuration.Current;
cfg.Layout.SelectedNavId = value?.Id;
_configuration.Save(cfg);
- var savedSearch = cfg.Layout.SearchFor(value?.Id);
+ var savedSearch = cfg.Layout.SearchFor(Name, value?.Id);
if (Filter != savedSearch) {
_syncingLayout = true;
Filter = savedSearch;
@@ -486,7 +501,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
partial void OnFilterChanged(string value) {
if (!_syncingLayout) {
var cfg = _configuration.Current;
- cfg.Layout.SetSearch(SelectedNavItem?.Id, value);
+ cfg.Layout.SetSearch(Name, SelectedNavItem?.Id, value);
_configuration.Save(cfg);
}
@@ -1030,10 +1045,23 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
map[header] = filter.Snapshot();
var cfg = _configuration.Current;
- cfg.Layout.SetFilters(TableKey(), map);
+ cfg.Layout.SetFilters(Name, TableKey(), map);
_configuration.Save(cfg);
}
+ private void SortRows(List rows) {
+ var sort = _configuration.Current.Layout.SortFor(Name, TableKey());
+ if (sort is null)
+ return;
+
+ var comparer = new ResourceRowComparer(sort.Header);
+ var descending = string.Equals(sort.Direction, "Descending", StringComparison.OrdinalIgnoreCase);
+ rows.Sort((left, right) => {
+ var cmp = comparer.Compare(left, right);
+ return descending ? -cmp : cmp;
+ });
+ }
+
private static string NormalizeNamespace(string? value) =>
string.IsNullOrWhiteSpace(value) ? Configuration.AllNamespaces : value;