Skip to content

Commit 4bdefaf

Browse files
committed
fix(macos): restore switcher popover integration
1 parent 619455f commit 4bdefaf

2 files changed

Lines changed: 273 additions & 68 deletions

File tree

macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift

Lines changed: 252 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,46 @@ private enum WorkbenchWorkspaceMetrics {
2424
static let paneCornerRadius: CGFloat = 10
2525
}
2626

27+
private enum WorkbenchPopoverLayoutMetrics {
28+
static let leadingOverlap: CGFloat = 10
29+
static let viewportMargin: CGFloat = 8
30+
static let arrowWidth: CGFloat = 22
31+
static let arrowHeight: CGFloat = 12
32+
}
33+
34+
private struct WorkbenchPopoverArrow: Shape {
35+
func path(in rect: CGRect) -> Path {
36+
var path = Path()
37+
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
38+
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
39+
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
40+
path.closeSubpath()
41+
return path
42+
}
43+
}
44+
45+
private struct ProjectSwitcherButtonBoundsPreferenceKey: PreferenceKey {
46+
static var defaultValue: Anchor<CGRect>?
47+
48+
static func reduce(
49+
value: inout Anchor<CGRect>?,
50+
nextValue: () -> Anchor<CGRect>?
51+
) {
52+
value = nextValue() ?? value
53+
}
54+
}
55+
56+
private struct BranchSwitcherButtonBoundsPreferenceKey: PreferenceKey {
57+
static var defaultValue: Anchor<CGRect>?
58+
59+
static func reduce(
60+
value: inout Anchor<CGRect>?,
61+
nextValue: () -> Anchor<CGRect>?
62+
) {
63+
value = nextValue() ?? value
64+
}
65+
}
66+
2767
struct WorkbenchView: View {
2868
private let moduleUIRegistry = WorkbenchModuleUIComposition.builtIn
2969
@EnvironmentObject private var model: AppModel
@@ -234,26 +274,34 @@ struct WorkbenchView: View {
234274
} message: {
235275
Text(model.pendingDiscardHunk?.change.path ?? "This action cannot be undone by Lithe.")
236276
}
237-
.confirmationDialog(
238-
"Push '\(pendingTopBarPushReference?.shortName ?? "")'?",
239-
isPresented: Binding(
240-
get: { pendingTopBarPushReference != nil },
241-
set: { if !$0 { pendingTopBarPushReference = nil } }
242-
),
243-
titleVisibility: .visible
244-
) {
245-
Button("Push") {
246-
guard let reference = pendingTopBarPushReference else { return }
247-
pendingTopBarPushReference = nil
248-
Task { await model.pushBranch(reference) }
277+
.sheet(item: $pendingTopBarPushReference) { reference in
278+
GitPushDialog(
279+
projectName: model.projectName,
280+
reference: reference,
281+
onPush: {
282+
Task { await model.pushBranch(reference) }
283+
}
284+
)
285+
}
286+
.overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self) { bounds in
287+
GeometryReader { geometry in
288+
if isProjectSwitcherPresented, let bounds {
289+
projectSwitcherOverlay(
290+
buttonFrame: geometry[bounds],
291+
viewportSize: geometry.size
292+
)
293+
}
249294
}
250-
.lithePointer()
251-
Button("Cancel", role: .cancel) {
252-
pendingTopBarPushReference = nil
295+
}
296+
.overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self) { bounds in
297+
GeometryReader { geometry in
298+
if isBranchSwitcherPresented, let bounds {
299+
branchSwitcherOverlay(
300+
buttonFrame: geometry[bounds],
301+
viewportSize: geometry.size
302+
)
303+
}
253304
}
254-
.lithePointer()
255-
} message: {
256-
Text("This sends the current branch to its configured remote.")
257305
}
258306
.overlay(alignment: .bottom) {
259307
if let message = model.notificationMessage {
@@ -406,7 +454,10 @@ struct WorkbenchView: View {
406454
private var topBar: some View {
407455
HStack(spacing: 9) {
408456
Button {
409-
isProjectSwitcherPresented.toggle()
457+
updateSwitcherPresentation(
458+
project: !isProjectSwitcherPresented,
459+
branch: false
460+
)
410461
} label: {
411462
HStack(spacing: 8) {
412463
LitheLogo(size: 24)
@@ -431,36 +482,21 @@ struct WorkbenchView: View {
431482
.buttonStyle(.plain)
432483
.lithePointer()
433484
.accessibilityIdentifier("project-switcher-\(model.id.uuidString)")
434-
.popover(isPresented: $isProjectSwitcherPresented, arrowEdge: .bottom) {
435-
ProjectSwitcherPopover(
436-
isPresented: $isProjectSwitcherPresented,
437-
onNewProject: {
438-
isProjectSwitcherPresented = false
439-
model.chooseProject(title: "New Project", prompt: "Choose Folder")
440-
},
441-
onOpenProject: {
442-
isProjectSwitcherPresented = false
443-
model.chooseProject()
444-
},
445-
onCloneRepository: {
446-
isProjectSwitcherPresented = false
447-
model.showCloneRepository()
448-
},
449-
onOpenRecentProject: { project in
450-
isProjectSwitcherPresented = false
451-
model.openProject(project.url)
452-
}
453-
)
454-
.environmentObject(model)
455-
}
485+
.anchorPreference(
486+
key: ProjectSwitcherButtonBoundsPreferenceKey.self,
487+
value: .bounds
488+
) { $0 }
456489

457490
Rectangle()
458491
.fill(LitheTheme.divider)
459492
.frame(width: 1, height: 20)
460493
.padding(.horizontal, 5)
461494

462495
Button {
463-
isBranchSwitcherPresented.toggle()
496+
updateSwitcherPresentation(
497+
project: false,
498+
branch: !isBranchSwitcherPresented
499+
)
464500
if isBranchSwitcherPresented {
465501
Task { await model.refreshGitHistory() }
466502
}
@@ -490,58 +526,206 @@ struct WorkbenchView: View {
490526
}
491527
.buttonStyle(.plain)
492528
.lithePointer()
493-
.popover(isPresented: $isBranchSwitcherPresented, arrowEdge: .bottom) {
529+
.anchorPreference(
530+
key: BranchSwitcherButtonBoundsPreferenceKey.self,
531+
value: .bounds
532+
) { $0 }
533+
534+
Spacer(minLength: 22)
535+
536+
runConfigurationPicker
537+
runLaunchButton
538+
debugLaunchButton
539+
if hasActiveExecution {
540+
stopExecutionButton
541+
}
542+
543+
backgroundPickerButton
544+
545+
}
546+
.padding(.leading, 76)
547+
.padding(.trailing, 10)
548+
.frame(height: LitheTheme.Metrics.toolbarHeight)
549+
.background {
550+
(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar)
551+
.contentShape(Rectangle())
552+
.onTapGesture(count: 2) {
553+
(NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)?
554+
.toggleWorkspaceZoom()
555+
}
556+
}
557+
}
558+
559+
private func projectSwitcherOverlay(
560+
buttonFrame: CGRect,
561+
viewportSize: CGSize
562+
) -> some View {
563+
let popupMetrics = ProjectSwitcherLayoutMetrics.self
564+
let chromeMetrics = WorkbenchPopoverLayoutMetrics.self
565+
let placement = workbenchPopoverPlacement(
566+
buttonFrame: buttonFrame,
567+
viewportWidth: viewportSize.width,
568+
popupWidth: popupMetrics.width
569+
)
570+
571+
return ZStack(alignment: .topLeading) {
572+
Color.clear
573+
.contentShape(Rectangle())
574+
.onTapGesture { updateSwitcherPresentation(project: false) }
575+
576+
ZStack(alignment: .topLeading) {
577+
WorkbenchPopoverArrow()
578+
.fill(LitheTheme.popupBackground)
579+
.overlay {
580+
WorkbenchPopoverArrow()
581+
.stroke(LitheTheme.panelBorder, lineWidth: 1)
582+
}
583+
.frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight)
584+
.offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2))
585+
586+
ProjectSwitcherPopover(
587+
isPresented: instantProjectSwitcherPresentation,
588+
onNewProject: {
589+
updateSwitcherPresentation(project: false)
590+
model.chooseProject(title: "New Project", prompt: "Choose Folder")
591+
},
592+
onOpenProject: {
593+
updateSwitcherPresentation(project: false)
594+
model.chooseProject()
595+
},
596+
onCloneRepository: {
597+
updateSwitcherPresentation(project: false)
598+
model.showCloneRepository()
599+
},
600+
onOpenRecentProject: { project in
601+
updateSwitcherPresentation(project: false)
602+
model.openProject(project.url)
603+
}
604+
)
605+
.environmentObject(model)
606+
.lithePopupChrome()
607+
.padding(.top, chromeMetrics.arrowHeight - 1)
608+
}
609+
.offset(x: placement.popupX, y: buttonFrame.maxY)
610+
}
611+
.transaction { transaction in
612+
transaction.animation = nil
613+
transaction.disablesAnimations = true
614+
}
615+
.onExitCommand { updateSwitcherPresentation(project: false) }
616+
}
617+
618+
private func branchSwitcherOverlay(
619+
buttonFrame: CGRect,
620+
viewportSize: CGSize
621+
) -> some View {
622+
let popupMetrics = BranchSwitcherPopover.Metrics.self
623+
let chromeMetrics = WorkbenchPopoverLayoutMetrics.self
624+
let placement = workbenchPopoverPlacement(
625+
buttonFrame: buttonFrame,
626+
viewportWidth: viewportSize.width,
627+
popupWidth: popupMetrics.popupWidth
628+
)
629+
630+
return ZStack(alignment: .topLeading) {
631+
Color.clear
632+
.contentShape(Rectangle())
633+
.onTapGesture { updateSwitcherPresentation(branch: false) }
634+
635+
ZStack(alignment: .topLeading) {
636+
WorkbenchPopoverArrow()
637+
.fill(LitheTheme.popupBackground)
638+
.overlay {
639+
WorkbenchPopoverArrow()
640+
.stroke(LitheTheme.panelBorder, lineWidth: 1)
641+
}
642+
.frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight)
643+
.offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2))
644+
494645
BranchSwitcherPopover(
495-
isPresented: $isBranchSwitcherPresented,
646+
isPresented: instantBranchSwitcherPresentation,
496647
onCommit: {
497-
isBranchSwitcherPresented = false
648+
updateSwitcherPresentation(branch: false)
498649
model.selectedSidebar = .changes
499650
},
500651
onPush: { reference in
501-
isBranchSwitcherPresented = false
652+
updateSwitcherPresentation(branch: false)
502653
pendingTopBarPushReference = reference
503654
},
504655
onNewBranch: { reference in
505-
isBranchSwitcherPresented = false
656+
updateSwitcherPresentation(branch: false)
506657
newBranchReference = reference
507658
},
508659
onCheckoutRevision: {
509-
isBranchSwitcherPresented = false
660+
updateSwitcherPresentation(branch: false)
510661
isCheckoutRevisionPresented = true
511662
},
512663
onManageBranches: {
513-
isBranchSwitcherPresented = false
664+
updateSwitcherPresentation(branch: false)
514665
if !model.isGitLogVisible {
515666
model.selectedSidebar = .changes
516667
Task { await model.toggleGitLog() }
517668
}
518669
}
519670
)
520671
.environmentObject(model)
672+
.padding(.top, chromeMetrics.arrowHeight - 1)
521673
}
674+
.offset(x: placement.popupX, y: buttonFrame.maxY)
675+
}
676+
.transaction { transaction in
677+
transaction.animation = nil
678+
transaction.disablesAnimations = true
679+
}
680+
.onExitCommand { updateSwitcherPresentation(branch: false) }
681+
}
522682

523-
Spacer(minLength: 22)
683+
private func workbenchPopoverPlacement(
684+
buttonFrame: CGRect,
685+
viewportWidth: CGFloat,
686+
popupWidth: CGFloat
687+
) -> (popupX: CGFloat, arrowCenterX: CGFloat) {
688+
let metrics = WorkbenchPopoverLayoutMetrics.self
689+
let desiredX = buttonFrame.minX - metrics.leadingOverlap
690+
let maximumX = max(
691+
metrics.viewportMargin,
692+
viewportWidth - popupWidth - metrics.viewportMargin
693+
)
694+
let popupX = min(max(desiredX, metrics.viewportMargin), maximumX)
695+
let arrowCenterX = min(
696+
max(buttonFrame.midX - popupX, metrics.arrowWidth),
697+
popupWidth - metrics.arrowWidth
698+
)
699+
return (popupX, arrowCenterX)
700+
}
524701

525-
runConfigurationPicker
526-
runLaunchButton
527-
debugLaunchButton
528-
if hasActiveExecution {
529-
stopExecutionButton
530-
}
702+
private var instantProjectSwitcherPresentation: Binding<Bool> {
703+
Binding(
704+
get: { isProjectSwitcherPresented },
705+
set: { updateSwitcherPresentation(project: $0) }
706+
)
707+
}
531708

532-
backgroundPickerButton
709+
private var instantBranchSwitcherPresentation: Binding<Bool> {
710+
Binding(
711+
get: { isBranchSwitcherPresented },
712+
set: { updateSwitcherPresentation(branch: $0) }
713+
)
714+
}
533715

534-
}
535-
.padding(.leading, 76)
536-
.padding(.trailing, 10)
537-
.frame(height: LitheTheme.Metrics.toolbarHeight)
538-
.background {
539-
(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar)
540-
.contentShape(Rectangle())
541-
.onTapGesture(count: 2) {
542-
(NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)?
543-
.toggleWorkspaceZoom()
544-
}
716+
private func updateSwitcherPresentation(
717+
project: Bool? = nil,
718+
branch: Bool? = nil
719+
) {
720+
var transaction = Transaction(animation: nil)
721+
transaction.disablesAnimations = true
722+
withTransaction(transaction) {
723+
if let project {
724+
isProjectSwitcherPresented = project
725+
}
726+
if let branch {
727+
isBranchSwitcherPresented = branch
728+
}
545729
}
546730
}
547731

0 commit comments

Comments
 (0)