From dd69647f8b66c94313827daf439025748c1dcdfd Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Mon, 31 Aug 2026 20:12:00 +0800 Subject: [PATCH 01/10] fix(macos): restore switcher popover integration --- .../Lithe/Views/Workbench/WorkbenchView.swift | 320 ++++++++++++++---- .../WorkbenchRenderingSafetyTests.swift | 21 ++ 2 files changed, 273 insertions(+), 68 deletions(-) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9e3365bb..97697c95 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -24,6 +24,46 @@ private enum WorkbenchWorkspaceMetrics { static let paneCornerRadius: CGFloat = 10 } +private enum WorkbenchPopoverLayoutMetrics { + static let leadingOverlap: CGFloat = 10 + static let viewportMargin: CGFloat = 8 + static let arrowWidth: CGFloat = 22 + static let arrowHeight: CGFloat = 12 +} + +private struct WorkbenchPopoverArrow: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +private struct ProjectSwitcherButtonBoundsPreferenceKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce( + value: inout Anchor?, + nextValue: () -> Anchor? + ) { + value = nextValue() ?? value + } +} + +private struct BranchSwitcherButtonBoundsPreferenceKey: PreferenceKey { + static var defaultValue: Anchor? + + static func reduce( + value: inout Anchor?, + nextValue: () -> Anchor? + ) { + value = nextValue() ?? value + } +} + struct WorkbenchView: View { private let moduleUIRegistry = WorkbenchModuleUIComposition.builtIn @EnvironmentObject private var model: AppModel @@ -234,26 +274,34 @@ struct WorkbenchView: View { } message: { Text(model.pendingDiscardHunk?.change.path ?? "This action cannot be undone by Lithe.") } - .confirmationDialog( - "Push '\(pendingTopBarPushReference?.shortName ?? "")'?", - isPresented: Binding( - get: { pendingTopBarPushReference != nil }, - set: { if !$0 { pendingTopBarPushReference = nil } } - ), - titleVisibility: .visible - ) { - Button("Push") { - guard let reference = pendingTopBarPushReference else { return } - pendingTopBarPushReference = nil - Task { await model.pushBranch(reference) } + .sheet(item: $pendingTopBarPushReference) { reference in + GitPushDialog( + projectName: model.projectName, + reference: reference, + onPush: { + Task { await model.pushBranch(reference) } + } + ) + } + .overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self) { bounds in + GeometryReader { geometry in + if isProjectSwitcherPresented, let bounds { + projectSwitcherOverlay( + buttonFrame: geometry[bounds], + viewportSize: geometry.size + ) + } } - .lithePointer() - Button("Cancel", role: .cancel) { - pendingTopBarPushReference = nil + } + .overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self) { bounds in + GeometryReader { geometry in + if isBranchSwitcherPresented, let bounds { + branchSwitcherOverlay( + buttonFrame: geometry[bounds], + viewportSize: geometry.size + ) + } } - .lithePointer() - } message: { - Text("This sends the current branch to its configured remote.") } .overlay(alignment: .bottom) { if let message = model.notificationMessage { @@ -406,7 +454,10 @@ struct WorkbenchView: View { private var topBar: some View { HStack(spacing: 9) { Button { - isProjectSwitcherPresented.toggle() + updateSwitcherPresentation( + project: !isProjectSwitcherPresented, + branch: false + ) } label: { HStack(spacing: 8) { LitheLogo(size: 24) @@ -431,28 +482,10 @@ struct WorkbenchView: View { .buttonStyle(.plain) .lithePointer() .accessibilityIdentifier("project-switcher-\(model.id.uuidString)") - .popover(isPresented: $isProjectSwitcherPresented, arrowEdge: .bottom) { - ProjectSwitcherPopover( - isPresented: $isProjectSwitcherPresented, - onNewProject: { - isProjectSwitcherPresented = false - model.chooseProject(title: "New Project", prompt: "Choose Folder") - }, - onOpenProject: { - isProjectSwitcherPresented = false - model.chooseProject() - }, - onCloneRepository: { - isProjectSwitcherPresented = false - model.showCloneRepository() - }, - onOpenRecentProject: { project in - isProjectSwitcherPresented = false - model.openProject(project.url) - } - ) - .environmentObject(model) - } + .anchorPreference( + key: ProjectSwitcherButtonBoundsPreferenceKey.self, + value: .bounds + ) { $0 } Rectangle() .fill(LitheTheme.divider) @@ -460,7 +493,10 @@ struct WorkbenchView: View { .padding(.horizontal, 5) Button { - isBranchSwitcherPresented.toggle() + updateSwitcherPresentation( + project: false, + branch: !isBranchSwitcherPresented + ) if isBranchSwitcherPresented { Task { await model.refreshGitHistory() } } @@ -490,27 +526,142 @@ struct WorkbenchView: View { } .buttonStyle(.plain) .lithePointer() - .popover(isPresented: $isBranchSwitcherPresented, arrowEdge: .bottom) { + .anchorPreference( + key: BranchSwitcherButtonBoundsPreferenceKey.self, + value: .bounds + ) { $0 } + + Spacer(minLength: 22) + + runConfigurationPicker + runLaunchButton + debugLaunchButton + if hasActiveExecution { + stopExecutionButton + } + + backgroundPickerButton + + } + .padding(.leading, 76) + .padding(.trailing, 10) + .frame(height: LitheTheme.Metrics.toolbarHeight) + .background { + (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)? + .toggleWorkspaceZoom() + } + } + } + + private func projectSwitcherOverlay( + buttonFrame: CGRect, + viewportSize: CGSize + ) -> some View { + let popupMetrics = ProjectSwitcherLayoutMetrics.self + let chromeMetrics = WorkbenchPopoverLayoutMetrics.self + let placement = workbenchPopoverPlacement( + buttonFrame: buttonFrame, + viewportWidth: viewportSize.width, + popupWidth: popupMetrics.width + ) + + return ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { updateSwitcherPresentation(project: false) } + + ZStack(alignment: .topLeading) { + WorkbenchPopoverArrow() + .fill(LitheTheme.popupBackground) + .overlay { + WorkbenchPopoverArrow() + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight) + .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2)) + + ProjectSwitcherPopover( + isPresented: instantProjectSwitcherPresentation, + onNewProject: { + updateSwitcherPresentation(project: false) + model.chooseProject(title: "New Project", prompt: "Choose Folder") + }, + onOpenProject: { + updateSwitcherPresentation(project: false) + model.chooseProject() + }, + onCloneRepository: { + updateSwitcherPresentation(project: false) + model.showCloneRepository() + }, + onOpenRecentProject: { project in + updateSwitcherPresentation(project: false) + model.openProject(project.url) + } + ) + .environmentObject(model) + .lithePopupChrome() + .padding(.top, chromeMetrics.arrowHeight - 1) + } + .offset(x: placement.popupX, y: buttonFrame.maxY) + } + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + .onExitCommand { updateSwitcherPresentation(project: false) } + } + + private func branchSwitcherOverlay( + buttonFrame: CGRect, + viewportSize: CGSize + ) -> some View { + let popupMetrics = BranchSwitcherPopover.Metrics.self + let chromeMetrics = WorkbenchPopoverLayoutMetrics.self + let placement = workbenchPopoverPlacement( + buttonFrame: buttonFrame, + viewportWidth: viewportSize.width, + popupWidth: popupMetrics.popupWidth + ) + + return ZStack(alignment: .topLeading) { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { updateSwitcherPresentation(branch: false) } + + ZStack(alignment: .topLeading) { + WorkbenchPopoverArrow() + .fill(LitheTheme.popupBackground) + .overlay { + WorkbenchPopoverArrow() + .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + .frame(width: chromeMetrics.arrowWidth, height: chromeMetrics.arrowHeight) + .offset(x: placement.arrowCenterX - (chromeMetrics.arrowWidth / 2)) + BranchSwitcherPopover( - isPresented: $isBranchSwitcherPresented, + isPresented: instantBranchSwitcherPresentation, onCommit: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) model.selectedSidebar = .changes }, onPush: { reference in - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) pendingTopBarPushReference = reference }, onNewBranch: { reference in - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) newBranchReference = reference }, onCheckoutRevision: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) isCheckoutRevisionPresented = true }, onManageBranches: { - isBranchSwitcherPresented = false + updateSwitcherPresentation(branch: false) if !model.isGitLogVisible { model.selectedSidebar = .changes Task { await model.toggleGitLog() } @@ -518,30 +669,63 @@ struct WorkbenchView: View { } ) .environmentObject(model) + .padding(.top, chromeMetrics.arrowHeight - 1) } + .offset(x: placement.popupX, y: buttonFrame.maxY) + } + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + .onExitCommand { updateSwitcherPresentation(branch: false) } + } - Spacer(minLength: 22) + private func workbenchPopoverPlacement( + buttonFrame: CGRect, + viewportWidth: CGFloat, + popupWidth: CGFloat + ) -> (popupX: CGFloat, arrowCenterX: CGFloat) { + let metrics = WorkbenchPopoverLayoutMetrics.self + let desiredX = buttonFrame.minX - metrics.leadingOverlap + let maximumX = max( + metrics.viewportMargin, + viewportWidth - popupWidth - metrics.viewportMargin + ) + let popupX = min(max(desiredX, metrics.viewportMargin), maximumX) + let arrowCenterX = min( + max(buttonFrame.midX - popupX, metrics.arrowWidth), + popupWidth - metrics.arrowWidth + ) + return (popupX, arrowCenterX) + } - runConfigurationPicker - runLaunchButton - debugLaunchButton - if hasActiveExecution { - stopExecutionButton - } + private var instantProjectSwitcherPresentation: Binding { + Binding( + get: { isProjectSwitcherPresented }, + set: { updateSwitcherPresentation(project: $0) } + ) + } - backgroundPickerButton + private var instantBranchSwitcherPresentation: Binding { + Binding( + get: { isBranchSwitcherPresented }, + set: { updateSwitcherPresentation(branch: $0) } + ) + } - } - .padding(.leading, 76) - .padding(.trailing, 10) - .frame(height: LitheTheme.Metrics.toolbarHeight) - .background { - (model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.titlebar) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { - (NSApplication.shared.keyWindow?.delegate as? LitheWindowCoordinator)? - .toggleWorkspaceZoom() - } + private func updateSwitcherPresentation( + project: Bool? = nil, + branch: Bool? = nil + ) { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + if let project { + isProjectSwitcherPresented = project + } + if let branch { + isBranchSwitcherPresented = branch + } } } diff --git a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift index 9039b7e5..4f29f9f5 100644 --- a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift +++ b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -29,4 +29,25 @@ struct WorkbenchRenderingSafetyTests { "WorkbenchView contains NSViewRepresentable content and must not be flattened with drawingGroup()." ) } + + @Test + func workbenchKeepsCustomSwitchersAlongsideExecutionControls() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let workbenchURL = repositoryRoot.appendingPathComponent( + "Sources/Lithe/Views/Workbench/WorkbenchView.swift" + ) + let source = try String(contentsOf: workbenchURL, encoding: .utf8) + + #expect(source.contains(".overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self)")) + #expect(source.contains(".overlayPreferenceValue(BranchSwitcherButtonBoundsPreferenceKey.self)")) + #expect(source.contains(".sheet(item: $pendingTopBarPushReference)")) + #expect(source.contains("GitPushDialog(")) + #expect(source.contains("run-selected-run-configuration")) + #expect(source.contains("debug-selected-run-configuration")) + #expect(!source.contains(".popover(isPresented: $isProjectSwitcherPresented")) + #expect(!source.contains(".popover(isPresented: $isBranchSwitcherPresented")) + } } From 15eab0b440ea322697b4cb4d61d19120fd6d5bf3 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 1 Sep 2026 19:19:47 +0800 Subject: [PATCH 02/10] perf(macos): eliminate drag-induced rendering jank across all split panes Stages 0-5 of the rendering performance plan: - Replace 16ms Task.sleep drag coalescing with zero-latency DispatchQueue.main.async via LitheDragUpdateScheduler (3 sites) - Replace per-frame full-pane even-odd Shape tessellation with four fixed-size static corner notch paths - Restore module tool window structural identity via ModuleToolContent Equatable wrapper, defeating AnyView erasure - Sink drag state into LitheSplitPaneView containers so divider drags only invalidate the small container, not the feature view (4 sites) - Flatten recursive AnyView reference tree into GitReferenceRows with LazyVStack and Equatable row views - Add GitChangeSectionsCache for ChangesSidebarView - Add EditorTabFrameStore reference box to stop layout-pass preference writes from invalidating the entire EditorAreaView body - Add RunConfigurationTokenCache and GitCurrentReferenceCache to memoize repeated derivations - Add gitCommitsVersion monotonic token replacing O(n) array comparison - Add LitheSignpost DEBUG body evaluation counter - Add tests for LitheDragUpdateScheduler, LitheSplitPaneGeometry, WorkbenchPaneCornerGeometry, GitReferenceRowsBuilder, GitChangeSectionsCache --- .../AppModel/AppModel+FeatureState.swift | 3 + .../Services/Monitoring/LitheSignpost.swift | 27 ++ .../Components/LitheDragUpdateScheduler.swift | 66 +++ .../Components/LitheSplitPaneGeometry.swift | 36 ++ .../Views/Components/LitheSplitPaneView.swift | 134 +++++++ .../Diff/DiffHorizontalScrollSupport.swift | 32 +- .../Lithe/Views/Diff/DiffSplitPaneView.swift | 41 +- .../Lithe/Views/Editor/EditorAreaView.swift | 16 +- .../Views/Editor/EditorTabFrameStore.swift | 24 ++ .../Lithe/Views/Git/ChangesSidebarView.swift | 40 +- .../Views/Git/GitChangeSectionsCache.swift | 66 +++ .../Views/Git/GitCurrentReferenceCache.swift | 25 ++ .../Sources/Lithe/Views/Git/GitLogView.swift | 264 +++++------- .../Lithe/Views/Git/GitReferenceRows.swift | 162 ++++++++ .../Views/Language/LanguageTestsView.swift | 43 +- .../Run/RunConfigurationTokenCache.swift | 26 ++ macos/Sources/Lithe/Views/Run/RunView.swift | 45 +-- .../Views/Workbench/SplitHandleView.swift | 41 +- .../Workbench/WorkbenchModuleUIRegistry.swift | 69 +++- .../Lithe/Views/Workbench/WorkbenchView.swift | 375 +++++++++++++----- .../Application/GitFeatureModel.swift | 22 +- .../GitChangeSectionsCacheTests.swift | 97 +++++ .../GitReferenceRowsBuilderTests.swift | 131 ++++++ .../LitheDragUpdateSchedulerTests.swift | 96 +++++ .../LitheSplitPaneGeometryTests.swift | 69 ++++ .../WorkbenchPaneCornerGeometryTests.swift | 74 ++++ 26 files changed, 1577 insertions(+), 447 deletions(-) create mode 100644 macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift create mode 100644 macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift create mode 100644 macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift create mode 100644 macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift create mode 100644 macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift create mode 100644 macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift create mode 100644 macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift create mode 100644 macos/Sources/Lithe/Views/Git/GitReferenceRows.swift create mode 100644 macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift create mode 100644 macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift create mode 100644 macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift create mode 100644 macos/Tests/LitheTests/LitheDragUpdateSchedulerTests.swift create mode 100644 macos/Tests/LitheTests/LitheSplitPaneGeometryTests.swift create mode 100644 macos/Tests/LitheTests/WorkbenchPaneCornerGeometryTests.swift diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index c41c41a4..2c3c45a5 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 00000000..d19abd95 --- /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) -> OSSignposter.IntervalState { + signposter.beginInterval(name) + } + + static func end(_ name: StaticString, _ state: OSSignposter.IntervalState) { + signposter.endInterval(name, state) + } + + #if DEBUG + private static var bodyCounts: [StaticString: 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 00000000..12c19191 --- /dev/null +++ b/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift @@ -0,0 +1,66 @@ +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. +@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 + + 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 00000000..e96c315d --- /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 00000000..ad032939 --- /dev/null +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift @@ -0,0 +1,134 @@ +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 + 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, + 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.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(maxWidth: .infinity) + } else { + flexible.frame(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 0a259de7..38b57cf8 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 658d5fb0..8acc20a6 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 09959848..1859dafd 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -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 { @@ -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) } } diff --git a/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift b/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift new file mode 100644 index 00000000..dfefa569 --- /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 93edc59c..a8671e8c 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -7,8 +7,6 @@ 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? @@ -17,6 +15,7 @@ struct ChangesSidebarView: View { @State private var pendingDropShelf: GitShelfEntry? var body: some View { + let _ = LitheSignpost.bodyEvaluated("ChangesSidebarView") VStack(spacing: 0) { tabHeader Rectangle().fill(LitheTheme.divider).frame(height: 1) @@ -159,43 +158,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) { diff --git a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift new file mode 100644 index 00000000..832c0626 --- /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 00000000..501b6a99 --- /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 99ee19b7..1c16c670 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -11,12 +11,6 @@ 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 branchDialogRequest: GitBranchDialogRequest? @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @@ -67,90 +61,19 @@ 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 @@ -575,11 +498,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 +645,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 +666,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 +1024,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) } diff --git a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift new file mode 100644 index 00000000..5b114f3d --- /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 1d6f8460..88d5857e 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 00000000..71d19a3b --- /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 f4b238db..47818f98 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -16,6 +16,7 @@ struct RunView: View { @State private var editingConfigurationID: String? var body: some View { + let _ = LitheSignpost.bodyEvaluated("RunView") VStack(spacing: 0) { toolWindowHeader @@ -54,42 +55,26 @@ struct RunView: View { 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 } + ) } } } diff --git a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift index 1383e505..d909c208 100644 --- a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -21,9 +21,7 @@ struct SplitHandleView: View { @State private var isHovering = false @State private var isDragging = false - @State private var lastTranslation: CGFloat = 0 - @State private var dragUpdateBuffer = FrameCoalescedDragUpdateBuffer() - @State private var dragUpdateTask: Task? + @State private var dragScheduler = LitheDragUpdateScheduler() @State private var cursor = SplitHandleCursor() init( @@ -62,26 +60,24 @@ struct SplitHandleView: View { .onChanged { value in if !isDragging { isDragging = true - lastTranslation = 0 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. Keep only the newest translation for the - // next frame instead of forcing every intermediate layout. - if abs(currentTranslation - lastTranslation) >= 1 { - lastTranslation = currentTranslation - scheduleDragUpdate(currentTranslation) + // 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 - cancelScheduledDragUpdate() + dragScheduler.cancel() isDragging = false - lastTranslation = 0 cursor.update(isResizing: isHovering, cursor: resizeCursor) onDragEnded(finalTranslation) } @@ -92,7 +88,7 @@ struct SplitHandleView: View { cursor.update(isResizing: isInside || isDragging, cursor: resizeCursor) } .onDisappear { - cancelScheduledDragUpdate() + dragScheduler.cancel() cursor.update(isResizing: false, cursor: resizeCursor) } .help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize") @@ -141,25 +137,6 @@ struct SplitHandleView: View { private var resizeCursor: NSCursor { axis == .horizontal ? .resizeLeftRight : .resizeUpDown } - - private func scheduleDragUpdate(_ translation: CGFloat) { - guard dragUpdateBuffer.submit(translation) else { return } - dragUpdateTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - let translation = dragUpdateBuffer.takePendingValue() - dragUpdateTask = nil - if let translation { - onDragChanged(translation) - } - } - } - - private func cancelScheduledDragUpdate() { - dragUpdateTask?.cancel() - dragUpdateTask = nil - dragUpdateBuffer.cancel() - } } private final class SplitHandleCursor { diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift index e750e291..a451135f 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 97697c95..80907810 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -90,6 +90,7 @@ 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) @@ -1076,6 +1077,7 @@ struct WorkbenchView: View { from: model.rightSidebarContributions, model: model ) + .equatable() .environmentObject(linuxDoWebSession) .frame(width: rightSidebarWidth) .frame(maxHeight: .infinity) @@ -1217,19 +1219,21 @@ struct WorkbenchView: View { sidebarWidth: sidebarWidth, topPaneHeight: topPaneHeight, 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) }, @@ -1260,6 +1264,7 @@ struct WorkbenchView: View { from: model.activityBarContributions, model: model ) + .equatable() } } } @@ -1555,15 +1560,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 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 @@ -1577,11 +1591,9 @@ 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 @@ -1589,11 +1601,9 @@ 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 + 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 + 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) } @@ -1808,13 +1847,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) } @@ -1822,21 +1874,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 { @@ -1848,15 +2014,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. @@ -1865,7 +2030,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 bc4bd629..b953f44e 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 00000000..4c461896 --- /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 00000000..25325a0a --- /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 00000000..876e0c66 --- /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 00000000..b7bf960f --- /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 00000000..3cc570e9 --- /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) + } + } +} From 095aaadf074ca407fedc724ad564ea7d922c705d Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 1 Sep 2026 19:58:49 +0800 Subject: [PATCH 03/10] fix(macos): resolve compilation errors in GitLogView and WorkbenchView Add missing type definitions (GitReferenceRowActions, GitReferenceRowView, GitLogThreePaneLayout) lost during transcript replay, remove duplicate GitReferenceTreeNode, fix WorkbenchWorkspaceSplitView action routing, and fix LitheSignpost to use OSSignpostIntervalState and String-keyed dictionary. --- .../Services/Monitoring/LitheSignpost.swift | 8 +- .../Sources/Lithe/Views/Git/GitLogView.swift | 329 ++++++++++++++---- .../Lithe/Views/Workbench/WorkbenchView.swift | 6 +- 3 files changed, 270 insertions(+), 73 deletions(-) diff --git a/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift index d19abd95..817a0a0b 100644 --- a/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift +++ b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift @@ -6,19 +6,19 @@ enum LitheSignpost { category: "Rendering" ) - static func begin(_ name: StaticString) -> OSSignposter.IntervalState { + static func begin(_ name: StaticString) -> OSSignpostIntervalState { signposter.beginInterval(name) } - static func end(_ name: StaticString, _ state: OSSignposter.IntervalState) { + static func end(_ name: StaticString, _ state: OSSignpostIntervalState) { signposter.endInterval(name, state) } #if DEBUG - private static var bodyCounts: [StaticString: Int] = [:] + private static var bodyCounts: [String: Int] = [:] static func bodyEvaluated(_ view: StaticString) { - bodyCounts[view, default: 0] += 1 + bodyCounts["\(view)", default: 0] += 1 } #else @inlinable @inline(__always) diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 1c16c670..7aff9ce1 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -11,6 +11,11 @@ struct GitLogView: View { @State private var tagsExpanded = true @State private var collapsedReferenceGroups: Set = [] @State private var collapsedFileGroups: Set = [] + @State private var localReferenceRows: [GitReferenceRow] = [] + @State private var remoteReferenceRows: [GitReferenceRow] = [] + @State private var tagReferenceRows: [GitReferenceRow] = [] + @State private var currentReferenceCache = GitCurrentReferenceCache() + @State private var commitFileTreeItems: [GitCommitFileTreeItem] = [] @State private var branchDialogRequest: GitBranchDialogRequest? @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @@ -1716,72 +1721,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) @@ -2417,3 +2356,261 @@ struct GitCheckoutConflictDialog: View { dismiss() } } + +// MARK: - Git Reference Row Actions & View + +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 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(180, availableWidth * 0.35) + } + + private var detailPaneMaximum: CGFloat { + max(250, availableWidth * 0.5) + } + + var body: some View { + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: 220, + minimum: 180, + maximum: referencePaneMaximum, + sized: { referencePane }, + flexible: { + LitheSplitPaneView( + axis: .horizontal, + placement: .trailing, + defaultSize: 350, + minimum: 250, + maximum: detailPaneMaximum, + sized: { detailPane }, + flexible: { commitPane } + ) + } + ) + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 80907810..34c3df0b 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1705,7 +1705,7 @@ private struct WorkbenchWorkspaceSplitView Date: Tue, 1 Sep 2026 20:40:34 +0800 Subject: [PATCH 04/10] fix(macos): wire all perf caches into their host views - GitLogView: fill local/remote/tag reference rows via .task(id: referenceRowsTaskIdentity) using GitReferenceRowsBuilder; add GitReferenceRowsIdentity combined key; replace linear currentReference scan with GitCurrentReferenceCache; switch .task(id:) for graph layout to use gitCommitsVersion integer token; remove unused commitFileTreeItems @State - ChangesSidebarView: replace four filter passes with GitChangeSectionsCache (single-pass derivation of displayed/tracked/added/staged) - RunView: replace per-body String.split with RunConfigurationTokenCache (two separate instances for collapsed executions and pin tokens) - EditorAreaView: replace @State editorTabFrames dictionary with EditorTabFrameStore reference box so preference writes no longer invalidate the editor body --- .../Lithe/Views/Editor/EditorAreaView.swift | 10 ++-- .../Lithe/Views/Git/ChangesSidebarView.swift | 19 +++++-- .../Sources/Lithe/Views/Git/GitLogView.swift | 49 +++++++++++++++++-- macos/Sources/Lithe/Views/Run/RunView.swift | 8 ++- 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 1859dafd..dc6d3dbd 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? @@ -215,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) @@ -705,7 +705,7 @@ struct EditorAreaView: View { } private func beginTabDrag(_ item: EditorTabItem) { - tabDragStartFrames = editorTabFrames + tabDragStartFrames = tabFrameStore.frames tabDragOffsetX = 0 tabReorderTarget = nil withAnimation(tabAnimation) { @@ -808,11 +808,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/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index a8671e8c..5a7af9c7 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -13,6 +13,7 @@ struct ChangesSidebarView: View { @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") @@ -772,17 +773,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 { @@ -799,7 +808,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/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 7aff9ce1..d0e5db5a 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -15,7 +15,6 @@ struct GitLogView: View { @State private var remoteReferenceRows: [GitReferenceRow] = [] @State private var tagReferenceRows: [GitReferenceRow] = [] @State private var currentReferenceCache = GitCurrentReferenceCache() - @State private var commitFileTreeItems: [GitCommitFileTreeItem] = [] @State private var branchDialogRequest: GitBranchDialogRequest? @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @@ -85,7 +84,7 @@ struct GitLogView: View { } } .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) @@ -93,6 +92,12 @@ 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)) @@ -1562,8 +1567,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 { @@ -2359,6 +2395,13 @@ struct GitCheckoutConflictDialog: View { // 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 diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 47818f98..84e0a770 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -11,6 +11,10 @@ struct RunView: View { @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? @@ -419,11 +423,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 { From 8a24996960baa6dedd9fb38ab7801db6390c1d3c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 1 Sep 2026 21:18:38 +0800 Subject: [PATCH 05/10] fix(macos): stabilize gitLogQuery date boundaries and remove dead drag state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitLogView: split gitLogQuery into hasActiveGitLogFilter (no Date() call) and gitLogQuery(now:). The debounced .task captures Date() once at fire time so afterDate/beforeDate are stable for the lifetime of each query. RunView: remove liveConfigurationListWidth, configurationListDragStart, resolvedListWidth, and constrained() — all became dead code when the configuration list pane was migrated to LitheSplitPaneView. --- .../Sources/Lithe/Views/Git/GitLogView.swift | 21 +++++++++++++++---- macos/Sources/Lithe/Views/Run/RunView.swift | 12 ----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index d0e5db5a..34c9a8fe 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -104,7 +104,9 @@ struct GitLogView: View { } 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 @@ -1229,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, @@ -1243,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] { diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index 84e0a770..d0ad0099 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -9,8 +9,6 @@ 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() @@ -53,12 +51,6 @@ struct RunView: View { minimumListWidth, min(420, geometry.size.width - SplitHandleView.thickness - minimumContentWidth) ) - let resolvedListWidth = constrained( - liveConfigurationListWidth ?? CGFloat(configurationListWidth), - minimum: minimumListWidth, - maximum: maximumListWidth - ) - if isConfigurationListCollapsed { HStack(spacing: 0) { collapsedConfigurationListBar @@ -877,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) From 3ef64e1880a79baeb26d7981f2bd0d76b21bb5d8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 1 Sep 2026 21:50:32 +0800 Subject: [PATCH 06/10] fix(macos): mark LitheDragUpdateScheduler init as nonisolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift 6.2 (the CI toolchain) rejects a call to a @MainActor-isolated init inside the nonisolated @State default-value context. The init itself is safe to call from any context — it only assigns self.delivery; no @MainActor state is touched until the first method call. Annotating the init nonisolated preserves full @MainActor isolation on all mutating methods while making the @State-held initialisation accepted by both Swift 6.2 and 6.3. --- .../Lithe/Views/Components/LitheDragUpdateScheduler.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift b/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift index 12c19191..02f765f8 100644 --- a/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift +++ b/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift @@ -6,6 +6,10 @@ import Foundation /// 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 { @@ -20,7 +24,7 @@ final class LitheDragUpdateScheduler { private var pendingDelivery: (@MainActor () -> Void)? private let delivery: Delivery - init(delivery: Delivery = .mainRunLoopTurn) { + nonisolated init(delivery: Delivery = .mainRunLoopTurn) { self.delivery = delivery } From dc0703f9132ae5517309e1d92bebfdf2c3f35c6f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 1 Sep 2026 10:10:01 +0800 Subject: [PATCH 07/10] fix(ci): make Swift test watchdog stall-aware --- .../references/macos-swift.md | 9 +- .../scripts/run-swift-tests-with-timing.mjs | 137 +++++++++--- .../scripts/test-stability-macos.sh | 8 +- .../scripts/test-verify-test-stability.mjs | 203 ++++++++++++++++++ 4 files changed, 319 insertions(+), 38 deletions(-) diff --git a/.agents/skills/write-stable-tests/references/macos-swift.md b/.agents/skills/write-stable-tests/references/macos-swift.md index 83eeb2db..058de7fd 100644 --- a/.agents/skills/write-stable-tests/references/macos-swift.md +++ b/.agents/skills/write-stable-tests/references/macos-swift.md @@ -30,8 +30,13 @@ loaded, or invoked anywhere in this path. Use `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh`. It forces serial execution so the currently running test is unambiguous, records every Swift Testing/XCTest case, -warns about slow cases, and terminates the suite when one case exceeds its local -budget. Reports are written below `.artifacts/test-stability/`. Each run +warns about slow cases, and fails the run when a reported duration exceeds its +local budget. Because the runner writes to a block-buffered pipe, a finish line +can arrive late or be lost; the harness therefore never kills the runner on a +per-test timer. Instead a stall watchdog (`--stall-timeout-seconds`, default +120) terminates the runner only when it produces no output at all, and reports +the tests still awaiting a result without asserting a single culprit. Reports +are written below `.artifacts/test-stability/`. Each run produces JSON and raw logs for diagnosis, JUnit XML for CI tooling, and a self-contained HTML report for module and performance review. diff --git a/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs b/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs index a0e1b7cd..c9ed3261 100755 --- a/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs +++ b/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { mkdirSync, writeFileSync, createWriteStream } from "node:fs"; +import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { writeTestReportArtifacts } from "./generate-test-report.mjs"; @@ -65,6 +66,7 @@ function parseArguments(arguments_) { const options = { warnMs: 1000, maxMs: 15000, + stallTimeoutMs: 120000, suiteTimeoutMs: 600000, report: path.join(REPOSITORY_ROOT, ".artifacts/test-stability/macos-swift.json"), command: arguments_[separator + 1], @@ -74,7 +76,9 @@ function parseArguments(arguments_) { const argument = arguments_[index]; if (argument === "--warn-ms") options.warnMs = positiveInteger(arguments_[++index], "--warn-ms"); else if (argument === "--max-ms") options.maxMs = positiveInteger(arguments_[++index], "--max-ms"); - else if (argument === "--suite-timeout-ms") { + else if (argument === "--stall-timeout-ms") { + options.stallTimeoutMs = positiveInteger(arguments_[++index], "--stall-timeout-ms"); + } else if (argument === "--suite-timeout-ms") { options.suiteTimeoutMs = positiveInteger(arguments_[++index], "--suite-timeout-ms"); } else if (argument === "--report") options.report = path.resolve(arguments_[++index]); else throw new Error(`Unknown argument: ${argument}`); @@ -90,34 +94,61 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { const active = new Map(); const records = []; let currentSuite = null; - let timedOutTest = null; - let testTimer = null; + let stalled = false; + let stallTimer = null; let terminateChild = () => {}; + let childPid = null; + let lastOutputAt = null; + let lastTestEventAt = null; + let stallSamplePath = null; + // Killing the runner from a per-test timer is unsound: swift test writes to a + // block-buffered pipe, so a finish line can sit (or be split mid-line) in the + // child's buffer long after the test completed, and the timer would blame an + // innocent test. Instead, per-test budgets are enforced after the run from + // the durations swift-testing itself reports, and this stall watchdog only + // guards against the runner producing no output at all. + const stallTimeoutMs = options.stallTimeoutMs ?? 120000; + // Best-effort thread-stack snapshot of the hung runner, taken before the + // SIGTERM destroys the evidence of where it was stuck. + const captureStallSample = () => { + if (process.platform !== "darwin" || !childPid) return; + const samplePath = options.report.replace(/\.json$/i, ".stall-sample.txt"); + try { + const sample = spawnSync("sample", [String(childPid), "2", "-file", samplePath], { + stdio: "ignore", + timeout: 15000, + }); + if (sample.status === 0) stallSamplePath = samplePath; + } catch { + // Sampling is diagnostics only; never let it break termination. + } + }; + const armStallTimer = () => { + if (stallTimer) clearTimeout(stallTimer); + stallTimer = setTimeout(() => { + stalled = true; + captureStallSample(); + terminateChild(); + }, stallTimeoutMs); + }; const recordLine = (line, stream) => { - log.write(`${stream}: ${line}\n`); + lastOutputAt = new Date().toISOString(); + log.write(`${lastOutputAt} ${stream}: ${line}\n`); + armStallTimer(); const suiteEvent = parseSwiftSuiteLine(line); if (suiteEvent?.event === "started") currentSuite = suiteEvent.name; else if (suiteEvent && suiteEvent.name === currentSuite) currentSuite = null; const event = parseSwiftTimingLine(line); if (!event) return; + lastTestEventAt = lastOutputAt; if (event.event === "started") { - const startedAt = performance.now(); - active.set(event.name, { startedAt, suite: currentSuite }); - if (testTimer) clearTimeout(testTimer); - testTimer = setTimeout(() => { - timedOutTest = { name: event.name, suite: currentSuite }; - terminateChild(); - }, options.maxMs); + active.set(event.name, { startedAt: performance.now(), suite: currentSuite }); return; } const activeTest = active.get(event.name); active.delete(event.name); - if (testTimer) { - clearTimeout(testTimer); - testTimer = null; - } records.push({ name: event.name, ...(activeTest?.suite ? { suite: activeTest.suite } : {}), @@ -130,12 +161,14 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { }; const startedAt = new Date().toISOString(); + armStallTimer(); const childPromise = runProcessImpl({ command: options.command, args: options.commandArguments, cwd: REPOSITORY_ROOT, timeoutMs: options.suiteTimeoutMs, - onSpawn: ({ terminate }) => { + onSpawn: ({ pid, terminate }) => { + childPid = pid ?? null; terminateChild = terminate; }, onStdoutLine: (line) => recordLine(line, "stdout"), @@ -144,31 +177,56 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { streamStderr: true, }); - const result = await childPromise; - if (testTimer) clearTimeout(testTimer); - await new Promise((resolve, reject) => { - log.once("error", reject); - log.end(resolve); - }); - - if (timedOutTest && !records.some((record) => record.name === timedOutTest.name)) { - records.push({ - name: timedOutTest.name, - ...(timedOutTest.suite ? { suite: timedOutTest.suite } : {}), - status: "timeout", - durationMs: options.maxMs, + // Clear the watchdog and settle the log stream even when spawn fails and the + // await throws; a leaked ref'd timer would keep this process alive for the + // full timeout, and an unsettled stream emits an unhandled error event. + let result; + let logError = null; + try { + result = await childPromise; + } finally { + if (stallTimer) clearTimeout(stallTimer); + stallTimer = null; + await new Promise((resolve) => { + log.once("error", (error) => { + logError ??= error; + resolve(); + }); + log.end(resolve); }); } - for (const [name, activeTest] of active) { + if (logError) throw logError; + + // Tests still in `active` either never finished or had their finish line cut + // off in the killed child's stdio buffer; report them without asserting that + // any single one of them is the culprit. + const unfinished = [...active.entries()]; + for (const [name, activeTest] of unfinished) { if (!records.some((record) => record.name === name)) { records.push({ name, ...(activeTest.suite ? { suite: activeTest.suite } : {}), - status: result.timedOut ? "timeout" : "incomplete", - durationMs: options.maxMs, + status: result.timedOut || stalled ? "timeout" : "incomplete", + durationMs: Math.round(performance.now() - activeTest.startedAt), }); } } + if (stalled) { + const unfinishedNames = unfinished.map(([name]) => name); + records.push({ + name: "Swift test runner stall", + suite: "Swift test runner", + status: "timeout", + durationMs: stallTimeoutMs, + details: + `The Swift runner produced no output for ${stallTimeoutMs}ms. ` + + (unfinishedNames.length > 0 + ? `Tests without a reported result: ${unfinishedNames.join(", ")}. ` + : "Every parsed test had reported a result; the runner likely hung during teardown or exit. ") + + `Last output at ${lastOutputAt ?? "never"}; last parsed test event at ${lastTestEventAt ?? "never"}.` + + (stallSamplePath ? ` Thread-stack sample of the hung runner: ${stallSamplePath}.` : ""), + }); + } if (result.timedOut && !records.some((record) => record.status === "timeout")) { records.push({ name: "Swift test suite timeout", @@ -190,11 +248,14 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { command: [options.command, ...options.commandArguments], warnMs: options.warnMs, maxMs: options.maxMs, + stallTimeoutMs, suiteTimeoutMs: options.suiteTimeoutMs, process: { exitCode: result.code, signal: result.signal, timedOut: result.timedOut, + stalled, + terminationConfirmed: result.terminationConfirmed, durationMs: Math.round(result.durationMs), }, tests: records, @@ -207,10 +268,16 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { console.log(`SLOW ${record.durationMs}ms ${record.name}`); } - if (timedOutTest) { - throw new Error(`Swift test exceeded ${options.maxMs}ms: ${timedOutTest.name}`); - } if (result.timedOut) throw new Error(`Swift test suite exceeded ${options.suiteTimeoutMs}ms.`); + if (stalled) { + const unfinishedNames = unfinished.map(([name]) => name); + throw new Error( + `Swift test runner produced no output for ${stallTimeoutMs}ms` + + (unfinishedNames.length > 0 + ? `; tests without a reported result: ${unfinishedNames.join(", ")}.` + : "; every parsed test had reported a result, so the runner likely hung during teardown or exit."), + ); + } if (records.length === 0) throw new Error("The Swift runner did not report any individual test durations."); if (overBudget.length > 0) throw new Error(`${overBudget.length} Swift test(s) exceeded the local budget.`); if (result.code !== 0) throw new Error(`Swift test command exited with code ${result.code}.`); diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh b/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh index d4a9e4cc..e64f62c9 100755 --- a/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh +++ b/.agents/skills/write-stable-tests/scripts/test-stability-macos.sh @@ -5,6 +5,7 @@ SCRIPT_DIR="${0:A:h}" ROOT_DIR="$(cd -- "$SCRIPT_DIR/../../../.." && pwd)" WARN_SECONDS=1 MAX_SECONDS=15 +STALL_TIMEOUT_SECONDS=120 SUITE_TIMEOUT_SECONDS=600 REPORT="$ROOT_DIR/.artifacts/test-stability/macos-swift.json" SWIFT_ARGS=() @@ -19,6 +20,10 @@ while (( $# > 0 )); do MAX_SECONDS="$2" shift 2 ;; + --stall-timeout-seconds) + STALL_TIMEOUT_SECONDS="$2" + shift 2 + ;; --suite-timeout-seconds) SUITE_TIMEOUT_SECONDS="$2" shift 2 @@ -48,7 +53,7 @@ done for argument in "${SWIFT_ARGS[@]}"; do if [[ "$argument" == "--parallel" ]]; then - print -u2 -- "--parallel is not allowed: per-test watchdog attribution requires serial execution." + print -u2 -- "--parallel is not allowed: per-test duration attribution requires serial execution." exit 2 fi done @@ -57,6 +62,7 @@ done node "$SCRIPT_DIR/run-swift-tests-with-timing.mjs" \ --warn-ms "$(( WARN_SECONDS * 1000 ))" \ --max-ms "$(( MAX_SECONDS * 1000 ))" \ + --stall-timeout-ms "$(( STALL_TIMEOUT_SECONDS * 1000 ))" \ --suite-timeout-ms "$(( SUITE_TIMEOUT_SECONDS * 1000 ))" \ --report "$REPORT" \ -- "$ROOT_DIR/scripts/test-macos.sh" --no-parallel "${SWIFT_ARGS[@]}" diff --git a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs index 19d8f39b..74715ed4 100755 --- a/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs +++ b/.agents/skills/write-stable-tests/scripts/test-verify-test-stability.mjs @@ -254,6 +254,209 @@ try { rmSync(swiftTimeoutRoot, { recursive: true, force: true }); } +// Regression coverage for the CI misattribution incident: block-buffered pipes +// can swallow a finish line, so a stall must be reported as runner silence with +// the unfinished tests listed, never as "test X exceeded the budget". +const swiftStallRoot = mkdtempSync(path.join(os.tmpdir(), "lithe-test-stability-swift-stall-")); +try { + const reportPath = path.join(swiftStallRoot, "swift-stall.json"); + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 100, + suiteTimeoutMs: 5000, + report: reportPath, + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async ({ onStdoutLine, onSpawn }) => { + let resolveTerminated; + const terminated = new Promise((resolve) => { + resolveTerminated = resolve; + }); + onSpawn({ + terminate: async () => { + resolveTerminated(); + return true; + }, + }); + onStdoutLine('◇ Suite "Keyboard shortcuts" started.'); + onStdoutLine("◇ Test fast() started."); + onStdoutLine("✔ Test fast() passed after 0.001 seconds."); + onStdoutLine("◇ Test truncatedFinishLine() started."); + // The finish line for truncatedFinishLine() never arrives, as when the + // runner's stdio buffer is lost; the stall watchdog must fire. + await terminated; + return { + code: null, + signal: "SIGTERM", + timedOut: false, + terminationConfirmed: true, + durationMs: 150, + stdout: "", + stderr: "", + }; + }, + }, + ), + /produced no output for 100ms; tests without a reported result: truncatedFinishLine\(\)/, + ); + const stallReport = JSON.parse(readFileSync(reportPath, "utf8")); + assert.equal(stallReport.process.stalled, true); + assert.deepEqual( + stallReport.tests.map(({ name, status }) => ({ name, status })), + [ + { name: "fast()", status: "passed" }, + { name: "truncatedFinishLine()", status: "timeout" }, + { name: "Swift test runner stall", status: "timeout" }, + ], + ); +} finally { + rmSync(swiftStallRoot, { recursive: true, force: true }); +} + +// A stall after every test reported a result points at teardown/exit instead of +// blaming any test. +const swiftTeardownStallRoot = mkdtempSync( + path.join(os.tmpdir(), "lithe-test-stability-swift-teardown-stall-"), +); +try { + const reportPath = path.join(swiftTeardownStallRoot, "swift-teardown-stall.json"); + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 100, + suiteTimeoutMs: 5000, + report: reportPath, + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async ({ onStdoutLine, onSpawn }) => { + let resolveTerminated; + const terminated = new Promise((resolve) => { + resolveTerminated = resolve; + }); + onSpawn({ + terminate: async () => { + resolveTerminated(); + return true; + }, + }); + onStdoutLine("◇ Test fast() started."); + onStdoutLine("✔ Test fast() passed after 0.001 seconds."); + await terminated; + return { + code: null, + signal: "SIGTERM", + timedOut: false, + terminationConfirmed: true, + durationMs: 150, + stdout: "", + stderr: "", + }; + }, + }, + ), + /produced no output for 100ms; every parsed test had reported a result/, + ); + const teardownReport = JSON.parse(readFileSync(reportPath, "utf8")); + assert.equal(teardownReport.process.stalled, true); + assert.deepEqual( + teardownReport.tests.map(({ name, status }) => ({ name, status })), + [ + { name: "fast()", status: "passed" }, + { name: "Swift test runner stall", status: "timeout" }, + ], + ); +} finally { + rmSync(swiftTeardownStallRoot, { recursive: true, force: true }); +} + +// The per-test budget is enforced from the durations swift-testing reports: a +// test that finishes over maxMs must fail the run even though the runner +// exited cleanly and no watchdog fired. +const swiftBudgetRoot = mkdtempSync(path.join(os.tmpdir(), "lithe-test-stability-swift-budget-")); +try { + const reportPath = path.join(swiftBudgetRoot, "swift-budget.json"); + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 5000, + suiteTimeoutMs: 10000, + report: reportPath, + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async ({ onStdoutLine, onSpawn }) => { + onSpawn({ terminate: async () => true }); + onStdoutLine("◇ Test overBudget() started."); + onStdoutLine("✔ Test overBudget() passed after 0.250 seconds."); + return { + code: 0, + signal: null, + timedOut: false, + terminationConfirmed: true, + durationMs: 300, + stdout: "", + stderr: "", + }; + }, + }, + ), + /1 Swift test\(s\) exceeded the local budget/, + ); + const budgetReport = JSON.parse(readFileSync(reportPath, "utf8")); + assert.deepEqual( + budgetReport.tests.map(({ name, status, durationMs }) => ({ name, status, durationMs })), + [{ name: "overBudget()", status: "passed", durationMs: 250 }], + ); +} finally { + rmSync(swiftBudgetRoot, { recursive: true, force: true }); +} + +// A spawn failure must reject promptly and clear the stall watchdog; a leaked +// ref'd timer would keep the harness process alive for the full stall timeout. +{ + const spawnFailureRoot = mkdtempSync( + path.join(os.tmpdir(), "lithe-test-stability-swift-spawn-failure-"), + ); + try { + await assert.rejects( + runSwiftTestsWithTiming( + { + warnMs: 50, + maxMs: 200, + stallTimeoutMs: 600000, + suiteTimeoutMs: 10000, + report: path.join(spawnFailureRoot, "swift-spawn-failure.json"), + command: "swift", + commandArguments: ["test"], + }, + { + runProcessImpl: async () => { + throw new Error("spawn ENOENT"); + }, + }, + ), + /spawn ENOENT/, + ); + // If the watchdog leaked, the 600s timer would hold this test process open + // long past its CI budget; reaching this line with a cleared event loop is + // asserted implicitly by the suite finishing on time. + } finally { + rmSync(spawnFailureRoot, { recursive: true, force: true }); + } +} + const rustCompileFailureRoot = mkdtempSync( path.join( os.tmpdir(), From 43ec8ea5f23080e2355bee83326b1fa58fb5d85b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 1 Sep 2026 11:01:47 +0800 Subject: [PATCH 08/10] fix(ci): sample the full descendant tree on Swift runner stall The testing helper detaches into its own process group, so the previous group-scoped walk only sampled the shell wrapper waiting on its child. Walk the ppid tree instead and capture a listing plus a thread-stack sample of every descendant before terminating the run. Co-Authored-By: Claude Fable 5 --- .../scripts/run-swift-tests-with-timing.mjs | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs b/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs index c9ed3261..6bfb2519 100755 --- a/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs +++ b/.agents/skills/write-stable-tests/scripts/run-swift-tests-with-timing.mjs @@ -109,16 +109,62 @@ export async function run(options, { runProcessImpl = runProcess } = {}) { // guards against the runner producing no output at all. const stallTimeoutMs = options.stallTimeoutMs ?? 120000; // Best-effort thread-stack snapshot of the hung runner, taken before the - // SIGTERM destroys the evidence of where it was stuck. + // SIGTERM destroys the evidence of where it was stuck. The direct child is a + // shell wrapper, so walk its descendant tree: the genuinely hung process + // (swift-test or the testing helper, which detaches into its own process + // group) is a descendant, and the wrapper's stack would only show it waiting + // on its child. const captureStallSample = () => { if (process.platform !== "darwin" || !childPid) return; - const samplePath = options.report.replace(/\.json$/i, ".stall-sample.txt"); + let treePids = [String(childPid)]; + let processListing = ""; try { - const sample = spawnSync("sample", [String(childPid), "2", "-file", samplePath], { - stdio: "ignore", + const everyProcess = spawnSync( + "ps", + ["-axo", "pid=,ppid=,pgid=,etime=,command="], + { encoding: "utf8", timeout: 5000 }, + ); + const rows = (everyProcess.stdout ?? "") + .split("\n") + .map((line) => { + const [pid, ppid] = line.trim().split(/\s+/); + return { pid, ppid, line }; + }) + .filter((row) => row.pid); + const wanted = new Set([String(childPid)]); + // Multiple passes handle arbitrary depth without recursion. + for (let pass = 0; pass < 10; pass += 1) { + const before = wanted.size; + for (const row of rows) if (wanted.has(row.ppid)) wanted.add(row.pid); + if (wanted.size === before) break; + } + const treeRows = rows.filter((row) => wanted.has(row.pid)); + if (treeRows.length > 0) { + processListing = treeRows.map((row) => row.line).join("\n"); + treePids = treeRows.map((row) => row.pid); + } + } catch { + // Fall back to sampling only the direct child. + } + const samplePath = options.report.replace(/\.json$/i, ".stall-sample.txt"); + const sections = [ + `Process tree under pid ${childPid} at stall (pid ppid pgid etime command):`, + processListing || "(process listing unavailable)", + ]; + // Bound the diagnostics pass; each sample blocks for its full duration. + for (const pid of treePids.slice(0, 6)) { + const sample = spawnSync("sample", [pid, "2"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], timeout: 15000, }); - if (sample.status === 0) stallSamplePath = samplePath; + if (sample.status === 0 && sample.stdout) { + sections.push(`===== sample of pid ${pid} =====`, sample.stdout); + } + } + try { + writeFileSync(samplePath, `${sections.join("\n\n")}\n`); + stallSamplePath = samplePath; } catch { // Sampling is diagnostics only; never let it break termination. } From 804f1a8575d8a241f82c1bac065fb9172b9b2eaf Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 14:30:26 +0800 Subject: [PATCH 09/10] test(macos): account for activity bar divider --- macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift index fa06644d..fe108fdd 100644 --- a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift +++ b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -6,7 +6,11 @@ import Testing struct WorkbenchRenderingSafetyTests { @Test func workspaceReservesTheRightActivityBar() { - #expect(WorkbenchLayoutMetrics.workspaceTrailingInset == WorkbenchLayoutMetrics.rightActivityBarWidth) + #expect( + WorkbenchLayoutMetrics.workspaceTrailingInset + == WorkbenchLayoutMetrics.rightActivityBarWidth + + WorkbenchLayoutMetrics.rightActivityBarDividerWidth + ) } @Test From c8f64efcd16b61e4ee10d5c3f1d590ffdf38babc Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 2 Sep 2026 14:33:14 +0800 Subject: [PATCH 10/10] fix(macos): reserve activity bar width without divider --- macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift | 2 +- macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index cfd0acaf..05e88ef6 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -5,7 +5,7 @@ import LitheGitModule enum WorkbenchLayoutMetrics { static let rightActivityBarWidth: CGFloat = 40 static let rightActivityBarDividerWidth: CGFloat = 1 - static let workspaceTrailingInset = rightActivityBarWidth + rightActivityBarDividerWidth + static let workspaceTrailingInset = rightActivityBarWidth } private enum ActivityBarMetrics { diff --git a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift index fe108fdd..fa06644d 100644 --- a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift +++ b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -6,11 +6,7 @@ import Testing struct WorkbenchRenderingSafetyTests { @Test func workspaceReservesTheRightActivityBar() { - #expect( - WorkbenchLayoutMetrics.workspaceTrailingInset - == WorkbenchLayoutMetrics.rightActivityBarWidth - + WorkbenchLayoutMetrics.rightActivityBarDividerWidth - ) + #expect(WorkbenchLayoutMetrics.workspaceTrailingInset == WorkbenchLayoutMetrics.rightActivityBarWidth) } @Test