diff --git a/src/Netclaw.Cli.Tests/Tui/Config/ConfigEditorCoverageAuditTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/ConfigEditorCoverageAuditTests.cs index bb8f2ad7a..05ab27d40 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/ConfigEditorCoverageAuditTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/ConfigEditorCoverageAuditTests.cs @@ -408,7 +408,11 @@ private string[] DiscoverVisibleConfigLeafEditorIds() .Select(static item => RouteToEditorId(item.Route!)); using var security = new SecurityAccessViewModel(_paths); - var securityEditors = security.Items.Select(SecurityAccessItemToEditorId); + var securityEditors = security.Items + // "Done" is a navigation row (back to the config dashboard), not a leaf editor — exclude it, + // the same way the dashboard's non-routed "Run Full Doctor"/"Quit" rows are excluded above. + .Where(static item => item.Label != "Done") + .Select(SecurityAccessItemToEditorId); return rootEditors.Concat(securityEditors).OrderBy(static id => id).ToArray(); } diff --git a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs index 7f9954f11..caeb88749 100644 --- a/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/Config/SecurityAccessViewModelTests.cs @@ -28,10 +28,69 @@ public void Security_access_lists_expected_leaf_entries() "Security Posture", "Enabled Features", "Audience Profiles", - "Exposure Mode" + "Exposure Mode", + "Done" ], labels); } + [Fact] + public void Done_item_returns_to_the_config_dashboard() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + string? routed = null; + vm.RouteRequested = route => routed = route; + + var doneIndex = vm.Items + .Select((item, index) => (item, index)) + .Single(entry => entry.item.Label == "Done") + .index; + vm.SelectedIndex.Value = doneIndex; + vm.ActivateSelected(); + + Assert.Equal("/config", routed); + } + + [Fact] + public void Posture_editor_done_row_backs_out_to_menu() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.OpenPostureEditor(); + vm.SelectedPostureIndex.Value = vm.PostureOptions.Count; // the appended Done row + vm.ApplySelectedPosture(); // Enter on Done + Assert.Equal(SecurityAccessEditorMode.Menu, vm.Mode.Value); + } + + [Fact] + public void Feature_editor_done_row_backs_out_to_menu() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.OpenFeatureEditor(); + vm.SelectedFeatureIndex.Value = vm.FeatureNames.Count; // the appended Done row + vm.ToggleSelectedFeature(); // Space/Enter on Done + Assert.Equal(SecurityAccessEditorMode.Menu, vm.Mode.Value); + } + + [Fact] + public void Audience_list_done_row_backs_out_to_menu() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.OpenAudienceList(); + vm.SelectedAudienceIndex.Value = vm.AudienceOptions.Count; // the appended Done row + vm.OpenSelectedAudienceProfile(); // Enter on Done + Assert.Equal(SecurityAccessEditorMode.Menu, vm.Mode.Value); + } + + [Fact] + public void Audience_profile_done_row_backs_out_to_audience_list() + { + using var vm = new SecurityAccessViewModel(Context.Paths); + vm.OpenAudienceList(); + vm.OpenSelectedAudienceProfile(); // enter the first audience's profile + vm.SelectedAudienceRowIndex.Value = vm.ProfileRows.Count; // the appended Done row + vm.ActivateSelectedAudienceProfileRow(); // Space/Enter on Done + Assert.Equal(SecurityAccessEditorMode.AudienceList, vm.Mode.Value); + } + [Fact] public void Unparseable_posture_fails_loud_and_closed_not_permissive() { diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs index 5f8334fee..8f9b8d66e 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessPage.cs @@ -96,6 +96,11 @@ private ILayoutNode BuildPostureEditor() active)); } + var doneFocused = ViewModel.SelectedPostureIndex.Value == options.Count; + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Security & Access.", doneFocused)); + return layout; } @@ -138,6 +143,11 @@ private ILayoutNode BuildFeatureToggles() enabled)); } + var doneFocused = ViewModel.SelectedFeatureIndex.Value == names.Count; + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Security & Access.", doneFocused)); + return layout; } @@ -162,6 +172,11 @@ private ILayoutNode BuildAudienceList() focused)); } + var doneFocused = ViewModel.SelectedAudienceIndex.Value == options.Count; + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Security & Access.", doneFocused)); + return layout; } @@ -206,10 +221,19 @@ private ILayoutNode BuildAudienceProfile() layout = layout.WithChild(Row(line, focused, enabled)); } - var focusedRow = rows[ViewModel.SelectedAudienceRowIndex.Value]; + var doneFocused = ViewModel.SelectedAudienceRowIndex.Value == rows.Count; layout = layout .WithChild(Layouts.Empty().Height(1)) - .WithChild(Hint($" {ViewModel.AudienceRowHelp(focusedRow.Kind)}")); + .WithChild(Row($"{FocusPrefix(doneFocused)}Done Return to Audiences.", doneFocused)); + + // Per-row help applies only to a real row; the appended Done row (index == rows.Count) has none. + if (!doneFocused) + { + var focusedRow = rows[ViewModel.SelectedAudienceRowIndex.Value]; + layout = layout + .WithChild(Layouts.Empty().Height(1)) + .WithChild(Hint($" {ViewModel.AudienceRowHelp(focusedRow.Kind)}")); + } return layout; } diff --git a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs index bca0e3044..086583c2a 100644 --- a/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs +++ b/src/Netclaw.Cli/Tui/Config/SecurityAccessViewModel.cs @@ -157,11 +157,13 @@ public void MoveSelection(int delta) SelectedIndex.Value = next; } - public void MovePostureSelection(int delta) => Move(SelectedPostureIndex, delta, Postures.Length); + // Each editor appends a "Done" row after its real items (index == item count), so navigation extends one + // past the array; activation at that index backs out instead of acting on a row (see the action guards). + public void MovePostureSelection(int delta) => Move(SelectedPostureIndex, delta, Postures.Length + 1); public void MoveCascadeSelection(int delta) => Move(SelectedCascadeIndex, delta, CascadeOptions.Length); - public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureConfigPaths.Length); - public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length); - public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length); + public void MoveFeatureSelection(int delta) => Move(SelectedFeatureIndex, delta, FeatureConfigPaths.Length + 1); + public void MoveAudienceSelection(int delta) => Move(SelectedAudienceIndex, delta, Audiences.Length + 1); + public void MoveAudienceRow(int delta) => Move(SelectedAudienceRowIndex, delta, AudienceRows.Length + 1); public void ActivateSelected() { @@ -203,6 +205,10 @@ internal void Activate(SecurityAccessItem item) case "Audience Profiles": OpenAudienceList(); return; + case "Done": + // Discoverable equivalent of Esc — back out to the config dashboard. + GoBack(); + return; } if (item.Route is not null) @@ -256,6 +262,12 @@ public void OpenPostureEditor() public void ApplySelectedPosture() { + if (SelectedPostureIndex.Value >= Postures.Length) + { + GoBack(); + return; + } + var posture = Postures[SelectedPostureIndex.Value].Value; if (posture == CurrentPosture) { @@ -314,6 +326,12 @@ public void OpenFeatureEditor() public void ToggleSelectedFeature() { + if (SelectedFeatureIndex.Value >= FeatureConfigPaths.Length) + { + GoBack(); + return; + } + var index = SelectedFeatureIndex.Value; _enabledFeatures[index] = !_enabledFeatures[index]; @@ -340,6 +358,12 @@ public void OpenAudienceList() public void OpenSelectedAudienceProfile() { + if (SelectedAudienceIndex.Value >= Audiences.Length) + { + GoBack(); + return; + } + SelectedAudienceRowIndex.Value = 0; Mode.Value = SecurityAccessEditorMode.AudienceProfile; StatusMessage.Value = ""; @@ -386,6 +410,12 @@ public string AudienceValue(AudienceProfileRowKind kind) public void ActivateSelectedAudienceProfileRow() { + if (SelectedAudienceRowIndex.Value >= AudienceRows.Length) + { + GoBack(); + return; + } + var row = AudienceRows[SelectedAudienceRowIndex.Value]; switch (row.Kind) { @@ -423,6 +453,9 @@ public void ActivateSelectedAudienceProfileRow() public void ChangeSelectedAudienceProfileRow(int direction) { + if (SelectedAudienceRowIndex.Value >= AudienceRows.Length) + return; // the Done row has no value to cycle with ←/→ + var row = AudienceRows[SelectedAudienceRowIndex.Value]; switch (row.Kind) { @@ -657,7 +690,8 @@ private IReadOnlyList BuildItems() "Deployment trust stance."), new("Enabled Features", ReadEnabledFeaturesSummary(config), "Deployment-wide runtime feature gates."), new("Audience Profiles", ReadAudienceProfilesSummary(config), "Curated per-audience access rules."), - new("Exposure Mode", ReadExposureModeSummary(config), "Daemon reachability and tunnel topology.", "/exposure-mode") + new("Exposure Mode", ReadExposureModeSummary(config), "Daemon reachability and tunnel topology.", "/exposure-mode"), + new("Done", "", "Return to Settings Areas.") ]; }