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
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ extension AppModel {
var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] }
var recentGitReferences: [GitReference] { gitFeatureIfActive?.recentGitReferences ?? [] }
var gitCommits: [GitCommit] { gitFeatureIfActive?.gitCommits ?? [] }
/// Cheap stand-in for `gitCommits` as a change key. Comparing the array
/// itself made every `.task(id:)` evaluation walk the whole commit list.
var gitCommitsVersion: Int { gitFeatureIfActive?.gitCommitsVersion ?? 0 }
var gitLogMatchedCommitHashes: Set<String>? {
gitFeatureIfActive?.gitLogMatchedCommitHashes
}
Expand Down
27 changes: 27 additions & 0 deletions macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import os

enum LitheSignpost {
private static let signposter = OSSignposter(
subsystem: "com.openres.Lithe",
category: "Rendering"
)

static func begin(_ name: StaticString) -> OSSignpostIntervalState {
signposter.beginInterval(name)
}

static func end(_ name: StaticString, _ state: OSSignpostIntervalState) {
signposter.endInterval(name, state)
}

#if DEBUG
private static var bodyCounts: [String: Int] = [:]

static func bodyEvaluated(_ view: StaticString) {
bodyCounts["\(view)", default: 0] += 1
}
#else
@inlinable @inline(__always)
static func bodyEvaluated(_ view: StaticString) {}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// Coalesces continuous drag updates to one delivery per main run-loop turn.
///
/// Held as `@State` so the bookkeeping never invalidates the host view's body.
/// Uses `DispatchQueue.main.async` which, during `.eventTracking` mode, drains
/// at the end of the current run-loop turn with zero added latency — unlike the
/// 16ms `Task.sleep` pattern which adds a full frame of delay to every update.
///
/// `init` is `nonisolated` so that `@State` default-value initialisation —
/// which Swift 6.2 treats as a nonisolated context — compiles without a
/// diagnostic. All methods that access mutable state remain `@MainActor`.
@MainActor
final class LitheDragUpdateScheduler {
enum Delivery {
case mainRunLoopTurn
#if DEBUG
case manual
#endif
}

private var buffer = FrameCoalescedDragUpdateBuffer()
private var lastDeliveredValue: CGFloat?
private var pendingDelivery: (@MainActor () -> Void)?
private let delivery: Delivery

nonisolated init(delivery: Delivery = .mainRunLoopTurn) {
self.delivery = delivery
}

var pendingValue: CGFloat? { buffer.pendingValue }

func submit(
_ value: CGFloat,
minimumChange: CGFloat = 1,
deliver: @escaping @MainActor (CGFloat) -> Void
) {
if let last = lastDeliveredValue, abs(value - last) < minimumChange { return }
guard buffer.submit(value) else { return }
let work: @MainActor () -> Void = { [weak self] in
guard let self, let value = self.buffer.takePendingValue() else { return }
self.lastDeliveredValue = value
self.pendingDelivery = nil
deliver(value)
}
switch delivery {
case .mainRunLoopTurn:
DispatchQueue.main.async {
MainActor.assumeIsolated { work() }
}
#if DEBUG
case .manual:
pendingDelivery = work
#endif
}
}

func cancel() {
buffer.cancel()
lastDeliveredValue = nil
pendingDelivery = nil
}

#if DEBUG
func flushPendingDeliveryForTesting() {
pendingDelivery?()
pendingDelivery = nil
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import CoreGraphics

/// Pure geometry for split-pane resize calculations.
enum LitheSplitPaneGeometry {
enum Placement {
case leading
case trailing
}

static func resolve(
start: CGFloat,
translation: CGFloat,
placement: Placement,
minimum: CGFloat,
maximum: CGFloat
) -> CGFloat {
let raw: CGFloat
switch placement {
case .leading:
raw = start + translation
case .trailing:
raw = start - translation
}
return clamp(raw, minimum: minimum, maximum: maximum)
}

static func clamp(
_ value: CGFloat,
minimum: CGFloat,
maximum: CGFloat
) -> CGFloat {
let effectiveMinimum = min(minimum, maximum)
let effectiveMaximum = max(minimum, maximum)
return min(max(value, effectiveMinimum), effectiveMaximum)
}
}
139 changes: 139 additions & 0 deletions macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import SwiftUI

/// A two-pane split whose divider drag is confined to this container.
///
/// One pane has a tracked size and the other takes the remainder. The dragged
/// size lives here rather than in the hosting feature view, so moving a divider
/// invalidates only this container: `sized` and `flexible` were built by the
/// host's last body pass and are the same values on every re-evaluation, which
/// lets SwiftUI skip their bodies. That is the protection `WorkbenchWorkspaceSplitView`
/// already had and this generalizes to the Git, Run, and test tool windows.
struct LitheSplitPaneView<Sized: View, Flexible: View>: View {
let axis: LitheSplitAxis
let placement: LitheSplitPaneGeometry.Placement
/// The size used until the user drags, re-supplied by the host every body
/// pass. Hosts that derive it from live geometry keep following the window
/// until the first drag, matching the pre-extraction behavior.
let defaultSize: CGFloat
let minimum: CGFloat
let maximum: CGFloat
/// Minimum size reserved for the flexible pane, when the hosted content
/// has a product-level usability requirement of its own.
let flexibleMinimum: CGFloat?
let showsIdleDivider: Bool
/// Called with the final size when a drag ends. Hosts that persist the size
/// write it here; the container then defers to `defaultSize` again so the
/// persisted value is the single source of truth.
let onCommit: ((CGFloat) -> Void)?

private let sized: Sized
private let flexible: Flexible

@State private var draggedSize: CGFloat?
@State private var dragStart: CGFloat = 0

init(
axis: LitheSplitAxis,
placement: LitheSplitPaneGeometry.Placement,
defaultSize: CGFloat,
minimum: CGFloat,
maximum: CGFloat,
flexibleMinimum: CGFloat? = nil,
showsIdleDivider: Bool = true,
onCommit: ((CGFloat) -> Void)? = nil,
@ViewBuilder sized: () -> Sized,
@ViewBuilder flexible: () -> Flexible
) {
self.axis = axis
self.placement = placement
self.defaultSize = defaultSize
self.minimum = minimum
self.maximum = maximum
self.flexibleMinimum = flexibleMinimum
self.showsIdleDivider = showsIdleDivider
self.onCommit = onCommit
self.sized = sized()
self.flexible = flexible()
}

private var resolvedSize: CGFloat {
LitheSplitPaneGeometry.clamp(
draggedSize ?? defaultSize,
minimum: minimum,
maximum: maximum
)
}

var body: some View {
let size = resolvedSize
if axis == .horizontal {
HStack(spacing: 0) { panes(size) }
} else {
VStack(spacing: 0) { panes(size) }
}
}

@ViewBuilder
private func panes(_ size: CGFloat) -> some View {
switch placement {
case .leading:
sizedPane(size)
handle(size)
flexiblePane
case .trailing:
flexiblePane
handle(size)
sizedPane(size)
}
}

@ViewBuilder
private func sizedPane(_ size: CGFloat) -> some View {
if axis == .horizontal {
sized.frame(width: size)
} else {
sized.frame(height: size)
}
}

@ViewBuilder
private var flexiblePane: some View {
if axis == .horizontal {
flexible.frame(minWidth: flexibleMinimum, maxWidth: .infinity)
} else {
flexible.frame(minHeight: flexibleMinimum, maxHeight: .infinity)
}
}

private func handle(_ size: CGFloat) -> some View {
SplitHandleView(
axis: axis,
showsIdleDivider: showsIdleDivider,
onDragStarted: { dragStart = size },
onDragChanged: { translation in
draggedSize = resolved(from: translation)
},
onDragEnded: { translation in
let finalSize = resolved(from: translation)
if let onCommit {
onCommit(finalSize)
// The host now owns the value and feeds it back as
// `defaultSize`; keeping a dragged size too would shadow it.
draggedSize = nil
} else {
draggedSize = finalSize
}
}
)
}

private func resolved(from translation: CGFloat) -> CGFloat {
LitheSplitPaneGeometry.resolve(
start: dragStart,
translation: translation,
placement: placement,
minimum: minimum,
maximum: maximum
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ struct DiffHorizontalScroller: View {
@State private var dragStartOffset: CGFloat = 0
@State private var isDragging = false
@State private var isHovering = false
@State private var dragUpdateBuffer = FrameCoalescedDragUpdateBuffer()
@State private var dragUpdateTask: Task<Void, Never>?
@State private var dragScheduler = LitheDragUpdateScheduler()

private var maximumOffset: CGFloat {
max(0, contentWidth - viewportWidth)
Expand Down Expand Up @@ -53,16 +52,18 @@ struct DiffHorizontalScroller: View {
dragStartOffset = offset
}
guard travel > 0 else { return }
scheduleOffsetUpdate(constrained(
dragScheduler.submit(constrained(
dragStartOffset + (value.translation.width / travel) * maximumOffset
))
)) { nextOffset in
offset = nextOffset
}
}
.onEnded { value in
if travel > 0 {
let finalOffset = constrained(
dragStartOffset + (value.translation.width / travel) * maximumOffset
)
cancelScheduledOffsetUpdate()
dragScheduler.cancel()
offset = finalOffset
}
isDragging = false
Expand All @@ -79,7 +80,7 @@ struct DiffHorizontalScroller: View {
.opacity(maximumOffset > 0.5 ? 1 : 0)
.allowsHitTesting(maximumOffset > 0.5)
.accessibilityLabel("Synchronized diff horizontal scroll")
.onDisappear(perform: cancelScheduledOffsetUpdate)
.onDisappear { dragScheduler.cancel() }
.onChange(of: maximumOffset) { newMaximum in
offset = min(max(offset, 0), newMaximum)
}
Expand All @@ -88,25 +89,6 @@ struct DiffHorizontalScroller: View {
private func constrained(_ value: CGFloat) -> CGFloat {
min(max(value, 0), maximumOffset)
}

private func scheduleOffsetUpdate(_ nextOffset: CGFloat) {
guard dragUpdateBuffer.submit(nextOffset) else { return }
dragUpdateTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(16))
guard !Task.isCancelled else { return }
let nextOffset = dragUpdateBuffer.takePendingValue()
dragUpdateTask = nil
if let nextOffset {
offset = nextOffset
}
}
}

private func cancelScheduledOffsetUpdate() {
dragUpdateTask?.cancel()
dragUpdateTask = nil
dragUpdateBuffer.cancel()
}
}

/// Observes horizontal trackpad/wheel gestures over the diff without becoming
Expand Down
Loading
Loading