From 28c05aea89519e67a54d53fcf02a8dde453991be Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 1 Aug 2026 20:59:05 +0300 Subject: [PATCH] Fix #711: Retain sidebar panel state across tab switches Keep all WorkspacePanelView tab panels mounted and toggle visibility with opacity so navigator and inspector outline expand/scroll state survives switching tabs. --- .../CodeEditUI/Views/WorkspacePanelView.swift | 20 ++++- .../WorkspacePanelRetentionTests.swift | 85 +++++++++++++++++++ .../SidebarPanelRetentionUITests.swift | 55 ++++++++++++ .../App Window/InspectorSidebarView.md | 3 + .../App Window/NavigatorSidebarView.md | 5 ++ 5 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 CodeEditTests/Features/CodeEditUI/WorkspacePanelRetentionTests.swift create mode 100644 CodeEditUITests/Features/NavigatorArea/SidebarPanelRetentionUITests.swift diff --git a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift b/CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift index 4637d2785e..f062622756 100644 --- a/CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift +++ b/CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift @@ -34,11 +34,23 @@ struct WorkspacePanelView: var body: some View { VStack(spacing: 0) { - if let selection = selectedTab { - selection - } else { - CEContentUnavailableView("No Selection") + // Keep every sidebar panel mounted so expand/scroll state survives tab switches (#711). + // Hiding with opacity (instead of swapping `if selectedTab`) preserves NSOutlineView state. + ZStack { + ForEach(tabItems) { tab in + let isSelected = selectedTab == tab + tab + .opacity(isSelected ? 1 : 0) + .allowsHitTesting(isSelected) + .accessibilityHidden(!isSelected) + .zIndex(isSelected ? 1 : 0) + } + + if selectedTab == nil { + CEContentUnavailableView("No Selection") + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } .safeAreaInset(edge: .leading, spacing: 0) { if sidebarPosition == .side { diff --git a/CodeEditTests/Features/CodeEditUI/WorkspacePanelRetentionTests.swift b/CodeEditTests/Features/CodeEditUI/WorkspacePanelRetentionTests.swift new file mode 100644 index 0000000000..7221154c64 --- /dev/null +++ b/CodeEditTests/Features/CodeEditUI/WorkspacePanelRetentionTests.swift @@ -0,0 +1,85 @@ +// +// WorkspacePanelRetentionTests.swift +// CodeEditTests +// +// Created by Boris Serzhanovich on 1/8/26. +// + +import Testing +import SwiftUI +@testable import CodeEdit + +/// Verifies sidebar tab panels stay mounted so AppKit outline state can survive tab switches (#711). +@Suite("Workspace panel retention") +struct WorkspacePanelRetentionTests { + + private struct ProbeTab: WorkspacePanelTab { + let id: String + let title: String + let systemImage: String + let marker: String + + var body: some View { + Text(marker) + .accessibilityIdentifier("ProbeTab-\(id)") + } + } + + @MainActor + private final class ProbeViewModel: ObservableObject { + @Published var selectedTab: ProbeTab? + @Published var tabItems: [ProbeTab] + init(tabs: [ProbeTab], selected: ProbeTab?) { + self.tabItems = tabs + self.selectedTab = selected + } + } + + @Test + @MainActor + func keepsAllTabBodiesMountedWhenSelectionChanges() { + let project = ProbeTab(id: "project", title: "Project", systemImage: "folder", marker: "PROJECT") + let search = ProbeTab(id: "search", title: "Search", systemImage: "magnifyingglass", marker: "SEARCH") + let viewModel = ProbeViewModel(tabs: [project, search], selected: project) + + let view = WorkspacePanelView( + viewModel: viewModel, + selectedTab: Binding( + get: { viewModel.selectedTab }, + set: { viewModel.selectedTab = $0 } + ), + tabItems: Binding( + get: { viewModel.tabItems }, + set: { viewModel.tabItems = $0 } + ), + sidebarPosition: .top + ) + + let hosting = NSHostingView(rootView: view.frame(width: 240, height: 320)) + hosting.layoutSubtreeIfNeeded() + + #expect(hostingContains(hosting, text: "PROJECT")) + #expect(hostingContains(hosting, text: "SEARCH")) + + viewModel.selectedTab = search + hosting.layoutSubtreeIfNeeded() + + // Both panels remain in the hierarchy after switching — state retention depends on this. + #expect(hostingContains(hosting, text: "PROJECT")) + #expect(hostingContains(hosting, text: "SEARCH")) + } + + @MainActor + private func hostingContains(_ hosting: NSHostingView, text: String) -> Bool { + func search(_ view: NSView) -> Bool { + if let textField = view as? NSTextField, textField.stringValue.contains(text) { + return true + } + if let textView = view as? NSTextView, textView.string.contains(text) { + return true + } + return view.subviews.contains(where: search) + } + return search(hosting) + } +} diff --git a/CodeEditUITests/Features/NavigatorArea/SidebarPanelRetentionUITests.swift b/CodeEditUITests/Features/NavigatorArea/SidebarPanelRetentionUITests.swift new file mode 100644 index 0000000000..be6679f144 --- /dev/null +++ b/CodeEditUITests/Features/NavigatorArea/SidebarPanelRetentionUITests.swift @@ -0,0 +1,55 @@ +// +// SidebarPanelRetentionUITests.swift +// CodeEditUITests +// +// Created by Boris Serzhanovich on 1/8/26. +// + +import XCTest + +/// UI regression for #711 — navigator expand state must survive switching sidebar tabs. +final class SidebarPanelRetentionUITests: XCTestCase { + + var application: XCUIApplication! + + override func setUp() { + application = App.launchWithCodeEditWorkspace() + } + + func testProjectNavigatorKeepsExpansionAfterSidebarTabSwitch() { + let window = Query.getWindow(application) + XCTAssertTrue(window.exists, "Window not found") + window.toolbars.firstMatch.click() + + let navigator = Query.Window.getProjectNavigator(window) + XCTAssertTrue(navigator.waitForExistence(timeout: 2.0), "Navigator not found") + + let codeEditFolderRow = Query.Navigator.getProjectNavigatorRow(fileTitle: "CodeEdit", index: 1, navigator) + XCTAssertTrue(codeEditFolderRow.exists) + let folderDisclosureIndicator = Query.Navigator.disclosureIndicatorForRow(codeEditFolderRow) + XCTAssertTrue(folderDisclosureIndicator.exists) + + let collapsedRowCount = navigator.descendants(matching: .outlineRow).count + folderDisclosureIndicator.click() + let expandedRowCount = navigator.descendants(matching: .outlineRow).count + XCTAssertTrue(expandedRowCount > collapsedRowCount, "Folder did not expand") + + // Switch away to Search, then back to Project. + let searchTab = window.buttons["WorkspacePanelTab-Search"] + XCTAssertTrue(searchTab.waitForExistence(timeout: 2.0), "Search navigator tab not found") + searchTab.click() + + let projectTab = window.buttons["WorkspacePanelTab-Project"] + XCTAssertTrue(projectTab.waitForExistence(timeout: 2.0), "Project navigator tab not found") + projectTab.click() + + let navigatorAfter = Query.Window.getProjectNavigator(window) + XCTAssertTrue(navigatorAfter.waitForExistence(timeout: 2.0)) + let restoredRowCount = navigatorAfter.descendants(matching: .outlineRow).count + XCTAssertEqual( + restoredRowCount, + expandedRowCount, + "Project navigator lost expansion state after switching sidebar tabs" + ) + } +} diff --git a/Documentation.docc/App Window/InspectorSidebarView.md b/Documentation.docc/App Window/InspectorSidebarView.md index 6665c26317..49c4a5a76d 100644 --- a/Documentation.docc/App Window/InspectorSidebarView.md +++ b/Documentation.docc/App Window/InspectorSidebarView.md @@ -1,5 +1,8 @@ # ``CodeEdit/InspectorAreaView`` +Inspector tabs share ``WorkspacePanelView`` with the navigator: panels stay mounted and are +shown/hidden with opacity so selection and scroll state survive tab switches (#711). + ## Topics ### Toolbars diff --git a/Documentation.docc/App Window/NavigatorSidebarView.md b/Documentation.docc/App Window/NavigatorSidebarView.md index 3ead03b1c6..693968ad5a 100644 --- a/Documentation.docc/App Window/NavigatorSidebarView.md +++ b/Documentation.docc/App Window/NavigatorSidebarView.md @@ -1,5 +1,10 @@ # ``CodeEdit/NavigatorSidebarView`` +Navigator and inspector sidebars use ``WorkspacePanelView``, which keeps every tab panel +mounted and toggles visibility with opacity. That preserves AppKit outline expansion and +scroll position when switching between Project, Source Control, Search, and inspector tabs +(see issue #711). + ## Topics ### Toolbars