Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions CodeEdit/Features/CodeEditUI/Views/WorkspacePanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,23 @@ struct WorkspacePanelView<Tab: WorkspacePanelTab, ViewModel: ObservableObject>:

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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<some View>, 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)
}
}
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
3 changes: 3 additions & 0 deletions Documentation.docc/App Window/InspectorSidebarView.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 5 additions & 0 deletions Documentation.docc/App Window/NavigatorSidebarView.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down