Skip to content
Merged
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
320 changes: 252 additions & 68 deletions macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<CGRect>?

static func reduce(
value: inout Anchor<CGRect>?,
nextValue: () -> Anchor<CGRect>?
) {
value = nextValue() ?? value
}
}

private struct BranchSwitcherButtonBoundsPreferenceKey: PreferenceKey {
static var defaultValue: Anchor<CGRect>?

static func reduce(
value: inout Anchor<CGRect>?,
nextValue: () -> Anchor<CGRect>?
) {
value = nextValue() ?? value
}
}

struct WorkbenchView: View {
private let moduleUIRegistry = WorkbenchModuleUIComposition.builtIn
@EnvironmentObject private var model: AppModel
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -431,36 +482,21 @@ 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)
.frame(width: 1, height: 20)
.padding(.horizontal, 5)

Button {
isBranchSwitcherPresented.toggle()
updateSwitcherPresentation(
project: false,
branch: !isBranchSwitcherPresented
)
if isBranchSwitcherPresented {
Task { await model.refreshGitHistory() }
}
Expand Down Expand Up @@ -490,58 +526,206 @@ 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() }
}
}
)
.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<Bool> {
Binding(
get: { isProjectSwitcherPresented },
set: { updateSwitcherPresentation(project: $0) }
)
}

backgroundPickerButton
private var instantBranchSwitcherPresentation: Binding<Bool> {
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
}
}
}

Expand Down
Loading
Loading