diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index c41c41a4b..2c3c45a56 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -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? { gitFeatureIfActive?.gitLogMatchedCommitHashes } diff --git a/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift new file mode 100644 index 000000000..817a0a0b0 --- /dev/null +++ b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift @@ -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 +} diff --git a/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift b/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift new file mode 100644 index 000000000..02f765f81 --- /dev/null +++ b/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift @@ -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 +} diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift new file mode 100644 index 000000000..e96c315da --- /dev/null +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift @@ -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) + } +} diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift new file mode 100644 index 000000000..fe522b314 --- /dev/null +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift @@ -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: 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 + ) + } +} diff --git a/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift b/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift index 0a259de79..38b57cf89 100644 --- a/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift +++ b/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift @@ -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? + @State private var dragScheduler = LitheDragUpdateScheduler() private var maximumOffset: CGFloat { max(0, contentWidth - viewportWidth) @@ -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 @@ -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) } @@ -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 diff --git a/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift b/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift index 658d5fb02..8acc20a66 100644 --- a/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift +++ b/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift @@ -19,8 +19,7 @@ struct DiffSplitPaneView: View { let rowOverlay: (DiffRow, DiffSide) -> RowOverlay @State private var horizontalOffset: CGFloat = 0 - @State private var wheelUpdateBuffer = FrameCoalescedDragUpdateBuffer() - @State private var wheelUpdateTask: Task? + @State private var wheelScheduler = LitheDragUpdateScheduler() init( displayRows: [DiffDisplayRow], @@ -102,17 +101,24 @@ struct DiffSplitPaneView: View { .frame(width: viewportWidth, height: minimumHeight, alignment: .topLeading) .background { DiffHorizontalScrollWheelMonitor { delta in - let pendingOffset = wheelUpdateBuffer.pendingValue ?? horizontalOffset - scheduleWheelOffsetUpdate( - min(max(pendingOffset + delta, 0), maximumHorizontalOffset) - ) + // Wheel deltas are incremental, so accumulate onto the in-flight + // target rather than the last applied offset. minimumChange: 0 + // keeps sub-point wheel steps from being swallowed by the + // deadband, matching the pre-scheduler behavior. + let pendingOffset = wheelScheduler.pendingValue ?? horizontalOffset + wheelScheduler.submit( + min(max(pendingOffset + delta, 0), maximumHorizontalOffset), + minimumChange: 0 + ) { nextOffset in + horizontalOffset = nextOffset + } } } .onChange(of: contentWidth) { _ in - cancelScheduledWheelOffsetUpdate() + wheelScheduler.cancel() horizontalOffset = min(horizontalOffset, maximumHorizontalOffset) } - .onDisappear(perform: cancelScheduledWheelOffsetUpdate) + .onDisappear { wheelScheduler.cancel() } } private func sideViewport( @@ -129,25 +135,6 @@ struct DiffSplitPaneView: View { .background(LitheTheme.editor) } - private func scheduleWheelOffsetUpdate(_ nextOffset: CGFloat) { - guard wheelUpdateBuffer.submit(nextOffset) else { return } - wheelUpdateTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - let nextOffset = wheelUpdateBuffer.takePendingValue() - wheelUpdateTask = nil - if let nextOffset { - horizontalOffset = nextOffset - } - } - } - - private func cancelScheduledWheelOffsetUpdate() { - wheelUpdateTask?.cancel() - wheelUpdateTask = nil - wheelUpdateBuffer.cancel() - } - private var centerGutter: some View { LitheTheme.window .overlay(alignment: .leading) { diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index ccbcfd2f7..ed077f5d5 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -46,7 +46,7 @@ struct EditorAreaView: View { @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @State private var hoveredTabID: UUID? @State private var tabDragState = EditorTabDragState.idle - @State private var editorTabFrames: [EditorTabItem: CGRect] = [:] + @State private var tabFrameStore = EditorTabFrameStore() @State private var tabDragStartFrames: [EditorTabItem: CGRect] = [:] @State private var tabDragOffsetX: CGFloat = 0 @State private var tabReorderTarget: EditorTabReorderTarget? @@ -59,6 +59,7 @@ struct EditorAreaView: View { @State private var resolvedJavaDocumentIconKinds: [String: LitheIconKind] = [:] var body: some View { + let _ = LitheSignpost.bodyEvaluated("EditorAreaView") ZStack(alignment: .top) { Group { if model.selectedSidebar == .database { @@ -214,7 +215,7 @@ struct EditorAreaView: View { .coordinateSpace(name: editorTabCoordinateSpaceName) .onPreferenceChange(EditorTabFramePreferenceKey.self) { frames in guard tabDragState.draggedItem == nil else { return } - editorTabFrames = frames + tabFrameStore.update(frames) } .clipped() .animation(tabAnimation, value: model.editorTabItems) @@ -231,14 +232,25 @@ struct EditorAreaView: View { @ViewBuilder private var editorTabItems: some View { + // Index once per pass. Scanning `openDocuments` and `terminalSessions` + // per tab made this quadratic, and it re-runs on every layout pass. + let documentIndices = Dictionary( + model.openDocuments.enumerated().map { ($0.element.id, $0.offset) }, + // First match wins, matching the `firstIndex(where:)` this replaces. + uniquingKeysWith: { first, _ in first } + ) + let sessionsByID = Dictionary( + model.terminalSessions.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) ForEach(model.editorTabItems) { item in switch item { case .document(let documentID): - if let index = model.openDocuments.firstIndex(where: { $0.id == documentID }) { + if let index = documentIndices[documentID] { editorTab(model.openDocuments[index], at: index) } case .terminal(let sessionID): - if let session = model.terminalSessions.first(where: { $0.id == sessionID }) { + if let session = sessionsByID[sessionID] { editorTerminalTab(session) } } @@ -695,7 +707,7 @@ struct EditorAreaView: View { } private func beginTabDrag(_ item: EditorTabItem) { - tabDragStartFrames = editorTabFrames + tabDragStartFrames = tabFrameStore.frames tabDragOffsetX = 0 tabReorderTarget = nil withAnimation(tabAnimation) { @@ -798,11 +810,11 @@ struct EditorAreaView: View { EditorTabItem.terminal($0) } if let activeTerminalItem, - let sourceFrame = editorTabFrames[activeTerminalItem], + let sourceFrame = tabFrameStore[activeTerminalItem], sourceFrame.contains(location) { return nil } - let candidates = editorTabFrames.filter { item, _ in + let candidates = tabFrameStore.frames.filter { item, _ in item != tabDragState.draggedItem && item != activeTerminalItem } guard let nearest = candidates.min(by: { lhs, rhs in diff --git a/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift b/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift new file mode 100644 index 000000000..dfefa5699 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift @@ -0,0 +1,24 @@ +import CoreGraphics + +/// Where each editor tab currently sits, recorded from layout. +/// +/// Deliberately a reference box held as `@State` rather than `@State` storage of +/// the dictionary itself. The frames are only read when a drag or drop begins, +/// but the preference that reports them fires on *every* layout pass — including +/// every frame of a pane resize. Storing them as view state made each of those +/// passes re-evaluate the whole editor area; a reference box records them +/// without invalidating anything. +/// +/// Follows the same pattern as `EditorViewportStore` and `LithePointerCursor`. +@MainActor +final class EditorTabFrameStore { + private(set) var frames: [EditorTabItem: CGRect] = [:] + + func update(_ frames: [EditorTabItem: CGRect]) { + self.frames = frames + } + + subscript(item: EditorTabItem) -> CGRect? { + frames[item] + } +} diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index d0d0345ec..5ba403c14 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -7,16 +7,16 @@ struct ChangesSidebarView: View { @State private var selectedTab = CommitTab.commit @State private var trackedExpanded = true @State private var untrackedExpanded = true - @State private var commitAreaHeight: CGFloat = 124 - @State private var commitAreaDragStart: CGFloat = 124 @State private var stashMessage = "WIP" @State private var includeUntracked = true @State private var selectedStash: GitStash? @State private var selectedShelf: GitShelfEntry? @State private var pendingDropStash: GitStash? @State private var pendingDropShelf: GitShelfEntry? + @State private var sectionsCache = GitChangeSectionsCache() var body: some View { + let _ = LitheSignpost.bodyEvaluated("ChangesSidebarView") VStack(spacing: 0) { tabHeader Rectangle().fill(LitheTheme.divider).frame(height: 1) @@ -159,43 +159,26 @@ struct ChangesSidebarView: View { minimumCommitHeight, availableCommitHeight ) - let resolvedCommitHeight = constrained( - commitAreaHeight, - minimum: minimumCommitHeight, - maximum: maximumCommitHeight - ) VStack(spacing: 0) { commitToolbar Rectangle().fill(LitheTheme.divider).frame(height: 1) - changeList - .frame(minHeight: minimumListHeight) - SplitHandleView( + + LitheSplitPaneView( axis: .vertical, - onDragStarted: { - commitAreaDragStart = resolvedCommitHeight - }, - onDragChanged: { translation in - commitAreaHeight = constrained( - commitAreaDragStart - translation, - minimum: minimumCommitHeight, - maximum: maximumCommitHeight - ) - }, - onDragEnded: { translation in - commitAreaHeight = constrained( - commitAreaDragStart - translation, - minimum: minimumCommitHeight, - maximum: maximumCommitHeight - ) - } + placement: .trailing, + defaultSize: Self.defaultCommitAreaHeight, + minimum: minimumCommitHeight, + maximum: maximumCommitHeight, + sized: { commitArea }, + flexible: { changeList.frame(minHeight: minimumListHeight) } ) - commitArea - .frame(height: resolvedCommitHeight) } } } + private static let defaultCommitAreaHeight: CGFloat = 124 + private var shelfContent: some View { VStack(spacing: 0) { HStack(spacing: 6) { @@ -792,17 +775,25 @@ struct ChangesSidebarView: View { .padding(24) } + /// All four sections come from one pass over `gitChanges`; see + /// `GitChangeSectionsCache`. + private var changeSections: GitChangeSectionsCache.Sections { + sectionsCache.sections( + changes: model.gitChanges, + conflictFilterPaths: model.gitConflictFilterPaths + ) + } + private var trackedChanges: [GitChange] { - displayedChanges.filter { $0.kind != .added } + changeSections.tracked } private var addedChanges: [GitChange] { - displayedChanges.filter { $0.kind == .added } + changeSections.added } private var displayedChanges: [GitChange] { - guard !model.gitConflictFilterPaths.isEmpty else { return model.gitChanges } - return model.gitChanges.filter { model.gitConflictFilterPaths.contains($0.path) } + changeSections.displayed } private func isEffectivelyStaged(_ change: GitChange) -> Bool { @@ -819,7 +810,7 @@ struct ChangesSidebarView: View { } private var stagedChanges: [GitChange] { - model.gitChanges.filter(\.isStaged) + changeSections.staged } private var canCommit: Bool { diff --git a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift new file mode 100644 index 000000000..832c0626f --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift @@ -0,0 +1,66 @@ +import LitheGitModule + +/// Splits the working-tree changes into the sections the sidebar renders, once +/// per change of the underlying list. +/// +/// `displayedChanges` was recomputed by `trackedChanges`, `addedChanges`, and +/// the empty-state check, so one body pass filtered the whole change list +/// several times over and then partitioned it twice more. +/// +/// A reference box in `@State`, like `EditorViewportStore`, so caching cannot +/// invalidate the view that reads it. +@MainActor +final class GitChangeSectionsCache { + struct Sections { + /// All changes, minus anything hidden by an active conflict filter. + let displayed: [GitChange] + let tracked: [GitChange] + let added: [GitChange] + let staged: [GitChange] + } + + private var cachedChanges: [GitChange] = [] + private var cachedFilterPaths: Set = [] + private var cached: Sections? + + func sections( + changes: [GitChange], + conflictFilterPaths: Set + ) -> Sections { + if let cached, cachedChanges == changes, cachedFilterPaths == conflictFilterPaths { + return cached + } + + var displayed: [GitChange] = [] + var tracked: [GitChange] = [] + var added: [GitChange] = [] + var staged: [GitChange] = [] + displayed.reserveCapacity(changes.count) + + for change in changes { + // `staged` intentionally ignores the conflict filter, matching the + // commit-affordance checks that read it. + if change.isStaged { staged.append(change) } + guard conflictFilterPaths.isEmpty || conflictFilterPaths.contains(change.path) else { + continue + } + displayed.append(change) + if change.kind == .added { + added.append(change) + } else { + tracked.append(change) + } + } + + let sections = Sections( + displayed: displayed, + tracked: tracked, + added: added, + staged: staged + ) + cachedChanges = changes + cachedFilterPaths = conflictFilterPaths + cached = sections + return sections + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift b/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift new file mode 100644 index 000000000..501b6a99c --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift @@ -0,0 +1,25 @@ +import LitheGitModule + +/// Caches the "current" reference lookup against the reference list it came from. +/// +/// `GitLogView` asks for the current branch from more than twenty places in a +/// single body pass, and each ask was a linear scan of every branch, remote +/// branch, and tag in the repository. +/// +/// A reference box in `@State`, like `EditorViewportStore`, so caching cannot +/// invalidate the view that reads it. +@MainActor +final class GitCurrentReferenceCache { + private var cachedReferences: [GitReference] = [] + private var cachedResult: GitReference? + private var hasCached = false + + func reference(in references: [GitReference]) -> GitReference? { + if hasCached, cachedReferences == references { return cachedResult } + let result = references.first(where: \.isCurrent) + cachedReferences = references + cachedResult = result + hasCached = true + return result + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 99ee19b78..fe5fe3c35 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -11,12 +11,10 @@ struct GitLogView: View { @State private var tagsExpanded = true @State private var collapsedReferenceGroups: Set = [] @State private var collapsedFileGroups: Set = [] - @State private var referencePaneWidth: CGFloat = 260 - @State private var referencePaneDragStart: CGFloat = 260 - @State private var detailPaneWidth: CGFloat = 350 - @State private var detailPaneDragStart: CGFloat = 350 - @State private var filesPaneHeight: CGFloat? - @State private var filesPaneDragStart: CGFloat = 0 + @State private var localReferenceRows: [GitReferenceRow] = [] + @State private var remoteReferenceRows: [GitReferenceRow] = [] + @State private var tagReferenceRows: [GitReferenceRow] = [] + @State private var currentReferenceCache = GitCurrentReferenceCache() @State private var branchDialogRequest: GitBranchDialogRequest? @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @@ -67,97 +65,26 @@ struct GitLogView: View { } var body: some View { + let _ = LitheSignpost.bodyEvaluated("GitLogView") VStack(spacing: 0) { toolWindowHeader if selectedGitToolTab == .log { primaryActionBar GeometryReader { geometry in - let minimumReferencePaneWidth: CGFloat = 220 - let minimumCommitPaneWidth: CGFloat = 340 - let minimumDetailPaneWidth: CGFloat = 280 - let availablePaneWidth = max( - 0, - geometry.size.width - (SplitHandleView.thickness * 2) + GitLogThreePaneLayout( + availableWidth: geometry.size.width, + referencePane: { referencePane }, + commitPane: { commitPane }, + detailPane: { detailPane } ) - let maximumDetailPaneWidth = max( - minimumDetailPaneWidth, - min(520, availablePaneWidth - minimumReferencePaneWidth - minimumCommitPaneWidth) - ) - let resolvedDetailPaneWidth = constrained( - detailPaneWidth, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - let maximumReferencePaneWidth = max( - minimumReferencePaneWidth, - min(480, availablePaneWidth - resolvedDetailPaneWidth - minimumCommitPaneWidth) - ) - let resolvedReferencePaneWidth = constrained( - referencePaneWidth, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - - HStack(spacing: 0) { - referencePane - .frame(width: resolvedReferencePaneWidth) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - referencePaneDragStart = resolvedReferencePaneWidth - }, - onDragChanged: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - }, - onDragEnded: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - } - ) - - commitPane - .frame(minWidth: minimumCommitPaneWidth, maxWidth: .infinity) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - detailPaneDragStart = resolvedDetailPaneWidth - }, - onDragChanged: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - }, - onDragEnded: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - } - ) - - detailPane - .frame(width: resolvedDetailPaneWidth) - } } } else { gitConsolePane } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) - .task(id: model.gitCommits) { + .task(id: model.gitCommitsVersion) { let commits = model.gitCommits let updatedLayout = await Task.detached(priority: .userInitiated) { GitGraphLayoutService.layout(commits: commits) @@ -165,13 +92,21 @@ struct GitLogView: View { guard model.gitCommits == commits else { return } graphLayout = updatedLayout } + // The three section arrays are derived, not user state. Rebuilding them + // here rather than in `body` keeps the flattening off the render path + // while still reacting to both inputs it depends on. + .task(id: referenceRowsTaskIdentity) { + rebuildReferenceRows() + } .task(id: gitLogFilterTaskIdentity) { do { try await Task.sleep(for: .milliseconds(180)) } catch { return } - await model.applyGitLogFilter(gitLogQuery) + // `Date()` is captured here — once, at the moment the debounced + // task fires — so date-range boundaries are stable for this query. + await model.applyGitLogFilter(gitLogQuery(now: Date())) } .onChange(of: model.gitRepositoryRoot) { _ in selectedGitLogAuthor = nil @@ -575,11 +510,17 @@ struct GitLogView: View { } private func gitConsoleTimestamp(_ date: Date) -> String { + Self.gitConsoleTimestampFormatter.string(from: date) + } + + // A DateFormatter is expensive to construct, so build it once instead of on + // every console row of every body pass. + private static let gitConsoleTimestampFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm:ss.SSS" - return formatter.string(from: date) - } + return formatter + }() private var primaryActionBar: some View { HStack(spacing: 7) { @@ -716,8 +657,7 @@ struct GitLogView: View { kind: GitReferenceKind, expanded: Binding ) -> some View { - let references = model.gitReferences.filter { $0.kind == kind } - return VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: 1) { Button { expanded.wrappedValue.toggle() } label: { @@ -738,60 +678,86 @@ struct GitLogView: View { .lithePointer() if expanded.wrappedValue { - ForEach(GitReferenceTreeNode.build(from: references)) { node in - referenceTreeNode(node, kind: kind, depth: 0) + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(referenceRows(for: kind)) { row in + GitReferenceRowView( + row: row, + isSelected: isReferenceRowSelected(row), + isPerformingBranchOperation: model.isPerformingBranchOperation, + currentReferenceID: currentReference?.id, + comparisonSourceID: comparisonSourceReference?.id, + actions: referenceRowActions + ) + .equatable() + .id(row.id) + } } } } } - private func referenceTreeNode( - _ node: GitReferenceTreeNode, - kind: GitReferenceKind, - depth: Int - ) -> AnyView { - AnyView( - VStack(alignment: .leading, spacing: 1) { - if let reference = node.reference { - referenceButton(reference, title: node.name, icon: referenceIcon(reference)) - .padding(.leading, CGFloat(18 + depth * 18)) - } - - if !node.children.isEmpty { - Button { - let key = "\(kind.rawValue):\(node.path)" - if collapsedReferenceGroups.contains(key) { - collapsedReferenceGroups.remove(key) - } else { - collapsedReferenceGroups.insert(key) - } - } label: { - HStack(spacing: 7) { - Image(systemName: collapsedReferenceGroups.contains("\(kind.rawValue):\(node.path)") ? "chevron.right" : "chevron.down") - .font(.system(size: 8, weight: .bold)) - .frame(width: 10) - LitheSystemIcon(systemImage: "folder", size: 14) - Text(node.name) - .font(GitVisual.body) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - Spacer(minLength: 8) - } - .padding(.leading, CGFloat(18 + depth * 18)) - .padding(.trailing, 8) - .frame(maxWidth: .infinity, minHeight: GitVisual.treeRowHeight, alignment: .leading) - .contentShape(Rectangle()) - .litheRowHover(cornerRadius: 4) - } - .buttonStyle(.plain) - .lithePointer() + private func referenceRows(for kind: GitReferenceKind) -> [GitReferenceRow] { + switch kind { + case .local: localReferenceRows + case .remote: remoteReferenceRows + case .tag: tagReferenceRows + } + } - if !collapsedReferenceGroups.contains("\(kind.rawValue):\(node.path)") { - ForEach(node.children) { child in - referenceTreeNode(child, kind: kind, depth: depth + 1) - } - } + private func isReferenceRowSelected(_ row: GitReferenceRow) -> Bool { + guard case .reference(let reference) = row.content else { return false } + return model.selectedGitReference?.id == reference.id + || (model.selectedGitReference == nil && reference.isCurrent) + } + + /// Rebuilt on each body pass, but every closure is stable in behavior, and + /// `GitReferenceRowView.==` ignores this struct so it cannot by itself cause + /// a row to re-render. + private var referenceRowActions: GitReferenceRowActions { + GitReferenceRowActions( + select: { reference in + Task { await model.selectGitReference(reference) } + }, + toggleGroup: { key in + if collapsedReferenceGroups.contains(key) { + collapsedReferenceGroups.remove(key) + } else { + collapsedReferenceGroups.insert(key) } + }, + newBranch: { reference in + branchDialogRequest = GitBranchDialogRequest(kind: .create, reference: reference) + }, + renameBranch: { reference in + branchDialogRequest = GitBranchDialogRequest(kind: .rename, reference: reference) + }, + showDiffWithWorkingTree: { reference in + Task { await model.showComparisonWithWorkingTree(for: reference) } + }, + compareWithCurrent: { reference in + guard let currentReference else { return } + Task { await model.showComparison(from: reference, to: currentReference) } + }, + compareWithSelectedSource: { reference in + guard let source = comparisonSourceReference else { return } + comparisonSourceReference = nil + Task { await model.showComparison(from: source, to: reference) } + }, + selectForCompare: { reference in + comparisonSourceReference = reference + }, + comparisonSourceName: comparisonSourceReference?.shortName, + checkout: { reference in + Task { await model.checkoutReference(reference) } + }, + updateCurrentBranch: { reference in + Task { await model.updateCurrentBranch(reference) } + }, + push: { reference in + pendingPushReference = reference + }, + branchOperation: { kind, reference in + pendingBranchOperation = GitBranchOperationRequest(kind: kind, reference: reference) } ) } @@ -1070,40 +1036,18 @@ struct GitLogView: View { minimumFilesPaneHeight, geometry.size.height - SplitHandleView.thickness - minimumCommitDetailHeight ) - let resolvedFilesPaneHeight = constrained( - filesPaneHeight ?? (geometry.size.height - SplitHandleView.thickness - 156), + + LitheSplitPaneView( + axis: .vertical, + placement: .leading, + // Until the user drags, the files pane keeps tracking the + // container so the detail area stays at its designed height. + defaultSize: geometry.size.height - SplitHandleView.thickness - 156, minimum: minimumFilesPaneHeight, - maximum: maximumFilesPaneHeight + maximum: maximumFilesPaneHeight, + sized: { commitFilesPane }, + flexible: { commitDetail } ) - - VStack(spacing: 0) { - commitFilesPane - .frame(height: resolvedFilesPaneHeight) - - SplitHandleView( - axis: .vertical, - onDragStarted: { - filesPaneDragStart = resolvedFilesPaneHeight - }, - onDragChanged: { translation in - filesPaneHeight = constrained( - filesPaneDragStart + translation, - minimum: minimumFilesPaneHeight, - maximum: maximumFilesPaneHeight - ) - }, - onDragEnded: { translation in - filesPaneHeight = constrained( - filesPaneDragStart + translation, - minimum: minimumFilesPaneHeight, - maximum: maximumFilesPaneHeight - ) - } - ) - - commitDetail - .frame(maxHeight: .infinity) - } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) } @@ -1287,10 +1231,19 @@ struct GitLogView: View { } private var visibleCommitHashes: Set? { - guard !gitLogQuery.isEmpty else { return nil } + guard hasActiveGitLogFilter else { return nil } return model.gitLogMatchedCommitHashes } + /// True when any filter is active, without calling `Date()`. Used to decide + /// whether to show the filtered commit subset or the full log. + private var hasActiveGitLogFilter: Bool { + !model.gitLogSearchQuery.isEmpty + || selectedGitLogAuthor != nil + || selectedGitLogDatePreset != .anyTime + || !gitLogPathFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + private var gitLogFilterTaskIdentity: GitLogFilterTaskIdentity { GitLogFilterTaskIdentity( searchQuery: model.gitLogSearchQuery, @@ -1301,14 +1254,16 @@ struct GitLogView: View { ) } - private var gitLogQuery: GitLogQuery { + /// Builds the filter query with a caller-supplied `now`, so `Date()` is + /// only called once at the task execution site rather than on every body pass. + private func gitLogQuery(now: Date) -> GitLogQuery { let path = gitLogPathFilter.trimmingCharacters(in: .whitespacesAndNewlines) let query = GitLogQuery.parse(model.gitLogSearchQuery).addingStructuredFilters( currentUserOnly: selectedGitLogAuthor == .currentUser, exactAuthor: selectedGitLogAuthor?.exactAuthor, paths: path.isEmpty ? [] : [path] ) - return selectedGitLogDatePreset.applying(to: query, now: Date()) + return selectedGitLogDatePreset.applying(to: query, now: now) } private var gitLogAuthorOptions: [GitLogAuthorOption] { @@ -1625,8 +1580,39 @@ struct GitLogView: View { return components.suffix(2).joined(separator: "/") } + /// Asked for from many places in one body pass, so the linear scan is + /// memoized against the reference list it came from. private var currentReference: GitReference? { - model.gitReferences.first(where: \.isCurrent) + currentReferenceCache.reference(in: model.gitReferences) + } + + /// Both inputs the flattened rows depend on. `gitReferences` is compared by + /// value because it is small and changes rarely; the collapse set changes + /// only on an explicit disclosure toggle. + private var referenceRowsTaskIdentity: GitReferenceRowsIdentity { + GitReferenceRowsIdentity( + references: model.gitReferences, + collapsedGroups: collapsedReferenceGroups + ) + } + + private func rebuildReferenceRows() { + let references = model.gitReferences + localReferenceRows = GitReferenceRowsBuilder.rows( + from: references.filter { $0.kind == .local }, + kind: .local, + collapsedGroups: collapsedReferenceGroups + ) + remoteReferenceRows = GitReferenceRowsBuilder.rows( + from: references.filter { $0.kind == .remote }, + kind: .remote, + collapsedGroups: collapsedReferenceGroups + ) + tagReferenceRows = GitReferenceRowsBuilder.rows( + from: references.filter { $0.kind == .tag }, + kind: .tag, + collapsedGroups: collapsedReferenceGroups + ) } private func referenceIcon(_ reference: GitReference) -> String { @@ -1784,72 +1770,6 @@ enum GitLogDatePreset: String, CaseIterable, Identifiable, Hashable { } } -private struct GitReferenceTreeNode: Identifiable { - let path: String - let name: String - let reference: GitReference? - let children: [GitReferenceTreeNode] - - var id: String { path } - - static func build(from references: [GitReference]) -> [GitReferenceTreeNode] { - let root = MutableGitReferenceTreeNode(name: "", path: "") - - for reference in references { - let components = reference.shortName - .split(separator: "/") - .map(String.init) - guard !components.isEmpty else { continue } - - var node = root - var pathComponents: [String] = [] - for component in components { - pathComponents.append(component) - if node.children[component] == nil { - node.children[component] = MutableGitReferenceTreeNode( - name: component, - path: pathComponents.joined(separator: "/") - ) - } - node = node.children[component]! - } - node.reference = reference - } - - return makeNodes(from: root) - } - - private static func makeNodes(from node: MutableGitReferenceTreeNode) -> [GitReferenceTreeNode] { - node.children.values - .map { child in - GitReferenceTreeNode( - path: child.path, - name: child.name, - reference: child.reference, - children: makeNodes(from: child) - ) - } - .sorted { lhs, rhs in - if (lhs.reference != nil) != (rhs.reference != nil) { - return lhs.reference != nil - } - return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending - } - } -} - -private final class MutableGitReferenceTreeNode { - let name: String - let path: String - var reference: GitReference? - var children: [String: MutableGitReferenceTreeNode] = [:] - - init(name: String, path: String) { - self.name = name - self.path = path - } -} - private enum GitCommitFileTreeItem: Identifiable { case folder(GitCommitFileTreeNode, depth: Int) case file(GitCommitFile, depth: Int) @@ -2485,3 +2405,293 @@ struct GitCheckoutConflictDialog: View { dismiss() } } + +// MARK: - Git Reference Row Actions & View + +/// Combined `.task(id:)` key for the flattened reference rows, so the rows are +/// rebuilt when either the references or the collapse state changes. +private struct GitReferenceRowsIdentity: Equatable { + let references: [GitReference] + let collapsedGroups: Set +} + +private struct GitReferenceRowActions { + let select: (GitReference) -> Void + let toggleGroup: (String) -> Void + let newBranch: (GitReference) -> Void + let renameBranch: (GitReference) -> Void + let showDiffWithWorkingTree: (GitReference) -> Void + let compareWithCurrent: (GitReference) -> Void + let compareWithSelectedSource: (GitReference) -> Void + let selectForCompare: (GitReference) -> Void + let comparisonSourceName: String? + let checkout: (GitReference) -> Void + let updateCurrentBranch: (GitReference) -> Void + let push: (GitReference) -> Void + let branchOperation: (GitBranchOperationKind, GitReference) -> Void +} + +private struct GitReferenceRowView: View, Equatable { + let row: GitReferenceRow + let isSelected: Bool + let isPerformingBranchOperation: Bool + let currentReferenceID: String? + let comparisonSourceID: String? + let actions: GitReferenceRowActions + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.row == rhs.row + && lhs.isSelected == rhs.isSelected + && lhs.isPerformingBranchOperation == rhs.isPerformingBranchOperation + && lhs.currentReferenceID == rhs.currentReferenceID + && lhs.comparisonSourceID == rhs.comparisonSourceID + } + + var body: some View { + switch row.content { + case .group(let key, let isCollapsed): + groupRow(key: key, isCollapsed: isCollapsed) + case .reference(let reference): + referenceRow(reference) + } + } + + private func groupRow(key: String, isCollapsed: Bool) -> some View { + Button { + actions.toggleGroup(key) + } label: { + HStack(spacing: 7) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 8, weight: .bold)) + .frame(width: 10) + Image(systemName: "folder") + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + Text(row.name) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 8) + } + .padding(.leading, CGFloat(row.depth * 16)) + .padding(.trailing, 8) + .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) + .contentShape(Rectangle()) + .litheRowHover(cornerRadius: 4) + } + .buttonStyle(.plain) + .lithePointer() + } + + private func referenceRow(_ reference: GitReference) -> some View { + Button { + actions.select(reference) + } label: { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: referenceIcon(reference), size: 14) + .foregroundStyle(reference.kind == .tag ? LitheTheme.warning : LitheTheme.secondaryText) + .frame(width: 16) + Text(row.name) + .font(.system(size: 13)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + if reference.isCurrent { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(LitheTheme.accent) + } + Spacer(minLength: 8) + } + .padding(.leading, CGFloat(row.depth * 16)) + .padding(.trailing, 8) + .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + .litheRowHover( + isActive: isSelected, + cornerRadius: 4, + activeBackground: LitheTheme.subtleSelection + ) + } + .buttonStyle(.plain) + .lithePointer() + .contextMenu { + Button("New Branch from '\(reference.shortName)'…") { + actions.newBranch(reference) + } + + Button("Show Diff with Working Tree") { + actions.showDiffWithWorkingTree(reference) + } + + if let currentReferenceID, currentReferenceID != reference.id { + Button("Compare with Current Branch") { + actions.compareWithCurrent(reference) + } + } + + if let comparisonSourceID, comparisonSourceID != reference.id, + let sourceName = actions.comparisonSourceName { + Button("Compare '\(sourceName)' with '\(reference.shortName)'") { + actions.compareWithSelectedSource(reference) + } + } else { + Button("Select for Compare") { + actions.selectForCompare(reference) + } + } + + if !reference.isCurrent { + Divider() + + Button("Checkout") { + actions.checkout(reference) + } + .disabled(isPerformingBranchOperation) + + if reference.kind != .tag { + Button("Checkout and Rebase onto Current Branch") { + actions.branchOperation(.checkoutAndRebase, reference) + } + .disabled(isPerformingBranchOperation) + + Button("Merge into Current Branch") { + actions.branchOperation(.merge, reference) + } + .disabled(isPerformingBranchOperation) + + Button("Rebase Current Branch onto…") { + actions.branchOperation(.rebase, reference) + } + .disabled(isPerformingBranchOperation) + } + } + + if reference.kind == .remote { + Divider() + + Button("Pull with Rebase") { + actions.branchOperation(.pullRebase, reference) + } + .disabled(isPerformingBranchOperation) + + Button("Pull with Merge") { + actions.branchOperation(.pullMerge, reference) + } + .disabled(isPerformingBranchOperation) + } + + if reference.kind == .local { + Divider() + + Button("Update") { + actions.updateCurrentBranch(reference) + } + .disabled(!reference.isCurrent || isPerformingBranchOperation) + + Button("Push…") { + actions.push(reference) + } + .disabled(isPerformingBranchOperation) + + if !reference.isCurrent { + Button("Delete Branch", role: .destructive) { + actions.branchOperation(.delete, reference) + } + .disabled(isPerformingBranchOperation) + } + + Divider() + + Button("Rename…") { + actions.renameBranch(reference) + } + .disabled(isPerformingBranchOperation) + } + } + } + + private func referenceIcon(_ reference: GitReference) -> String { + switch reference.kind { + case .local: "point.3.connected.trianglepath.dotted" + case .remote: "cloud" + case .tag: "tag" + } + } +} + +// MARK: - Git Log Three-Pane Layout + +private enum GitLogThreePaneMetrics { + static let minimumReferencePaneWidth: CGFloat = 180 + static let minimumCommitPaneWidth: CGFloat = 340 + static let minimumDetailPaneWidth: CGFloat = 250 +} + +private struct GitLogThreePaneLayout: View { + let availableWidth: CGFloat + private let referencePane: ReferencePane + private let commitPane: CommitPane + private let detailPane: DetailPane + + init( + availableWidth: CGFloat, + @ViewBuilder referencePane: () -> ReferencePane, + @ViewBuilder commitPane: () -> CommitPane, + @ViewBuilder detailPane: () -> DetailPane + ) { + self.availableWidth = availableWidth + self.referencePane = referencePane() + self.commitPane = commitPane() + self.detailPane = detailPane() + } + + private var referencePaneMaximum: CGFloat { + max( + GitLogThreePaneMetrics.minimumReferencePaneWidth, + min( + availableWidth * 0.35, + availableWidth + - (SplitHandleView.thickness * 2) + - GitLogThreePaneMetrics.minimumCommitPaneWidth + - GitLogThreePaneMetrics.minimumDetailPaneWidth + ) + ) + } + + private var detailPaneMaximum: CGFloat { + max( + GitLogThreePaneMetrics.minimumDetailPaneWidth, + min( + availableWidth * 0.5, + availableWidth + - (SplitHandleView.thickness * 2) + - GitLogThreePaneMetrics.minimumCommitPaneWidth + - referencePaneMaximum + ) + ) + } + + var body: some View { + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: 220, + minimum: GitLogThreePaneMetrics.minimumReferencePaneWidth, + maximum: referencePaneMaximum, + flexibleMinimum: GitLogThreePaneMetrics.minimumCommitPaneWidth, + sized: { referencePane }, + flexible: { + LitheSplitPaneView( + axis: .horizontal, + placement: .trailing, + defaultSize: 350, + minimum: GitLogThreePaneMetrics.minimumDetailPaneWidth, + maximum: detailPaneMaximum, + sized: { detailPane }, + flexible: { commitPane } + ) + } + ) + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift new file mode 100644 index 000000000..5b114f3d1 --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift @@ -0,0 +1,162 @@ +import Foundation +import LitheGitModule + +/// One visible line of the Git log's reference tree. +/// +/// The tree used to render as a recursive `-> AnyView` function, which erased +/// every level's type, blocked `LazyVStack`, and forced the whole tree to +/// re-evaluate whenever `GitLogView` re-ran. Flattening to rows makes each line +/// independently comparable and lazily rendered. +struct GitReferenceRow: Identifiable, Equatable { + enum Content: Equatable { + /// A branch, remote branch, or tag the user can act on. + case reference(GitReference) + /// A path segment shared by several references, such as `feature` in + /// `feature/a` and `feature/b`. `key` is the collapse-state key. + case group(key: String, isCollapsed: Bool) + } + + let id: String + /// The last path component, which is what the row displays. + let name: String + /// Nesting level, used only for the row's leading indent. + let depth: Int + let content: Content +} + +/// Flattens references into the visible rows of one section, in render order. +enum GitReferenceRowsBuilder { + /// - Parameters: + /// - references: Already filtered to a single `kind` by the caller. + /// - collapsedGroups: Keys of groups whose children are hidden. + static func rows( + from references: [GitReference], + kind: GitReferenceKind, + collapsedGroups: Set + ) -> [GitReferenceRow] { + var rows: [GitReferenceRow] = [] + append( + GitReferenceTreeNode.build(from: references), + kind: kind, + depth: 0, + collapsedGroups: collapsedGroups, + into: &rows + ) + return rows + } + + private static func append( + _ nodes: [GitReferenceTreeNode], + kind: GitReferenceKind, + depth: Int, + collapsedGroups: Set, + into rows: inout [GitReferenceRow] + ) { + for node in nodes { + // A node can be both: `feature` may be a branch and also the prefix + // of `feature/x`, in which case it emits a reference row and a group + // row at the same depth. + if let reference = node.reference { + rows.append( + GitReferenceRow( + id: "reference:" + node.path, + name: node.name, + depth: depth, + content: .reference(reference) + ) + ) + } + + guard !node.children.isEmpty else { continue } + let key = "\(kind.rawValue):\(node.path)" + let isCollapsed = collapsedGroups.contains(key) + rows.append( + GitReferenceRow( + id: "group:" + node.path, + name: node.name, + depth: depth, + content: .group(key: key, isCollapsed: isCollapsed) + ) + ) + + guard !isCollapsed else { continue } + append( + node.children, + kind: kind, + depth: depth + 1, + collapsedGroups: collapsedGroups, + into: &rows + ) + } + } +} + +/// Intermediate tree used only to group references by their `/`-separated path +/// before flattening. +struct GitReferenceTreeNode: Identifiable { + let path: String + let name: String + let reference: GitReference? + let children: [GitReferenceTreeNode] + + var id: String { path } + + static func build(from references: [GitReference]) -> [GitReferenceTreeNode] { + let root = MutableGitReferenceTreeNode(name: "", path: "") + + for reference in references { + let components = reference.shortName + .split(separator: "/") + .map(String.init) + guard !components.isEmpty else { continue } + + var node = root + var pathComponents: [String] = [] + for component in components { + pathComponents.append(component) + if node.children[component] == nil { + node.children[component] = MutableGitReferenceTreeNode( + name: component, + path: pathComponents.joined(separator: "/") + ) + } + node = node.children[component]! + } + node.reference = reference + } + + return makeNodes(from: root) + } + + private static func makeNodes(from node: MutableGitReferenceTreeNode) -> [GitReferenceTreeNode] { + node.children.values + .map { child in + GitReferenceTreeNode( + path: child.path, + name: child.name, + reference: child.reference, + children: makeNodes(from: child) + ) + } + // Leaves before folders, then natural order, so the list is stable + // across rebuilds of the unordered child dictionary. + .sorted { lhs, rhs in + if (lhs.reference != nil) != (rhs.reference != nil) { + return lhs.reference != nil + } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } +} + +private final class MutableGitReferenceTreeNode { + let name: String + let path: String + var reference: GitReference? + var children: [String: MutableGitReferenceTreeNode] = [:] + + init(name: String, path: String) { + self.name = name + self.path = path + } +} diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift index 1d6f8460a..88d5857e8 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -30,42 +30,27 @@ struct LanguageTestsView: View { minimumListWidth, geometry.size.width - SplitHandleView.thickness - minimumContentWidth ) - let resolvedListWidth = min( - max(liveItemListWidth ?? CGFloat(itemListWidth), minimumListWidth), - maximumListWidth - ) - HStack(spacing: 0) { - if isItemListCollapsed { + if isItemListCollapsed { + HStack(spacing: 0) { collapsedItemListBar .frame(width: 32) Rectangle() .fill(LitheTheme.divider) .frame(width: 1) - } else { - testItemList - .frame(width: resolvedListWidth) - SplitHandleView( - axis: .horizontal, - onDragStarted: { itemListDragStart = resolvedListWidth }, - onDragChanged: { translation in - liveItemListWidth = min( - max(itemListDragStart + translation, minimumListWidth), - maximumListWidth - ) - }, - onDragEnded: { translation in - let finalWidth = min( - max(itemListDragStart + translation, minimumListWidth), - maximumListWidth - ) - itemListWidth = Double(finalWidth) - liveItemListWidth = nil - } - ) + selectedTestContent } - - selectedTestContent + } else { + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: CGFloat(itemListWidth), + minimum: minimumListWidth, + maximum: maximumListWidth, + onCommit: { itemListWidth = Double($0) }, + sized: { testItemList }, + flexible: { selectedTestContent } + ) } } } diff --git a/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift b/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift new file mode 100644 index 000000000..71d19a3b6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Parses the comma- and newline-separated token lists the Run tool window keeps +/// in `@AppStorage`, memoized on the raw string. +/// +/// The stored strings are re-parsed on every access, and `isPinned` is called +/// inside a filter over every configuration, so a body pass re-split the whole +/// list once per configuration. Caching on the raw value turns that O(n·m) back +/// into O(m) while keeping `@AppStorage` the source of truth. +/// +/// Held as a reference box in `@State` (like `EditorViewportStore`) so caching +/// never invalidates the view that reads it. +@MainActor +final class RunConfigurationTokenCache { + private var lastRawValue: String? + private var lastTokens: Set = [] + + /// - Parameter separator: `,` for collapsed executions, `\n` for pin tokens. + func tokens(from rawValue: String, separator: Character) -> Set { + if lastRawValue == rawValue { return lastTokens } + let tokens = Set(rawValue.split(separator: separator).map(String.init)) + lastRawValue = rawValue + lastTokens = tokens + return tokens + } +} diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index f4b238db8..d0ad0099a 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -9,13 +9,16 @@ struct RunView: View { @AppStorage("lithe.run.pinnedConfigurationIDs") private var pinnedConfigurationTokens = "" @AppStorage("lithe.run.configurationListWidth") private var configurationListWidth = 230.0 @AppStorage("lithe.run.configurationListCollapsed") private var isConfigurationListCollapsed = false - @State private var liveConfigurationListWidth: CGFloat? - @State private var configurationListDragStart: CGFloat = 230 + /// Separate caches: the two raw strings change independently, and one box + /// memoizes a single raw value. + @State private var collapsedExecutionCache = RunConfigurationTokenCache() + @State private var pinnedConfigurationCache = RunConfigurationTokenCache() /// The configuration whose editor popover is open. Held separately from the list /// selection so opening an editor does not switch which log is shown. @State private var editingConfigurationID: String? var body: some View { + let _ = LitheSignpost.bodyEvaluated("RunView") VStack(spacing: 0) { toolWindowHeader @@ -48,48 +51,26 @@ struct RunView: View { minimumListWidth, min(420, geometry.size.width - SplitHandleView.thickness - minimumContentWidth) ) - let resolvedListWidth = constrained( - liveConfigurationListWidth ?? CGFloat(configurationListWidth), - minimum: minimumListWidth, - maximum: maximumListWidth - ) - - HStack(spacing: 0) { - if isConfigurationListCollapsed { + if isConfigurationListCollapsed { + HStack(spacing: 0) { collapsedConfigurationListBar .frame(width: 32) Rectangle() .fill(LitheTheme.divider) .frame(width: 1) - } else { - moduleSessionList - .frame(width: resolvedListWidth) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - configurationListDragStart = resolvedListWidth - }, - onDragChanged: { translation in - liveConfigurationListWidth = constrained( - configurationListDragStart + translation, - minimum: minimumListWidth, - maximum: maximumListWidth - ) - }, - onDragEnded: { translation in - let finalWidth = constrained( - configurationListDragStart + translation, - minimum: minimumListWidth, - maximum: maximumListWidth - ) - configurationListWidth = Double(finalWidth) - liveConfigurationListWidth = nil - } - ) + selectedConfigurationContent } - - selectedConfigurationContent + } else { + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: CGFloat(configurationListWidth), + minimum: minimumListWidth, + maximum: maximumListWidth, + onCommit: { configurationListWidth = Double($0) }, + sized: { moduleSessionList }, + flexible: { selectedConfigurationContent } + ) } } } @@ -434,11 +415,11 @@ struct RunView: View { } private var collapsedExecutions: Set { - Set(collapsedExecutionIDs.split(separator: ",").map(String.init)) + collapsedExecutionCache.tokens(from: collapsedExecutionIDs, separator: ",") } private var pinnedConfigurationTokenSet: Set { - Set(pinnedConfigurationTokens.split(separator: "\n").map(String.init)) + pinnedConfigurationCache.tokens(from: pinnedConfigurationTokens, separator: "\n") } private func pinToken(for configuration: RunConfiguration) -> String { @@ -888,10 +869,6 @@ struct RunView: View { return trimmed.isEmpty ? nil : trimmed } - private func constrained(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat { - min(max(value, minimum), maximum) - } - private func sectionHeader(_ execution: RunConfigurationExecution, count: Int) -> some View { Button { toggleCollapsed(execution) diff --git a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift index f0d598ce7..d909c208b 100644 --- a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -21,6 +21,8 @@ struct SplitHandleView: View { @State private var isHovering = false @State private var isDragging = false + @State private var dragScheduler = LitheDragUpdateScheduler() + @State private var cursor = SplitHandleCursor() init( axis: LitheSplitAxis, @@ -40,37 +42,54 @@ struct SplitHandleView: View { self.onDragEnded = onDragEnded } - @ViewBuilder var body: some View { - if axis == .horizontal { - handleSurface.frame(maxHeight: .infinity) - } else { - handleSurface.frame(maxWidth: .infinity) - } - } - - private var handleSurface: some View { ZStack { trackBackground + Color.clear dividerLine - SplitHandleInteractionView( - axis: axis, - onHoverChanged: { isHovering = $0 }, - onDragStateChanged: { isDragging = $0 }, - onDragStarted: onDragStarted, - onDragChanged: onDragChanged, - onDragEnded: onDragEnded - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame( width: axis == .horizontal ? Self.thickness : nil, height: axis == .vertical ? Self.thickness : nil ) .contentShape(Rectangle()) + .gesture( + // The handle moves with the resized pane, so local coordinates create a + // feedback loop where translation jumps as the coordinate origin moves. + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + if !isDragging { + isDragging = true + cursor.update(isResizing: true, cursor: resizeCursor) + onDragStarted() + } + let currentTranslation = axis == .horizontal ? value.translation.width : value.translation.height + // Pointer devices can deliver substantially more events than + // the display can present. The scheduler keeps only the + // newest translation for the next run-loop turn and applies + // the sub-point deadband, so no per-event @State is written. + dragScheduler.submit(currentTranslation) { translation in + onDragChanged(translation) + } + } + .onEnded { value in + let finalTranslation = axis == .horizontal + ? value.translation.width + : value.translation.height + dragScheduler.cancel() + isDragging = false + cursor.update(isResizing: isHovering, cursor: resizeCursor) + onDragEnded(finalTranslation) + } + ) + .onHover { isInside in + guard isInside != isHovering else { return } + isHovering = isInside + cursor.update(isResizing: isInside || isDragging, cursor: resizeCursor) + } .onDisappear { - isHovering = false - isDragging = false + dragScheduler.cancel() + cursor.update(isResizing: false, cursor: resizeCursor) } .help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize") .accessibilityLabel(axis == .horizontal ? "Horizontal pane resize handle" : "Vertical pane resize handle") @@ -98,7 +117,7 @@ struct SplitHandleView: View { @ViewBuilder private var dividerLine: some View { let isHighlighted = isHovering || isDragging - if showsIdleDivider { + if showsIdleDivider || isHighlighted { let color = isHighlighted ? LitheTheme.accent : LitheTheme.divider if axis == .horizontal { @@ -115,183 +134,22 @@ struct SplitHandleView: View { } } -} - -private struct SplitHandleInteractionView: NSViewRepresentable { - let axis: LitheSplitAxis - let onHoverChanged: (Bool) -> Void - let onDragStateChanged: (Bool) -> Void - let onDragStarted: () -> Void - let onDragChanged: (CGFloat) -> Void - let onDragEnded: (CGFloat) -> Void - - func makeNSView(context: Context) -> SplitHandleInteractionNSView { - SplitHandleInteractionNSView( - axis: axis, - onHoverChanged: onHoverChanged, - onDragStateChanged: onDragStateChanged, - onDragStarted: onDragStarted, - onDragChanged: onDragChanged, - onDragEnded: onDragEnded - ) - } - - func updateNSView(_ nsView: SplitHandleInteractionNSView, context: Context) { - nsView.update( - axis: axis, - onHoverChanged: onHoverChanged, - onDragStateChanged: onDragStateChanged, - onDragStarted: onDragStarted, - onDragChanged: onDragChanged, - onDragEnded: onDragEnded - ) - } - - static func dismantleNSView(_ nsView: SplitHandleInteractionNSView, coordinator: ()) { - nsView.cancelInteraction() - } -} - -private final class SplitHandleInteractionNSView: NSView { - private var axis: LitheSplitAxis - private var onHoverChanged: (Bool) -> Void - private var onDragStateChanged: (Bool) -> Void - private var onDragStarted: () -> Void - private var onDragChanged: (CGFloat) -> Void - private var onDragEnded: (CGFloat) -> Void - private var trackingArea: NSTrackingArea? - private var dragStartInWindow: NSPoint? - private var isDragging = false - private var isInside = false - - init( - axis: LitheSplitAxis, - onHoverChanged: @escaping (Bool) -> Void, - onDragStateChanged: @escaping (Bool) -> Void, - onDragStarted: @escaping () -> Void, - onDragChanged: @escaping (CGFloat) -> Void, - onDragEnded: @escaping (CGFloat) -> Void - ) { - self.axis = axis - self.onHoverChanged = onHoverChanged - self.onDragStateChanged = onDragStateChanged - self.onDragStarted = onDragStarted - self.onDragChanged = onDragChanged - self.onDragEnded = onDragEnded - super.init(frame: .zero) - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func updateTrackingAreas() { - if let trackingArea { - removeTrackingArea(trackingArea) - } - let area = NSTrackingArea( - rect: bounds, - options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], - owner: self, - userInfo: nil - ) - addTrackingArea(area) - trackingArea = area - super.updateTrackingAreas() - } - - override func resetCursorRects() { - super.resetCursorRects() - addCursorRect(bounds, cursor: resizeCursor) - } - - override func cursorUpdate(with event: NSEvent) { - resizeCursor.set() - } - - override func mouseEntered(with event: NSEvent) { - isInside = true - onHoverChanged(true) - resizeCursor.set() - } - - override func mouseExited(with event: NSEvent) { - isInside = false - onHoverChanged(false) - if !isDragging { - NSCursor.arrow.set() - } - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - true - } - - override func mouseDown(with event: NSEvent) { - isDragging = true - dragStartInWindow = event.locationInWindow - onDragStateChanged(true) - resizeCursor.set() - onDragStarted() - } - - override func mouseDragged(with event: NSEvent) { - guard let dragStartInWindow else { return } - onDragChanged(translation(from: dragStartInWindow, to: event.locationInWindow)) - } - - override func mouseUp(with event: NSEvent) { - guard let dragStartInWindow else { return } - onDragEnded(translation(from: dragStartInWindow, to: event.locationInWindow)) - self.dragStartInWindow = nil - isDragging = false - onDragStateChanged(false) - (isInside ? resizeCursor : NSCursor.arrow).set() - } - - func update( - axis: LitheSplitAxis, - onHoverChanged: @escaping (Bool) -> Void, - onDragStateChanged: @escaping (Bool) -> Void, - onDragStarted: @escaping () -> Void, - onDragChanged: @escaping (CGFloat) -> Void, - onDragEnded: @escaping (CGFloat) -> Void - ) { - self.axis = axis - self.onHoverChanged = onHoverChanged - self.onDragStateChanged = onDragStateChanged - self.onDragStarted = onDragStarted - self.onDragChanged = onDragChanged - self.onDragEnded = onDragEnded - window?.invalidateCursorRects(for: self) - } - - func cancelInteraction() { - dragStartInWindow = nil - if isDragging { - isDragging = false - onDragStateChanged(false) - } - if isInside { - isInside = false - onHoverChanged(false) - } - NSCursor.arrow.set() - } - private var resizeCursor: NSCursor { axis == .horizontal ? .resizeLeftRight : .resizeUpDown } +} - private func translation(from start: NSPoint, to current: NSPoint) -> CGFloat { - axis == .horizontal ? current.x - start.x : start.y - current.y - } +private final class SplitHandleCursor { + private var isResizing = false - deinit { - if isDragging || isInside { - NSCursor.arrow.set() + @MainActor + func update(isResizing newValue: Bool, cursor: NSCursor) { + guard newValue != isResizing else { return } + isResizing = newValue + if newValue { + cursor.push() + } else { + NSCursor.pop() } } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift index e750e2912..a451135fb 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift @@ -25,6 +25,38 @@ struct WorkbenchModuleUIRegistry { let isVisible: @MainActor (AppModel) -> Bool let isSelected: @MainActor (AppModel) -> Bool let content: @MainActor (AppModel) -> AnyView + /// Distinguishes states the erased content cannot express by value — + /// chiefly "still loading" versus a specific attached feature object. + let contentIdentity: @MainActor (AppModel) -> AnyHashable + + init( + id: String, + ideaAssetPath: String?, + isVisible: @escaping @MainActor (AppModel) -> Bool, + isSelected: @escaping @MainActor (AppModel) -> Bool, + content: @escaping @MainActor (AppModel) -> AnyView, + // Renderers whose content depends on nothing but the hosted view's + // own observation of AppModel are always interchangeable. + contentIdentity: @escaping @MainActor (AppModel) -> AnyHashable = { _ in 0 } + ) { + self.id = id + self.ideaAssetPath = ideaAssetPath + self.isVisible = isVisible + self.isSelected = isSelected + self.content = content + self.contentIdentity = contentIdentity + } + + /// Identity for renderers built from an optional feature object. The + /// loading placeholder and each distinct feature instance must compare + /// differently, or switching workspaces would keep showing the previous + /// workspace's feature. + static func featureIdentity(_ feature: AnyObject?) -> AnyHashable { + guard let feature else { return AnyHashable(Self.loadingIdentity) } + return AnyHashable(ObjectIdentifier(feature)) + } + + private static let loadingIdentity = "module-loading" } struct Registration { @@ -100,14 +132,24 @@ struct WorkbenchModuleUIRegistry { func selectedToolContent( from contributions: [ModuleContribution], model: AppModel - ) -> AnyView { + ) -> ModuleToolContent { for contribution in contributions { guard let renderer = renderer(for: contribution), renderer.isSelected(model) else { continue } - return renderer.content(model) + return ModuleToolContent( + rendererID: renderer.id, + contentIdentity: renderer.contentIdentity(model), + content: renderer.content(model) + ) } - return AnyView(Self.moduleLoadingView) + return ModuleToolContent( + rendererID: Self.loadingRendererID, + contentIdentity: 0, + content: AnyView(Self.moduleLoadingView) + ) } + private static let loadingRendererID = "workbench.module-loading" + static var moduleLoadingView: some View { VStack(spacing: 8) { ProgressView() @@ -119,3 +161,24 @@ struct WorkbenchModuleUIRegistry { .background(LitheTheme.editor) } } + +/// Restores a comparable identity to a tool window that the registry had to +/// erase to `AnyView`. +/// +/// `AnyView` defeats SwiftUI's value comparison, so every `WorkbenchView.body` +/// pass — including one per frame while a divider is dragged — forced the whole +/// bottom tool window to re-evaluate. The hosted views observe `AppModel` +/// themselves and stay live when this one is skipped, so comparing which +/// renderer is showing (and which feature it was built from) is sufficient. +/// This mirrors `GitGraphRowView`, whose `==` deliberately ignores its actions. +struct ModuleToolContent: View, Equatable { + let rendererID: String + let contentIdentity: AnyHashable + let content: AnyView + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rendererID == rhs.rendererID && lhs.contentIdentity == rhs.contentIdentity + } + + var body: some View { content } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index af86c5c1e..05e88ef64 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -4,6 +4,7 @@ import LitheGitModule enum WorkbenchLayoutMetrics { static let rightActivityBarWidth: CGFloat = 40 + static let rightActivityBarDividerWidth: CGFloat = 1 static let workspaceTrailingInset = rightActivityBarWidth } @@ -18,11 +19,9 @@ private enum ActivityBarMetrics { } private enum WorkbenchWorkspaceMetrics { - static let paneInset: CGFloat = 0 + static let paneInset: CGFloat = 6 static let paneSpacing: CGFloat = 6 static let paneCornerRadius: CGFloat = 10 - static let minimumTopPaneHeight: CGFloat = 220 - static let changesMinimumTopPaneHeight: CGFloat = 332 } private enum WorkbenchPopoverLayoutMetrics { @@ -92,15 +91,21 @@ struct WorkbenchView: View { @State private var isBackgroundPickerPresented = false var body: some View { + let _ = LitheSignpost.bodyEvaluated("WorkbenchView") VStack(spacing: 0) { topBar + Rectangle().fill(LitheTheme.divider).frame(height: 1) if projectSessions.openProjects.count > 1 { projectTabBar + Rectangle().fill(LitheTheme.divider).frame(height: 1) } HStack(spacing: 0) { activityBar + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) workspaceArea .padding(.trailing, WorkbenchLayoutMetrics.workspaceTrailingInset) } @@ -109,6 +114,7 @@ struct WorkbenchView: View { rightHoverRegion } + Rectangle().fill(LitheTheme.divider).frame(height: 1) statusBar } .background { @@ -324,53 +330,17 @@ struct WorkbenchView: View { } } } - .overlay(alignment: .bottomTrailing) { - if !model.activeNotifications.isEmpty { - VStack(alignment: .trailing, spacing: 8) { - ForEach(model.activeNotifications) { notification in - HStack(alignment: .center, spacing: 10) { - Image(systemName: "info.circle.fill") - .font(.system(size: 14)) - .foregroundStyle(LitheTheme.accent) - - Text(LocalizedStringKey(notification.message)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 4) - - Button { - model.dismissNotification(notification.id) - } label: { - Image(systemName: "xmark") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(LitheTheme.tertiaryText) - } - .buttonStyle(.plain) - .frame(width: 16, height: 16) - .contentShape(Rectangle()) - .litheRowHover(cornerRadius: LitheTheme.Metrics.cornerRadius, animation: nil) - .accessibilityLabel("Dismiss notification") - } - .padding(.leading, 12) - .padding(.trailing, 6) - .padding(.vertical, 10) - .frame(minWidth: 280, maxWidth: 360, alignment: .topLeading) - .background(LitheTheme.notificationBackground) - .clipShape(RoundedRectangle(cornerRadius: 7)) - .contentShape(RoundedRectangle(cornerRadius: 7)) - .onContinuousHover(coordinateSpace: .local) { phase in - if case .active = phase { - NSCursor.arrow.set() - } - } - } - } - .contentShape(Rectangle()) - .onHover { model.setNotificationStackHovered($0) } - .padding(.trailing, WorkbenchLayoutMetrics.rightActivityBarWidth + 12) - .padding(.bottom, 38) + .overlay(alignment: .bottom) { + if let message = model.notificationMessage { + Text(LocalizedStringKey(message)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 14) + .frame(height: 34) + .background(LitheTheme.raised) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .shadow(color: .black.opacity(0.35), radius: 12, y: 4) + .padding(.bottom, 38) } } .overlay { @@ -427,6 +397,46 @@ struct WorkbenchView: View { } } } + .overlay(alignment: .bottomTrailing) { + if !model.activeNotifications.isEmpty { + VStack(alignment: .trailing, spacing: 8) { + ForEach(model.activeNotifications) { notification in + HStack(alignment: .center, spacing: 10) { + Image(systemName: "info.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(LitheTheme.accent) + Text(LocalizedStringKey(notification.message)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + Button { + model.dismissNotification(notification.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + .litheRowHover(cornerRadius: LitheTheme.Metrics.cornerRadius, animation: nil) + .accessibilityLabel("Dismiss notification") + } + .padding(.leading, 12) + .padding(.trailing, 6) + .padding(.vertical, 10) + .frame(minWidth: 280, maxWidth: 360, alignment: .topLeading) + .background(LitheTheme.notificationBackground) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .contentShape(RoundedRectangle(cornerRadius: 7)) + } + } + .onHover { model.setNotificationStackHovered($0) } + .padding(.trailing, WorkbenchLayoutMetrics.rightActivityBarWidth + 12) + .padding(.bottom, 38) + } + } .frame(height: LitheTheme.Metrics.tabHeight + 4) .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.toolHeader) } @@ -544,6 +554,11 @@ struct WorkbenchView: View { value: .bounds ) { $0 } + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1, height: 20) + .padding(.horizontal, 5) + Button { updateSwitcherPresentation( project: false, @@ -1132,6 +1147,7 @@ struct WorkbenchView: View { from: model.rightSidebarContributions, model: model ) + .equatable() .environmentObject(linuxDoWebSession) .frame(width: rightSidebarWidth) .frame(maxHeight: .infinity) @@ -1164,6 +1180,9 @@ struct WorkbenchView: View { } } } + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) pluginActivityBar } .fixedSize(horizontal: true, vertical: false) @@ -1269,26 +1288,22 @@ struct WorkbenchView: View { WorkbenchWorkspaceSplitView( sidebarWidth: sidebarWidth, topPaneHeight: topPaneHeight, - sidebarPaneBackground: model.selectedSidebar == .changes - ? LitheTheme.toolHeader - : LitheTheme.editor, - minimumTopPaneHeight: model.selectedSidebar == .changes - ? WorkbenchWorkspaceMetrics.changesMinimumTopPaneHeight - : WorkbenchWorkspaceMetrics.minimumTopPaneHeight, isBottomToolVisible: isBottomToolVisible, - onSidebarWidthCommitted: { width in - sidebarWidth = width - saveLayout(sidebarWidth: width, topPaneHeight: topPaneHeight) - }, - onTopPaneHeightCommitted: { height in - topPaneHeight = height - saveLayout(sidebarWidth: sidebarWidth, topPaneHeight: height) - }, + actions: WorkbenchWorkspaceSplitActions( + onSidebarWidthCommitted: { width in + sidebarWidth = width + saveLayout(sidebarWidth: width, topPaneHeight: topPaneHeight) + }, + onTopPaneHeightCommitted: { height in + topPaneHeight = height + saveLayout(sidebarWidth: sidebarWidth, topPaneHeight: height) + }, + onBottomToolMinimize: { + model.closeGitLog() + } + ), showsBottomToolMinimize: model.isGitLogVisible, hasWorkbenchBackground: model.workbenchBackgroundFeature.hasImage, - onBottomToolMinimize: { - model.closeGitLog() - }, sidebar: { activeSidebar(projectTreeRowHeight: settings.projectTreeRowHeight) }, @@ -1319,6 +1334,7 @@ struct WorkbenchView: View { from: model.activityBarContributions, model: model ) + .equatable() } } } @@ -1554,6 +1570,10 @@ private struct WorkbenchNotificationCenterView: View { .padding(.horizontal, 14) .frame(height: 38) + Rectangle() + .fill(LitheTheme.divider) + .frame(height: 1) + if model.notifications.isEmpty { VStack(spacing: 8) { Image(systemName: "bell") @@ -1610,17 +1630,24 @@ private struct WorkbenchNotificationCenterView: View { } } +/// The callbacks the workspace split view hands back to the workbench. +/// +/// Grouped into one value, following `GitGraphRowActions`, so the split view +/// carries a single stored property instead of three freshly allocated escaping +/// closures per parent body pass. +private struct WorkbenchWorkspaceSplitActions { + let onSidebarWidthCommitted: (CGFloat) -> Void + let onTopPaneHeightCommitted: (CGFloat) -> Void + let onBottomToolMinimize: () -> Void +} + private struct WorkbenchWorkspaceSplitView: View { let sidebarWidth: CGFloat let topPaneHeight: CGFloat? - let sidebarPaneBackground: Color - let minimumTopPaneHeight: CGFloat let isBottomToolVisible: Bool - let onSidebarWidthCommitted: (CGFloat) -> Void - let onTopPaneHeightCommitted: (CGFloat) -> Void + let actions: WorkbenchWorkspaceSplitActions let showsBottomToolMinimize: Bool let hasWorkbenchBackground: Bool - let onBottomToolMinimize: () -> Void let sidebar: Sidebar let editor: Editor let bottomTool: BottomTool @@ -1633,28 +1660,20 @@ private struct WorkbenchWorkspaceSplitView Void, - onTopPaneHeightCommitted: @escaping (CGFloat) -> Void, + actions: WorkbenchWorkspaceSplitActions, showsBottomToolMinimize: Bool, hasWorkbenchBackground: Bool, - onBottomToolMinimize: @escaping () -> Void, @ViewBuilder sidebar: () -> Sidebar, @ViewBuilder editor: () -> Editor, @ViewBuilder bottomTool: () -> BottomTool ) { self.sidebarWidth = sidebarWidth self.topPaneHeight = topPaneHeight - self.sidebarPaneBackground = sidebarPaneBackground - self.minimumTopPaneHeight = minimumTopPaneHeight self.isBottomToolVisible = isBottomToolVisible - self.onSidebarWidthCommitted = onSidebarWidthCommitted - self.onTopPaneHeightCommitted = onTopPaneHeightCommitted + self.actions = actions self.showsBottomToolMinimize = showsBottomToolMinimize self.hasWorkbenchBackground = hasWorkbenchBackground - self.onBottomToolMinimize = onBottomToolMinimize self.sidebar = sidebar() self.editor = editor() self.bottomTool = bottomTool() @@ -1664,12 +1683,12 @@ private struct WorkbenchWorkspaceSplitView some View { + SplitHandleView( + axis: .horizontal, + showsIdleDivider: false, + onDragStarted: { + sidebarDragStart = resolvedSidebarWidth + }, + onDragChanged: { translation in + liveSidebarWidth = constrained( + sidebarDragStart + translation, + minimum: minimumSidebarWidth, + maximum: maximumSidebarWidth + ) + }, + onDragEnded: { translation in + let finalWidth = constrained( + sidebarDragStart + translation, + minimum: minimumSidebarWidth, + maximum: maximumSidebarWidth + ) + liveSidebarWidth = finalWidth + actions.onSidebarWidthCommitted(finalWidth) + } + ) + .padding(.top, WorkbenchWorkspaceMetrics.paneInset) + .padding(.bottom, bottomInset) + .offset( + x: WorkbenchWorkspaceMetrics.paneInset + + resolvedSidebarWidth + + WorkbenchWorkspaceMetrics.paneSpacing / 2 + - SplitHandleView.thickness / 2 + ) + } + + private func topPaneResizeHandle( + resolvedTopPaneHeight: CGFloat, + minimumTopPaneHeight: CGFloat, + maximumTopPaneHeight: CGFloat + ) -> some View { + SplitHandleView( + axis: .vertical, + showsIdleDivider: false, + onDragStarted: { + topPaneDragStart = resolvedTopPaneHeight + }, + onDragChanged: { translation in + liveTopPaneHeight = constrained( + topPaneDragStart + translation, + minimum: minimumTopPaneHeight, + maximum: maximumTopPaneHeight + ) + }, + onDragEnded: { translation in + let finalHeight = constrained( + topPaneDragStart + translation, + minimum: minimumTopPaneHeight, + maximum: maximumTopPaneHeight + ) + liveTopPaneHeight = finalHeight + actions.onTopPaneHeightCommitted(finalHeight) + } + ) + .padding(.horizontal, WorkbenchWorkspaceMetrics.paneInset) + .offset( + y: resolvedTopPaneHeight + + WorkbenchWorkspaceMetrics.paneSpacing / 2 + - SplitHandleView.thickness / 2 + ) + } + private func constrained(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat { min(max(value, minimum), maximum) } @@ -1875,13 +1917,26 @@ private struct WorkbenchPaneChromeModifier: ViewModifier { @ViewBuilder func body(content: Content) -> some View { if roundsCorners { + // Four fixed-size corner notches instead of one pane-sized even-odd + // fill. The notch geometry only depends on the corner radius, so it + // is built once and merely repositioned while a pane resizes, rather + // than re-tessellating a full-pane vector path every frame. Absolute + // positioning (not leading/trailing alignment) keeps the notches on + // the same physical corners the previous fill used. content .background(background) .overlay { - WorkbenchPaneCornerCutouts( - cornerRadius: WorkbenchWorkspaceMetrics.paneCornerRadius - ) - .fill(surrounding, style: FillStyle(eoFill: true)) + GeometryReader { proxy in + let radius = WorkbenchWorkspaceMetrics.paneCornerRadius + let half = radius / 2 + ZStack { + notch(.topLeading).position(x: half, y: half) + notch(.topTrailing).position(x: proxy.size.width - half, y: half) + notch(.bottomLeading).position(x: half, y: proxy.size.height - half) + notch(.bottomTrailing) + .position(x: proxy.size.width - half, y: proxy.size.height - half) + } + } .allowsHitTesting(false) .accessibilityHidden(true) } @@ -1889,21 +1944,135 @@ private struct WorkbenchPaneChromeModifier: ViewModifier { content.background(background) } } -} -private struct WorkbenchPaneCornerCutouts: Shape { - let cornerRadius: CGFloat + private func notch(_ corner: WorkbenchPaneCornerGeometry.Corner) -> some View { + WorkbenchPaneCornerNotch(corner: corner) + .fill(surrounding) + .frame( + width: WorkbenchWorkspaceMetrics.paneCornerRadius, + height: WorkbenchWorkspaceMetrics.paneCornerRadius + ) + } +} +/// One corner of the gap between a pane's square bounds and its rounded +/// silhouette, painted in the surrounding color so the pane reads as rounded +/// without clipping the AppKit-backed content inside it. +/// +/// The path is a compile-time constant: the radius is fixed, so every instance +/// reuses the same geometry and resizing a pane only moves it. +private struct WorkbenchPaneCornerNotch: Shape { + let corner: WorkbenchPaneCornerGeometry.Corner + + /// Ignores `rect` because the caller always frames this at exactly + /// `paneCornerRadius` square; honoring an arbitrary rect would mean + /// rebuilding the path on every layout, which is the cost being removed. func path(in rect: CGRect) -> Path { + WorkbenchPaneCornerGeometry.path(for: corner) + } +} + +/// Pure geometry for the four pane corner notches, separated from the `Shape` +/// so the arc direction can be verified without rendering. +enum WorkbenchPaneCornerGeometry { + enum Corner: CaseIterable { + case topLeading + case topTrailing + case bottomLeading + case bottomTrailing + } + + /// The notch path in a `radius`-square box, cached per corner. + static func path(for corner: Corner) -> Path { + paths[corner] ?? Path() + } + + private static let radius = WorkbenchWorkspaceMetrics.paneCornerRadius + + private static let paths: [Corner: Path] = Dictionary( + uniqueKeysWithValues: Corner.allCases.map { ($0, makePath(for: $0, radius: radius)) } + ) + + static func makePath(for corner: Corner, radius: CGFloat) -> Path { + // The arc is centered on the box corner diagonally opposite the pane + // corner being rounded, so it stays tangent to both pane edges. + let center: CGPoint + let start: CGPoint + let end: CGPoint + switch corner { + case .topLeading: + center = CGPoint(x: radius, y: radius) + start = CGPoint(x: radius, y: 0) + end = CGPoint(x: 0, y: radius) + case .topTrailing: + center = CGPoint(x: 0, y: radius) + start = CGPoint(x: 0, y: 0) + end = CGPoint(x: radius, y: radius) + case .bottomLeading: + center = CGPoint(x: radius, y: 0) + start = CGPoint(x: radius, y: radius) + end = CGPoint(x: 0, y: 0) + case .bottomTrailing: + center = CGPoint(x: 0, y: 0) + start = CGPoint(x: 0, y: radius) + end = CGPoint(x: radius, y: 0) + } + + // Quarter arc as a cubic Bézier. Building it from the two tangent points + // rather than sweep angles keeps the direction unambiguous in SwiftUI's + // y-down space, where `clockwise:` reads inverted. + let handle = radius * 0.5522847498307936 + let startTangent = unitTangent(from: center, through: start, toward: end) + let endTangent = unitTangent(from: center, through: end, toward: start) + var path = Path() - path.addRect(rect) - path.addRoundedRect( - in: rect, - cornerSize: CGSize(width: cornerRadius, height: cornerRadius), - style: .continuous + path.move(to: paneCorner(for: corner, radius: radius)) + path.addLine(to: start) + path.addCurve( + to: end, + control1: CGPoint( + x: start.x + startTangent.dx * handle, + y: start.y + startTangent.dy * handle + ), + control2: CGPoint( + x: end.x + endTangent.dx * handle, + y: end.y + endTangent.dy * handle + ) ) + path.closeSubpath() return path } + + /// The square corner the notch fills in, in box-local coordinates. + private static func paneCorner(for corner: Corner, radius: CGFloat) -> CGPoint { + switch corner { + case .topLeading: CGPoint(x: 0, y: 0) + case .topTrailing: CGPoint(x: radius, y: 0) + case .bottomLeading: CGPoint(x: 0, y: radius) + case .bottomTrailing: CGPoint(x: radius, y: radius) + } + } + + /// Unit tangent to the circle at `point`, oriented so the arc sweeps toward + /// `destination` along the 90-degree side. + private static func unitTangent( + from center: CGPoint, + through point: CGPoint, + toward destination: CGPoint + ) -> CGVector { + let radial = CGVector(dx: point.x - center.x, dy: point.y - center.y) + // Rotating the radius by 90 degrees gives the tangent; the sign that + // points at the other endpoint is the one that sweeps the minor arc. + let candidate = CGVector(dx: -radial.dy, dy: radial.dx) + let towardDestination = CGVector( + dx: destination.x - point.x, + dy: destination.y - point.y + ) + let alignment = candidate.dx * towardDestination.dx + candidate.dy * towardDestination.dy + let length = max(hypot(radial.dx, radial.dy), 0.0001) + let sign: CGFloat = alignment >= 0 ? 1 : -1 + return CGVector(dx: sign * candidate.dx / length, dy: sign * candidate.dy / length) + } } private struct WorkbenchBackgroundImageView: View { @@ -1915,15 +2084,14 @@ private struct WorkbenchBackgroundImageView: View { ZStack { LitheTheme.window - GeometryReader { geometry in - if let image { - Image(nsImage: image) - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: geometry.size.height) - .clipped() - .opacity(opacity) - } + if let image { + // Fill and clip at the container instead of measuring with a + // GeometryReader, so a window resize no longer re-evaluates a + // geometry closure just to restate the size the layout offers. + Image(nsImage: image) + .resizable() + .scaledToFill() + .opacity(opacity) } // Preserve the source image's colour while keeping text legible. @@ -1932,7 +2100,11 @@ private struct WorkbenchBackgroundImageView: View { // effect at the full 100% setting. (colorScheme == .dark ? Color.black.opacity(0.46) : Color.white.opacity(0.25)) } - .compositingGroup() + .clipped() + // Deliberately not a compositing group: no group-wide opacity or blend + // mode is applied here, so flattening these layers offscreen changed + // nothing visually while forcing the whole window to recomposite on + // every resize. .allowsHitTesting(false) } } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 3067d8872..9d4cd2c50 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -43,7 +43,27 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var gitLineChangeMarkers: [URL: [GitLineChangeMarker]] = [:] @Published package private(set) var gitReferences: [GitReference] = [] @Published package private(set) var recentGitReferences: [GitReference] = [] - @Published package private(set) var gitCommits: [GitCommit] = [] + @Published package private(set) var gitCommits: [GitCommit] = [] { + didSet { gitCommitsVersion = Self.nextGitCommitsVersion() } + } + /// Monotonic token for `gitCommits`, so `.task(id:)` keys and filter + /// identities compare in constant time instead of hashing a list that + /// routinely holds thousands of commits. + /// + /// Maintained by `didSet` rather than at each assignment, so a future write + /// site cannot forget to bump it. Not `@Published`: it only ever changes + /// alongside `gitCommits`, which already publishes, and a second publish + /// would mean a second invalidation for one logical change. + package private(set) var gitCommitsVersion = 0 + + /// Counts across instances, so reopening a workspace cannot hand a fresh + /// feature model a version a previous one already used. + private static var gitCommitsVersionCounter = 0 + + private static func nextGitCommitsVersion() -> Int { + gitCommitsVersionCounter &+= 1 + return gitCommitsVersionCounter + } @Published package private(set) var gitLogMatchedCommitHashes: Set? @Published package private(set) var isFilteringGitLog = false @Published package var selectedGitReference: GitReference? diff --git a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift new file mode 100644 index 000000000..4c4618961 --- /dev/null +++ b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheGitModule + +/// The sidebar's four change sections come from one cached pass. A wrong +/// partition silently files a change under the wrong header or breaks the +/// commit affordance, so the split and the conflict filter are pinned here. +@MainActor +@Suite("Git change sections") +struct GitChangeSectionsCacheTests { + private let root = URL(fileURLWithPath: "/tmp/repo") + + private func change( + _ path: String, + indexStatus: Character = " ", + workTreeStatus: Character = "M" + ) -> GitChange { + GitChange( + repositoryRoot: root, + path: path, + originalPath: nil, + indexStatus: indexStatus, + workTreeStatus: workTreeStatus + ) + } + + @Test + func addedChangesSplitAwayFromTrackedOnes() { + let added = change("new.swift", indexStatus: "A", workTreeStatus: " ") + let modified = change("existing.swift") + let cache = GitChangeSectionsCache() + + let sections = cache.sections(changes: [added, modified], conflictFilterPaths: []) + + #expect(sections.displayed.count == 2) + #expect(sections.added.map(\.path) == ["new.swift"]) + #expect(sections.tracked.map(\.path) == ["existing.swift"]) + } + + @Test + func aConflictFilterHidesEverythingOutsideIt() { + let cache = GitChangeSectionsCache() + let changes = [change("a.swift"), change("b.swift"), change("c.swift")] + + let sections = cache.sections( + changes: changes, + conflictFilterPaths: ["b.swift"] + ) + + #expect(sections.displayed.map(\.path) == ["b.swift"]) + #expect(sections.tracked.map(\.path) == ["b.swift"]) + } + + @Test + func stagedIgnoresTheConflictFilter() { + // The commit affordances ask "is anything staged" about the repository, + // not about whatever subset the conflict banner is showing. + let staged = change("staged.swift", indexStatus: "M", workTreeStatus: " ") + let other = change("other.swift") + let cache = GitChangeSectionsCache() + + let sections = cache.sections( + changes: [staged, other], + conflictFilterPaths: ["other.swift"] + ) + + #expect(sections.displayed.map(\.path) == ["other.swift"]) + #expect(sections.staged.map(\.path) == ["staged.swift"]) + } + + @Test + func aChangedListInvalidatesTheCache() { + let cache = GitChangeSectionsCache() + let first = cache.sections(changes: [change("a.swift")], conflictFilterPaths: []) + #expect(first.displayed.count == 1) + + let second = cache.sections( + changes: [change("a.swift"), change("b.swift")], + conflictFilterPaths: [] + ) + #expect(second.displayed.count == 2) + } + + @Test + func aChangedFilterInvalidatesTheCacheEvenWhenTheChangesMatch() { + // Both inputs key the cache; only comparing the change list would leave + // the conflict banner showing a stale file list. + let cache = GitChangeSectionsCache() + let changes = [change("a.swift"), change("b.swift")] + + _ = cache.sections(changes: changes, conflictFilterPaths: []) + let filtered = cache.sections(changes: changes, conflictFilterPaths: ["a.swift"]) + + #expect(filtered.displayed.map(\.path) == ["a.swift"]) + } +} diff --git a/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift new file mode 100644 index 000000000..25325a0a9 --- /dev/null +++ b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift @@ -0,0 +1,131 @@ +import Testing +@testable import Lithe +@testable import LitheGitModule + +/// The reference tree used to render as a recursive `AnyView`; flattening it to +/// rows moved ordering, nesting, and collapse handling out of the view. These +/// tests pin that behavior, because a wrong row order or a missing collapse +/// silently reshuffles the user's branch list. +@Suite("Git reference rows") +struct GitReferenceRowsBuilderTests { + private func reference( + _ shortName: String, + kind: GitReferenceKind = .local, + isCurrent: Bool = false + ) -> GitReference { + GitReference( + fullName: "refs/heads/\(shortName)", + shortName: shortName, + kind: kind, + isCurrent: isCurrent, + upstreamShortName: nil + ) + } + + private func rows( + _ shortNames: [String], + collapsed: Set = [] + ) -> [GitReferenceRow] { + GitReferenceRowsBuilder.rows( + from: shortNames.map { reference($0) }, + kind: .local, + collapsedGroups: collapsed + ) + } + + @Test + func flatReferencesBecomeOneRowEach() { + let result = rows(["main", "develop"]) + + #expect(result.count == 2) + #expect(result.allSatisfy { $0.depth == 0 }) + // Natural ordering, not insertion order. + #expect(result.map(\.name) == ["develop", "main"]) + } + + @Test + func aSlashSeparatedNameNestsUnderAGroup() { + let result = rows(["feature/login"]) + + #expect(result.count == 2) + #expect(result[0].name == "feature") + #expect(result[0].depth == 0) + if case .group = result[0].content {} else { + Issue.record("the shared prefix should render as a group row") + } + #expect(result[1].name == "login") + #expect(result[1].depth == 1) + if case .reference = result[1].content {} else { + Issue.record("the leaf should render as a reference row") + } + } + + @Test + func deepPathsNestOneLevelPerComponent() { + let result = rows(["refs/heads/a/b/c"]) + #expect(result.map(\.depth) == [0, 1, 2, 3, 4]) + #expect(result.map(\.name) == ["refs", "heads", "a", "b", "c"]) + } + + @Test + func collapsingAGroupHidesItsDescendantsButKeepsTheGroup() { + let expanded = rows(["feature/login", "feature/signup", "main"]) + let collapsed = rows( + ["feature/login", "feature/signup", "main"], + collapsed: ["local:feature"] + ) + + #expect(expanded.map(\.name) == ["main", "feature", "login", "signup"]) + // The group row survives so the user can expand it again. + #expect(collapsed.map(\.name) == ["main", "feature"]) + if case .group(_, let isCollapsed) = collapsed[1].content { + #expect(isCollapsed) + } else { + Issue.record("expected the feature group row") + } + } + + @Test + func aNameThatIsBothABranchAndAPrefixEmitsTwoRows() { + // `feature` is a branch and also the parent of `feature/login`, which the + // recursive renderer handled by drawing both a reference and a group. + let result = rows(["feature", "feature/login"]) + + #expect(result.count == 3) + #expect(result[0].name == "feature") + if case .reference = result[0].content {} else { + Issue.record("the branch itself should come first") + } + #expect(result[1].name == "feature") + if case .group = result[1].content {} else { + Issue.record("the shared prefix should follow as a group") + } + #expect(result[2].name == "login") + // Two rows for one path still need distinct identities. + #expect(result[0].id != result[1].id) + } + + @Test + func referencesSortBeforeFoldersAtTheSameLevel() { + let result = rows(["zebra", "alpha/nested"]) + #expect(result.map(\.name) == ["zebra", "alpha", "nested"]) + } + + @Test + func everyRowIdentifierIsUnique() { + let result = rows(["feature", "feature/login", "feature/signup", "main", "release/1.0"]) + #expect(Set(result.map(\.id)).count == result.count) + } + + @Test + func theCollapseKeyIsScopedByReferenceKind() { + // Local and remote sections can hold the same path; their collapse state + // must not be shared. + let remote = GitReferenceRowsBuilder.rows( + from: [reference("feature/login", kind: .remote)], + kind: .remote, + collapsedGroups: ["local:feature"] + ) + #expect(remote.count == 2, "a local collapse key must not collapse the remote group") + } +} diff --git a/macos/Tests/LitheTests/LitheDragUpdateSchedulerTests.swift b/macos/Tests/LitheTests/LitheDragUpdateSchedulerTests.swift new file mode 100644 index 000000000..876e0c66b --- /dev/null +++ b/macos/Tests/LitheTests/LitheDragUpdateSchedulerTests.swift @@ -0,0 +1,96 @@ +import CoreGraphics +import Testing +@testable import Lithe + +/// Drives `LitheDragUpdateScheduler` in manual delivery mode so coalescing, the +/// deadband, and cancellation are observable without spinning a run loop. +@MainActor +@Suite("Drag update scheduler") +struct LitheDragUpdateSchedulerTests { + private func makeScheduler() -> LitheDragUpdateScheduler { + LitheDragUpdateScheduler(delivery: .manual) + } + + @Test + func aBurstDeliversOnlyTheNewestValueOnce() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + scheduler.submit(12) { delivered.append($0) } + scheduler.submit(28) { delivered.append($0) } + scheduler.submit(41) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + + #expect(delivered == [41]) + + // A second flush has nothing left to deliver, proving the burst + // collapsed into exactly one update. + scheduler.flushPendingDeliveryForTesting() + #expect(delivered == [41]) + } + + @Test + func subPointChangesAreSuppressedAfterDelivery() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + scheduler.submit(100) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + // 0.5pt movement from the delivered value is below the default deadband + // and must not queue anything. + scheduler.submit(100.5) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + + #expect(delivered == [100]) + + // A movement past the deadband delivers again. + scheduler.submit(102) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + #expect(delivered == [100, 102]) + } + + @Test + func zeroDeadbandDeliversEveryDistinctValue() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + // The diff wheel path disables the deadband so sub-point steps still move. + scheduler.submit(10, minimumChange: 0) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + scheduler.submit(10.25, minimumChange: 0) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + + #expect(delivered == [10, 10.25]) + } + + @Test + func pendingValueReflectsInFlightTargetAndClearsOnDelivery() { + let scheduler = makeScheduler() + + #expect(scheduler.pendingValue == nil) + scheduler.submit(30, minimumChange: 0) { _ in } + scheduler.submit(55, minimumChange: 0) { _ in } + // Wheel accumulation reads this to add onto the in-flight target. + #expect(scheduler.pendingValue == 55) + + scheduler.flushPendingDeliveryForTesting() + #expect(scheduler.pendingValue == nil) + } + + @Test + func cancelDropsPendingDeliveryAndResetsDeadband() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + scheduler.submit(70) { delivered.append($0) } + scheduler.cancel() + scheduler.flushPendingDeliveryForTesting() + #expect(delivered.isEmpty) + + // A value that would have fallen inside the previous deadband still + // delivers, proving the deadband was reset along with the gesture. + scheduler.submit(70.5) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + #expect(delivered == [70.5]) + } +} diff --git a/macos/Tests/LitheTests/LitheSplitPaneGeometryTests.swift b/macos/Tests/LitheTests/LitheSplitPaneGeometryTests.swift new file mode 100644 index 000000000..b7bf960f8 --- /dev/null +++ b/macos/Tests/LitheTests/LitheSplitPaneGeometryTests.swift @@ -0,0 +1,69 @@ +import CoreGraphics +import Testing +@testable import Lithe + +/// Pane sizing is where a split refactor silently inverts a divider: the four +/// converted sites disagree on which side is tracked, so the sign flip and the +/// clamping are pinned here rather than left to a view test. +@Suite("Split pane geometry") +struct LitheSplitPaneGeometryTests { + @Test + func aLeadingPaneGrowsWithPositiveTranslation() { + let size = LitheSplitPaneGeometry.resolve( + start: 200, + translation: 40, + placement: .leading, + minimum: 100, + maximum: 400 + ) + #expect(size == 240) + } + + @Test + func aTrailingPaneShrinksWithPositiveTranslation() { + // Dragging the divider to the right makes a right-hand pane narrower. + let size = LitheSplitPaneGeometry.resolve( + start: 200, + translation: 40, + placement: .trailing, + minimum: 100, + maximum: 400 + ) + #expect(size == 160) + } + + @Test + func draggingPastAnEdgeClampsRatherThanOvershooting() { + let tooSmall = LitheSplitPaneGeometry.resolve( + start: 120, + translation: -500, + placement: .leading, + minimum: 100, + maximum: 400 + ) + let tooLarge = LitheSplitPaneGeometry.resolve( + start: 380, + translation: 500, + placement: .leading, + minimum: 100, + maximum: 400 + ) + + #expect(tooSmall == 100) + #expect(tooLarge == 400) + } + + @Test + func minimumWinsWhenLiveGeometryPushesMaximumBelowIt() { + // Hosts derive `maximum` from the container size, so shrinking a window + // far enough can invert the range. The pane must not collapse past its + // minimum or the layout flips inside out. + let size = LitheSplitPaneGeometry.clamp(300, minimum: 220, maximum: 80) + #expect(size == 220) + } + + @Test + func aSizeInsideTheRangeIsLeftAlone() { + #expect(LitheSplitPaneGeometry.clamp(250, minimum: 100, maximum: 400) == 250) + } +} diff --git a/macos/Tests/LitheTests/WorkbenchPaneCornerGeometryTests.swift b/macos/Tests/LitheTests/WorkbenchPaneCornerGeometryTests.swift new file mode 100644 index 000000000..3cc570e95 --- /dev/null +++ b/macos/Tests/LitheTests/WorkbenchPaneCornerGeometryTests.swift @@ -0,0 +1,74 @@ +import CoreGraphics +import SwiftUI +import Testing +@testable import Lithe + +/// The pane corner notch is what makes a workbench pane read as rounded without +/// clipping its AppKit-backed content. It is built from tangent points rather +/// than sweep angles, so these tests pin the arc to the correct side: an arc +/// bulging the wrong way would paint over the pane instead of its square corner. +@Suite("Workbench pane corner geometry") +struct WorkbenchPaneCornerGeometryTests { + private let radius: CGFloat = 10 + + @Test(arguments: WorkbenchPaneCornerGeometry.Corner.allCases) + func theNotchFillsTheSquareCornerAndNotThePaneInterior( + corner: WorkbenchPaneCornerGeometry.Corner + ) { + let path = WorkbenchPaneCornerGeometry.makePath(for: corner, radius: radius) + + // Just inside the square corner: this is the sliver the rounded + // silhouette leaves behind and the notch must cover it. + #expect(path.contains(nearPaneCorner(corner))) + // The diagonally opposite point is deep inside the rounded pane and must + // stay uncovered, which is exactly what a reversed arc would break. + #expect(!path.contains(nearArcCenter(corner))) + } + + @Test(arguments: WorkbenchPaneCornerGeometry.Corner.allCases) + func theNotchStaysInsideItsOwnBox(corner: WorkbenchPaneCornerGeometry.Corner) { + let path = WorkbenchPaneCornerGeometry.makePath(for: corner, radius: radius) + let bounds = path.boundingRect + + // The notch is overlaid unclipped, so escaping the radius-square box + // would paint the surrounding color across pane content. + #expect(bounds.minX >= -0.01) + #expect(bounds.minY >= -0.01) + #expect(bounds.maxX <= radius + 0.01) + #expect(bounds.maxY <= radius + 0.01) + } + + @Test + func theNotchCoversTheCornerButNotTheTangentMidpoint() { + let path = WorkbenchPaneCornerGeometry.makePath(for: .topLeading, radius: radius) + + #expect(path.contains(CGPoint(x: 0.5, y: 0.5))) + // The arc passes through radius * (1 - 1/sqrt(2)) ≈ 2.93 on the diagonal; + // a point beyond it is inside the rounded pane. + #expect(!path.contains(CGPoint(x: 4, y: 4))) + } + + /// A point just inside the pane's square corner, which the notch covers. + private func nearPaneCorner(_ corner: WorkbenchPaneCornerGeometry.Corner) -> CGPoint { + let near: CGFloat = 0.5 + let far = radius - 0.5 + return switch corner { + case .topLeading: CGPoint(x: near, y: near) + case .topTrailing: CGPoint(x: far, y: near) + case .bottomLeading: CGPoint(x: near, y: far) + case .bottomTrailing: CGPoint(x: far, y: far) + } + } + + /// A point next to the arc center, which lies inside the rounded pane. + private func nearArcCenter(_ corner: WorkbenchPaneCornerGeometry.Corner) -> CGPoint { + let near: CGFloat = 0.5 + let far = radius - 0.5 + return switch corner { + case .topLeading: CGPoint(x: far, y: far) + case .topTrailing: CGPoint(x: near, y: far) + case .bottomLeading: CGPoint(x: far, y: near) + case .bottomTrailing: CGPoint(x: near, y: near) + } + } +}