diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d813f8..78e2531 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+## [0.4.2] - 2026-08-21
+
+### Fixed
+
+- Switching kubectl current-context only patches `current-context` and never writes sibling `config.bak.*` files (Lens treats those as extra kubeconfigs). Blank cluster/user names reuse existing context entries (or match the context name) instead of creating `{name}-cluster` / `{name}-user` orphans. Structural kubeconfig saves also skip `.bak` siblings for the same reason.
+- ConfigMap and Secret Data editors accept multiline values and multiline paste via a dedicated value box under the key list.
+
## [0.4.1] - 2026-08-21
### Fixed
diff --git a/README.md b/README.md
index 4a66d78..812c781 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# MaksIT.ClusterConsole
-
-
-
+
+
+



diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index fe2f03b..97c61f0 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
latest
enable
enable
- 0.4.1
+ 0.4.2
MaksIT.ClusterConsole
MaksIT.ClusterConsole
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs b/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs
index e84f28d..07df425 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs
@@ -1,4 +1,5 @@
using System.Text;
+using System.Text.RegularExpressions;
using k8s;
using k8s.KubeConfigModels;
using YamlDotNet.Serialization;
@@ -8,6 +9,10 @@ using YamlDotNet.Serialization.NamingConventions;
namespace MaksIT.ClusterConsole.Client;
internal static class KubeConfigEditor {
+ private static readonly Regex CurrentContextLine = new(
+ @"^[ \t]*current-context:[ \t]*.*$",
+ RegexOptions.Multiline | RegexOptions.CultureInvariant);
+
public static K8SConfiguration LoadOrCreate(string path) {
if (!File.Exists(path)) {
return new K8SConfiguration {
@@ -27,13 +32,6 @@ internal static class KubeConfigEditor {
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
- if (File.Exists(path)) {
- var backup = path + ".bak." + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
- if (File.Exists(backup))
- backup += "-" + Guid.NewGuid().ToString("N")[..6];
- File.Copy(path, backup, overwrite: false);
- }
-
if (string.IsNullOrWhiteSpace(config.ApiVersion))
config.ApiVersion = "v1";
if (string.IsNullOrWhiteSpace(config.Kind))
@@ -47,21 +45,72 @@ internal static class KubeConfigEditor {
| DefaultValuesHandling.OmitDefaults
| DefaultValuesHandling.OmitEmptyCollections)
.Build();
- File.WriteAllText(path, serializer.Serialize(config));
+
+ // Write in place — never create config.bak.* beside kubeconfig. Tools like Lens
+ // treat those sibling files as extra kubeconfigs / duplicate connections.
+ var temp = path + ".tmp." + Guid.NewGuid().ToString("N")[..8];
+ File.WriteAllText(temp, serializer.Serialize(config));
+ File.Move(temp, path, overwrite: true);
}
- public static string EffectiveClusterName(KubeConnectionRequest request) =>
- string.IsNullOrWhiteSpace(request.ClusterName)
- ? request.ContextName + "-cluster"
- : request.ClusterName.Trim();
+ ///
+ /// Updates only the current-context line without rewriting clusters/users or creating a backup.
+ ///
+ public static bool TrySetCurrentContext(string path, string contextName) {
+ if (!File.Exists(path) || string.IsNullOrWhiteSpace(contextName))
+ return false;
- public static string EffectiveUserName(KubeConnectionRequest request) =>
- string.IsNullOrWhiteSpace(request.UserName)
- ? request.ContextName + "-user"
- : request.UserName.Trim();
+ var text = File.ReadAllText(path);
+ var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
+ var line = "current-context: " + contextName.Trim();
+ string updated;
+ var match = CurrentContextLine.Match(text);
+ if (match.Success) {
+ var replacement = match.Value.EndsWith('\r') ? line + "\r" : line;
+ updated = CurrentContextLine.Replace(text, replacement, 1);
+ }
+ else {
+ var insertAt = text.IndexOf("contexts:", StringComparison.Ordinal);
+ if (insertAt < 0)
+ insertAt = text.IndexOf("clusters:", StringComparison.Ordinal);
+ if (insertAt < 0)
+ return false;
+ updated = text.Insert(insertAt, line + newline);
+ }
+
+ if (string.Equals(updated, text, StringComparison.Ordinal))
+ return true;
+
+ var temp = path + ".tmp." + Guid.NewGuid().ToString("N")[..8];
+ File.WriteAllText(temp, updated);
+ File.Move(temp, path, overwrite: true);
+ return true;
+ }
+
+ public static string EffectiveClusterName(KubeConnectionRequest request, K8SConfiguration? config = null) {
+ if (!string.IsNullOrWhiteSpace(request.ClusterName))
+ return request.ClusterName.Trim();
+
+ var existing = config?.Contexts?.FirstOrDefault(c => c.Name == request.ContextName);
+ if (existing?.ContextDetails?.Cluster is { Length: > 0 } cluster)
+ return cluster;
+
+ return request.ContextName.Trim();
+ }
+
+ public static string EffectiveUserName(KubeConnectionRequest request, K8SConfiguration? config = null) {
+ if (!string.IsNullOrWhiteSpace(request.UserName))
+ return request.UserName.Trim();
+
+ var existing = config?.Contexts?.FirstOrDefault(c => c.Name == request.ContextName);
+ if (existing?.ContextDetails?.User is { Length: > 0 } user)
+ return user;
+
+ return request.ContextName.Trim();
+ }
public static Cluster UpsertCluster(K8SConfiguration config, KubeConnectionRequest request) {
- var name = EffectiveClusterName(request);
+ var name = EffectiveClusterName(request, config);
var clusters = config.Clusters?.ToList() ?? [];
var cluster = clusters.FirstOrDefault(c => c.Name == name);
if (cluster is null) {
@@ -78,7 +127,7 @@ internal static class KubeConfigEditor {
}
public static User UpsertUser(K8SConfiguration config, KubeConnectionRequest request) {
- var name = EffectiveUserName(request);
+ var name = EffectiveUserName(request, config);
var users = config.Users?.ToList() ?? [];
var user = users.FirstOrDefault(u => u.Name == name);
if (user is null) {
@@ -92,6 +141,23 @@ internal static class KubeConfigEditor {
return user;
}
+ public static void PruneUnreferenced(K8SConfiguration config) {
+ var contexts = config.Contexts ?? [];
+ var usedClusters = contexts
+ .Select(c => c.ContextDetails?.Cluster)
+ .Where(n => !string.IsNullOrEmpty(n))
+ .ToHashSet(StringComparer.Ordinal);
+ var usedUsers = contexts
+ .Select(c => c.ContextDetails?.User)
+ .Where(n => !string.IsNullOrEmpty(n))
+ .ToHashSet(StringComparer.Ordinal);
+
+ if (config.Clusters is not null)
+ config.Clusters = config.Clusters.Where(c => usedClusters.Contains(c.Name)).ToList();
+ if (config.Users is not null)
+ config.Users = config.Users.Where(u => usedUsers.Contains(u.Name)).ToList();
+ }
+
public static Context UpsertContext(
K8SConfiguration config,
KubeConnectionRequest request,
diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs b/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs
index 3f3d43a..918cf22 100644
--- a/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs
+++ b/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs
@@ -79,8 +79,14 @@ public sealed class KubeConfigService : IKubeConfigService {
if (config.Contexts is null || config.Contexts.All(c => c.Name != contextName))
return Result.NotFound("Context not found: " + contextName);
- config.CurrentContext = contextName;
- KubeConfigEditor.Save(path, config);
+ if (string.Equals(config.CurrentContext, contextName, StringComparison.Ordinal))
+ return Result.Ok("Already using context: " + contextName);
+
+ // Only patch current-context — never rewrite or create sibling .bak files
+ // (Lens and similar tools treat config.bak.* as extra kubeconfigs).
+ if (!KubeConfigEditor.TrySetCurrentContext(path, contextName))
+ return Result.InternalServerError("Could not update current-context in kubeconfig.");
+
return Result.Ok("Switched to context: " + contextName);
}
catch (Exception ex) {
@@ -99,6 +105,7 @@ public sealed class KubeConfigService : IKubeConfigService {
var cluster = KubeConfigEditor.UpsertCluster(config, request);
var user = KubeConfigEditor.UpsertUser(config, request);
KubeConfigEditor.UpsertContext(config, request, cluster.Name, user.Name);
+ KubeConfigEditor.PruneUnreferenced(config);
KubeConfigEditor.Save(path, config);
return Result.Ok(
"Added/updated context: " + request.ContextName
diff --git a/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs
index e321e93..3622817 100644
--- a/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs
+++ b/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs
@@ -84,8 +84,8 @@ public class KubeConfigServiceTests {
Assert.True(listed.IsSuccess);
var item = Assert.Single(listed.Value!);
Assert.Equal("k3s", item.Name);
- Assert.Equal("k3s-cluster", item.Cluster);
- Assert.Equal("k3s-user", item.User);
+ Assert.Equal("k3s", item.Cluster);
+ Assert.Equal("k3s", item.User);
Assert.Equal("https://127.0.0.1:6443", item.Server);
Assert.True(item.SkipTlsVerify);
Assert.Equal("Auth: token present", item.AuthSummary);
@@ -168,6 +168,57 @@ public class KubeConfigServiceTests {
Assert.False(added.IsSuccess);
}
+ [Fact]
+ public void UpsertConnection_reuses_existing_cluster_and_user_names() {
+ var path = CopyFixture();
+ var service = new KubeConfigService();
+ var updated = service.UpsertConnection(new KubeConnectionRequest {
+ ContextName = "lab",
+ Server = "https://127.0.0.1:6443",
+ AuthKind = KubeAuthKind.Token,
+ Token = "rotated-token",
+ InsecureSkipTlsVerify = true,
+ UseAfterAdd = false
+ }, path);
+
+ Assert.True(updated.IsSuccess, string.Join("; ", updated.Messages));
+ var details = service.ListContextDetails(path).Value!.Single(d => d.Name == "lab");
+ Assert.Equal("lab", details.Cluster);
+ Assert.Equal("admin", details.User);
+
+ var config = k8s.KubernetesClientConfiguration.LoadKubeConfig(path);
+ Assert.Single(config.Clusters!);
+ Assert.Single(config.Users!);
+ }
+
+ [Fact]
+ public void UseContext_updates_current_context_without_backup() {
+ var path = CopyFixture();
+ var service = new KubeConfigService();
+
+ var switched = service.UseContext("other", path);
+ Assert.True(switched.IsSuccess, string.Join("; ", switched.Messages));
+ Assert.Equal("other", service.GetCurrentContext(path).Value);
+
+ Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, Path.GetFileName(path) + ".bak.*"));
+ }
+
+ [Fact]
+ public void UpsertConnection_does_not_create_sibling_bak_files() {
+ var path = CopyFixture();
+ var service = new KubeConfigService();
+ var added = service.UpsertConnection(new KubeConnectionRequest {
+ ContextName = "edge",
+ Server = "https://10.0.0.2:6443",
+ AuthKind = KubeAuthKind.Token,
+ Token = "edge-token",
+ UseAfterAdd = false
+ }, path);
+
+ Assert.True(added.IsSuccess, string.Join("; ", added.Messages));
+ Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, Path.GetFileName(path) + ".bak.*"));
+ }
+
private static string CopyFixture() {
var source = Path.Combine(AppContext.BaseDirectory, "Fixtures", "kubeconfig.yaml");
var path = Path.Combine(Path.GetTempPath(), "maksit-cluster-console-" + Guid.NewGuid().ToString("N") + ".yaml");
diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml
index 8e7d5cc..650e681 100644
--- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml
+++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml
@@ -622,15 +622,28 @@
+
+
+
+
-
+
diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
index 62faa47..d10f44a 100644
--- a/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
+++ b/src/MaksIT.ClusterConsole.UI/ViewModels/ClusterPageViewModel.cs
@@ -69,6 +69,11 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public ObservableCollection DataEntries { get; } = [];
+ [ObservableProperty]
+ private DataEntryViewModel? selectedDataEntry;
+
+ public bool HasSelectedDataEntry => SelectedDataEntry is not null;
+
public ObservableCollection WorkloadCounts { get; } = [];
public ObservableCollection NodeUsages { get; } = [];
@@ -538,6 +543,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
OnPropertyChanged(nameof(CanApplyLimits));
}
+ partial void OnSelectedDataEntryChanged(DataEntryViewModel? value) =>
+ OnPropertyChanged(nameof(HasSelectedDataEntry));
+
partial void OnSelectedRelatedPodChanged(ResourceRow? value) {
if (_updatingPodContext)
return;
@@ -680,7 +688,10 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[RelayCommand]
private void AddDataEntry() {
- DataEntries.Add(new DataEntryViewModel { Key = "new-key", Value = "" });
+ var entry = new DataEntryViewModel { Key = "new-key", Value = "" };
+ entry.PropertyChanged += OnDataEntryPropertyChanged;
+ DataEntries.Add(entry);
+ SelectedDataEntry = entry;
IsDirty = true;
}
@@ -1375,7 +1386,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
EventsText = "";
LogsText = "";
TerminalText = "";
- DataEntries.Clear();
+ ReplaceDataEntries(null);
ReplaceRelatedPods([]);
ApplyContainers(null);
NotifyDetailsUi();
@@ -1431,17 +1442,37 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
private void ReplaceDataEntries(JsonObject? document) {
+ foreach (var entry in DataEntries)
+ entry.PropertyChanged -= OnDataEntryPropertyChanged;
+
+ var keepKey = SelectedDataEntry?.Key;
DataEntries.Clear();
- if (document is null)
+ if (document is null) {
+ SelectedDataEntry = null;
+ OnPropertyChanged(nameof(HasSelectedDataEntry));
return;
+ }
foreach (var entry in ResourceDocument.ReadDataEntries(document)) {
- DataEntries.Add(new DataEntryViewModel {
+ var vm = new DataEntryViewModel {
Key = entry.Key,
Value = entry.Value,
IsBinary = entry.IsBinary
- });
+ };
+ vm.PropertyChanged += OnDataEntryPropertyChanged;
+ DataEntries.Add(vm);
}
+
+ SelectedDataEntry = DataEntries.FirstOrDefault(e => e.Key == keepKey)
+ ?? DataEntries.FirstOrDefault();
+ OnPropertyChanged(nameof(HasSelectedDataEntry));
+ }
+
+ private void OnDataEntryPropertyChanged(object? sender, PropertyChangedEventArgs e) {
+ if (e.PropertyName is nameof(DataEntryViewModel.Key)
+ or nameof(DataEntryViewModel.Value)
+ or nameof(DataEntryViewModel.IsBinary))
+ IsDirty = true;
}
private async Task LoadLogsAsync() {
diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/ConnectionWizardViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/ConnectionWizardViewModel.cs
index 0e5c6ed..6d5a9b0 100644
--- a/src/MaksIT.ClusterConsole.UI/ViewModels/ConnectionWizardViewModel.cs
+++ b/src/MaksIT.ClusterConsole.UI/ViewModels/ConnectionWizardViewModel.cs
@@ -109,10 +109,10 @@ public partial class ConnectionWizardViewModel : ObservableObject {
public bool IsBasicAuth => SelectedAuthKind?.Id == "basic";
public string ClusterWatermark =>
- string.IsNullOrWhiteSpace(ContextName) ? "blank → {context}-cluster" : ContextName.Trim() + "-cluster";
+ string.IsNullOrWhiteSpace(ContextName) ? "blank → same as context" : ContextName.Trim();
public string UserWatermark =>
- string.IsNullOrWhiteSpace(ContextName) ? "blank → {context}-user" : ContextName.Trim() + "-user";
+ string.IsNullOrWhiteSpace(ContextName) ? "blank → same as context" : ContextName.Trim();
partial void OnStepChanged(int value) {
OnPropertyChanged(nameof(IsIdentityStep));
diff --git a/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs b/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs
index 7e18f84..b91f479 100644
--- a/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs
+++ b/src/MaksIT.ClusterConsole.UI/ViewModels/MainViewModel.cs
@@ -163,10 +163,24 @@ public partial class DataEntryViewModel : ObservableObject {
private string key = string.Empty;
[ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ValuePreview))]
private string value = string.Empty;
[ObservableProperty]
private bool isBinary;
+
+ public string ValuePreview {
+ get {
+ if (string.IsNullOrEmpty(Value))
+ return "";
+
+ var end = Value.IndexOfAny(['\r', '\n']);
+ if (end < 0)
+ return Value;
+
+ return Value[..end] + "…";
+ }
+ }
}
public partial class CatalogItemViewModel : ObservableObject {