Skip to content

Commit 28c05ae

Browse files
committed
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.
1 parent cec6287 commit 28c05ae

5 files changed

Lines changed: 164 additions & 4 deletions

File tree

‎CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,23 @@ struct WorkspacePanelView<Tab: WorkspacePanelTab, ViewModel: ObservableObject>:
3434

3535
var body: some View {
3636
VStack(spacing: 0) {
37-
if let selection = selectedTab {
38-
selection
39-
} else {
40-
CEContentUnavailableView("No Selection")
37+
// Keep every sidebar panel mounted so expand/scroll state survives tab switches (#711).
38+
// Hiding with opacity (instead of swapping `if selectedTab`) preserves NSOutlineView state.
39+
ZStack {
40+
ForEach(tabItems) { tab in
41+
let isSelected = selectedTab == tab
42+
tab
43+
.opacity(isSelected ? 1 : 0)
44+
.allowsHitTesting(isSelected)
45+
.accessibilityHidden(!isSelected)
46+
.zIndex(isSelected ? 1 : 0)
47+
}
48+
49+
if selectedTab == nil {
50+
CEContentUnavailableView("No Selection")
51+
}
4152
}
53+
.frame(maxWidth: .infinity, maxHeight: .infinity)
4254
}
4355
.safeAreaInset(edge: .leading, spacing: 0) {
4456
if sidebarPosition == .side {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//
2+
// WorkspacePanelRetentionTests.swift
3+
// CodeEditTests
4+
//
5+
// Created by Boris Serzhanovich on 1/8/26.
6+
//
7+
8+
import Testing
9+
import SwiftUI
10+
@testable import CodeEdit
11+
12+
/// Verifies sidebar tab panels stay mounted so AppKit outline state can survive tab switches (#711).
13+
@Suite("Workspace panel retention")
14+
struct WorkspacePanelRetentionTests {
15+
16+
private struct ProbeTab: WorkspacePanelTab {
17+
let id: String
18+
let title: String
19+
let systemImage: String
20+
let marker: String
21+
22+
var body: some View {
23+
Text(marker)
24+
.accessibilityIdentifier("ProbeTab-\(id)")
25+
}
26+
}
27+
28+
@MainActor
29+
private final class ProbeViewModel: ObservableObject {
30+
@Published var selectedTab: ProbeTab?
31+
@Published var tabItems: [ProbeTab]
32+
init(tabs: [ProbeTab], selected: ProbeTab?) {
33+
self.tabItems = tabs
34+
self.selectedTab = selected
35+
}
36+
}
37+
38+
@Test
39+
@MainActor
40+
func keepsAllTabBodiesMountedWhenSelectionChanges() {
41+
let project = ProbeTab(id: "project", title: "Project", systemImage: "folder", marker: "PROJECT")
42+
let search = ProbeTab(id: "search", title: "Search", systemImage: "magnifyingglass", marker: "SEARCH")
43+
let viewModel = ProbeViewModel(tabs: [project, search], selected: project)
44+
45+
let view = WorkspacePanelView(
46+
viewModel: viewModel,
47+
selectedTab: Binding(
48+
get: { viewModel.selectedTab },
49+
set: { viewModel.selectedTab = $0 }
50+
),
51+
tabItems: Binding(
52+
get: { viewModel.tabItems },
53+
set: { viewModel.tabItems = $0 }
54+
),
55+
sidebarPosition: .top
56+
)
57+
58+
let hosting = NSHostingView(rootView: view.frame(width: 240, height: 320))
59+
hosting.layoutSubtreeIfNeeded()
60+
61+
#expect(hostingContains(hosting, text: "PROJECT"))
62+
#expect(hostingContains(hosting, text: "SEARCH"))
63+
64+
viewModel.selectedTab = search
65+
hosting.layoutSubtreeIfNeeded()
66+
67+
// Both panels remain in the hierarchy after switching — state retention depends on this.
68+
#expect(hostingContains(hosting, text: "PROJECT"))
69+
#expect(hostingContains(hosting, text: "SEARCH"))
70+
}
71+
72+
@MainActor
73+
private func hostingContains(_ hosting: NSHostingView<some View>, text: String) -> Bool {
74+
func search(_ view: NSView) -> Bool {
75+
if let textField = view as? NSTextField, textField.stringValue.contains(text) {
76+
return true
77+
}
78+
if let textView = view as? NSTextView, textView.string.contains(text) {
79+
return true
80+
}
81+
return view.subviews.contains(where: search)
82+
}
83+
return search(hosting)
84+
}
85+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// SidebarPanelRetentionUITests.swift
3+
// CodeEditUITests
4+
//
5+
// Created by Boris Serzhanovich on 1/8/26.
6+
//
7+
8+
import XCTest
9+
10+
/// UI regression for #711 — navigator expand state must survive switching sidebar tabs.
11+
final class SidebarPanelRetentionUITests: XCTestCase {
12+
13+
var application: XCUIApplication!
14+
15+
override func setUp() {
16+
application = App.launchWithCodeEditWorkspace()
17+
}
18+
19+
func testProjectNavigatorKeepsExpansionAfterSidebarTabSwitch() {
20+
let window = Query.getWindow(application)
21+
XCTAssertTrue(window.exists, "Window not found")
22+
window.toolbars.firstMatch.click()
23+
24+
let navigator = Query.Window.getProjectNavigator(window)
25+
XCTAssertTrue(navigator.waitForExistence(timeout: 2.0), "Navigator not found")
26+
27+
let codeEditFolderRow = Query.Navigator.getProjectNavigatorRow(fileTitle: "CodeEdit", index: 1, navigator)
28+
XCTAssertTrue(codeEditFolderRow.exists)
29+
let folderDisclosureIndicator = Query.Navigator.disclosureIndicatorForRow(codeEditFolderRow)
30+
XCTAssertTrue(folderDisclosureIndicator.exists)
31+
32+
let collapsedRowCount = navigator.descendants(matching: .outlineRow).count
33+
folderDisclosureIndicator.click()
34+
let expandedRowCount = navigator.descendants(matching: .outlineRow).count
35+
XCTAssertTrue(expandedRowCount > collapsedRowCount, "Folder did not expand")
36+
37+
// Switch away to Search, then back to Project.
38+
let searchTab = window.buttons["WorkspacePanelTab-Search"]
39+
XCTAssertTrue(searchTab.waitForExistence(timeout: 2.0), "Search navigator tab not found")
40+
searchTab.click()
41+
42+
let projectTab = window.buttons["WorkspacePanelTab-Project"]
43+
XCTAssertTrue(projectTab.waitForExistence(timeout: 2.0), "Project navigator tab not found")
44+
projectTab.click()
45+
46+
let navigatorAfter = Query.Window.getProjectNavigator(window)
47+
XCTAssertTrue(navigatorAfter.waitForExistence(timeout: 2.0))
48+
let restoredRowCount = navigatorAfter.descendants(matching: .outlineRow).count
49+
XCTAssertEqual(
50+
restoredRowCount,
51+
expandedRowCount,
52+
"Project navigator lost expansion state after switching sidebar tabs"
53+
)
54+
}
55+
}

‎Documentation.docc/App Window/InspectorSidebarView.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# ``CodeEdit/InspectorAreaView``
22

3+
Inspector tabs share ``WorkspacePanelView`` with the navigator: panels stay mounted and are
4+
shown/hidden with opacity so selection and scroll state survive tab switches (#711).
5+
36
## Topics
47

58
### Toolbars

‎Documentation.docc/App Window/NavigatorSidebarView.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# ``CodeEdit/NavigatorSidebarView``
22

3+
Navigator and inspector sidebars use ``WorkspacePanelView``, which keeps every tab panel
4+
mounted and toggles visibility with opacity. That preserves AppKit outline expansion and
5+
scroll position when switching between Project, Source Control, Search, and inspector tabs
6+
(see issue #711).
7+
38
## Topics
49

510
### Toolbars

0 commit comments

Comments
 (0)