Skip to content

Commit fea225b

Browse files
authored
Merge branch 'preview' into codex/git-history-pagination
2 parents 03a5ea9 + e4544e1 commit fea225b

26 files changed

Lines changed: 2080 additions & 778 deletions

macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ extension AppModel {
218218
var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] }
219219
var recentGitReferences: [GitReference] { gitFeatureIfActive?.recentGitReferences ?? [] }
220220
var gitCommits: [GitCommit] { gitFeatureIfActive?.gitCommits ?? [] }
221+
/// Cheap stand-in for `gitCommits` as a change key. Comparing the array
222+
/// itself made every `.task(id:)` evaluation walk the whole commit list.
223+
var gitCommitsVersion: Int { gitFeatureIfActive?.gitCommitsVersion ?? 0 }
221224
var gitLogMatchedCommitHashes: Set<String>? {
222225
gitFeatureIfActive?.gitLogMatchedCommitHashes
223226
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os
2+
3+
enum LitheSignpost {
4+
private static let signposter = OSSignposter(
5+
subsystem: "com.openres.Lithe",
6+
category: "Rendering"
7+
)
8+
9+
static func begin(_ name: StaticString) -> OSSignpostIntervalState {
10+
signposter.beginInterval(name)
11+
}
12+
13+
static func end(_ name: StaticString, _ state: OSSignpostIntervalState) {
14+
signposter.endInterval(name, state)
15+
}
16+
17+
#if DEBUG
18+
private static var bodyCounts: [String: Int] = [:]
19+
20+
static func bodyEvaluated(_ view: StaticString) {
21+
bodyCounts["\(view)", default: 0] += 1
22+
}
23+
#else
24+
@inlinable @inline(__always)
25+
static func bodyEvaluated(_ view: StaticString) {}
26+
#endif
27+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import Foundation
2+
3+
/// Coalesces continuous drag updates to one delivery per main run-loop turn.
4+
///
5+
/// Held as `@State` so the bookkeeping never invalidates the host view's body.
6+
/// Uses `DispatchQueue.main.async` which, during `.eventTracking` mode, drains
7+
/// at the end of the current run-loop turn with zero added latency — unlike the
8+
/// 16ms `Task.sleep` pattern which adds a full frame of delay to every update.
9+
///
10+
/// `init` is `nonisolated` so that `@State` default-value initialisation —
11+
/// which Swift 6.2 treats as a nonisolated context — compiles without a
12+
/// diagnostic. All methods that access mutable state remain `@MainActor`.
13+
@MainActor
14+
final class LitheDragUpdateScheduler {
15+
enum Delivery {
16+
case mainRunLoopTurn
17+
#if DEBUG
18+
case manual
19+
#endif
20+
}
21+
22+
private var buffer = FrameCoalescedDragUpdateBuffer()
23+
private var lastDeliveredValue: CGFloat?
24+
private var pendingDelivery: (@MainActor () -> Void)?
25+
private let delivery: Delivery
26+
27+
nonisolated init(delivery: Delivery = .mainRunLoopTurn) {
28+
self.delivery = delivery
29+
}
30+
31+
var pendingValue: CGFloat? { buffer.pendingValue }
32+
33+
func submit(
34+
_ value: CGFloat,
35+
minimumChange: CGFloat = 1,
36+
deliver: @escaping @MainActor (CGFloat) -> Void
37+
) {
38+
if let last = lastDeliveredValue, abs(value - last) < minimumChange { return }
39+
guard buffer.submit(value) else { return }
40+
let work: @MainActor () -> Void = { [weak self] in
41+
guard let self, let value = self.buffer.takePendingValue() else { return }
42+
self.lastDeliveredValue = value
43+
self.pendingDelivery = nil
44+
deliver(value)
45+
}
46+
switch delivery {
47+
case .mainRunLoopTurn:
48+
DispatchQueue.main.async {
49+
MainActor.assumeIsolated { work() }
50+
}
51+
#if DEBUG
52+
case .manual:
53+
pendingDelivery = work
54+
#endif
55+
}
56+
}
57+
58+
func cancel() {
59+
buffer.cancel()
60+
lastDeliveredValue = nil
61+
pendingDelivery = nil
62+
}
63+
64+
#if DEBUG
65+
func flushPendingDeliveryForTesting() {
66+
pendingDelivery?()
67+
pendingDelivery = nil
68+
}
69+
#endif
70+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import CoreGraphics
2+
3+
/// Pure geometry for split-pane resize calculations.
4+
enum LitheSplitPaneGeometry {
5+
enum Placement {
6+
case leading
7+
case trailing
8+
}
9+
10+
static func resolve(
11+
start: CGFloat,
12+
translation: CGFloat,
13+
placement: Placement,
14+
minimum: CGFloat,
15+
maximum: CGFloat
16+
) -> CGFloat {
17+
let raw: CGFloat
18+
switch placement {
19+
case .leading:
20+
raw = start + translation
21+
case .trailing:
22+
raw = start - translation
23+
}
24+
return clamp(raw, minimum: minimum, maximum: maximum)
25+
}
26+
27+
static func clamp(
28+
_ value: CGFloat,
29+
minimum: CGFloat,
30+
maximum: CGFloat
31+
) -> CGFloat {
32+
let effectiveMinimum = min(minimum, maximum)
33+
let effectiveMaximum = max(minimum, maximum)
34+
return min(max(value, effectiveMinimum), effectiveMaximum)
35+
}
36+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import SwiftUI
2+
3+
/// A two-pane split whose divider drag is confined to this container.
4+
///
5+
/// One pane has a tracked size and the other takes the remainder. The dragged
6+
/// size lives here rather than in the hosting feature view, so moving a divider
7+
/// invalidates only this container: `sized` and `flexible` were built by the
8+
/// host's last body pass and are the same values on every re-evaluation, which
9+
/// lets SwiftUI skip their bodies. That is the protection `WorkbenchWorkspaceSplitView`
10+
/// already had and this generalizes to the Git, Run, and test tool windows.
11+
struct LitheSplitPaneView<Sized: View, Flexible: View>: View {
12+
let axis: LitheSplitAxis
13+
let placement: LitheSplitPaneGeometry.Placement
14+
/// The size used until the user drags, re-supplied by the host every body
15+
/// pass. Hosts that derive it from live geometry keep following the window
16+
/// until the first drag, matching the pre-extraction behavior.
17+
let defaultSize: CGFloat
18+
let minimum: CGFloat
19+
let maximum: CGFloat
20+
/// Minimum size reserved for the flexible pane, when the hosted content
21+
/// has a product-level usability requirement of its own.
22+
let flexibleMinimum: CGFloat?
23+
let showsIdleDivider: Bool
24+
/// Called with the final size when a drag ends. Hosts that persist the size
25+
/// write it here; the container then defers to `defaultSize` again so the
26+
/// persisted value is the single source of truth.
27+
let onCommit: ((CGFloat) -> Void)?
28+
29+
private let sized: Sized
30+
private let flexible: Flexible
31+
32+
@State private var draggedSize: CGFloat?
33+
@State private var dragStart: CGFloat = 0
34+
35+
init(
36+
axis: LitheSplitAxis,
37+
placement: LitheSplitPaneGeometry.Placement,
38+
defaultSize: CGFloat,
39+
minimum: CGFloat,
40+
maximum: CGFloat,
41+
flexibleMinimum: CGFloat? = nil,
42+
showsIdleDivider: Bool = true,
43+
onCommit: ((CGFloat) -> Void)? = nil,
44+
@ViewBuilder sized: () -> Sized,
45+
@ViewBuilder flexible: () -> Flexible
46+
) {
47+
self.axis = axis
48+
self.placement = placement
49+
self.defaultSize = defaultSize
50+
self.minimum = minimum
51+
self.maximum = maximum
52+
self.flexibleMinimum = flexibleMinimum
53+
self.showsIdleDivider = showsIdleDivider
54+
self.onCommit = onCommit
55+
self.sized = sized()
56+
self.flexible = flexible()
57+
}
58+
59+
private var resolvedSize: CGFloat {
60+
LitheSplitPaneGeometry.clamp(
61+
draggedSize ?? defaultSize,
62+
minimum: minimum,
63+
maximum: maximum
64+
)
65+
}
66+
67+
var body: some View {
68+
let size = resolvedSize
69+
if axis == .horizontal {
70+
HStack(spacing: 0) { panes(size) }
71+
} else {
72+
VStack(spacing: 0) { panes(size) }
73+
}
74+
}
75+
76+
@ViewBuilder
77+
private func panes(_ size: CGFloat) -> some View {
78+
switch placement {
79+
case .leading:
80+
sizedPane(size)
81+
handle(size)
82+
flexiblePane
83+
case .trailing:
84+
flexiblePane
85+
handle(size)
86+
sizedPane(size)
87+
}
88+
}
89+
90+
@ViewBuilder
91+
private func sizedPane(_ size: CGFloat) -> some View {
92+
if axis == .horizontal {
93+
sized.frame(width: size)
94+
} else {
95+
sized.frame(height: size)
96+
}
97+
}
98+
99+
@ViewBuilder
100+
private var flexiblePane: some View {
101+
if axis == .horizontal {
102+
flexible.frame(minWidth: flexibleMinimum, maxWidth: .infinity)
103+
} else {
104+
flexible.frame(minHeight: flexibleMinimum, maxHeight: .infinity)
105+
}
106+
}
107+
108+
private func handle(_ size: CGFloat) -> some View {
109+
SplitHandleView(
110+
axis: axis,
111+
showsIdleDivider: showsIdleDivider,
112+
onDragStarted: { dragStart = size },
113+
onDragChanged: { translation in
114+
draggedSize = resolved(from: translation)
115+
},
116+
onDragEnded: { translation in
117+
let finalSize = resolved(from: translation)
118+
if let onCommit {
119+
onCommit(finalSize)
120+
// The host now owns the value and feeds it back as
121+
// `defaultSize`; keeping a dragged size too would shadow it.
122+
draggedSize = nil
123+
} else {
124+
draggedSize = finalSize
125+
}
126+
}
127+
)
128+
}
129+
130+
private func resolved(from translation: CGFloat) -> CGFloat {
131+
LitheSplitPaneGeometry.resolve(
132+
start: dragStart,
133+
translation: translation,
134+
placement: placement,
135+
minimum: minimum,
136+
maximum: maximum
137+
)
138+
}
139+
}

macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ struct DiffHorizontalScroller: View {
1212
@State private var dragStartOffset: CGFloat = 0
1313
@State private var isDragging = false
1414
@State private var isHovering = false
15-
@State private var dragUpdateBuffer = FrameCoalescedDragUpdateBuffer()
16-
@State private var dragUpdateTask: Task<Void, Never>?
15+
@State private var dragScheduler = LitheDragUpdateScheduler()
1716

1817
private var maximumOffset: CGFloat {
1918
max(0, contentWidth - viewportWidth)
@@ -53,16 +52,18 @@ struct DiffHorizontalScroller: View {
5352
dragStartOffset = offset
5453
}
5554
guard travel > 0 else { return }
56-
scheduleOffsetUpdate(constrained(
55+
dragScheduler.submit(constrained(
5756
dragStartOffset + (value.translation.width / travel) * maximumOffset
58-
))
57+
)) { nextOffset in
58+
offset = nextOffset
59+
}
5960
}
6061
.onEnded { value in
6162
if travel > 0 {
6263
let finalOffset = constrained(
6364
dragStartOffset + (value.translation.width / travel) * maximumOffset
6465
)
65-
cancelScheduledOffsetUpdate()
66+
dragScheduler.cancel()
6667
offset = finalOffset
6768
}
6869
isDragging = false
@@ -79,7 +80,7 @@ struct DiffHorizontalScroller: View {
7980
.opacity(maximumOffset > 0.5 ? 1 : 0)
8081
.allowsHitTesting(maximumOffset > 0.5)
8182
.accessibilityLabel("Synchronized diff horizontal scroll")
82-
.onDisappear(perform: cancelScheduledOffsetUpdate)
83+
.onDisappear { dragScheduler.cancel() }
8384
.onChange(of: maximumOffset) { newMaximum in
8485
offset = min(max(offset, 0), newMaximum)
8586
}
@@ -88,25 +89,6 @@ struct DiffHorizontalScroller: View {
8889
private func constrained(_ value: CGFloat) -> CGFloat {
8990
min(max(value, 0), maximumOffset)
9091
}
91-
92-
private func scheduleOffsetUpdate(_ nextOffset: CGFloat) {
93-
guard dragUpdateBuffer.submit(nextOffset) else { return }
94-
dragUpdateTask = Task { @MainActor in
95-
try? await Task.sleep(for: .milliseconds(16))
96-
guard !Task.isCancelled else { return }
97-
let nextOffset = dragUpdateBuffer.takePendingValue()
98-
dragUpdateTask = nil
99-
if let nextOffset {
100-
offset = nextOffset
101-
}
102-
}
103-
}
104-
105-
private func cancelScheduledOffsetUpdate() {
106-
dragUpdateTask?.cancel()
107-
dragUpdateTask = nil
108-
dragUpdateBuffer.cancel()
109-
}
11092
}
11193

11294
/// Observes horizontal trackpad/wheel gestures over the diff without becoming

0 commit comments

Comments
 (0)