Skip to content

fix: keep the active tab when a tab close is cancelled - #157

Open
jafin wants to merge 2 commits into
robertpopa22:mainfrom
jafin:fix/tab-close-cancel-keeps-active-tab
Open

fix: keep the active tab when a tab close is cancelled#157
jafin wants to merge 2 commits into
robertpopa22:mainfrom
jafin:fix/tab-close-cancel-keeps-active-tab

Conversation

@jafin

@jafin jafin commented Aug 6, 2026

Copy link
Copy Markdown

Problem

Closing a connection tab shows the "are you sure you want to close" prompt. Clicking Cancel correctly keeps the tab open, but the active connection switches to the tab on its left.

Root cause

Not in the confirmation logic — it is DockPanelSuite. DockPaneStripBase.TryCloseTab:

DockPane.CloseContent(content);
if (PatchController.EnableSelectClosestOnClose == true)
    SelectClosestPane(index);        // DockPane.ActiveContent = Tabs[index - 1].Content

SelectClosestPane runs unconditionally — it never checks whether the close actually happened. EnableSelectClosestOnClose defaults to true, and connDock.DocumentStyle is DockingWindow whenever more than one tab is open, so both of its guards pass. When ConnectionTab.OnFormClosing sets e.Cancel = true, the tab survives but DPS has already moved the selection.

It only bites when the tab being closed is not the first one (SelectClosestPane is guarded by index > 0), which is why the bug can look intermittent.

Fix

DockPaneStripNG wraps the close and puts the selection back when the tab is still on display:

private void CloseTab(int index)
{
    IDockContent tabContent = Tabs[index].Content;
    IDockContent? activeBeforeClose = DockPane.ActiveContent;

    TryCloseTab(index);

    if (activeBeforeClose == null || ReferenceEquals(DockPane.ActiveContent, activeBeforeClose))
        return;

    if (DockPane.DisplayingContents.Contains(tabContent) &&
        DockPane.DisplayingContents.Contains(activeBeforeClose))
    {
        activeBeforeClose.DockHandler.Activate();
    }
}

Keying off DisplayingContents rather than a cancel flag covers every refusal — the confirmation prompt, a protocol veto, KeepTabsOpenAfterDisconnect — and it deliberately does nothing on a successful close, so the existing MRU/adjacent-tab behaviour is untouched. Activate() rather than a bare ActiveContent assignment also restores keyboard focus to the tab. Both entry points into QueueCloseTab (close button and middle-click) are covered.

Verification

Solution builds clean.

The NUnit suite could not be executed on my machine — every group reports NUnit couldn't run the N discovered tests: Only supported on Windows10.0.26100.0, because mRemoteNGTests.csproj sets SupportedOSPlatformVersion 10.0.26100.0 while the host is Windows 10.0.22631. Pre-existing and unrelated to this change; CI should run the suite normally.

Behaviour was verified by driving the real DockPaneStripNG from a standalone STA harness against the built assembly:

PASS  Doc2 starts active
PASS  cancelled close leaves Doc2 open
PASS  cancelled close keeps Doc2 active
  (control) after raw TryCloseTab, Doc2 open=True, active=Doc1
PASS  uncancelled close still closes Doc3

The control line is the bug reproduced directly: calling DPS's TryCloseTab leaves the tab open but hands the selection to Doc1.

New test: DockPaneStripNGTests.CancellingATabClose_LeavesTheActiveTabUnchanged, using a FormClosing handler that cancels — headless, no dialog, following the existing message-pump pattern in that fixture.

Dismissing the "are you sure you want to close" prompt left the tab open but
switched the active connection to the tab on its left.

DockPanelSuite's TryCloseTab calls SelectClosestPane straight after
DockPane.CloseContent without checking whether the content actually closed,
and it sets ActiveContent to Tabs[index - 1]. EnableSelectClosestOnClose
defaults to true and connDock uses DocumentStyle.DockingWindow whenever more
than one tab is open, so both of its guards pass. The selection therefore
moved even though ConnectionTab.OnFormClosing had cancelled the close, which
is why it only showed up on tabs other than the first.

Wrap the close so the previous selection is restored when the tab is still
displaying afterwards. Keying off DisplayingContents rather than a cancel
flag covers every refusal - the confirmation prompt, a protocol veto,
KeepTabsOpenAfterDisconnect - and leaves a successful close alone.
Copilot AI lite review requested due to automatic review settings August 6, 2026 00:15
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix cancelled tab-close changing the active connection tab

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Preserve the active tab when a tab-close is cancelled by FormClosing.
• Wrap DockPanelSuite tab-close to restore selection only when close is refused.
• Add an STA/message-pump NUnit test covering middle-click close cancellation.
Diagram

graph TD
  UI["DockPaneStripNG"] --> Close["CloseTab wrapper"] --> DPS["TryCloseTab (DPS)"]
  Close --> Check{"Close refused?"}
  Check -->|"Yes"| Restore["Activate previous"] --> Pane["DockPane DisplayingContents"]
  Check -->|"No"| Adj["Keep close behavior"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Patch/upgrade DockPanelSuite behavior
  • ➕ Fixes the issue at the source for all consumers
  • ➕ Avoids local workaround logic in mRemoteNG
  • ➖ Requires upstream change/release or maintaining a fork
  • ➖ Higher coordination and potential regression surface area
2. Disable DPS 'select closest on close'
  • ➕ Simpler change (configuration/flag)
  • ➕ Eliminates the problematic selection change entirely
  • ➖ Changes expected UX on successful closes (adjacent/MRU selection)
  • ➖ May affect other docking scenarios beyond connection tabs
3. Track cancellation explicitly (e.g., via closing event state)
  • ➕ More direct signal than inferring via DisplayingContents
  • ➕ Could be cheaper than querying collections
  • ➖ Harder to cover all refusal reasons (prompts, protocol vetoes, settings)
  • ➖ More coupling to specific close/cancel mechanisms

Recommendation: The implemented wrapper approach is the best fit here: it’s narrowly scoped to the custom DockPaneStripNG, preserves existing behavior on successful closes, and detects any close refusal via DisplayingContents rather than relying on a single cancel pathway.

Files changed (2) +94 / -1

Bug fix (1) +29 / -1
DockPaneStripNG.csWrap tab close to restore active tab when close is refused +29/-1

Wrap tab close to restore active tab when close is refused

• Replaces direct TryCloseTab calls with a CloseTab wrapper that records the active content before closing. After TryCloseTab, it restores the previous active tab (and focus) when both contents are still present in DisplayingContents, indicating the close was cancelled/refused.

mRemoteNG/UI/Tabs/DockPaneStripNG.cs

Tests (1) +65 / -0
DockPaneStripNGTests.csAdd regression test for cancelled tab-close keeping active tab +65/-0

Add regression test for cancelled tab-close keeping active tab

• Adds an STA/message-pump NUnit test that simulates a cancelled close via FormClosing (e.Cancel = true) and invokes middle-click close through reflection. Verifies the tab remains open and remains the active content after the close attempt.

mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a UI regression where cancelling a connection tab close (e.g., dismissing the “are you sure” prompt) leaves the tab open but incorrectly shifts the active selection to the adjacent tab, by restoring the prior active content when the close is refused.

Changes:

  • Wrap DockPanelSuite’s TryCloseTab in a CloseTab helper that re-activates the previously active content when the close attempt doesn’t actually remove the tab.
  • Route queued tab-close actions through the new CloseTab helper to cover the existing close entry points.
  • Add an NUnit UI test that simulates a cancelled close (via FormClosing cancellation) and asserts the active tab remains unchanged.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
mRemoteNG/UI/Tabs/DockPaneStripNG.cs Adds CloseTab(int) wrapper around TryCloseTab to restore selection when close is refused.
mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs Adds regression test ensuring cancelling a tab close keeps the active tab selected.

Comment on lines +165 to +170
DateTime start = DateTime.Now;
while ((DateTime.Now - start).TotalSeconds < 2)
{
Application.DoEvents();
Thread.Sleep(10);
}
@qodo-code-review

qodo-code-review Bot commented Aug 6, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Unconditional 2s test wait 🐞 Bug ☼ Reliability
Description
The new test CancellingATabClose_LeavesTheActiveTabUnchanged always spins the message pump for the
full 2 seconds with no early-exit condition, adding a fixed delay to every run and making the test
more timeout-sensitive under load. This is avoidable because the test can poll for the expected
stable state and break immediately once reached (as the existing test in the same file already
does).
Code

mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[R166-169]

+            while ((DateTime.Now - start).TotalSeconds < 2)
+            {
+                Application.DoEvents();
+                Thread.Sleep(10);
Evidence
The new test introduces a fixed-duration loop that always waits 2 seconds, whereas a similar
existing test in the same file already demonstrates an early-exit approach, indicating the new loop
can be improved without changing intent.

mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[165-170]
mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[96-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The new NUnit test uses a fixed 2-second `while` loop that always runs to completion, regardless of whether the UI work (`BeginInvoke` from the tab close path) has already completed. This unnecessarily slows the test suite and can cause intermittent failures if the expected state is not reached before the hardcoded timeout.

### Issue Context
The existing test `MiddleClick_ClosesSpecificTab_NotAll` already uses a better pattern: it pumps events but breaks early once the expected condition is satisfied.

### Fix Focus Areas
- mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[165-175]

### Suggested fix
- Replace the unconditional 2-second loop with a bounded polling loop that:
 - Calls `Application.DoEvents()`
 - Breaks early once both are true:
   - `doc2.DockState == DockState.Document`
   - `doc2.DockHandler.Pane.ActiveContent == doc2`
 - Fails with a clear message if the condition is not met within the timeout.
- Prefer `Stopwatch` over `DateTime.Now` for measuring elapsed time.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. hostForm.Show() in test 📘 Rule violation ▣ Testability
Description
The new test opens a real WinForms window via hostForm.Show(), which can create interactive UI
during automated runs and cause flakiness in headless/CI environments. This violates the rule to
avoid interactive UI calls in automated tests.
Code

mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[R134-136]

+            hostForm.Controls.Add(dockPanel);
+            hostForm.Show();
+
Evidence
PR Compliance ID 104331 prohibits interactive UI calls in automated tests. The added test explicitly
shows a WinForms Form (hostForm.Show()), which is an interactive UI action.

Rule 104331: Avoid real interactive UI or process-launch calls in automated tests
mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[134-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new NUnit test calls `hostForm.Show()`, which opens a real WinForms window during automated test execution.

## Issue Context
Compliance requires automated tests to avoid real interactive UI calls. This test can likely be made non-interactive by forcing handle creation without showing the form (e.g., using `Handle`/`CreateControl()`), or by refactoring to test the behavior without constructing/showing real WinForms windows.

## Fix Focus Areas
- mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[134-170]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context used
✅ Compliance rules (platform): 52 rules

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +134 to +136
hostForm.Controls.Add(dockPanel);
hostForm.Show();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

1. hostform.show() in test 📘 Rule violation ▣ Testability

The new test opens a real WinForms window via hostForm.Show(), which can create interactive UI
during automated runs and cause flakiness in headless/CI environments. This violates the rule to
avoid interactive UI calls in automated tests.
Agent Prompt
## Issue description
The new NUnit test calls `hostForm.Show()`, which opens a real WinForms window during automated test execution.

## Issue Context
Compliance requires automated tests to avoid real interactive UI calls. This test can likely be made non-interactive by forcing handle creation without showing the form (e.g., using `Handle`/`CreateControl()`), or by refactoring to test the behavior without constructing/showing real WinForms windows.

## Fix Focus Areas
- mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[134-170]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +166 to +169
while ((DateTime.Now - start).TotalSeconds < 2)
{
Application.DoEvents();
Thread.Sleep(10);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Unconditional 2s test wait 🐞 Bug ☼ Reliability

The new test CancellingATabClose_LeavesTheActiveTabUnchanged always spins the message pump for the
full 2 seconds with no early-exit condition, adding a fixed delay to every run and making the test
more timeout-sensitive under load. This is avoidable because the test can poll for the expected
stable state and break immediately once reached (as the existing test in the same file already
does).
Agent Prompt
### Issue description
The new NUnit test uses a fixed 2-second `while` loop that always runs to completion, regardless of whether the UI work (`BeginInvoke` from the tab close path) has already completed. This unnecessarily slows the test suite and can cause intermittent failures if the expected state is not reached before the hardcoded timeout.

### Issue Context
The existing test `MiddleClick_ClosesSpecificTab_NotAll` already uses a better pattern: it pumps events but breaks early once the expected condition is satisfied.

### Fix Focus Areas
- mRemoteNGTests/UI/Tabs/DockPaneStripNGTests.cs[165-175]

### Suggested fix
- Replace the unconditional 2-second loop with a bounded polling loop that:
  - Calls `Application.DoEvents()`
  - Breaks early once both are true:
    - `doc2.DockState == DockState.Document`
    - `doc2.DockHandler.Pane.ActiveContent == doc2`
  - Fails with a clear message if the condition is not met within the timeout.
- Prefer `Stopwatch` over `DateTime.Now` for measuring elapsed time.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…le timeout

Thread.Interrupt only unblocks interruptible waits, so a wedged UI thread
survived the timeout, and because it was a foreground thread it could hold
the whole test run open.

The test body is now posted onto an STA thread running
Application.Run(new ApplicationContext()) and calls Application.ExitThread
in a finally, so the loop unwinds as soon as the body finishes. On timeout
the loop is asked to exit and joined again briefly before failing. The
thread is also marked IsBackground as a last-resort net: even if it never
pumps again it cannot keep the process alive.

This helper is shared by the whole fixture, so MiddleClick_ClosesSpecificTab_NotAll
gets the same treatment.
@jafin

jafin commented Aug 6, 2026

Copy link
Copy Markdown
Author

Carried over a review fix from the upstream counterpart (mRemoteNG#3408): RunWithMessagePump did not reliably stop its STA thread.

Thread.Interrupt() only unblocks interruptible waits, so a wedged UI thread survived the timeout — and because it was a foreground thread it could hold the whole test run open, hanging CI.

  • The test body is now posted with BeginInvoke onto a thread running Application.Run(new ApplicationContext()), calling Application.ExitThread() in a finally.
  • On timeout the loop is asked to exit and joined again for 5s before Assert.Fail.
  • IsBackground = true is the last-resort net; Thread.Interrupt() is gone.

The helper is shared by the whole fixture, so the pre-existing MiddleClick_ClosesSpecificTab_NotAll benefits too. All 5 tests in the fixture pass:

Passed CancellingATabClose_LeavesTheActiveTabUnchanged [2 s]
Passed MiddleClick_ClosesSpecificTab_NotAll [210 ms]
Passed IsWithinUndockSuppressionZone_* (3)

The helper's timeout path itself was verified separately with a standalone copy at a 2s timeout: a body of Thread.Sleep(Timeout.Infinite) times out and the process then exits immediately, which it did not do before.

Two notes for anyone copying the helper: it uses Action rather than MethodInvoker (this file imports System.Reflection, which also has a MethodInvoker on .NET 8+), and catches only InvalidOperationException (ObjectDisposedException derives from it, so a separate clause is unreachable — CS0160).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants