From 4bdefaf69fe7efb6bd174629cb79129839edd66a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 31 Aug 2026 20:12:00 +0800 Subject: [PATCH] fix(macos): restore switcher popover integration --- .../Lithe/Views/Workbench/WorkbenchView.swift | 320 ++++++++++++++---- .../WorkbenchRenderingSafetyTests.swift | 21 ++ 2 files changed, 273 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9e3365bb..97697c95 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -24,6 +24,46 @@ private enum WorkbenchWorkspaceMetrics { static let paneCornerRadius: CGFloat = 10 } +private enum WorkbenchPopoverLayoutMetrics { + static let leadingOverlap: CGFloat = 10 + static let viewportMargin: CGFloat = 8 + static let arrowWidth: CGFloat = 22 + static let arrowHeight: CGFloat = 12 +} + +private struct WorkbenchPopoverArrow: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +private struct ProjectSwitcherButtonBoundsPreferenceKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce( + value: inout Anchor?, + nextValue: () -> Anchor? + ) { + value = nextValue() ?? value + } +} + +private struct BranchSwitcherButtonBoundsPreferenceKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce( + value: inout Anchor?, + nextValue: () -> Anchor? + ) { + value = nextValue() ?? value + } +} + struct WorkbenchView: View { private let moduleUIRegistry = WorkbenchModuleUIComposition.builtIn @EnvironmentObject private var model: AppModel @@ -234,26 +274,34 @@ struct WorkbenchView: View { } message: { Text(model.pendingDiscardHunk?.change.path ?? "This action cannot be undone by Lithe.") } - .confirmationDialog( - "Push '\(pendingTopBarPushReference?.shortName ?? "")'?", - isPresented: Binding( - get: { pendingTopBarPushReference != nil }, - set: { if !$0 { pendingTopBarPushReference = nil } } - ), - titleVisibility: .visible - ) { - Button("Push") { - guard let reference = pendingTopBarPushReference else { return } - pendingTopBarPushReference = nil - Task { await model.pushBranch(reference) } + .sheet(item: $pendingTopBarPushReference) { reference in + GitPushDialog( + projectName: model.projectName, + reference: reference, + onPush: { + Task { await model.pushBranch(reference) } + } + ) + } + .overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self) { bounds in + GeometryReader { geometry in + if isProjectSwitcherPresented, let bounds { + projectSwitcherOverlay( + buttonFrame: geometry[bounds], + viewportSize: geometry.size + ) + } } - .lithePointer() - Button("Cancel", role: .cancel) { - pendingTopBarPushReference = nil + } + .overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self) { bounds in + GeometryReader { geometry in + if isBranchSwitcherPresented, let bounds { + branchSwitcherOverlay( + buttonFrame: geometry[bounds], + viewportSize: geometry.size + ) + } } - .lithePointer() - } message: { - Text("This sends the current branch to its configured remote.") } .overlay(alignment: .bottom) { if let message = model.notificationMessage { @@ -406,7 +454,10 @@ struct WorkbenchView: View { private var topBar: some View { HStack(spacing: 9) { Button { - isProjectSwitcherPresented.toggle() + updateSwitcherPresentation( + project: !isProjectSwitcherPresented, + branch: false + ) } label: { HStack(spacing: 8) { LitheLogo(size: 24) @@ -431,28 +482,10 @@ struct WorkbenchView: View { .buttonStyle(.plain) .lithePointer() .accessibilityIdentifier("project-switcher-\(model.id.uuidString)") - .popover(isPresented: $isProjectSwitcherPresented, arrowEdge: .bottom) { - ProjectSwitcherPopover( - isPresented: $isProjectSwitcherPresented, - onNewProject: { - isProjectSwitcherPresented = false - model.chooseProject(title: "New Project", prompt: "Choose Folder") - }, - onOpenProject: { - isProjectSwitcherPresented = false - model.chooseProject() - }, - onCloneRepository: { - isProjectSwitcherPresented = false - model.showCloneRepository() - }, - onOpenRecentProject: { project in - isProjectSwitcherPresented = false - model.openProject(project.url) - } - ) - .environmentObject(model) - } + .anchorPreference( + key: ProjectSwitcherButtonBoundsPreferenceKey.self, + value: .bounds + ) { $0 } Rectangle() .fill(LitheTheme.divider) @@ -460,7 +493,10 @@ struct WorkbenchView: View { .padding(.horizontal, 5) Button { - isBranchSwitcherPresented.toggle() + updateSwitcherPresentation( + project: false, + branch: !isBranchSwitcherPresented + ) if isBranchSwitcherPresented { Task { await model.refreshGitHistory() } } @@ -490,27 +526,142 @@ struct WorkbenchView: View { } .buttonStyle(.plain) .lithePointer() - .popover(isPresented: $isBranchSwitcherPresented, arrowEdge: .bottom) { + .anchorPreference( + key: BranchSwitcherButtonBoundsPreferenceKey.self, + value: .bounds + ) { $0 } + + Spacer(minLength: 22) + + runConfigurationPicker + runLaunchButton + debugLaunchButton + if hasActiveExecution { + stopExecutionButton + } + + backgroundPickerButton + + } + .padding(.leading, 76) + .padding(.trailing, 10) + .frame(height: LitheTheme.Metrics.toolbarHeight) + .background { + (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)? + .toggleWorkspaceZoom() + } + } + } + + private func projectSwitcherOverlay( + buttonFrame: CGRect, + viewportSize: CGSize + ) -> some View { + let popupMetrics = ProjectSwitcherLayoutMetrics.self + let chromeMetrics = WorkbenchPopoverLayoutMetrics.self + let placement = workbenchPopoverPlacement( + buttonFrame: buttonFrame, + viewportWidth: viewportSize.width, + popupWidth: popupMetrics.width + ) + + return ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { updateSwitcherPresentation(project: false) } + + ZStack(alignment: .topLeading) { + WorkbenchPopoverArrow() + .fill(LitheTheme.popupBackground) + .overlay { + WorkbenchPopoverArrow() + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight) + .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2)) + + ProjectSwitcherPopover( + isPresented: instantProjectSwitcherPresentation, + onNewProject: { + updateSwitcherPresentation(project: false) + model.chooseProject(title: "New Project", prompt: "Choose Folder") + }, + onOpenProject: { + updateSwitcherPresentation(project: false) + model.chooseProject() + }, + onCloneRepository: { + updateSwitcherPresentation(project: false) + model.showCloneRepository() + }, + onOpenRecentProject: { project in + updateSwitcherPresentation(project: false) + model.openProject(project.url) + } + ) + .environmentObject(model) + .lithePopupChrome() + .padding(.top, chromeMetrics.arrowHeight - 1) + } + .offset(x: placement.popupX, y: buttonFrame.maxY) + } + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + .onExitCommand { updateSwitcherPresentation(project: false) } + } + + private func branchSwitcherOverlay( + buttonFrame: CGRect, + viewportSize: CGSize + ) -> some View { + let popupMetrics = BranchSwitcherPopover.Metrics.self + let chromeMetrics = WorkbenchPopoverLayoutMetrics.self + let placement = workbenchPopoverPlacement( + buttonFrame: buttonFrame, + viewportWidth: viewportSize.width, + popupWidth: popupMetrics.popupWidth + ) + + return ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { updateSwitcherPresentation(branch: false) } + + ZStack(alignment: .topLeading) { + WorkbenchPopoverArrow() + .fill(LitheTheme.popupBackground) + .overlay { + WorkbenchPopoverArrow() + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight) + .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2)) + BranchSwitcherPopover( - isPresented: $isBranchSwitcherPresented, + isPresented: instantBranchSwitcherPresentation, onCommit: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) model.selectedSidebar = .changes }, onPush: { reference in - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) pendingTopBarPushReference = reference }, onNewBranch: { reference in - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) newBranchReference = reference }, onCheckoutRevision: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) isCheckoutRevisionPresented = true }, onManageBranches: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) if !model.isGitLogVisible { model.selectedSidebar = .changes Task { await model.toggleGitLog() } @@ -518,30 +669,63 @@ struct WorkbenchView: View { } ) .environmentObject(model) + .padding(.top, chromeMetrics.arrowHeight - 1) } + .offset(x: placement.popupX, y: buttonFrame.maxY) + } + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + .onExitCommand { updateSwitcherPresentation(branch: false) } + } - Spacer(minLength: 22) + private func workbenchPopoverPlacement( + buttonFrame: CGRect, + viewportWidth: CGFloat, + popupWidth: CGFloat + ) -> (popupX: CGFloat, arrowCenterX: CGFloat) { + let metrics = WorkbenchPopoverLayoutMetrics.self + let desiredX = buttonFrame.minX - metrics.leadingOverlap + let maximumX = max( + metrics.viewportMargin, + viewportWidth - popupWidth - metrics.viewportMargin + ) + let popupX = min(max(desiredX, metrics.viewportMargin), maximumX) + let arrowCenterX = min( + max(buttonFrame.midX - popupX, metrics.arrowWidth), + popupWidth - metrics.arrowWidth + ) + return (popupX, arrowCenterX) + } - runConfigurationPicker - runLaunchButton - debugLaunchButton - if hasActiveExecution { - stopExecutionButton - } + private var instantProjectSwitcherPresentation: Binding { + Binding( + get: { isProjectSwitcherPresented }, + set: { updateSwitcherPresentation(project: $0) } + ) + } - backgroundPickerButton + private var instantBranchSwitcherPresentation: Binding { + Binding( + get: { isBranchSwitcherPresented }, + set: { updateSwitcherPresentation(branch: $0) } + ) + } - } - .padding(.leading, 76) - .padding(.trailing, 10) - .frame(height: LitheTheme.Metrics.toolbarHeight) - .background { - (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { - (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)? - .toggleWorkspaceZoom() - } + private func updateSwitcherPresentation( + project: Bool? = nil, + branch: Bool? = nil + ) { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + if let project { + isProjectSwitcherPresented = project + } + if let branch { + isBranchSwitcherPresented = branch + } } } diff --git a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift index 9039b7e5..4f29f9f5 100644 --- a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift +++ b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -29,4 +29,25 @@ struct WorkbenchRenderingSafetyTests { "WorkbenchView contains NSViewRepresentable content and must not be flattened with drawingGroup()." ) } + + @Test + func workbenchKeepsCustomSwitchersAlongsideExecutionControls() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let workbenchURL = repositoryRoot.appendingPathComponent( + "Sources/Lithe/Views/Workbench/WorkbenchView.swift" + ) + let source = try String(contentsOf: workbenchURL, encoding: .utf8) + + #expect(source.contains(".overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self)")) + #expect(source.contains(".overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self)")) + #expect(source.contains(".sheet(item: $pendingTopBarPushReference)")) + #expect(source.contains("GitPushDialog(")) + #expect(source.contains("run-selected-run-configuration")) + #expect(source.contains("debug-selected-run-configuration")) + #expect(!source.contains(".popover(isPresented: $isProjectSwitcherPresented")) + #expect(!source.contains(".popover(isPresented: $isBranchSwitcherPresented")) + } }