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/24] 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 9e3365bb3..97697c959 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 9039b7e5f..4f29f9f54 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/24] 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 c41c41a4b..2c3c45a56 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -218,6 +218,9 @@ extension AppModel { var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] } var recentGitReferences: [GitReference] { gitFeatureIfActive?.recentGitReferences ?? [] } var gitCommits: [GitCommit] { gitFeatureIfActive?.gitCommits ?? [] } + /// Cheap stand-in for `gitCommits` as a change key. Comparing the array + /// itself made every `.task(id:)` evaluation walk the whole commit list. + var gitCommitsVersion: Int { gitFeatureIfActive?.gitCommitsVersion ?? 0 } var gitLogMatchedCommitHashes: Set? { gitFeatureIfActive?.gitLogMatchedCommitHashes } diff --git a/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift new file mode 100644 index 000000000..d19abd956 --- /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 000000000..12c19191c --- /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 000000000..e96c315da --- /dev/null +++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift @@ -0,0 +1,36 @@ +import CoreGraphics + +/// Pure geometry for split-pane resize calculations. +enum LitheSplitPaneGeometry { + enum Placement { + case leading + case trailing + } + + static func resolve( + start: CGFloat, + translation: CGFloat, + placement: Placement, + minimum: CGFloat, + maximum: CGFloat + ) -> CGFloat { + let raw: CGFloat + switch placement { + case .leading: + raw = start + translation + case .trailing: + raw = start - translation + } + return clamp(raw, minimum: minimum, maximum: maximum) + } + + static func clamp( + _ value: CGFloat, + minimum: CGFloat, + maximum: CGFloat + ) -> CGFloat { + let effectiveMinimum = min(minimum, maximum) + let effectiveMaximum = max(minimum, maximum) + return min(max(value, effectiveMinimum), effectiveMaximum) + } +} diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift new file mode 100644 index 000000000..ad0329398 --- /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 0a259de79..38b57cf89 100644 --- a/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift +++ b/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift @@ -12,8 +12,7 @@ struct DiffHorizontalScroller: View { @State private var dragStartOffset: CGFloat = 0 @State private var isDragging = false @State private var isHovering = false - @State private var dragUpdateBuffer = FrameCoalescedDragUpdateBuffer() - @State private var dragUpdateTask: Task? + @State private var dragScheduler = LitheDragUpdateScheduler() private var maximumOffset: CGFloat { max(0, contentWidth - viewportWidth) @@ -53,16 +52,18 @@ struct DiffHorizontalScroller: View { dragStartOffset = offset } guard travel > 0 else { return } - scheduleOffsetUpdate(constrained( + dragScheduler.submit(constrained( dragStartOffset + (value.translation.width / travel) * maximumOffset - )) + )) { nextOffset in + offset = nextOffset + } } .onEnded { value in if travel > 0 { let finalOffset = constrained( dragStartOffset + (value.translation.width / travel) * maximumOffset ) - cancelScheduledOffsetUpdate() + dragScheduler.cancel() offset = finalOffset } isDragging = false @@ -79,7 +80,7 @@ struct DiffHorizontalScroller: View { .opacity(maximumOffset > 0.5 ? 1 : 0) .allowsHitTesting(maximumOffset > 0.5) .accessibilityLabel("Synchronized diff horizontal scroll") - .onDisappear(perform: cancelScheduledOffsetUpdate) + .onDisappear { dragScheduler.cancel() } .onChange(of: maximumOffset) { newMaximum in offset = min(max(offset, 0), newMaximum) } @@ -88,25 +89,6 @@ struct DiffHorizontalScroller: View { private func constrained(_ value: CGFloat) -> CGFloat { min(max(value, 0), maximumOffset) } - - private func scheduleOffsetUpdate(_ nextOffset: CGFloat) { - guard dragUpdateBuffer.submit(nextOffset) else { return } - dragUpdateTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - let nextOffset = dragUpdateBuffer.takePendingValue() - dragUpdateTask = nil - if let nextOffset { - offset = nextOffset - } - } - } - - private func cancelScheduledOffsetUpdate() { - dragUpdateTask?.cancel() - dragUpdateTask = nil - dragUpdateBuffer.cancel() - } } /// Observes horizontal trackpad/wheel gestures over the diff without becoming diff --git a/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift b/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift index 658d5fb02..8acc20a66 100644 --- a/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift +++ b/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift @@ -19,8 +19,7 @@ struct DiffSplitPaneView: View { let rowOverlay: (DiffRow, DiffSide) -> RowOverlay @State private var horizontalOffset: CGFloat = 0 - @State private var wheelUpdateBuffer = FrameCoalescedDragUpdateBuffer() - @State private var wheelUpdateTask: Task? + @State private var wheelScheduler = LitheDragUpdateScheduler() init( displayRows: [DiffDisplayRow], @@ -102,17 +101,24 @@ struct DiffSplitPaneView: View { .frame(width: viewportWidth, height: minimumHeight, alignment: .topLeading) .background { DiffHorizontalScrollWheelMonitor { delta in - let pendingOffset = wheelUpdateBuffer.pendingValue ?? horizontalOffset - scheduleWheelOffsetUpdate( - min(max(pendingOffset + delta, 0), maximumHorizontalOffset) - ) + // Wheel deltas are incremental, so accumulate onto the in-flight + // target rather than the last applied offset. minimumChange: 0 + // keeps sub-point wheel steps from being swallowed by the + // deadband, matching the pre-scheduler behavior. + let pendingOffset = wheelScheduler.pendingValue ?? horizontalOffset + wheelScheduler.submit( + min(max(pendingOffset + delta, 0), maximumHorizontalOffset), + minimumChange: 0 + ) { nextOffset in + horizontalOffset = nextOffset + } } } .onChange(of: contentWidth) { _ in - cancelScheduledWheelOffsetUpdate() + wheelScheduler.cancel() horizontalOffset = min(horizontalOffset, maximumHorizontalOffset) } - .onDisappear(perform: cancelScheduledWheelOffsetUpdate) + .onDisappear { wheelScheduler.cancel() } } private func sideViewport( @@ -129,25 +135,6 @@ struct DiffSplitPaneView: View { .background(LitheTheme.editor) } - private func scheduleWheelOffsetUpdate(_ nextOffset: CGFloat) { - guard wheelUpdateBuffer.submit(nextOffset) else { return } - wheelUpdateTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - let nextOffset = wheelUpdateBuffer.takePendingValue() - wheelUpdateTask = nil - if let nextOffset { - horizontalOffset = nextOffset - } - } - } - - private func cancelScheduledWheelOffsetUpdate() { - wheelUpdateTask?.cancel() - wheelUpdateTask = nil - wheelUpdateBuffer.cancel() - } - private var centerGutter: some View { LitheTheme.window .overlay(alignment: .leading) { diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 099598488..1859dafd0 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 000000000..dfefa5699 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift @@ -0,0 +1,24 @@ +import CoreGraphics + +/// Where each editor tab currently sits, recorded from layout. +/// +/// Deliberately a reference box held as `@State` rather than `@State` storage of +/// the dictionary itself. The frames are only read when a drag or drop begins, +/// but the preference that reports them fires on *every* layout pass — including +/// every frame of a pane resize. Storing them as view state made each of those +/// passes re-evaluate the whole editor area; a reference box records them +/// without invalidating anything. +/// +/// Follows the same pattern as `EditorViewportStore` and `LithePointerCursor`. +@MainActor +final class EditorTabFrameStore { + private(set) var frames: [EditorTabItem: CGRect] = [:] + + func update(_ frames: [EditorTabItem: CGRect]) { + self.frames = frames + } + + subscript(item: EditorTabItem) -> CGRect? { + frames[item] + } +} diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 93edc59c4..a8671e8cc 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 000000000..832c0626f --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift @@ -0,0 +1,66 @@ +import LitheGitModule + +/// Splits the working-tree changes into the sections the sidebar renders, once +/// per change of the underlying list. +/// +/// `displayedChanges` was recomputed by `trackedChanges`, `addedChanges`, and +/// the empty-state check, so one body pass filtered the whole change list +/// several times over and then partitioned it twice more. +/// +/// A reference box in `@State`, like `EditorViewportStore`, so caching cannot +/// invalidate the view that reads it. +@MainActor +final class GitChangeSectionsCache { + struct Sections { + /// All changes, minus anything hidden by an active conflict filter. + let displayed: [GitChange] + let tracked: [GitChange] + let added: [GitChange] + let staged: [GitChange] + } + + private var cachedChanges: [GitChange] = [] + private var cachedFilterPaths: Set = [] + private var cached: Sections? + + func sections( + changes: [GitChange], + conflictFilterPaths: Set + ) -> Sections { + if let cached, cachedChanges == changes, cachedFilterPaths == conflictFilterPaths { + return cached + } + + var displayed: [GitChange] = [] + var tracked: [GitChange] = [] + var added: [GitChange] = [] + var staged: [GitChange] = [] + displayed.reserveCapacity(changes.count) + + for change in changes { + // `staged` intentionally ignores the conflict filter, matching the + // commit-affordance checks that read it. + if change.isStaged { staged.append(change) } + guard conflictFilterPaths.isEmpty || conflictFilterPaths.contains(change.path) else { + continue + } + displayed.append(change) + if change.kind == .added { + added.append(change) + } else { + tracked.append(change) + } + } + + let sections = Sections( + displayed: displayed, + tracked: tracked, + added: added, + staged: staged + ) + cachedChanges = changes + cachedFilterPaths = conflictFilterPaths + cached = sections + return sections + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift b/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift new file mode 100644 index 000000000..501b6a99c --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift @@ -0,0 +1,25 @@ +import LitheGitModule + +/// Caches the "current" reference lookup against the reference list it came from. +/// +/// `GitLogView` asks for the current branch from more than twenty places in a +/// single body pass, and each ask was a linear scan of every branch, remote +/// branch, and tag in the repository. +/// +/// A reference box in `@State`, like `EditorViewportStore`, so caching cannot +/// invalidate the view that reads it. +@MainActor +final class GitCurrentReferenceCache { + private var cachedReferences: [GitReference] = [] + private var cachedResult: GitReference? + private var hasCached = false + + func reference(in references: [GitReference]) -> GitReference? { + if hasCached, cachedReferences == references { return cachedResult } + let result = references.first(where: \.isCurrent) + cachedReferences = references + cachedResult = result + hasCached = true + return result + } +} diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 99ee19b78..1c16c6705 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 000000000..5b114f3d1 --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift @@ -0,0 +1,162 @@ +import Foundation +import LitheGitModule + +/// One visible line of the Git log's reference tree. +/// +/// The tree used to render as a recursive `-> AnyView` function, which erased +/// every level's type, blocked `LazyVStack`, and forced the whole tree to +/// re-evaluate whenever `GitLogView` re-ran. Flattening to rows makes each line +/// independently comparable and lazily rendered. +struct GitReferenceRow: Identifiable, Equatable { + enum Content: Equatable { + /// A branch, remote branch, or tag the user can act on. + case reference(GitReference) + /// A path segment shared by several references, such as `feature` in + /// `feature/a` and `feature/b`. `key` is the collapse-state key. + case group(key: String, isCollapsed: Bool) + } + + let id: String + /// The last path component, which is what the row displays. + let name: String + /// Nesting level, used only for the row's leading indent. + let depth: Int + let content: Content +} + +/// Flattens references into the visible rows of one section, in render order. +enum GitReferenceRowsBuilder { + /// - Parameters: + /// - references: Already filtered to a single `kind` by the caller. + /// - collapsedGroups: Keys of groups whose children are hidden. + static func rows( + from references: [GitReference], + kind: GitReferenceKind, + collapsedGroups: Set + ) -> [GitReferenceRow] { + var rows: [GitReferenceRow] = [] + append( + GitReferenceTreeNode.build(from: references), + kind: kind, + depth: 0, + collapsedGroups: collapsedGroups, + into: &rows + ) + return rows + } + + private static func append( + _ nodes: [GitReferenceTreeNode], + kind: GitReferenceKind, + depth: Int, + collapsedGroups: Set, + into rows: inout [GitReferenceRow] + ) { + for node in nodes { + // A node can be both: `feature` may be a branch and also the prefix + // of `feature/x`, in which case it emits a reference row and a group + // row at the same depth. + if let reference = node.reference { + rows.append( + GitReferenceRow( + id: "reference:" + node.path, + name: node.name, + depth: depth, + content: .reference(reference) + ) + ) + } + + guard !node.children.isEmpty else { continue } + let key = "\(kind.rawValue):\(node.path)" + let isCollapsed = collapsedGroups.contains(key) + rows.append( + GitReferenceRow( + id: "group:" + node.path, + name: node.name, + depth: depth, + content: .group(key: key, isCollapsed: isCollapsed) + ) + ) + + guard !isCollapsed else { continue } + append( + node.children, + kind: kind, + depth: depth + 1, + collapsedGroups: collapsedGroups, + into: &rows + ) + } + } +} + +/// Intermediate tree used only to group references by their `/`-separated path +/// before flattening. +struct GitReferenceTreeNode: Identifiable { + let path: String + let name: String + let reference: GitReference? + let children: [GitReferenceTreeNode] + + var id: String { path } + + static func build(from references: [GitReference]) -> [GitReferenceTreeNode] { + let root = MutableGitReferenceTreeNode(name: "", path: "") + + for reference in references { + let components = reference.shortName + .split(separator: "/") + .map(String.init) + guard !components.isEmpty else { continue } + + var node = root + var pathComponents: [String] = [] + for component in components { + pathComponents.append(component) + if node.children[component] == nil { + node.children[component] = MutableGitReferenceTreeNode( + name: component, + path: pathComponents.joined(separator: "/") + ) + } + node = node.children[component]! + } + node.reference = reference + } + + return makeNodes(from: root) + } + + private static func makeNodes(from node: MutableGitReferenceTreeNode) -> [GitReferenceTreeNode] { + node.children.values + .map { child in + GitReferenceTreeNode( + path: child.path, + name: child.name, + reference: child.reference, + children: makeNodes(from: child) + ) + } + // Leaves before folders, then natural order, so the list is stable + // across rebuilds of the unordered child dictionary. + .sorted { lhs, rhs in + if (lhs.reference != nil) != (rhs.reference != nil) { + return lhs.reference != nil + } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } +} + +private final class MutableGitReferenceTreeNode { + let name: String + let path: String + var reference: GitReference? + var children: [String: MutableGitReferenceTreeNode] = [:] + + init(name: String, path: String) { + self.name = name + self.path = path + } +} diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift index 1d6f8460a..88d5857e8 100644 --- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift +++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -30,42 +30,27 @@ struct LanguageTestsView: View { minimumListWidth, geometry.size.width - SplitHandleView.thickness - minimumContentWidth ) - let resolvedListWidth = min( - max(liveItemListWidth ?? CGFloat(itemListWidth), minimumListWidth), - maximumListWidth - ) - HStack(spacing: 0) { - if isItemListCollapsed { + if isItemListCollapsed { + HStack(spacing: 0) { collapsedItemListBar .frame(width: 32) Rectangle() .fill(LitheTheme.divider) .frame(width: 1) - } else { - testItemList - .frame(width: resolvedListWidth) - SplitHandleView( - axis: .horizontal, - onDragStarted: { itemListDragStart = resolvedListWidth }, - onDragChanged: { translation in - liveItemListWidth = min( - max(itemListDragStart + translation, minimumListWidth), - maximumListWidth - ) - }, - onDragEnded: { translation in - let finalWidth = min( - max(itemListDragStart + translation, minimumListWidth), - maximumListWidth - ) - itemListWidth = Double(finalWidth) - liveItemListWidth = nil - } - ) + selectedTestContent } - - selectedTestContent + } else { + LitheSplitPaneView( + axis: .horizontal, + placement: .leading, + defaultSize: CGFloat(itemListWidth), + minimum: minimumListWidth, + maximum: maximumListWidth, + onCommit: { itemListWidth = Double($0) }, + sized: { testItemList }, + flexible: { selectedTestContent } + ) } } } diff --git a/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift b/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift new file mode 100644 index 000000000..71d19a3b6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Parses the comma- and newline-separated token lists the Run tool window keeps +/// in `@AppStorage`, memoized on the raw string. +/// +/// The stored strings are re-parsed on every access, and `isPinned` is called +/// inside a filter over every configuration, so a body pass re-split the whole +/// list once per configuration. Caching on the raw value turns that O(n·m) back +/// into O(m) while keeping `@AppStorage` the source of truth. +/// +/// Held as a reference box in `@State` (like `EditorViewportStore`) so caching +/// never invalidates the view that reads it. +@MainActor +final class RunConfigurationTokenCache { + private var lastRawValue: String? + private var lastTokens: Set = [] + + /// - Parameter separator: `,` for collapsed executions, `\n` for pin tokens. + func tokens(from rawValue: String, separator: Character) -> Set { + if lastRawValue == rawValue { return lastTokens } + let tokens = Set(rawValue.split(separator: separator).map(String.init)) + lastRawValue = rawValue + lastTokens = tokens + return tokens + } +} diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index f4b238db8..47818f98f 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 1383e505c..d909c208b 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 e750e2912..a451135fb 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift @@ -25,6 +25,38 @@ struct WorkbenchModuleUIRegistry { let isVisible: @MainActor (AppModel) -> Bool let isSelected: @MainActor (AppModel) -> Bool let content: @MainActor (AppModel) -> AnyView + /// Distinguishes states the erased content cannot express by value — + /// chiefly "still loading" versus a specific attached feature object. + let contentIdentity: @MainActor (AppModel) -> AnyHashable + + init( + id: String, + ideaAssetPath: String?, + isVisible: @escaping @MainActor (AppModel) -> Bool, + isSelected: @escaping @MainActor (AppModel) -> Bool, + content: @escaping @MainActor (AppModel) -> AnyView, + // Renderers whose content depends on nothing but the hosted view's + // own observation of AppModel are always interchangeable. + contentIdentity: @escaping @MainActor (AppModel) -> AnyHashable = { _ in 0 } + ) { + self.id = id + self.ideaAssetPath = ideaAssetPath + self.isVisible = isVisible + self.isSelected = isSelected + self.content = content + self.contentIdentity = contentIdentity + } + + /// Identity for renderers built from an optional feature object. The + /// loading placeholder and each distinct feature instance must compare + /// differently, or switching workspaces would keep showing the previous + /// workspace's feature. + static func featureIdentity(_ feature: AnyObject?) -> AnyHashable { + guard let feature else { return AnyHashable(Self.loadingIdentity) } + return AnyHashable(ObjectIdentifier(feature)) + } + + private static let loadingIdentity = "module-loading" } struct Registration { @@ -100,14 +132,24 @@ struct WorkbenchModuleUIRegistry { func selectedToolContent( from contributions: [ModuleContribution], model: AppModel - ) -> AnyView { + ) -> ModuleToolContent { for contribution in contributions { guard let renderer = renderer(for: contribution), renderer.isSelected(model) else { continue } - return renderer.content(model) + return ModuleToolContent( + rendererID: renderer.id, + contentIdentity: renderer.contentIdentity(model), + content: renderer.content(model) + ) } - return AnyView(Self.moduleLoadingView) + return ModuleToolContent( + rendererID: Self.loadingRendererID, + contentIdentity: 0, + content: AnyView(Self.moduleLoadingView) + ) } + private static let loadingRendererID = "workbench.module-loading" + static var moduleLoadingView: some View { VStack(spacing: 8) { ProgressView() @@ -119,3 +161,24 @@ struct WorkbenchModuleUIRegistry { .background(LitheTheme.editor) } } + +/// Restores a comparable identity to a tool window that the registry had to +/// erase to `AnyView`. +/// +/// `AnyView` defeats SwiftUI's value comparison, so every `WorkbenchView.body` +/// pass — including one per frame while a divider is dragged — forced the whole +/// bottom tool window to re-evaluate. The hosted views observe `AppModel` +/// themselves and stay live when this one is skipped, so comparing which +/// renderer is showing (and which feature it was built from) is sufficient. +/// This mirrors `GitGraphRowView`, whose `==` deliberately ignores its actions. +struct ModuleToolContent: View, Equatable { + let rendererID: String + let contentIdentity: AnyHashable + let content: AnyView + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.rendererID == rhs.rendererID && lhs.contentIdentity == rhs.contentIdentity + } + + var body: some View { content } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 97697c959..809078107 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 bc4bd6296..b953f44e5 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -43,7 +43,27 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var gitLineChangeMarkers: [URL: [GitLineChangeMarker]] = [:] @Published package private(set) var gitReferences: [GitReference] = [] @Published package private(set) var recentGitReferences: [GitReference] = [] - @Published package private(set) var gitCommits: [GitCommit] = [] + @Published package private(set) var gitCommits: [GitCommit] = [] { + didSet { gitCommitsVersion = Self.nextGitCommitsVersion() } + } + /// Monotonic token for `gitCommits`, so `.task(id:)` keys and filter + /// identities compare in constant time instead of hashing a list that + /// routinely holds thousands of commits. + /// + /// Maintained by `didSet` rather than at each assignment, so a future write + /// site cannot forget to bump it. Not `@Published`: it only ever changes + /// alongside `gitCommits`, which already publishes, and a second publish + /// would mean a second invalidation for one logical change. + package private(set) var gitCommitsVersion = 0 + + /// Counts across instances, so reopening a workspace cannot hand a fresh + /// feature model a version a previous one already used. + private static var gitCommitsVersionCounter = 0 + + private static func nextGitCommitsVersion() -> Int { + gitCommitsVersionCounter &+= 1 + return gitCommitsVersionCounter + } @Published package private(set) var gitLogMatchedCommitHashes: Set? @Published package private(set) var isFilteringGitLog = false @Published package var selectedGitReference: GitReference? diff --git a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift new file mode 100644 index 000000000..4c4618961 --- /dev/null +++ b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheGitModule + +/// The sidebar's four change sections come from one cached pass. A wrong +/// partition silently files a change under the wrong header or breaks the +/// commit affordance, so the split and the conflict filter are pinned here. +@MainActor +@Suite("Git change sections") +struct GitChangeSectionsCacheTests { + private let root = URL(fileURLWithPath: "/tmp/repo") + + private func change( + _ path: String, + indexStatus: Character = " ", + workTreeStatus: Character = "M" + ) -> GitChange { + GitChange( + repositoryRoot: root, + path: path, + originalPath: nil, + indexStatus: indexStatus, + workTreeStatus: workTreeStatus + ) + } + + @Test + func addedChangesSplitAwayFromTrackedOnes() { + let added = change("new.swift", indexStatus: "A", workTreeStatus: " ") + let modified = change("existing.swift") + let cache = GitChangeSectionsCache() + + let sections = cache.sections(changes: [added, modified], conflictFilterPaths: []) + + #expect(sections.displayed.count == 2) + #expect(sections.added.map(\.path) == ["new.swift"]) + #expect(sections.tracked.map(\.path) == ["existing.swift"]) + } + + @Test + func aConflictFilterHidesEverythingOutsideIt() { + let cache = GitChangeSectionsCache() + let changes = [change("a.swift"), change("b.swift"), change("c.swift")] + + let sections = cache.sections( + changes: changes, + conflictFilterPaths: ["b.swift"] + ) + + #expect(sections.displayed.map(\.path) == ["b.swift"]) + #expect(sections.tracked.map(\.path) == ["b.swift"]) + } + + @Test + func stagedIgnoresTheConflictFilter() { + // The commit affordances ask "is anything staged" about the repository, + // not about whatever subset the conflict banner is showing. + let staged = change("staged.swift", indexStatus: "M", workTreeStatus: " ") + let other = change("other.swift") + let cache = GitChangeSectionsCache() + + let sections = cache.sections( + changes: [staged, other], + conflictFilterPaths: ["other.swift"] + ) + + #expect(sections.displayed.map(\.path) == ["other.swift"]) + #expect(sections.staged.map(\.path) == ["staged.swift"]) + } + + @Test + func aChangedListInvalidatesTheCache() { + let cache = GitChangeSectionsCache() + let first = cache.sections(changes: [change("a.swift")], conflictFilterPaths: []) + #expect(first.displayed.count == 1) + + let second = cache.sections( + changes: [change("a.swift"), change("b.swift")], + conflictFilterPaths: [] + ) + #expect(second.displayed.count == 2) + } + + @Test + func aChangedFilterInvalidatesTheCacheEvenWhenTheChangesMatch() { + // Both inputs key the cache; only comparing the change list would leave + // the conflict banner showing a stale file list. + let cache = GitChangeSectionsCache() + let changes = [change("a.swift"), change("b.swift")] + + _ = cache.sections(changes: changes, conflictFilterPaths: []) + let filtered = cache.sections(changes: changes, conflictFilterPaths: ["a.swift"]) + + #expect(filtered.displayed.map(\.path) == ["a.swift"]) + } +} diff --git a/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift new file mode 100644 index 000000000..25325a0a9 --- /dev/null +++ b/macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift @@ -0,0 +1,131 @@ +import Testing +@testable import Lithe +@testable import LitheGitModule + +/// The reference tree used to render as a recursive `AnyView`; flattening it to +/// rows moved ordering, nesting, and collapse handling out of the view. These +/// tests pin that behavior, because a wrong row order or a missing collapse +/// silently reshuffles the user's branch list. +@Suite("Git reference rows") +struct GitReferenceRowsBuilderTests { + private func reference( + _ shortName: String, + kind: GitReferenceKind = .local, + isCurrent: Bool = false + ) -> GitReference { + GitReference( + fullName: "refs/heads/\(shortName)", + shortName: shortName, + kind: kind, + isCurrent: isCurrent, + upstreamShortName: nil + ) + } + + private func rows( + _ shortNames: [String], + collapsed: Set = [] + ) -> [GitReferenceRow] { + GitReferenceRowsBuilder.rows( + from: shortNames.map { reference($0) }, + kind: .local, + collapsedGroups: collapsed + ) + } + + @Test + func flatReferencesBecomeOneRowEach() { + let result = rows(["main", "develop"]) + + #expect(result.count == 2) + #expect(result.allSatisfy { $0.depth == 0 }) + // Natural ordering, not insertion order. + #expect(result.map(\.name) == ["develop", "main"]) + } + + @Test + func aSlashSeparatedNameNestsUnderAGroup() { + let result = rows(["feature/login"]) + + #expect(result.count == 2) + #expect(result[0].name == "feature") + #expect(result[0].depth == 0) + if case .group = result[0].content {} else { + Issue.record("the shared prefix should render as a group row") + } + #expect(result[1].name == "login") + #expect(result[1].depth == 1) + if case .reference = result[1].content {} else { + Issue.record("the leaf should render as a reference row") + } + } + + @Test + func deepPathsNestOneLevelPerComponent() { + let result = rows(["refs/heads/a/b/c"]) + #expect(result.map(\.depth) == [0, 1, 2, 3, 4]) + #expect(result.map(\.name) == ["refs", "heads", "a", "b", "c"]) + } + + @Test + func collapsingAGroupHidesItsDescendantsButKeepsTheGroup() { + let expanded = rows(["feature/login", "feature/signup", "main"]) + let collapsed = rows( + ["feature/login", "feature/signup", "main"], + collapsed: ["local:feature"] + ) + + #expect(expanded.map(\.name) == ["main", "feature", "login", "signup"]) + // The group row survives so the user can expand it again. + #expect(collapsed.map(\.name) == ["main", "feature"]) + if case .group(_, let isCollapsed) = collapsed[1].content { + #expect(isCollapsed) + } else { + Issue.record("expected the feature group row") + } + } + + @Test + func aNameThatIsBothABranchAndAPrefixEmitsTwoRows() { + // `feature` is a branch and also the parent of `feature/login`, which the + // recursive renderer handled by drawing both a reference and a group. + let result = rows(["feature", "feature/login"]) + + #expect(result.count == 3) + #expect(result[0].name == "feature") + if case .reference = result[0].content {} else { + Issue.record("the branch itself should come first") + } + #expect(result[1].name == "feature") + if case .group = result[1].content {} else { + Issue.record("the shared prefix should follow as a group") + } + #expect(result[2].name == "login") + // Two rows for one path still need distinct identities. + #expect(result[0].id != result[1].id) + } + + @Test + func referencesSortBeforeFoldersAtTheSameLevel() { + let result = rows(["zebra", "alpha/nested"]) + #expect(result.map(\.name) == ["zebra", "alpha", "nested"]) + } + + @Test + func everyRowIdentifierIsUnique() { + let result = rows(["feature", "feature/login", "feature/signup", "main", "release/1.0"]) + #expect(Set(result.map(\.id)).count == result.count) + } + + @Test + func theCollapseKeyIsScopedByReferenceKind() { + // Local and remote sections can hold the same path; their collapse state + // must not be shared. + let remote = GitReferenceRowsBuilder.rows( + from: [reference("feature/login", kind: .remote)], + kind: .remote, + collapsedGroups: ["local:feature"] + ) + #expect(remote.count == 2, "a local collapse key must not collapse the remote group") + } +} diff --git a/macos/Tests/LitheTests/LitheDragUpdateSchedulerTests.swift b/macos/Tests/LitheTests/LitheDragUpdateSchedulerTests.swift new file mode 100644 index 000000000..876e0c66b --- /dev/null +++ b/macos/Tests/LitheTests/LitheDragUpdateSchedulerTests.swift @@ -0,0 +1,96 @@ +import CoreGraphics +import Testing +@testable import Lithe + +/// Drives `LitheDragUpdateScheduler` in manual delivery mode so coalescing, the +/// deadband, and cancellation are observable without spinning a run loop. +@MainActor +@Suite("Drag update scheduler") +struct LitheDragUpdateSchedulerTests { + private func makeScheduler() -> LitheDragUpdateScheduler { + LitheDragUpdateScheduler(delivery: .manual) + } + + @Test + func aBurstDeliversOnlyTheNewestValueOnce() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + scheduler.submit(12) { delivered.append($0) } + scheduler.submit(28) { delivered.append($0) } + scheduler.submit(41) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + + #expect(delivered == [41]) + + // A second flush has nothing left to deliver, proving the burst + // collapsed into exactly one update. + scheduler.flushPendingDeliveryForTesting() + #expect(delivered == [41]) + } + + @Test + func subPointChangesAreSuppressedAfterDelivery() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + scheduler.submit(100) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + // 0.5pt movement from the delivered value is below the default deadband + // and must not queue anything. + scheduler.submit(100.5) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + + #expect(delivered == [100]) + + // A movement past the deadband delivers again. + scheduler.submit(102) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + #expect(delivered == [100, 102]) + } + + @Test + func zeroDeadbandDeliversEveryDistinctValue() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + // The diff wheel path disables the deadband so sub-point steps still move. + scheduler.submit(10, minimumChange: 0) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + scheduler.submit(10.25, minimumChange: 0) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + + #expect(delivered == [10, 10.25]) + } + + @Test + func pendingValueReflectsInFlightTargetAndClearsOnDelivery() { + let scheduler = makeScheduler() + + #expect(scheduler.pendingValue == nil) + scheduler.submit(30, minimumChange: 0) { _ in } + scheduler.submit(55, minimumChange: 0) { _ in } + // Wheel accumulation reads this to add onto the in-flight target. + #expect(scheduler.pendingValue == 55) + + scheduler.flushPendingDeliveryForTesting() + #expect(scheduler.pendingValue == nil) + } + + @Test + func cancelDropsPendingDeliveryAndResetsDeadband() { + let scheduler = makeScheduler() + var delivered: [CGFloat] = [] + + scheduler.submit(70) { delivered.append($0) } + scheduler.cancel() + scheduler.flushPendingDeliveryForTesting() + #expect(delivered.isEmpty) + + // A value that would have fallen inside the previous deadband still + // delivers, proving the deadband was reset along with the gesture. + scheduler.submit(70.5) { delivered.append($0) } + scheduler.flushPendingDeliveryForTesting() + #expect(delivered == [70.5]) + } +} diff --git a/macos/Tests/LitheTests/LitheSplitPaneGeometryTests.swift b/macos/Tests/LitheTests/LitheSplitPaneGeometryTests.swift new file mode 100644 index 000000000..b7bf960f8 --- /dev/null +++ b/macos/Tests/LitheTests/LitheSplitPaneGeometryTests.swift @@ -0,0 +1,69 @@ +import CoreGraphics +import Testing +@testable import Lithe + +/// Pane sizing is where a split refactor silently inverts a divider: the four +/// converted sites disagree on which side is tracked, so the sign flip and the +/// clamping are pinned here rather than left to a view test. +@Suite("Split pane geometry") +struct LitheSplitPaneGeometryTests { + @Test + func aLeadingPaneGrowsWithPositiveTranslation() { + let size = LitheSplitPaneGeometry.resolve( + start: 200, + translation: 40, + placement: .leading, + minimum: 100, + maximum: 400 + ) + #expect(size == 240) + } + + @Test + func aTrailingPaneShrinksWithPositiveTranslation() { + // Dragging the divider to the right makes a right-hand pane narrower. + let size = LitheSplitPaneGeometry.resolve( + start: 200, + translation: 40, + placement: .trailing, + minimum: 100, + maximum: 400 + ) + #expect(size == 160) + } + + @Test + func draggingPastAnEdgeClampsRatherThanOvershooting() { + let tooSmall = LitheSplitPaneGeometry.resolve( + start: 120, + translation: -500, + placement: .leading, + minimum: 100, + maximum: 400 + ) + let tooLarge = LitheSplitPaneGeometry.resolve( + start: 380, + translation: 500, + placement: .leading, + minimum: 100, + maximum: 400 + ) + + #expect(tooSmall == 100) + #expect(tooLarge == 400) + } + + @Test + func minimumWinsWhenLiveGeometryPushesMaximumBelowIt() { + // Hosts derive `maximum` from the container size, so shrinking a window + // far enough can invert the range. The pane must not collapse past its + // minimum or the layout flips inside out. + let size = LitheSplitPaneGeometry.clamp(300, minimum: 220, maximum: 80) + #expect(size == 220) + } + + @Test + func aSizeInsideTheRangeIsLeftAlone() { + #expect(LitheSplitPaneGeometry.clamp(250, minimum: 100, maximum: 400) == 250) + } +} diff --git a/macos/Tests/LitheTests/WorkbenchPaneCornerGeometryTests.swift b/macos/Tests/LitheTests/WorkbenchPaneCornerGeometryTests.swift new file mode 100644 index 000000000..3cc570e95 --- /dev/null +++ b/macos/Tests/LitheTests/WorkbenchPaneCornerGeometryTests.swift @@ -0,0 +1,74 @@ +import CoreGraphics +import SwiftUI +import Testing +@testable import Lithe + +/// The pane corner notch is what makes a workbench pane read as rounded without +/// clipping its AppKit-backed content. It is built from tangent points rather +/// than sweep angles, so these tests pin the arc to the correct side: an arc +/// bulging the wrong way would paint over the pane instead of its square corner. +@Suite("Workbench pane corner geometry") +struct WorkbenchPaneCornerGeometryTests { + private let radius: CGFloat = 10 + + @Test(arguments: WorkbenchPaneCornerGeometry.Corner.allCases) + func theNotchFillsTheSquareCornerAndNotThePaneInterior( + corner: WorkbenchPaneCornerGeometry.Corner + ) { + let path = WorkbenchPaneCornerGeometry.makePath(for: corner, radius: radius) + + // Just inside the square corner: this is the sliver the rounded + // silhouette leaves behind and the notch must cover it. + #expect(path.contains(nearPaneCorner(corner))) + // The diagonally opposite point is deep inside the rounded pane and must + // stay uncovered, which is exactly what a reversed arc would break. + #expect(!path.contains(nearArcCenter(corner))) + } + + @Test(arguments: WorkbenchPaneCornerGeometry.Corner.allCases) + func theNotchStaysInsideItsOwnBox(corner: WorkbenchPaneCornerGeometry.Corner) { + let path = WorkbenchPaneCornerGeometry.makePath(for: corner, radius: radius) + let bounds = path.boundingRect + + // The notch is overlaid unclipped, so escaping the radius-square box + // would paint the surrounding color across pane content. + #expect(bounds.minX >= -0.01) + #expect(bounds.minY >= -0.01) + #expect(bounds.maxX <= radius + 0.01) + #expect(bounds.maxY <= radius + 0.01) + } + + @Test + func theNotchCoversTheCornerButNotTheTangentMidpoint() { + let path = WorkbenchPaneCornerGeometry.makePath(for: .topLeading, radius: radius) + + #expect(path.contains(CGPoint(x: 0.5, y: 0.5))) + // The arc passes through radius * (1 - 1/sqrt(2)) ≈ 2.93 on the diagonal; + // a point beyond it is inside the rounded pane. + #expect(!path.contains(CGPoint(x: 4, y: 4))) + } + + /// A point just inside the pane's square corner, which the notch covers. + private func nearPaneCorner(_ corner: WorkbenchPaneCornerGeometry.Corner) -> CGPoint { + let near: CGFloat = 0.5 + let far = radius - 0.5 + return switch corner { + case .topLeading: CGPoint(x: near, y: near) + case .topTrailing: CGPoint(x: far, y: near) + case .bottomLeading: CGPoint(x: near, y: far) + case .bottomTrailing: CGPoint(x: far, y: far) + } + } + + /// A point next to the arc center, which lies inside the rounded pane. + private func nearArcCenter(_ corner: WorkbenchPaneCornerGeometry.Corner) -> CGPoint { + let near: CGFloat = 0.5 + let far = radius - 0.5 + return switch corner { + case .topLeading: CGPoint(x: far, y: far) + case .topTrailing: CGPoint(x: near, y: far) + case .bottomLeading: CGPoint(x: far, y: near) + case .bottomTrailing: CGPoint(x: near, y: near) + } + } +} 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/24] 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 d19abd956..817a0a0b0 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 1c16c6705..7aff9ce18 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 809078107..34c3df0ba 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/24] 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 1859dafd0..dc6d3dbd6 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 a8671e8cc..5a7af9c7f 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 7aff9ce18..d0e5db5a7 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 47818f98f..84e0a770a 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/24] 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 d0e5db5a7..34c9a8fef 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 84e0a770a..d0ad0099a 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/24] 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 12c19191c..02f765f81 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 b70833692224813985cf44a05fb5170398f2c2bb Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 1 Sep 2026 21:47:11 +0800 Subject: [PATCH 07/24] feat(macos): align branch popup rows with IDEA's action menu The branch popup checked out a reference on a plain click, so scanning the list could switch the working tree by accident; checkout was otherwise only reachable through the right-click menu. Branch rows now present their actions through a native pop-up menu, with Checkout as one explicit entry. The native menu also supplies IDEA's hover-safety path: once a row's menu is open, moving to another row opens that menu without a click. Rows whose branch or upstream name the fixed popup width truncates expose the full pair as a hover tooltip. --- .../Views/Git/BranchSwitcherPopover.swift | 156 ++++++++++++++---- .../BranchSwitcherPopoverBehaviorTests.swift | 79 +++++++++ 2 files changed, 204 insertions(+), 31 deletions(-) create mode 100644 macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift diff --git a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift index 5686d4dd8..de2a56834 100644 --- a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift @@ -364,6 +364,9 @@ struct BranchSwitcherPopover: View { .lithePointer() } + /// A branch line. Clicking it opens the reference's action menu instead of + /// checking out directly, matching IDEA: checkout is an explicit menu entry, + /// so a stray click on the list can never switch the working tree. private func branchRow( _ reference: GitReference, indented: Bool, @@ -371,46 +374,106 @@ struct BranchSwitcherPopover: View { ) -> some View { let highlightsCurrent = presentation == .recent && reference.isCurrent - return Button { - guard !reference.isCurrent else { return } - isPresented = false - Task { await model.checkoutReference(reference) } - } label: { - HStack(spacing: 8) { - Image(systemName: referenceIcon(reference, marksCurrent: presentation == .recent)) - .font(.system(size: 11.5)) - .foregroundStyle(highlightsCurrent ? LitheTheme.warning : LitheTheme.secondaryText) - .frame(width: 17) - Text(branchDisplayName(reference, presentation: presentation)) - .font(.system(size: 12.5)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 10) - if let upstream = reference.upstreamShortName { - Text(upstream) + return BranchActionMenuRow( + label: { + HStack(spacing: 8) { + Image(systemName: referenceIcon(reference, marksCurrent: presentation == .recent)) .font(.system(size: 11.5)) - .foregroundStyle(LitheTheme.secondaryText) + .foregroundStyle(highlightsCurrent ? LitheTheme.warning : LitheTheme.secondaryText) + .frame(width: 17) + Text(branchDisplayName(reference, presentation: presentation)) + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) .lineLimit(1) .truncationMode(.middle) - } - if !reference.isCurrent { + Spacer(minLength: 10) + if let upstream = reference.upstreamShortName { + Text(upstream) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.middle) + } Image(systemName: "chevron.right") .font(.system(size: 8, weight: .bold)) .foregroundStyle(LitheTheme.secondaryText) } + .padding(.leading, branchRowLeadingPadding(indented: indented, presentation: presentation)) + .padding(.trailing, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: Metrics.branchRowHeight) + .background(highlightsCurrent ? LitheTheme.subtleSelection : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .contentShape(Rectangle()) + // Branch and upstream names are truncated to keep the row width + // fixed, so the untruncated pair is only reachable on hover. + .help(branchRowTooltip(reference)) + }, + menuContent: { branchActionMenu(for: reference) } + ) + .disabled(model.isPerformingBranchOperation) + } + + /// The full branch name, plus its upstream when tracked, for rows whose text + /// the fixed popup width truncates. + private func branchRowTooltip(_ reference: GitReference) -> String { + guard let upstream = reference.upstreamShortName else { return reference.shortName } + return "\(reference.shortName) → \(upstream)" + } + + /// The per-reference action list, ordered like IDEA's branch menu: creation + /// and comparison first, then checkout and integration, then destructive + /// entries last. + @ViewBuilder + private func branchActionMenu(for reference: GitReference) -> some View { + Button("New Branch from '\(reference.shortName)'…") { + dismissAndRun { onNewBranch(reference) } + } + + Button("Show Diff with Working Tree") { + dismissAndRun { Task { await model.showComparisonWithWorkingTree(for: reference) } } + } + + if let current = model.currentGitReference, current.id != reference.id { + Button("Compare with Current Branch") { + dismissAndRun { Task { await model.showComparison(from: reference, to: current) } } + } + } + + if !reference.isCurrent { + Divider() + + Button("Checkout") { + dismissAndRun { Task { await model.checkoutReference(reference) } } + } + } + + if reference.kind != .tag { + Divider() + + Button("Update") { + dismissAndRun { Task { await model.updateCurrentBranch(reference) } } + } + + Button("Push…") { + dismissAndRun { onPush(reference) } + } + } + + if reference.kind == .local, !reference.isCurrent { + Divider() + + Button("Delete") { + dismissAndRun { Task { await model.deleteBranch(reference) } } } - .padding(.leading, branchRowLeadingPadding(indented: indented, presentation: presentation)) - .padding(.trailing, 9) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: Metrics.branchRowHeight) - .background(highlightsCurrent ? LitheTheme.subtleSelection : .clear) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .lithePointer() - .disabled(model.isPerformingBranchOperation) + } + + /// Closes the popover before running a branch action so the action's own + /// sheet or dialog is not presented behind a popover that is about to go away. + private func dismissAndRun(_ action: @escaping () -> Void) { + isPresented = false + action() } private var recentReferences: [GitReference] { @@ -577,6 +640,37 @@ struct BranchSwitcherPopover: View { } } +/// A branch row that surfaces its actions through a native pop-up menu rather +/// than a direct checkout. +/// +/// Using `SwiftUI.Menu` with `.menuStyle(.borderlessButton)` produces a native +/// NSMenu, which works correctly inside the outer popover, positions itself to +/// avoid screen edges, and provides the hover-safety path that IDEA exposes: +/// once any row's menu is open, moving the cursor to another row opens that +/// menu immediately without a click. +private struct BranchActionMenuRow: View { + @ViewBuilder let label: () -> Label + @ViewBuilder let menuContent: () -> MenuContent + + @State private var isHovering = false + + var body: some View { + SwiftUI.Menu { + menuContent() + } label: { + label() + .background(isHovering ? LitheTheme.subtleSelection : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + // Constrain to the list width so the menu button does not stretch. + .fixedSize(horizontal: false, vertical: true) + .lithePointer() + .onHover { isHovering = $0 } + } +} + private enum BranchRowPresentation { case recent case grouped diff --git a/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift new file mode 100644 index 000000000..f8222dd28 --- /dev/null +++ b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import Lithe + +/// Guards the branch popup's IDEA-aligned interaction contract. The rows are +/// SwiftUI views without a testable state surface, so these checks read the +/// source: the regression they protect against is a branch row silently going +/// back to checking out on a plain click, which switches the working tree from +/// a stray click while scanning the list. +@Suite("Branch switcher popover behavior") +struct BranchSwitcherPopoverBehaviorTests { + private static func popoverSource() throws -> String { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let popoverURL = repositoryRoot.appendingPathComponent( + "Sources/Lithe/Views/Git/BranchSwitcherPopover.swift" + ) + return try String(contentsOf: popoverURL, encoding: .utf8) + } + + @Test + func branchRowsOpenAnActionMenuInsteadOfCheckingOutOnClick() throws { + let source = try Self.popoverSource() + + #expect( + source.contains("BranchActionMenuRow("), + "Branch rows must route through BranchActionMenuRow so a click opens the action menu." + ) + #expect( + source.contains("private func branchActionMenu(for reference: GitReference)"), + "The per-reference action list must exist for the menu to present." + ) + } + + @Test + func checkoutIsReachableOnlyAsAnExplicitMenuEntry() throws { + let source = try Self.popoverSource() + + // The single permitted checkout call site is the menu's Checkout entry. + let checkoutCallSites = source.components(separatedBy: "model.checkoutReference(").count - 1 + #expect( + checkoutCallSites == 1, + "Checkout must have exactly one call site, the explicit Checkout menu entry." + ) + + guard let checkoutRange = source.range(of: "model.checkoutReference(") else { + Issue.record("Expected a checkout call site in the branch popup.") + return + } + let precedingSource = source[source.startIndex.. Date: Tue, 1 Sep 2026 10:10:01 +0800 Subject: [PATCH 08/24] 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 83eeb2dba..058de7fda 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 a0e1b7cde..c9ed3261b 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 d4a9e4cc6..e64f62c98 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 19d8f39ba..74715ed4d 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 09/24] 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 c9ed3261b..6bfb25199 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 4a9e569d663a6acea8bb9385dd0ae6499f21989e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 1 Sep 2026 10:10:01 +0800 Subject: [PATCH 10/24] 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 83eeb2dba..058de7fda 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 a0e1b7cde..c9ed3261b 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 d4a9e4cc6..e64f62c98 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 19d8f39ba..74715ed4d 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 1563dec4d59c3eb6161825e6e804836f6dc06474 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Tue, 1 Sep 2026 11:01:47 +0800 Subject: [PATCH 11/24] 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 c9ed3261b..6bfb25199 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 c5a7d4a8dc5ef08f4694e8730397f70bc5f3604c Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Wed, 2 Sep 2026 09:09:12 +0800 Subject: [PATCH 12/24] fix: address branch popover review findings --- .../scripts/test-timing-lib.mjs | 79 +++++++++++++++++-- .../scripts/test-verify-test-stability.mjs | 3 + .../Views/Git/BranchSwitcherPopover.swift | 8 +- .../Lithe/Views/Workbench/WorkbenchView.swift | 30 +++++++ .../BranchSwitcherPopoverBehaviorTests.swift | 47 ++++++++++- 5 files changed, 155 insertions(+), 12 deletions(-) diff --git a/.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs b/.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs index 5a3e79416..b35e661b3 100755 --- a/.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs +++ b/.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs @@ -14,6 +14,44 @@ function processGroupIsRunning(processID) { } } +function processIsRunning(processID) { + try { + process.kill(processID, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +function addDescendantProcessIDs(rootProcessID, processIDs) { + let rows; + try { + const listing = spawnSync("ps", ["-axo", "pid=,ppid="], { + encoding: "utf8", + timeout: 5000, + }); + if (listing.status !== 0) return; + rows = listing.stdout + .split("\n") + .map((line) => line.trim().split(/\s+/).map(Number)) + .filter(([pid, ppid]) => Number.isInteger(pid) && Number.isInteger(ppid)); + } catch { + // The direct process group remains the portable fallback when ps is unavailable. + return; + } + + processIDs.add(rootProcessID); + let changed = true; + while (changed) { + changed = false; + for (const [pid, ppid] of rows) { + if (!processIDs.has(ppid) || processIDs.has(pid)) continue; + processIDs.add(pid); + changed = true; + } + } +} + function signalProcessGroup(child, signal) { try { process.kill(-child.pid, signal); @@ -27,12 +65,30 @@ function signalProcessGroup(child, signal) { } } -async function waitForProcessGroupExit(processID, timeoutMs, pollIntervalMs) { +function signalDescendantProcesses(rootProcessID, processIDs, signal) { + // The process-group signal handles ordinary descendants. Signal every known + // non-root PID as well because swift-testing may create a new process group. + for (const processID of processIDs) { + if (processID === rootProcessID) continue; + try { + process.kill(processID, signal); + } catch { + // It either exited between the snapshot and signal or is already gone. + } + } +} + +function processTreeIsRunning(processID, processIDs) { + return processGroupIsRunning(processID) + || [...processIDs].some((candidate) => processIsRunning(candidate)); +} + +async function waitForProcessTreeExit(processID, processIDs, timeoutMs, pollIntervalMs) { const deadline = performance.now() + timeoutMs; - while (processGroupIsRunning(processID) && performance.now() < deadline) { + while (processTreeIsRunning(processID, processIDs) && performance.now() < deadline) { await delay(pollIntervalMs); } - return !processGroupIsRunning(processID); + return !processTreeIsRunning(processID, processIDs); } export async function terminateProcessTree( @@ -52,10 +108,23 @@ export async function terminateProcessTree( return true; } + const processIDs = new Set(); + addDescendantProcessIDs(child.pid, processIDs); signalProcessGroup(child, "SIGTERM"); - if (await waitForProcessGroupExit(child.pid, gracePeriodMs, pollIntervalMs)) return true; + signalDescendantProcesses(child.pid, processIDs, "SIGTERM"); + if (await waitForProcessTreeExit(child.pid, processIDs, gracePeriodMs, pollIntervalMs)) return true; + + // Refresh before forcing termination so descendants created during graceful + // shutdown cannot escape the cleanup pass. + addDescendantProcessIDs(child.pid, processIDs); signalProcessGroup(child, "SIGKILL"); - return waitForProcessGroupExit(child.pid, forcedTerminationTimeoutMs, pollIntervalMs); + signalDescendantProcesses(child.pid, processIDs, "SIGKILL"); + return waitForProcessTreeExit( + child.pid, + processIDs, + forcedTerminationTimeoutMs, + pollIntervalMs, + ); } function lineCollector(callback) { 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 74715ed4d..9b7c8b574 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 @@ -566,10 +566,13 @@ try { if (process.platform !== "win32") { let rootPID = null; let descendantPID = null; + // swift-testing may detach its helper into a separate process group. The + // timeout owner must still discover and terminate that descendant. const descendantSource = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; const rootSource = ` const { spawn } = require("node:child_process"); const descendant = spawn(process.execPath, ["-e", ${JSON.stringify(descendantSource)}], { + detached: true, stdio: "ignore", }); console.log(descendant.pid); diff --git a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift index de2a56834..6ff479118 100644 --- a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift +++ b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift @@ -15,6 +15,7 @@ struct BranchSwitcherPopover: View { @Binding var isPresented: Bool let onCommit: () -> Void let onPush: (GitReference) -> Void + let onDelete: (GitReference) -> Void let onNewBranch: (GitReference) -> Void let onCheckoutRevision: () -> Void let onManageBranches: () -> Void @@ -448,12 +449,13 @@ struct BranchSwitcherPopover: View { } } - if reference.kind != .tag { + if reference.kind == .local { Divider() Button("Update") { dismissAndRun { Task { await model.updateCurrentBranch(reference) } } } + .disabled(!reference.isCurrent) Button("Push…") { dismissAndRun { onPush(reference) } @@ -463,8 +465,8 @@ struct BranchSwitcherPopover: View { if reference.kind == .local, !reference.isCurrent { Divider() - Button("Delete") { - dismissAndRun { Task { await model.deleteBranch(reference) } } + Button("Delete", role: .destructive) { + dismissAndRun { onDelete(reference) } } } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9e3365bb3..c818a0793 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -41,6 +41,7 @@ struct WorkbenchView: View { @State private var newBranchReference: GitReference? @State private var isCheckoutRevisionPresented = false @State private var pendingTopBarPushReference: GitReference? + @State private var pendingTopBarDeleteReference: GitReference? @State private var isProjectSwitcherPresented = false @State private var isPluginPanelPresented = false @State private var isNotificationCenterPresented = false @@ -255,6 +256,31 @@ struct WorkbenchView: View { } message: { Text("This sends the current branch to its configured remote.") } + .confirmationDialog( + "Delete branch?", + isPresented: Binding( + get: { pendingTopBarDeleteReference != nil }, + set: { if !$0 { pendingTopBarDeleteReference = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + guard let reference = pendingTopBarDeleteReference else { return } + pendingTopBarDeleteReference = nil + Task { await model.deleteBranch(reference) } + } + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button("Cancel", role: .cancel) { + pendingTopBarDeleteReference = nil + } + .lithePointer() + } message: { + Text( + "Delete the local branch \(pendingTopBarDeleteReference?.shortName ?? "")? " + + "Git will refuse if it contains unmerged work." + ) + } .overlay(alignment: .bottom) { if let message = model.notificationMessage { Text(LocalizedStringKey(message)) @@ -501,6 +527,10 @@ struct WorkbenchView: View { isBranchSwitcherPresented = false pendingTopBarPushReference = reference }, + onDelete: { reference in + isBranchSwitcherPresented = false + pendingTopBarDeleteReference = reference + }, onNewBranch: { reference in isBranchSwitcherPresented = false newBranchReference = reference diff --git a/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift index f8222dd28..6928b550f 100644 --- a/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift +++ b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift @@ -9,15 +9,23 @@ import Testing /// a stray click while scanning the list. @Suite("Branch switcher popover behavior") struct BranchSwitcherPopoverBehaviorTests { - private static func popoverSource() throws -> String { + private static func source(at relativePath: String) throws -> String { let repositoryRoot = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() - let popoverURL = repositoryRoot.appendingPathComponent( - "Sources/Lithe/Views/Git/BranchSwitcherPopover.swift" + return try String( + contentsOf: repositoryRoot.appendingPathComponent(relativePath), + encoding: .utf8 ) - return try String(contentsOf: popoverURL, encoding: .utf8) + } + + private static func popoverSource() throws -> String { + try source(at: "Sources/Lithe/Views/Git/BranchSwitcherPopover.swift") + } + + private static func workbenchSource() throws -> String { + try source(at: "Sources/Lithe/Views/Workbench/WorkbenchView.swift") } @Test @@ -76,4 +84,35 @@ struct BranchSwitcherPopoverBehaviorTests { "Rows truncate branch and upstream names, so hover must reveal the untruncated pair." ) } + + @Test + func updateAndPushAreLimitedToSupportedLocalBranches() throws { + let source = try Self.popoverSource() + + guard let localActions = source.range(of: "if reference.kind == .local {") else { + Issue.record("Update and Push must be grouped under a local-branch capability check.") + return + } + let actions = source[localActions.lowerBound...] + #expect(actions.contains("Button(\"Update\")")) + #expect( + actions.contains(".disabled(!reference.isCurrent)"), + "Only the current local branch can be updated." + ) + #expect(actions.contains("Button(\"Push…\")")) + } + + @Test + func deleteUsesTheWorkbenchConfirmationFlow() throws { + let popover = try Self.popoverSource() + let workbench = try Self.workbenchSource() + + #expect(popover.contains("dismissAndRun { onDelete(reference) }")) + #expect( + !popover.contains("model.deleteBranch(reference)"), + "The popover must not delete a branch before the user confirms." + ) + #expect(workbench.contains("Button(\"Delete\", role: .destructive)")) + #expect(workbench.contains("Task { await model.deleteBranch(reference) }")) + } } From 0a034dd1c0dc529d76410588b450749bf2b86b55 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 10:27:03 +0800 Subject: [PATCH 13/24] feat(git): rebuild PR 354 on latest preview --- .../Lithe/Core/Rust/RustCoreBridge.swift | 17 + .../Lithe/Core/Rust/RustGitOperations.swift | 33 + .../AppModel/AppModel+FeatureState.swift | 6 + .../Lithe/Models/AppModel/AppModel.swift | 31 + .../Lithe/Views/Git/GitGraphView.swift | 2 + .../Sources/Lithe/Views/Git/GitLogView.swift | 411 +++++++--- .../Application/GitFeatureModel.swift | 137 +++- .../LitheGitModule/Models/GitModels.swift | 54 +- .../LitheGitModule/Ports/GitPorts.swift | 56 ++ .../LitheGitModule/Services/GitService.swift | 28 + .../LitheGitModuleTests/GitModuleTests.swift | 709 +++++++++++++++++- rust/lithe-core/src/git/mod.rs | 509 ++++++++++++- rust/lithe-core/src/protocol/contracts.rs | 3 + rust/lithe-core/src/tests/git.rs | 496 ++++++++++++ shared/contracts/rust-core-api.md | 36 +- shared/fixtures/git/history-response-v1.json | 4 + shared/fixtures/git/tag-names.json | 35 + shared/fixtures/git/write.json | 53 ++ .../features/git/api/git-branches-api.test.ts | 2 + .../git/api/git-integration-api.test.ts | 5 + .../src/features/git/api/git-push-api.test.ts | 1 + .../features/git/api/git-remotes-api.test.ts | 1 + .../git/api/git-worktrees-api.test.ts | 1 + .../tauri/src/features/git/types/git.types.ts | 1 + .../git/utils/git-reference-actions.test.ts | 1 + .../git/utils/git-reference-tree.test.ts | 2 + .../core-result-adapter.history.test.ts | 19 + .../tauri/src/platform/core-result-adapter.ts | 2 + 28 files changed, 2556 insertions(+), 99 deletions(-) create mode 100644 shared/fixtures/git/tag-names.json diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 8e3247df0..491be4a21 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -818,6 +818,18 @@ struct RustCoreBridge: Sendable { let conflictedPaths: [String] } + struct TagDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + let kind: GitTagKind + let message: String? + } + + struct BranchDeletion: Decodable, Sendable { + let name: String + let deletedTarget: String + } + struct Warning: Decodable, Sendable { let code: String let message: String @@ -832,6 +844,8 @@ struct RustCoreBridge: Sendable { let invocations: [Invocation]? let operationError: OperationError? let stashRestore: StashRestore? + let tagDeletion: TagDeletion? + let branchDeletion: BranchDeletion? let warnings: [Warning]? } @@ -906,6 +920,7 @@ struct RustCoreBridge: Sendable { let fullName: String let shortName: String let kind: String + let peelsToCommit: Bool let isCurrent: Bool let upstreamShortName: String? } @@ -936,6 +951,7 @@ struct RustCoreBridge: Sendable { fullName: reference.fullName, shortName: reference.shortName, kind: kind, + peelsToCommit: reference.peelsToCommit, isCurrent: reference.isCurrent, upstreamShortName: reference.upstreamShortName ) @@ -946,6 +962,7 @@ struct RustCoreBridge: Sendable { fullName: reference.fullName, shortName: reference.shortName, kind: kind, + peelsToCommit: reference.peelsToCommit, isCurrent: reference.isCurrent, upstreamShortName: reference.upstreamShortName ) diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 69cc7c356..77d92e37f 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -31,6 +31,20 @@ struct RustGitOperations: GitOperations, Sendable { conflictedPaths: $0.conflictedPaths ) }, + tagDeletion: response.tagDeletion.map { + GitTagDeletion( + name: $0.name, + deletedTarget: $0.deletedTarget, + kind: $0.kind, + message: $0.message + ) + }, + branchDeletion: response.branchDeletion.map { + GitBranchDeletion( + name: $0.name, + deletedTarget: $0.deletedTarget + ) + }, warnings: response.warnings?.map { GitOperationWarning(code: $0.code, message: $0.message, details: $0.details) } ?? [] @@ -319,6 +333,25 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "stageAll") } + func createTag( + named name: String, + at revision: String, + message: String?, + rootURL: URL + ) -> GitProcessResult? { + write( + at: rootURL, + operation: "createTag", + revision: revision, + name: name, + message: message + ) + } + + func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "deleteTag", name: name) + } + func snapshot(at rootURL: URL) -> GitSnapshot? { core.gitStatus(at: rootURL)?.makeSnapshot(at: rootURL) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index c41c41a4b..2247ea025 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -213,6 +213,12 @@ extension AppModel { var requestedStashReference: String? { gitFeatureIfActive?.requestedStashReference } + var recentlyDeletedTag: GitTagDeletion? { + gitFeatureIfActive?.recentlyDeletedTag + } + var recentlyDeletedBranch: GitBranchDeletion? { + gitFeatureIfActive?.recentlyDeletedBranch + } var isCommitting: Bool { gitFeatureIfActive?.isCommitting ?? false } var gitBlameLines: [URL: [GitBlameLine]] { gitFeatureIfActive?.gitBlameLines ?? [:] } var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 3514c3a68..2c0154576 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1645,6 +1645,37 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.deleteBranch(reference) } + func restoreRecentlyDeletedBranch() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.restoreRecentlyDeletedBranch() + } + + func dismissDeletedBranchBanner() { + gitFeatureIfActive?.dismissDeletedBranchBanner() + } + + /// Returns nil on success, otherwise the error message a tag dialog + /// should show where the user typed. + @discardableResult + func createTag(at commit: GitCommit, name: String, message: String) async -> String? { + guard let gitFeature = await activateGitModule() else { return "No Git repository is open" } + return await gitFeature.createTag(at: commit, name: name, message: message) + } + + func deleteTag(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.deleteTag(reference) + } + + func restoreRecentlyDeletedTag() async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.restoreRecentlyDeletedTag() + } + + func dismissDeletedTagBanner() { + gitFeatureIfActive?.dismissDeletedTagBanner() + } + func mergeBranch(_ reference: GitReference) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.mergeBranch(reference) diff --git a/macos/Sources/Lithe/Views/Git/GitGraphView.swift b/macos/Sources/Lithe/Views/Git/GitGraphView.swift index 55769df1d..01c9bbbfc 100644 --- a/macos/Sources/Lithe/Views/Git/GitGraphView.swift +++ b/macos/Sources/Lithe/Views/Git/GitGraphView.swift @@ -11,6 +11,7 @@ struct GitGraphRowActions { let onCherryPick: (GitCommit) -> Void let onRevert: (GitCommit) -> Void let onReset: (GitCommit) -> Void + let onCreateTag: (GitCommit) -> Void } struct GitGraphView: View { @@ -136,6 +137,7 @@ private struct GitGraphRowView: View, Equatable { NSPasteboard.general.setString(row.commit.shortHash, forType: .string) } Divider() + Button("New Tag…") { actions.onCreateTag(row.commit) } Button("Cherry-pick Commit…") { actions.onCherryPick(row.commit) } Button("Revert Commit…") { actions.onRevert(row.commit) } Button("Reset Current Branch to Here…") { actions.onReset(row.commit) } diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 99ee19b78..3e7efea15 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -18,9 +18,11 @@ struct GitLogView: View { @State private var filesPaneHeight: CGFloat? @State private var filesPaneDragStart: CGFloat = 0 @State private var branchDialogRequest: GitBranchDialogRequest? + @State private var tagDialogRequest: GitTagDialogRequest? @State private var pendingPushReference: GitReference? @State private var pendingCommitOperation: GitCommitOperationRequest? @State private var pendingBranchOperation: GitBranchOperationRequest? + @State private var pendingTagDeletion: GitReference? @State private var comparisonSourceReference: GitReference? @State private var showCommitDecorations = false @State private var selectedGitToolTab = GitToolTab.log @@ -69,92 +71,7 @@ struct GitLogView: View { var body: some View { 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) - ) - 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 - } + primaryContent } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) .task(id: model.gitCommits) { @@ -302,6 +219,170 @@ struct GitLogView: View { Text(operation.kind.message(for: operation.reference)) } } + .modifier(GitTagDialogsModifier( + tagDialogRequest: $tagDialogRequest, + pendingTagDeletion: $pendingTagDeletion + )) + } + + /// The tab split lives outside `body` because the main expression is + /// already close to the type-checker limit. + @ViewBuilder + private var primaryContent: some View { + if selectedGitToolTab == .log { + logTabContent + } else { + gitConsolePane + } + } + + private var logTabContent: some View { + Group { + primaryActionBar + if let deletedBranch = model.recentlyDeletedBranch { + deletedReferenceBanner( + icon: "arrow.triangle.branch", + message: "Deleted branch '\(deletedBranch.name)'", + onRestore: { await model.restoreRecentlyDeletedBranch() }, + onDismiss: { model.dismissDeletedBranchBanner() } + ) + } + if let deletedTag = model.recentlyDeletedTag { + deletedReferenceBanner( + icon: "tag", + message: "Deleted tag '\(deletedTag.name)'", + onRestore: { await model.restoreRecentlyDeletedTag() }, + onDismiss: { model.dismissDeletedTagBanner() } + ) + } + logPanes + } + } + + private var logPanes: some View { + 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) + ) + 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) + } + } + } + + /// The New Tag sheet and its delete confirmation live in a modifier + /// because the main `body` expression is already close to the type-checker + /// limit; an explicit `ViewModifier` keeps both type-checkable. + private struct GitTagDialogsModifier: ViewModifier { + @Binding var tagDialogRequest: GitTagDialogRequest? + @Binding var pendingTagDeletion: GitReference? + @EnvironmentObject private var model: AppModel + + func body(content: Content) -> some View { + content + .sheet(item: $tagDialogRequest) { request in + GitTagNameDialog(request: request) { name, message in + // Returning the failure keeps the dialog open so the + // error appears where the user typed, like IntelliJ's + // New Tag dialog. + await model.createTag(at: request.commit, name: name, message: message) + } + } + .confirmationDialog( + "Delete tag '\(pendingTagDeletion?.shortName ?? "")'?", + isPresented: Binding( + get: { pendingTagDeletion != nil }, + set: { if !$0 { pendingTagDeletion = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + guard let reference = pendingTagDeletion else { return } + pendingTagDeletion = nil + Task { await model.deleteTag(reference) } + } + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button("Cancel", role: .cancel) { + pendingTagDeletion = nil + } + .lithePointer() + } message: { + Text("This removes the tag from the repository and affects collaborators who reference it. You can restore it from the banner afterwards.") + } + } } private var toolWindowHeader: some View { @@ -643,6 +724,48 @@ struct GitLogView: View { } } + /// IntelliJ-style "deleted ref [Restore]" notice. The restore record lives + /// in session state, so closing the banner ends the restore opportunity. + private func deletedReferenceBanner( + icon: String, + message: String, + onRestore: @escaping () async -> Void, + onDismiss: @escaping () -> Void + ) -> some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: icon, size: 13) + .foregroundStyle(LitheTheme.warning) + Text(message) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Spacer(minLength: 8) + Button("Restore") { + Task { await onRestore() } + } + .controlSize(.small) + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .disabled(model.isPerformingBranchOperation) + .lithePointer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + } + .litheIconButton() + .help("Dismiss") + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(LitheTheme.raised) + .overlay(alignment: .bottom) { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + } + private var referencePane: some View { VStack(spacing: 0) { HStack(spacing: 4) { @@ -932,6 +1055,20 @@ struct GitLogView: View { } .disabled(model.isPerformingBranchOperation) } + + if reference.kind == .tag { + Divider() + + if reference.supportsTagDeletion { + Button("Delete Tag…", role: .destructive) { + pendingTagDeletion = reference + } + .disabled(model.isPerformingBranchOperation) + } else { + Button("Delete Tag… (target is not a commit)") {} + .disabled(true) + } + } } } @@ -1282,6 +1419,9 @@ struct GitLogView: View { }, onReset: { commit in pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset, commit: commit) + }, + onCreateTag: { commit in + tagDialogRequest = GitTagDialogRequest(commit: commit) } ) } @@ -2055,6 +2195,105 @@ private struct GitBranchNameDialog: View { } } +private struct GitTagDialogRequest: Identifiable { + let id = UUID() + let commit: GitCommit +} + +/// New Tag dialog mirroring IntelliJ's: a required name plus an optional +/// message (annotated tag when non-empty). Local validation shows inline and +/// keeps the dialog open; a server-side failure returned by `onSubmit` (for +/// example a duplicate name) is shown here as well instead of a notification. +private struct GitTagNameDialog: View { + @Environment(\.dismiss) private var dismiss + let request: GitTagDialogRequest + let onSubmit: (String, String) async -> String? + + @State private var name = "" + @State private var message = "" + @State private var submitError: String? + @State private var isSubmitting = false + @FocusState private var nameFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text("New Tag") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text("Create on commit \(request.commit.shortHash). Leave the message empty for a lightweight tag.") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + + TextField("Tag name", text: $name) + .textFieldStyle(.roundedBorder) + .focused($nameFieldFocused) + .onSubmit(submit) + + VStack(alignment: .leading, spacing: 3) { + TextField("Message (optional)", text: $message, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + Text("A message creates an annotated tag.") + .font(.system(size: 10.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + + if let error = validationError ?? submitError { + Text(error) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.error) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + .lithePointer() + Button("Create", action: submit) + .buttonStyle(.borderedProminent) + .lithePointer() + .tint(LitheTheme.accent) + .keyboardShortcut(.defaultAction) + .disabled(trimmedName.isEmpty || validationError != nil || isSubmitting) + } + } + .padding(20) + .frame(width: 420) + .background(LitheTheme.raised) + .onAppear { nameFieldFocused = true } + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Mirrors the refname rules the Rust core enforces so illegal names are + /// rejected before a request is sent. + private var validationError: String? { + let name = trimmedName + guard !name.isEmpty else { return nil } + return GitTagNameValidator.validationError(for: name) + } + + private func submit() { + guard !trimmedName.isEmpty, validationError == nil, !isSubmitting else { return } + isSubmitting = true + submitError = nil + Task { + let error = await onSubmit(trimmedName, message) + isSubmitting = false + if let error { + submitError = error + } else { + dismiss() + } + } + } +} + /// Offered when local changes would be overwritten by a checkout, so the user can pick a /// resolution instead of being handed Git's raw refusal. /// Offers to stash when uncommitted changes block a merge or rebase. diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 3067d8872..b7e335abb 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -33,6 +33,14 @@ package final class GitFeatureModel: ObservableObject { @Published package var pendingConflictRollback: GitConflictRollbackRequest? @Published package private(set) var pendingStashRestoreConflict: GitStashRestoreConflictRequest? @Published package private(set) var isStashRestoreConflictNoticeVisible = false + /// The most recently deleted tag, kept in session state so the Git log can + /// offer a restore. A later tag deletion replaces it on success or clears + /// it on failure; branch recovery is independent, so both banners may be + /// visible. Closing the banner or `reset()` clears this session-only state. + @Published package private(set) var recentlyDeletedTag: GitTagDeletion? + /// The most recently deleted local branch. Later branch attempts follow the + /// same replace-or-clear rule without changing tag recovery state. + @Published package private(set) var recentlyDeletedBranch: GitBranchDeletion? @Published package private(set) var gitConflictFilterPaths: Set = [] @Published package private(set) var requestedStashReference: String? /// Set whenever Git is mid-merge, mid-rebase, mid-cherry-pick, or mid-revert. @@ -170,6 +178,8 @@ package final class GitFeatureModel: ObservableObject { pendingConflictRollback = nil pendingStashRestoreConflict = nil isStashRestoreConflictNoticeVisible = false + recentlyDeletedTag = nil + recentlyDeletedBranch = nil gitConflictFilterPaths = [] requestedStashReference = nil deferredSavedChanges = nil @@ -1634,10 +1644,135 @@ package final class GitFeatureModel: ObservableObject { isPerformingBranchOperation = true let result = await withGitOperation { await service.deleteBranch(reference, at: gitRepositoryRoot) } isPerformingBranchOperation = false - notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) + if let deletion = result.branchDeletion { + recentlyDeletedBranch = deletion + notify?( + result.succeeded + ? successfulMessage(result, fallback: "Deleted branch \(deletion.name)") + : trimmedMessage(result) + ) + } else { + recentlyDeletedBranch = nil + notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) + } await refreshGit() } + /// Rebuilds the deleted branch at its recorded commit. A failure (for + /// example the name was re-created elsewhere) keeps the record so the user + /// can retry or close the banner themselves. + package func restoreRecentlyDeletedBranch() async { + guard let deletion = recentlyDeletedBranch, let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createBranch( + named: deletion.name, + from: GitReference( + fullName: deletion.deletedTarget, + shortName: deletion.deletedTarget, + kind: .local, + isCurrent: false, + upstreamShortName: nil + ), + checkout: false, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + recentlyDeletedBranch = nil + notify?("Restored branch \(deletion.name)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + + package func dismissDeletedBranchBanner() { + recentlyDeletedBranch = nil + } + + /// Creates a lightweight or annotated tag. Returns `nil` on success so a + /// dialog can stay open and show the failure where the user typed; the + /// caller decides whether to surface the returned message itself. + @discardableResult + package func createTag( + at commit: GitCommit, + name rawName: String, + message: String + ) async -> String? { + guard let gitRepositoryRoot else { return "No Git repository is open" } + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return "Enter a tag name" } + let annotation = message.trimmingCharacters(in: .whitespacesAndNewlines) + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createTag( + named: name, + at: commit.hash, + message: annotation.isEmpty ? nil : annotation, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + notify?("Created tag \(name)") + await refreshGit() + return nil + } + return trimmedMessage(result) + } + + package func deleteTag(_ reference: GitReference) async { + guard let gitRepositoryRoot else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.deleteTag(named: reference.shortName, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + if result.succeeded, let deletion = result.tagDeletion { + recentlyDeletedTag = deletion + notify?("Deleted tag \(deletion.name)") + } else { + recentlyDeletedTag = nil + notify?(trimmedMessage(result)) + } + await refreshGit() + } + + /// Rebuilds the deleted tag at its recorded commit. A failure (for example + /// the name was re-created elsewhere) keeps the record so the user can + /// retry or close the banner themselves. + package func restoreRecentlyDeletedTag() async { + guard let deletion = recentlyDeletedTag, let gitRepositoryRoot else { return } + guard deletion.hasConsistentKindAndMessage else { + recentlyDeletedTag = nil + notify?("The deleted tag recovery record is invalid") + return + } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.createTag( + named: deletion.name, + at: deletion.deletedTarget, + message: deletion.message, + at: gitRepositoryRoot + ) + } + isPerformingBranchOperation = false + if result.succeeded { + recentlyDeletedTag = nil + notify?("Restored tag \(deletion.name)") + await refreshGit() + } else { + notify?(trimmedMessage(result)) + } + } + + package func dismissDeletedTagBanner() { + recentlyDeletedTag = nil + } + /// Records the merge or rebase commit Git is waiting on once its conflicts are /// resolved. Rust refuses while any file is still conflicted, so the failure /// message names what is left. diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 236b2a4a9..e035b5349 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -3,6 +3,42 @@ import LitheCoreContracts package typealias GitWatchContext = LitheCoreContracts.GitWatchContext +/// Mirrors the shared Rust refname checks used by tag mutations so the macOS +/// dialog can reject the same invalid names before crossing the Core boundary. +public enum GitTagNameValidator { + public static func isValid(_ value: String) -> Bool { + !isInvalid(value) + } + + public static func validationError(for value: String) -> String? { + isInvalid(value) ? "Invalid Git tag name." : nil + } + + private static func isInvalid(_ value: String) -> Bool { + if value.isEmpty + || value.hasPrefix("-") + || value == "@" + || value.hasPrefix("/") + || value.hasSuffix("/") + || value.hasSuffix(".") + || value.contains("..") + || value.contains("@{") + || value.contains("//") + { + return true + } + if value.unicodeScalars.contains(where: { scalar in + CharacterSet.controlCharacters.contains(scalar) + || " ~^:?*[\\".unicodeScalars.contains(scalar) + }) { + return true + } + return value.split(separator: "/", omittingEmptySubsequences: false).contains { component in + component.hasPrefix(".") || component.hasSuffix(".lock") + } + } +} + package struct GitSnapshot: Sendable { package let repositoryRoot: URL package let branch: String @@ -20,11 +56,27 @@ package struct GitReference: Identifiable, Hashable, Sendable { package let fullName: String package let shortName: String package let kind: GitReferenceKind + package let peelsToCommit: Bool package let isCurrent: Bool package let upstreamShortName: String? - package init(fullName: String, shortName: String, kind: GitReferenceKind, isCurrent: Bool, upstreamShortName: String?) { self.fullName = fullName; self.shortName = shortName; self.kind = kind; self.isCurrent = isCurrent; self.upstreamShortName = upstreamShortName } + package init( + fullName: String, + shortName: String, + kind: GitReferenceKind, + peelsToCommit: Bool = true, + isCurrent: Bool, + upstreamShortName: String? + ) { + self.fullName = fullName + self.shortName = shortName + self.kind = kind + self.peelsToCommit = peelsToCommit + self.isCurrent = isCurrent + self.upstreamShortName = upstreamShortName + } package var id: String { fullName } + package var supportsTagDeletion: Bool { kind == .tag && peelsToCommit } } package struct GitStash: Identifiable, Hashable, Sendable { diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index df478682d..fbfd620b6 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -21,6 +21,56 @@ public struct GitProcessInvocation: Equatable, Sendable { public var output: String { standardOutput + standardError } } +/// The two tag object forms supported by the shared Git contract. +public enum GitTagKind: String, Codable, Sendable { + case lightweight + case annotated +} + +/// Everything a host needs to rebuild a deleted tag later: the record is kept +/// in session state only, and restores replay `createTag` with these values. +public struct GitTagDeletion: Equatable, Sendable { + public let name: String + /// The commit the deleted ref resolved to (peeled for annotated tags). + public let deletedTarget: String + /// Tag form taken from the deleted ref's object type. + public let kind: GitTagKind + /// Original annotation, if any; lightweight tags carry `nil`. + public let message: String? + + public init(name: String, deletedTarget: String, kind: GitTagKind, message: String?) { + self.name = name + self.deletedTarget = deletedTarget + self.kind = kind + self.message = message + } + + public var isAnnotated: Bool { kind == .annotated } + + /// A lightweight tag has no annotation, while an annotated tag always + /// carries a message value (which may be empty) so restore preserves form. + public var hasConsistentKindAndMessage: Bool { + switch kind { + case .lightweight: + message == nil + case .annotated: + message != nil + } + } +} + +/// A deleted local branch and the commit it pointed at, kept in session state +/// so the host can offer a restore. +public struct GitBranchDeletion: Equatable, Sendable { + public let name: String + public let deletedTarget: String + + public init(name: String, deletedTarget: String) { + self.name = name + self.deletedTarget = deletedTarget + } +} + public struct GitOperationWarning: Equatable, Sendable { public let code: String public let message: String @@ -42,6 +92,8 @@ public struct GitProcessResult: Sendable { public let invocations: [GitProcessInvocation] public let operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? + public let tagDeletion: GitTagDeletion? + public let branchDeletion: GitBranchDeletion? public let warnings: [GitOperationWarning] public init( arguments: [String] = [], @@ -52,6 +104,8 @@ public struct GitProcessResult: Sendable { invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil, warnings: [GitOperationWarning] = [] ) { self.arguments = arguments @@ -62,6 +116,8 @@ public struct GitProcessResult: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.tagDeletion = tagDeletion + self.branchDeletion = branchDeletion self.warnings = warnings } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 9c93abf20..1d3c89ea4 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -119,6 +119,8 @@ package protocol GitOperations: Sendable { func popStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? func stageAll(at rootURL: URL) -> GitProcessResult? + func createTag(named name: String, at revision: String, message: String?, rootURL: URL) -> GitProcessResult? + func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? } package typealias GitWatchContextProviding = LitheCoreContracts.GitWatchContextProviding @@ -142,6 +144,8 @@ package struct GitService: Sendable { package let invocations: [GitProcessInvocation] package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? + package let tagDeletion: GitTagDeletion? + package let branchDeletion: GitBranchDeletion? package let warnings: [GitOperationWarning] package init( @@ -154,6 +158,8 @@ package struct GitService: Sendable { invocations: [GitProcessInvocation] = [], operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil, + tagDeletion: GitTagDeletion? = nil, + branchDeletion: GitBranchDeletion? = nil, warnings: [GitOperationWarning] = [] ) { self.workingDirectory = workingDirectory @@ -165,6 +171,8 @@ package struct GitService: Sendable { self.invocations = invocations self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict + self.tagDeletion = tagDeletion + self.branchDeletion = branchDeletion self.warnings = warnings } @@ -635,6 +643,24 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.stageAll(at: repositoryRoot) } } + /// Creates a lightweight or annotated tag: a non-empty `message` produces + /// the annotated form. `revision` is the commit hash or resolvable + /// revision the tag should point at. + func createTag( + named name: String, + at revision: String, + message: String?, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.createTag(named: name, at: revision, message: message, rootURL: repositoryRoot) + } + } + + func deleteTag(named name: String, at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.deleteTag(named: name, rootURL: repositoryRoot) } + } + private func command( at workingDirectory: URL? = nil, fallbackArguments: [String] = [], @@ -655,6 +681,8 @@ package struct GitService: Sendable { invocations: result?.invocations ?? [], operationErrorMessage: result?.operationErrorMessage, stashRestoreConflict: result?.stashRestoreConflict, + tagDeletion: result?.tagDeletion, + branchDeletion: result?.branchDeletion, warnings: result?.warnings ?? [] ) }.value diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 6ca0bbfc6..219251eff 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -441,9 +441,609 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.first?.succeeded == true) } + // MARK: Tag management + @Test - func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { + func gitTagDeletionCapabilityRequiresACommitTarget() { + let commitTag = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + peelsToCommit: true, + isCurrent: false, + upstreamShortName: nil + ) + let treeTag = GitReference( + fullName: "refs/tags/tree-tag", + shortName: "tree-tag", + kind: .tag, + peelsToCommit: false, + isCurrent: false, + upstreamShortName: nil + ) + + #expect(commitTag.supportsTagDeletion) + #expect(!treeTag.supportsTagDeletion) + } + + private func makeTagTestFeature( + _ operations: TestGitOperations, + onNotify: @escaping @MainActor (String) -> Void = { _ in } + ) -> (GitFeatureModel, URL) { let root = URL(fileURLWithPath: "/workspace") + let service = GitService(operations: operations) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: onNotify, + onStateRefreshed: {} + ) + return (feature, root) + } + + private func makeTagCommit() -> GitCommit { + GitCommit( + hash: "abc123def456", + shortHash: "abc123d", + parentHashes: [], + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + date: "2026/08/30 10:00", + subject: "Initial", + decorations: "" + ) + } + + @Test + func gitTagNameValidationMatchesTheSharedContractFixture() throws { + struct TagNames: Decodable { + let valid: [String] + let invalid: [String] + } + + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // LitheGitModuleTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // macos + .deletingLastPathComponent() // repository root + .appendingPathComponent("shared/fixtures/git/tag-names.json") + let fixture = try JSONDecoder().decode(TagNames.self, from: Data(contentsOf: fixtureURL)) + + for name in fixture.valid { + #expect(GitTagNameValidator.isValid(name), "expected valid tag name: \(name)") + } + for name in fixture.invalid { + #expect(!GitTagNameValidator.isValid(name), "expected invalid tag name: \(name)") + } + } + + @Test + func gitTagDeletionRequiresKindAndMessageToDescribeTheSameTagForm() { + #expect(GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ).hasConsistentKindAndMessage) + #expect(GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "" + ).hasConsistentKindAndMessage) + #expect(!GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: "release" + ).hasConsistentKindAndMessage) + #expect(!GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: nil + ).hasConsistentKindAndMessage) + } + + @Test + func gitTagCreationSucceedsSilentlyForTheDialogAndNotifiesOnSuccess() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "v1.0", "abc123def456"], output: "", exitCode: 0) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + // An empty result would mean the dialog shows a generic failure, so a + // successful create must return nil and notify instead. + let error = await feature.createTag(at: makeTagCommit(), name: "v1.0", message: "") + + #expect(error == nil) + #expect(notifications == ["Created tag v1.0"]) + } + + @Test + func gitTagCreationReturnsTheFailureToTheDialogWithoutNotifying() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + let error = await feature.createTag(at: makeTagCommit(), name: "v1.0", message: "") + + #expect(error == "Rust Core Git operation failed") + #expect(notifications.isEmpty) + } + + @Test + func gitTagDeletionKeepsARestorableSessionRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + + #expect(feature.recentlyDeletedTag == GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + )) + #expect(notifications == ["Deleted tag v1.0"]) + + feature.dismissDeletedTagBanner() + #expect(feature.recentlyDeletedTag == nil) + } + + @Test + func gitTagDeletionFailureRecordsNothingAndNotifiesTheError() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "The tag 'v1.0' does not exist", + exitCode: 1 + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + + #expect(feature.recentlyDeletedTag == nil) + #expect(notifications == ["The tag 'v1.0' does not exist"]) + } + + @Test + func gitTagRestoreReplaysRecordedNameTargetAndMessage() async { + var notifications: [String] = [] + let recorder = TagCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "-a", "v1.0", "-m", "release", "abc123def456"], output: "", exitCode: 0), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .annotated, + message: "release" + ) + ), + tagCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + // Exactly one delete and one restore create must have run, and the + // restore must replay exactly the recorded deletion record so the + // rebuilt annotated tag points at the original commit with its + // message. The delete itself records no revision. + #expect(recorder.recorded.count == 2) + #expect(recorder.recorded.first?.name == "v1.0") + #expect(recorder.recorded.last == TagCallRecorder.Call( + name: "v1.0", + revision: "abc123def456", + message: "release" + )) + #expect(feature.recentlyDeletedTag == nil) + #expect(notifications == ["Deleted tag v1.0", "Restored tag v1.0"]) + } + + @Test + func gitTagRestoreFailureKeepsTheRecordForARetry() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult( + arguments: ["tag", "v1.0", "abc123def456"], + output: "A tag named 'v1.0' already exists", + exitCode: 1 + ), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + // The user can retry after fixing the conflict, or close the banner. + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(notifications == ["Deleted tag v1.0", "A tag named 'v1.0' already exists"]) + } + + @Test + func gitTagRestoreRejectsAnInconsistentRecoveryRecord() async { + var notifications: [String] = [] + let recorder = TagCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createTagResult: GitProcessResult(arguments: ["tag", "v1.0"], output: "", exitCode: 0), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: "unexpected annotation" + ) + ), + tagCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + await feature.restoreRecentlyDeletedTag() + + #expect(feature.recentlyDeletedTag == nil) + #expect(recorder.recorded.count == 1, "invalid recovery data must not issue createTag") + #expect(notifications == ["Deleted tag v1.0", "The deleted tag recovery record is invalid"]) + } + + @Test + func gitFeatureModelResetClearsTheRestorableTagRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteTag(reference) + #expect(feature.recentlyDeletedTag != nil) + + // Project close resets the model; the deletion record must not survive + // into the next session. + feature.reset() + #expect(feature.recentlyDeletedTag == nil) + } + + // MARK: Branch deletion restore + + @Test + func gitBranchDeletionKeepsARestorableSessionRecord() async { + var notifications: [String] = [] + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/short-lived"], + output: "Deleted branch feature/short-lived\n", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ) + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + + #expect(feature.recentlyDeletedBranch == GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + )) + #expect(notifications == ["Deleted branch feature/short-lived"]) + + feature.dismissDeletedBranchBanner() + #expect(feature.recentlyDeletedBranch == nil) + } + + @Test + func gitBranchConfigCleanupFailureKeepsTheRestorableDeletionRecord() async { + var notifications: [String] = [] + let warning = "Could not remove configuration for deleted branch 'feature/short-lived'" + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResult: GitProcessResult( + arguments: ["update-ref", "-d", "refs/heads/feature/short-lived"], + output: "", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ), + warnings: [GitOperationWarning( + code: "branch_config_cleanup_failed", + message: warning + )] + ) + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + + #expect(feature.recentlyDeletedBranch == GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + )) + #expect(notifications == ["Deleted branch feature/short-lived: \(warning)"]) + } + + @Test + func gitBranchDeletionFailureClearsThePreviousRecoveryRecord() async { + var notifications: [String] = [] + let results = GitProcessResultQueue([ + GitProcessResult( + arguments: ["branch", "-d", "--", "feature/a"], + output: "Deleted branch feature/a\n", + exitCode: 0, + branchDeletion: GitBranchDeletion(name: "feature/a", deletedTarget: "abc123def456") + ), + GitProcessResult( + arguments: ["branch", "-d", "--", "feature/b"], + output: "The branch 'feature/b' does not exist", + exitCode: 1 + ) + ]) + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteBranchResults: results + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/a", + shortName: "feature/a", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + #expect(feature.recentlyDeletedBranch?.name == "feature/a") + + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/b", + shortName: "feature/b", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + + #expect(feature.recentlyDeletedBranch == nil) + #expect(notifications == ["Deleted branch feature/a", "The branch 'feature/b' does not exist"]) + } + + @Test + func gitTagAndBranchRecoveryRecordsCanCoexist() async { + let (feature, _) = makeTagTestFeature(TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResult: GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/a"], + output: "Deleted branch feature/a\n", + exitCode: 0, + branchDeletion: GitBranchDeletion(name: "feature/a", deletedTarget: "abc123def456") + ) + )) + await feature.refreshGit() + + await feature.deleteTag(GitReference( + fullName: "refs/tags/v1.0", + shortName: "v1.0", + kind: .tag, + isCurrent: false, + upstreamShortName: nil + )) + await feature.deleteBranch(GitReference( + fullName: "refs/heads/feature/a", + shortName: "feature/a", + kind: .local, + isCurrent: false, + upstreamShortName: nil + )) + + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(feature.recentlyDeletedBranch?.name == "feature/a") + } + + @Test + func gitBranchRestoreReplaysRecordedNameAndTarget() async { + var notifications: [String] = [] + let recorder = BranchCallRecorder() + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + createBranchResult: GitProcessResult(arguments: ["branch", "feature/short-lived", "abc123def456"], output: "", exitCode: 0), + deleteBranchResult: GitProcessResult( + arguments: ["branch", "-d", "--", "feature/short-lived"], + output: "Deleted branch feature/short-lived\n", + exitCode: 0, + branchDeletion: GitBranchDeletion( + name: "feature/short-lived", + deletedTarget: "abc123def456" + ) + ), + branchCallRecorder: recorder + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + let reference = GitReference( + fullName: "refs/heads/feature/short-lived", + shortName: "feature/short-lived", + kind: .local, + isCurrent: false, + upstreamShortName: nil + ) + + await feature.deleteBranch(reference) + await feature.restoreRecentlyDeletedBranch() + + // The restore replays createBranch against the recorded commit without + // checking the branch out. + #expect(Array(recorder.recorded.suffix(1)) == [ + BranchCallRecorder.Call(name: "feature/short-lived", reference: "abc123def456", checkout: false) + ]) + #expect(feature.recentlyDeletedBranch == nil) + #expect(notifications == ["Deleted branch feature/short-lived", "Restored branch feature/short-lived"]) + + // Project close drops the restorable record as well. + feature.reset() + #expect(feature.recentlyDeletedBranch == nil) + } + + @Test + func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { let root = URL(fileURLWithPath: "/workspace") let change = GitChange( repositoryRoot: root, path: "README.md", @@ -1456,6 +2056,72 @@ private func waitForGitWorkToBecomeIdle( return !isActive() } +/// Records tag create/delete arguments so restore flows can be asserted on +/// the exact parameters the feature model replays. +private final class TagCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let name: String + let revision: String + let message: String? + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Records branch create/delete arguments for the branch restore flow. +private final class BranchCallRecorder: @unchecked Sendable { + struct Call: Equatable { + let name: String + let reference: String + let checkout: Bool + } + + private let lock = NSLock() + private var calls: [Call] = [] + + func record(_ call: Call) { + lock.lock() + calls.append(call) + lock.unlock() + } + + var recorded: [Call] { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +/// Supplies deterministic per-call results for consecutive branch mutations. +private final class GitProcessResultQueue: @unchecked Sendable { + private let lock = NSLock() + private var results: [GitProcessResult] + + init(_ results: [GitProcessResult]) { + self.results = results + } + + func next() -> GitProcessResult? { + lock.lock() + defer { lock.unlock() } + guard !results.isEmpty else { return nil } + return results.removeFirst() + } +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? @@ -1469,6 +2135,13 @@ private struct TestGitOperations: GitOperations { private let runGate: TestGitRunGate? private let filesRecorder: GitFilesCallRecorder? private let filesGate: GitFilesLoadGate? + private let createTagResult: GitProcessResult? + private let deleteTagResult: GitProcessResult? + private let tagCallRecorder: TagCallRecorder? + private let createBranchResult: GitProcessResult? + private let deleteBranchResult: GitProcessResult? + private let deleteBranchResults: GitProcessResultQueue? + private let branchCallRecorder: BranchCallRecorder? init( snapshotValue: GitSnapshot? = nil, @@ -1482,7 +2155,14 @@ private struct TestGitOperations: GitOperations { stageResult: GitProcessResult? = nil, runGate: TestGitRunGate? = nil, filesRecorder: GitFilesCallRecorder? = nil, - filesGate: GitFilesLoadGate? = nil + filesGate: GitFilesLoadGate? = nil, + createTagResult: GitProcessResult? = nil, + deleteTagResult: GitProcessResult? = nil, + tagCallRecorder: TagCallRecorder? = nil, + createBranchResult: GitProcessResult? = nil, + deleteBranchResult: GitProcessResult? = nil, + deleteBranchResults: GitProcessResultQueue? = nil, + branchCallRecorder: BranchCallRecorder? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue @@ -1496,6 +2176,13 @@ private struct TestGitOperations: GitOperations { self.runGate = runGate self.filesRecorder = filesRecorder self.filesGate = filesGate + self.createTagResult = createTagResult + self.deleteTagResult = deleteTagResult + self.tagCallRecorder = tagCallRecorder + self.createBranchResult = createBranchResult + self.deleteBranchResult = deleteBranchResult + self.deleteBranchResults = deleteBranchResults + self.branchCallRecorder = branchCallRecorder } func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { @@ -1540,9 +2227,15 @@ private struct TestGitOperations: GitOperations { func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } - func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { nil } + func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { + branchCallRecorder?.record(BranchCallRecorder.Call(name: name, reference: reference.fullName, checkout: checkout)) + return createBranchResult + } func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { nil } - func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { + branchCallRecorder?.record(BranchCallRecorder.Call(name: reference.shortName, reference: reference.fullName, checkout: false)) + return deleteBranchResults?.next() ?? deleteBranchResult + } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { @@ -1582,4 +2275,12 @@ private struct TestGitOperations: GitOperations { func popStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } func stageAll(at rootURL: URL) -> GitProcessResult? { nil } + func createTag(named name: String, at revision: String, message: String?, rootURL: URL) -> GitProcessResult? { + tagCallRecorder?.record(TagCallRecorder.Call(name: name, revision: revision, message: message)) + return createTagResult + } + func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? { + tagCallRecorder?.record(TagCallRecorder.Call(name: name, revision: "", message: nil)) + return deleteTagResult + } } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 4f5328cd3..2dcd5eeb3 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -124,6 +124,14 @@ pub struct GitCommandResponse { /// output. #[serde(skip_serializing_if = "Option::is_none")] pub stash_restore: Option, + /// Present when a tag deletion succeeded, carrying everything a host needs + /// to offer a restore without re-querying the repository. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag_deletion: Option, + /// Present when a local branch deletion succeeded, carrying the commit the + /// branch pointed at so the host can offer to recreate it. + #[serde(skip_serializing_if = "Option::is_none")] + pub branch_deletion: Option, /// Non-fatal follow-up failures after the requested repository mutation succeeded. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, @@ -179,6 +187,8 @@ impl GitProcessOutput { invocations: vec![invocation], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), } } @@ -254,6 +264,34 @@ pub struct GitStashRestoreResponse { pub conflicted_paths: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host rebuild the deleted tag later. +/// +/// `deleted_target` is the commit the deleted ref resolved to (peeled for +/// annotated tags), so a restore can re-point a new tag at the same commit. +pub struct GitTagDeletionResponse { + /// Short name of the deleted tag, without the `refs/tags/` prefix. + pub name: String, + pub deleted_target: String, + /// `lightweight` or `annotated`, taken from the tag object type. + pub kind: String, + /// Annotation message; `None` only for lightweight tags. Empty annotated + /// messages remain `Some` so a restore does not change the tag form. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Deletion record that lets a host recreate the deleted local branch later. +pub struct GitBranchDeletionResponse { + /// Short branch name, without the `refs/heads/` prefix. + pub name: String, + /// Commit the deleted branch pointed at when it was removed. + pub deleted_target: String, +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Complete identity of a Git reference supplied by a platform client. @@ -825,7 +863,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let reference = write_request_reference(&root, &request)?; @@ -933,6 +971,44 @@ fn write_with_trace(request: GitWriteRequest) -> Result { + let name = validated_tag_name(request.name.as_deref())?; + let requested_target = validated_revision(request.revision.as_deref())?; + // Existence and resolvability probes run before Git so a duplicate + // or unresolvable target fails with a stable message instead of + // leaving the caller to parse localized `git tag` stderr. + if tag_exists(&root, &name)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("A tag named '{name}' already exists"), + )); + } + // Git can tag trees and blobs, but the deletion/restore contract + // promises a commit target, so anything else is rejected here and + // the tag is created against the resolved commit id. + let Some(target) = resolved_commit_target(&root, &requested_target)? else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("Could not resolve tag target '{requested_target}'"), + )); + }; + // An explicit message field (even an empty one) selects the + // annotated form. Verbatim cleanup keeps restored CRLF and trailing + // blank lines intact; UIs trim newly entered messages before send. + arguments = match request.message.as_deref() { + Some(message) => vec![ + "tag".into(), + "-a".into(), + "--cleanup=verbatim".into(), + name, + "-m".into(), + message.to_string(), + target, + ], + None => vec!["tag".into(), name, target], + }; + } + "deleteTag" => return delete_tag(&root, request.name.as_deref()), "clone" => { let remote = required_text(request.remote.as_deref(), "clone source")?; let destination = required_text(request.destination.as_deref(), "clone destination")?; @@ -1483,7 +1559,7 @@ pub fn history(request: GitHistoryRequest) -> Result Result GitCommandResponse { invocations: Vec::new(), operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), } } @@ -3739,6 +3817,372 @@ fn validated_branch_name(root: &str, value: Option<&str>) -> Result) -> Result { + let value = required_text(value, "tag name")?; + if is_invalid_tag_name(&value) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git tag name", + )); + } + Ok(value) +} + +/// Refname rules from `git check-ref-format` plus command-line safety guards +/// (no leading dash) shared by every Git mutation argument. +fn is_invalid_tag_name(value: &str) -> bool { + if value.starts_with('-') + || value == "@" + || value.starts_with('/') + || value.ends_with('/') + || value.ends_with('.') + || value.contains("..") + || value.contains("@{") + || value.contains("//") + { + return true; + } + if value.chars().any(|character| { + character.is_control() + || matches!(character, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') + }) { + return true; + } + value + .split('/') + .any(|component| component.starts_with('.') || component.ends_with(".lock")) +} + +/// Reports whether `refs/tags/` already resolves, using `--verify` so +/// the probe matches the exact ref instead of any revision expression. +fn tag_exists(root: &str, name: &str) -> Result { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("refs/tags/{name}"), + ], + None, + )?; + Ok(probe.exit_code == 0) +} + +/// Resolves a tag target revision to a commit and returns its object id. +/// Git allows tagging trees and blobs; the tag contract only promises commit +/// targets, so `^{commit}` both validates and yields the id the +/// mutation should point at. +fn resolved_commit_target(root: &str, target: &str) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("{target}^{{commit}}"), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes one tag and returns a structured deletion record so the host can +/// offer a restore. The probes run before the deletion because `git tag -d` +/// diagnostics are localized prose that cannot be mapped to stable errors. +fn delete_tag(root: &str, value: Option<&str>) -> Result { + let name = validated_tag_name(value)?; + let reference = format!("refs/tags/{name}"); + let expected_object = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + ) + })?; + let object_type = execute_git( + root, + &["cat-file".into(), "-t".into(), expected_object.clone()], + None, + )?; + if object_type.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + )); + } + let is_annotated = object_type.stdout.trim() == "tag"; + let mut message = None; + if is_annotated { + let tag_object = execute_git( + root, + &["cat-file".into(), "tag".into(), expected_object.clone()], + None, + )?; + if tag_object.exit_code == 0 { + message = annotation_message_from_tag_object(&tag_object.stdout); + } + } + // Peel the ref to a commit so pre-existing tree/blob tags cannot produce + // a recovery record that violates the restore contract. + let peeled = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + format!("{expected_object}^{{commit}}"), + ], + None, + )?; + if peeled.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve tag target '{name}'"), + ) + .with_details(peeled.output)); + } + let mut result = delete_ref_if_unchanged(root, &reference, &expected_object)?; + if result.exit_code == 0 { + result.tag_deletion = Some(GitTagDeletionResponse { + name, + deleted_target: peeled.stdout.trim().to_string(), + kind: if is_annotated { + "annotated" + } else { + "lightweight" + } + .to_string(), + message, + }); + } + Ok(result) +} + +/// Deletes one local branch and returns a structured deletion record so the +/// host can offer a restore. The commit is resolved before the deletion +/// because `git branch -d` diagnostics are localized prose. +fn delete_branch(root: &str, branch: &str) -> Result { + let reference = format!("refs/heads/{branch}"); + let target = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' does not exist"), + ) + })?; + ensure_branch_is_safely_deletable(root, branch, &reference, &target)?; + let mut result = delete_ref_if_unchanged(root, &reference, &target)?; + if result.exit_code == 0 { + result.branch_deletion = Some(GitBranchDeletionResponse { + name: branch.to_string(), + deleted_target: target, + }); + if let Err(error) = remove_branch_config(root, branch) { + // The ref mutation already committed. Preserve recovery data and + // report configuration cleanup as a diagnosable partial success. + result.warnings.push(GitOperationWarning::new( + "branch_config_cleanup_failed", + &error.message, + error.details, + )); + } + } + Ok(result) +} + +/// Resolves an exact refname to its current unpeeled object id. +fn resolve_ref_object(root: &str, reference: &str) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + reference.to_string(), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes a ref only when it still points at the object observed by the +/// caller. `update-ref` performs the comparison and mutation under the same +/// ref lock, closing the probe-then-mutate race. +fn delete_ref_if_unchanged( + root: &str, + reference: &str, + expected_object: &str, +) -> Result { + let mut result = execute_git( + root, + &[ + "update-ref".into(), + "-d".into(), + reference.to_string(), + expected_object.to_string(), + ], + None, + )?; + if result.exit_code != 0 { + result.operation_error = Some( + CoreError::new( + ErrorCode::InvalidRequest, + format!("The Git reference '{reference}' changed before it could be deleted"), + ) + .with_details(result.output.clone()), + ); + } + Ok(result) +} + +/// Preserves `git branch -d` safety before the atomic ref mutation: a branch +/// must not be checked out in any worktree and must be merged into its valid +/// upstream, or into HEAD when it has no usable upstream. +fn ensure_branch_is_safely_deletable( + root: &str, + branch: &str, + reference: &str, + target: &str, +) -> Result<(), CoreError> { + let worktrees = execute_git( + root, + &["worktree".into(), "list".into(), "--porcelain".into()], + None, + )?; + if worktrees.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git worktrees") + .with_details(worktrees.output), + ); + } + if worktrees + .stdout + .lines() + .any(|line| line == format!("branch {reference}")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is checked out in a worktree"), + )); + } + + let upstream = execute_git( + root, + &[ + "for-each-ref".into(), + "--format=%(upstream)".into(), + reference.to_string(), + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect the branch upstream", + ) + .with_details(upstream.output)); + } + let upstream = upstream.stdout.trim(); + let merge_target = if !upstream.is_empty() && resolved_commit_target(root, upstream)?.is_some() + { + upstream + } else { + "HEAD" + }; + let merged = execute_git( + root, + &[ + "merge-base".into(), + "--is-ancestor".into(), + target.to_string(), + merge_target.to_string(), + ], + None, + )?; + match merged.exit_code { + 0 => Ok(()), + 1 => Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is not fully merged"), + )), + _ => Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not verify whether branch '{branch}' is merged"), + ) + .with_details(merged.output)), + } +} + +/// Removes branch-local configuration after the ref has been deleted, matching +/// the metadata cleanup performed by `git branch -d`. +fn remove_branch_config(root: &str, branch: &str) -> Result<(), CoreError> { + let listing = capture_git_with_options( + root, + &[ + "config".into(), + "--name-only".into(), + "--get-regexp".into(), + "^branch\\.".into(), + ], + None, + false, + )?; + if listing.exit_code == 1 { + return Ok(()); + } + if listing.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect branch configuration", + ) + .with_details(String::from_utf8_lossy(&listing.stderr))); + } + let prefix = format!("branch.{branch}."); + if !String::from_utf8_lossy(&listing.stdout) + .lines() + .any(|key| key.starts_with(&prefix)) + { + return Ok(()); + } + let cleanup = capture_git_with_options( + root, + &[ + "config".into(), + "--remove-section".into(), + format!("branch.{branch}"), + ], + None, + false, + )?; + if cleanup.exit_code == 0 { + return Ok(()); + } + Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not remove configuration for deleted branch '{branch}'"), + ) + .with_details(String::from_utf8_lossy(&cleanup.stderr))) +} + +/// Extracts the annotation message from a raw tag object byte-for-byte, so a +/// restored tag keeps the original message including CRLF line endings and +/// trailing newlines. Only the signature block is cut, by locating its first +/// line in the raw content; the signature belongs to the previous tagger and +/// a restored tag would be signed separately. +fn annotation_message_from_tag_object(raw: &str) -> Option { + let (_, message) = raw.split_once("\n\n")?; + let mut offset = 0; + for line in message.split_inclusive('\n') { + let without_eol = line.trim_end_matches(['\r', '\n']); + if without_eol.starts_with("-----BEGIN ") && without_eol.ends_with("SIGNATURE-----") { + return Some(message[..offset].to_string()); + } + offset += line.len(); + } + Some(message.to_string()) +} + fn local_branch_name(reference: &str) -> Result { let branch = reference .strip_prefix("refs/heads/") @@ -3807,6 +4251,8 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { invocations: Vec::new(), operation_error: Some(error), stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), } } @@ -4812,7 +5258,7 @@ fn switch_validated_reference( fn parse_reference(line: &str) -> Option { let columns = line.split('\t').collect::>(); - if columns.len() < 4 || columns[1].ends_with("/HEAD") { + if columns.len() < 8 || columns[1].ends_with("/HEAD") { return None; } let kind = if columns[0].starts_with("refs/heads/") { @@ -4841,6 +5287,7 @@ fn parse_reference(line: &str) -> Option { // `fullName` and always exposes the namespace-relative short name. short_name: short_name.to_string(), kind: kind.to_string(), + peels_to_commit: kind != "tag" || columns[6] == "commit" || columns[7] == "commit", is_current: columns[2].trim() == "*", upstream_short_name, ahead, @@ -5596,8 +6043,9 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { #[cfg(test)] mod tests { use super::{ - line_similarity, pair_diff_entries, parse_diff, structured_diff_from_output, DiffEntry, - GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, + annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_diff, + structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, + GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitPushPreviewResponse, @@ -5605,6 +6053,47 @@ mod tests { }; use serde_json::Value; + #[test] + fn tag_annotation_parser_preserves_crlf_and_trailing_blank_lines() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "details\r\n", + "\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\ndetails\r\n\r\n") + ); + } + + #[test] + fn tag_annotation_parser_removes_only_the_signature_block() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "-----BEGIN PGP SIGNATURE-----\r\n", + "signature-data\r\n", + "-----END PGP SIGNATURE-----\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\n") + ); + } + #[cfg(target_os = "windows")] #[test] fn background_git_processes_do_not_create_windows_console() { @@ -5656,6 +6145,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; @@ -5835,6 +6326,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; @@ -5855,6 +6348,7 @@ mod tests { full_name: "refs/heads/feature/recent".into(), short_name: "feature/recent".into(), kind: "local".into(), + peels_to_commit: true, is_current: true, upstream_short_name: None, ahead: 0, @@ -5864,6 +6358,7 @@ mod tests { full_name: "refs/heads/main".into(), short_name: "main".into(), kind: "local".into(), + peels_to_commit: true, is_current: false, upstream_short_name: Some("origin/main".into()), ahead: 2, @@ -5966,6 +6461,8 @@ mod tests { "Invalid Git reference", )), stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index eac88a016..bb5ef2fb3 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -407,6 +407,9 @@ pub struct GitReferenceResponse { pub short_name: String, /// Reference category: local branch, remote branch, or tag. pub kind: String, + /// Whether the reference resolves to a commit and therefore supports + /// commit-only mutations such as restorable tag deletion. + pub peels_to_commit: bool, pub is_current: bool, pub upstream_short_name: Option, /// Commits present only on this local branch compared with its upstream. diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index f32fe04d7..1793aacb5 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1512,6 +1512,479 @@ fn git_write_executes_checkout_preflight_clone_and_validation() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_write_creates_deletes_and_records_tags() { + let root = temporary_root("git-tags"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + let head = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + // A lightweight tag points straight at the target commit and never + // carries the structured deletion record. + let lightweight = request( + "createTag", + serde_json::json!({"name": "v1.0", "revision": "HEAD"}), + ); + assert_eq!(lightweight["ok"], true, "{lightweight:?}"); + assert!(lightweight["data"].get("tagDeletion").is_none()); + assert_eq!( + String::from_utf8_lossy(&run(&["rev-parse", "refs/tags/v1.0"]).stdout).trim(), + head + ); + + // An annotation creates a tag object whose CRLF and trailing blank lines + // must survive the later delete/restore round trip byte for byte. + let annotated = request( + "createTag", + serde_json::json!({ + "name": "v2.0", + "revision": "HEAD", + "message": "release\r\n\r\nsecond paragraph\r\n\r\n" + }), + ); + assert_eq!(annotated["ok"], true, "{annotated:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["cat-file", "-t", "refs/tags/v2.0"]).stdout).trim(), + "tag" + ); + let annotated_tag_object = + String::from_utf8_lossy(&run(&["rev-parse", "refs/tags/v2.0"]).stdout) + .trim() + .to_string(); + + // An empty name is a missing required field. The format matrix below is + // shared with the macOS dialog through `tag-names.json`, so both sides + // reject exactly the same names: `git check-ref-format` refname rules, + // per-component checks such as `foo/.bar`, and the command-line guards. + // Each rejection must happen before any subprocess so the error stays a + // plain invalid_request envelope. + let tag_names: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/tag-names.json" + ))) + .expect("tag name fixture should be valid JSON"); + let empty = request( + "createTag", + serde_json::json!({"name": "", "revision": "HEAD"}), + ); + assert_eq!(empty["ok"], false, "{empty:?}"); + assert_eq!(empty["error"]["code"], "invalid_request"); + assert_eq!(empty["error"]["message"], "Missing or invalid Git tag name"); + for name in tag_names["invalid"].as_array().expect("invalid list") { + let name = name.as_str().expect("invalid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], false, "name {name:?}: {response:?}"); + assert_eq!( + response["error"]["code"], "invalid_request", + "name {name:?}" + ); + } + for name in tag_names["valid"].as_array().expect("valid list") { + let name = name.as_str().expect("valid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], true, "name {name:?}: {response:?}"); + } + + // A duplicate name is a structured operation error so callers can show it + // in place instead of parsing Git's stderr. + let duplicate = request( + "createTag", + serde_json::json!({"name": "v1.0", "revision": "HEAD"}), + ); + assert_eq!(duplicate["ok"], true, "{duplicate:?}"); + assert_eq!( + duplicate["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + duplicate["data"]["operationError"]["message"], + "A tag named 'v1.0' already exists" + ); + + let unresolvable = request( + "createTag", + serde_json::json!({"name": "v3.0", "revision": "no-such-revision"}), + ); + assert_eq!(unresolvable["ok"], true, "{unresolvable:?}"); + assert_eq!( + unresolvable["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + unresolvable["data"]["operationError"]["message"], + "Could not resolve tag target 'no-such-revision'" + ); + + // Tree and blob revisions are valid Git objects but not valid targets for + // this commit-only contract. Neither rejection may leave a tag behind. + let tree = String::from_utf8_lossy(&run(&["rev-parse", "HEAD^{tree}"]).stdout) + .trim() + .to_string(); + let blob = String::from_utf8_lossy(&run(&["hash-object", "example.txt"]).stdout) + .trim() + .to_string(); + for (name, revision) in [("tree-target", tree), ("blob-target", blob)] { + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": revision}), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["message"], + format!("Could not resolve tag target '{revision}'") + ); + assert_ne!( + run(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/tags/{name}") + ]) + .status + .code(), + Some(0) + ); + } + + // Deleting a lightweight tag reports the commit it pointed at. + let delete_lightweight = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(delete_lightweight["ok"], true, "{delete_lightweight:?}"); + assert_eq!(delete_lightweight["data"]["exitCode"], 0); + assert_eq!(delete_lightweight["data"]["tagDeletion"]["name"], "v1.0"); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["kind"], + "lightweight" + ); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["deletedTarget"], + head + ); + assert!(delete_lightweight["data"]["invocations"] + .as_array() + .expect("tag deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/tags/v1.0", head]) + })); + assert!(delete_lightweight["data"]["tagDeletion"] + .get("message") + .is_none()); + assert!( + run(&["rev-parse", "--verify", "--quiet", "refs/tags/v1.0"]) + .status + .code() + != Some(0) + ); + + // Deleting an annotated tag carries the peeled commit and the original + // annotation so a restore can rebuild the message byte for byte. + let delete_annotated = request("deleteTag", serde_json::json!({"name": "v2.0"})); + assert_eq!(delete_annotated["ok"], true, "{delete_annotated:?}"); + let deletion = &delete_annotated["data"]["tagDeletion"]; + assert_eq!(deletion["name"], "v2.0"); + assert_eq!(deletion["kind"], "annotated"); + assert_eq!( + deletion["message"], + "release\r\n\r\nsecond paragraph\r\n\r\n" + ); + assert_eq!(deletion["deletedTarget"], head); + assert!(delete_annotated["data"]["invocations"] + .as_array() + .expect("annotated tag deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/tags/v2.0", annotated_tag_object]) + })); + + // Deleting a missing tag fails with the stable not-exist message. + let missing = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(missing["ok"], true, "{missing:?}"); + assert_eq!(missing["data"]["operationError"]["code"], "invalid_request"); + assert_eq!( + missing["data"]["operationError"]["message"], + "The tag 'v1.0' does not exist" + ); + + // Restoring reuses createTag with the recorded target and message, so the + // rebuilt annotation must match the original message exactly. + let restore = request( + "createTag", + serde_json::json!({ + "name": deletion["name"], + "revision": deletion["deletedTarget"], + "message": deletion["message"] + }), + ); + assert_eq!(restore["ok"], true, "{restore:?}"); + let restored_object = run(&["cat-file", "tag", "refs/tags/v2.0"]); + assert!(restored_object + .stdout + .ends_with(b"\n\nrelease\r\n\r\nsecond paragraph\r\n\r\n")); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_records_the_deleted_branch_target() { + let root = temporary_root("git-branch-delete"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["branch", "feature/short-lived"]).status.success()); + assert!(run(&[ + "config", + "branch.feature/short-lived.description", + "temporary" + ]) + .status + .success()); + let head = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + // A successful branch deletion carries the commit the branch pointed at so + // the host can offer a restore, exactly like the tag deletion record. + let deletion = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(deletion["ok"], true, "{deletion:?}"); + assert_eq!(deletion["data"]["exitCode"], 0); + assert_eq!( + deletion["data"]["branchDeletion"]["name"], "feature/short-lived", + "{deletion:?}" + ); + assert_eq!(deletion["data"]["branchDeletion"]["deletedTarget"], head); + assert!(deletion["data"]["invocations"] + .as_array() + .expect("branch deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/heads/feature/short-lived", head]) + })); + assert!(deletion["data"].get("tagDeletion").is_none()); + assert_ne!( + run(&["config", "--get", "branch.feature/short-lived.description"]) + .status + .code(), + Some(0), + "branch-local configuration should be removed with the ref" + ); + + // Deleting a missing branch fails with the stable not-exist message. + let missing = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(missing["ok"], true, "{missing:?}"); + assert_eq!(missing["data"]["operationError"]["code"], "invalid_request"); + assert_eq!( + missing["data"]["operationError"]["message"], + "The branch 'feature/short-lived' does not exist" + ); + + // Restoring replays createBranch against the recorded commit. + let restore = request( + "createBranch", + serde_json::json!({ + "reference": deletion["data"]["branchDeletion"]["deletedTarget"], + "name": "feature/short-lived", + "checkout": false + }), + ); + assert_eq!(restore["ok"], true, "{restore:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["rev-parse", "refs/heads/feature/short-lived"]).stdout) + .trim(), + head + ); + + // A config lock can make metadata cleanup fail after the expected-OID ref + // deletion has already succeeded. The response must still carry recovery + // data so hosts can offer Restore alongside the cleanup warning. + assert!(run(&["branch", "feature/config-locked"]).status.success()); + assert!(run(&[ + "config", + "branch.feature/config-locked.description", + "temporary" + ]) + .status + .success()); + let config_lock = root.join(".git/config.lock"); + fs::write(&config_lock, "locked\n").expect("config lock should be creatable"); + let cleanup_warning = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/config-locked"}), + ); + fs::remove_file(config_lock).expect("config lock should be removable"); + assert_eq!(cleanup_warning["ok"], true, "{cleanup_warning:?}"); + assert!(cleanup_warning["data"].get("operationError").is_none()); + assert_eq!( + cleanup_warning["data"]["warnings"][0]["code"], + "branch_config_cleanup_failed" + ); + assert_eq!( + cleanup_warning["data"]["branchDeletion"]["name"], "feature/config-locked", + "{cleanup_warning:?}" + ); + assert_eq!( + cleanup_warning["data"]["branchDeletion"]["deletedTarget"], + head + ); + assert_ne!( + run(&[ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/config-locked" + ]) + .status + .code(), + Some(0) + ); + + // Atomic deletion must retain the safety contract of `git branch -d` and + // refuse a branch whose tip is not merged into its upstream or HEAD. + assert!(run(&["switch", "-q", "feature/short-lived"]) + .status + .success()); + fs::write(root.join("feature.txt"), "unmerged\n").expect("file should be writable"); + assert!(run(&["add", "feature.txt"]).status.success()); + assert!(run(&["commit", "-qm", "unmerged branch work"]) + .status + .success()); + assert!(run(&["switch", "-q", "main"]).status.success()); + let unmerged = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(unmerged["ok"], true, "{unmerged:?}"); + assert_eq!( + unmerged["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + unmerged["data"]["operationError"]["message"], + "The branch 'feature/short-lived' is not fully merged" + ); + assert!(run(&[ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/short-lived" + ]) + .status + .success()); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_write_rolls_back_large_selected_path_set_without_command_line_overflow() { let root = temporary_root("git-write-large-rollback"); @@ -2317,6 +2790,16 @@ fn git_history_returns_references_and_commit_graph_fields() { assert!(run(&["add", "example.txt"]).status.success()); assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["tag", "commit-tag", "HEAD"]).status.success()); + let tree = String::from_utf8_lossy(&run(&["rev-parse", "HEAD^{tree}"]).stdout) + .trim() + .to_string(); + assert!(run(&["tag", "tree-tag", &tree]).status.success()); + let blob = String::from_utf8_lossy(&run(&["hash-object", "example.txt"]).stdout) + .trim() + .to_string(); + assert!(run(&["tag", "blob-tag", &blob]).status.success()); + let commit_hash = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) .trim() .to_string(); @@ -2415,6 +2898,19 @@ fn git_history_returns_references_and_commit_graph_fields() { .expect("references should be an array") .iter() .any(|reference| reference["kind"] == "local")); + for (name, expected) in [ + ("commit-tag", true), + ("tree-tag", false), + ("blob-tag", false), + ] { + let reference = response["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .find(|reference| reference["shortName"] == name) + .expect("tag reference should be present"); + assert_eq!(reference["peelsToCommit"], expected, "tag {name}"); + } fs::remove_dir_all(root).expect("temporary workspace should be removable"); } diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 665fc1ff5..8034b542c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -250,7 +250,7 @@ response retains the invocation trace and includes the failure as `renameBranch`, `setUpstream`, `unsetUpstream`, `deleteBranch`, `merge`, `rebase`, `createWorktree`, `fetch`, `pull`, `push`, `checkout`, `checkoutAndRebase`, `checkoutRevision`, `clone`, `stashPush`, `stashApply`, `stashPop`, `stashDrop`, `deleteRemoteBranch`, `operationContinue`, -`operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, +`operationAbort`, `operationSkip`, `createTag`, and `deleteTag`. Optional fields are `paths`, `reference`, `referenceKind`, `gitReference`, `revision`, `revisions`, `name`, `message`, `remote`, `destination`, `mode`, `includeUntracked`, `checkout`, `amend`, `force`, `pushTags`, `expectedPush`, and `autoStash`. @@ -367,6 +367,38 @@ operation, a target outside the current branch's first-parent chain, a rewrite range containing a merge commit, or any rewritten commit reachable from `refs/remotes`. +`createTag` uses `name` for the new tag, `revision` as its target commit or +revision, and an optional `message`: when the field is present (including an +empty value), it creates an annotated tag (`git tag -a`); an absent field +creates a lightweight tag. UI callers trim new user-entered messages. Core +passes the supplied annotation with verbatim cleanup so restore preserves +CRLF and trailing blank lines, and an explicit empty value preserves an empty +annotated tag. Tag names must satisfy the `git check-ref-format` refname rules and must not +begin with a dash; `shared/fixtures/git/tag-names.json` pins the boundary cases +for Core and host-side validation. Before invoking Git, `createTag` probes the +repository so a duplicate tag (`A tag named '' already exists`) and an unresolvable +non-commit target (`Could not resolve tag target ''`) fail with stable +`invalid_request` messages instead of localized Git output. `deleteTag` uses +`name` and removes `refs/tags/`; a missing tag fails with +`The tag '' does not exist`. On success the response carries a +structured `tagDeletion` record — `{ "name": string, "deletedTarget": string, +"kind": "lightweight" | "annotated", "message": string? }` — where +`deletedTarget` is the peeled commit the deleted ref resolved to and +`message` is the original annotation with its line breaks preserved. Hosts +can rebuild the tag by replaying `createTag` with `name`, `deletedTarget`, +and `message`; the tagger identity and timestamp are intentionally not +preserved. Deletion supplies the observed unpeeled object ID to `update-ref`, +so a concurrent force-update fails atomically instead of deleting new state and +returning a stale recovery target. `deleteBranch` applies the same expected-OID +guard after checking the branch is fully merged and not checked out, then +on success, carries a structured `branchDeletion` record — +`{ "name": string, "deletedTarget": string }` — so hosts can offer to +recreate the branch at its previous commit; a missing branch fails with +`The branch '' does not exist`. If the ref deletion succeeds but branch +configuration cleanup fails, the response contains both `branchDeletion` and a +`branch_config_cleanup_failed` warning; hosts must preserve the Restore action while surfacing the +cleanup diagnostic. + `operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata to select the active merge, rebase, cherry-pick, or revert instead of accepting an operation kind from the caller. Continue is rejected while conflicted paths @@ -442,6 +474,8 @@ and the optional effective `userName` and `userEmail` from repository Git configuration. Commit parents are explicit so clients can render merge topology without re-parsing Git output. The identity fields let clients implement a stable `me` filter without guessing from recent commits. +Each reference includes `peelsToCommit`; hosts use it to disable commit-only +actions for legal tree/blob tags before the user reaches a failing mutation. Each local reference with an upstream also returns numeric `ahead` and `behind` counts against that fetched remote-tracking reference. References without an upstream, remote references, and tags return zero for both fields. diff --git a/shared/fixtures/git/history-response-v1.json b/shared/fixtures/git/history-response-v1.json index 595d3d0c1..a5e303309 100644 --- a/shared/fixtures/git/history-response-v1.json +++ b/shared/fixtures/git/history-response-v1.json @@ -4,6 +4,7 @@ "fullName": "refs/heads/feature/recent", "shortName": "feature/recent", "kind": "local", + "peelsToCommit": true, "isCurrent": true, "upstreamShortName": null, "ahead": 0, @@ -13,6 +14,7 @@ "fullName": "refs/heads/main", "shortName": "main", "kind": "local", + "peelsToCommit": true, "isCurrent": false, "upstreamShortName": "origin/main", "ahead": 2, @@ -24,6 +26,7 @@ "fullName": "refs/heads/feature/recent", "shortName": "feature/recent", "kind": "local", + "peelsToCommit": true, "isCurrent": true, "upstreamShortName": null, "ahead": 0, @@ -33,6 +36,7 @@ "fullName": "refs/heads/main", "shortName": "main", "kind": "local", + "peelsToCommit": true, "isCurrent": false, "upstreamShortName": "origin/main", "ahead": 2, diff --git a/shared/fixtures/git/tag-names.json b/shared/fixtures/git/tag-names.json new file mode 100644 index 000000000..2aa2589dd --- /dev/null +++ b/shared/fixtures/git/tag-names.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "valid": [ + "v1", + "v1.0.0", + "release-2026.08", + "feature/x", + "a/b/c", + "v1_rc1" + ], + "invalid": [ + "", + " ", + "a..b", + "-v1", + "a b", + "v~1", + "a:b", + "a?b", + "a*b", + "a[b", + "a\\b", + "@{x", + "a//b", + "a.lock", + ".hidden", + "@", + "head/", + "x/../y", + "foo/.bar", + "foo/bar.lock", + "a/.b/c", + "x/./y" + ] +} diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index e6b3cf62e..ba85a07b3 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -133,6 +133,51 @@ "message": "Save work", "includeUntracked": true } + }, + { + "operation": "createTag", + "payload": { + "name": "v1.0.0", + "revision": "2f1c9a4" + } + }, + { + "operation": "createTag", + "payload": { + "name": "v2.0.0", + "revision": "8e4b2d7", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteTag", + "payload": { + "name": "v2.0.0" + } + }, + { + "operation": "deleteBranch", + "payload": { + "reference": "refs/heads/feature/pending" + } + } + ], + "responses": [ + { + "operation": "deleteTag", + "tagDeletion": { + "name": "v2.0.0", + "deletedTarget": "8e4b2d7bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1", + "kind": "annotated", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteBranch", + "branchDeletion": { + "name": "feature/pending", + "deletedTarget": "2f1c9a4bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1" + } } ], "invalidRequests": [ @@ -151,6 +196,14 @@ }, "errorCode": "invalid_request" }, + { + "operation": "createTag", + "payload": { + "name": "bad tag name", + "revision": "HEAD" + }, + "errorCode": "invalid_request" + }, { "operation": "pull", "payload": { diff --git a/windows/tauri/src/features/git/api/git-branches-api.test.ts b/windows/tauri/src/features/git/api/git-branches-api.test.ts index a53646f0c..f9cebb3c0 100644 --- a/windows/tauri/src/features/git/api/git-branches-api.test.ts +++ b/windows/tauri/src/features/git/api/git-branches-api.test.ts @@ -31,6 +31,7 @@ describe("Git branch reference mutations", () => { fullName: "refs/remotes/origin/feature/orders", shortName: "origin/feature/orders", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -109,6 +110,7 @@ describe("Git branch reference mutations", () => { fullName: "refs/remotes/origin/main", shortName: "origin/main", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; await setBranchUpstream("C:/repo", "main", upstream); diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts index 3bf873322..23ab10c65 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.test.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -42,6 +42,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -123,6 +124,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -169,6 +171,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -200,6 +203,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -231,6 +235,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; diff --git a/windows/tauri/src/features/git/api/git-push-api.test.ts b/windows/tauri/src/features/git/api/git-push-api.test.ts index 950369083..56a6cf913 100644 --- a/windows/tauri/src/features/git/api/git-push-api.test.ts +++ b/windows/tauri/src/features/git/api/git-push-api.test.ts @@ -46,6 +46,7 @@ describe("Git push API", () => { fullName: "refs/heads/feature/push", shortName: "feature/push", kind: "local", + peelsToCommit: true, isCurrent: true, }); diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index e4828ca34..8c6d1acc7 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -112,6 +112,7 @@ describe("Git remote Pull API", () => { fullName: "refs/remotes/team/origin/feature/orders", shortName: "team/origin/feature/orders", kind: "remote", + peelsToCommit: true, isCurrent: false, }; await deleteRemoteBranch("C:/repo", reference); diff --git a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts index 7a8588550..86b7be15b 100644 --- a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts +++ b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts @@ -23,6 +23,7 @@ describe("Git reference worktrees", () => { fullName: "refs/remotes/origin/feature/orders", shortName: "origin/feature/orders", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index ecea27d99..11c081e69 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -34,6 +34,7 @@ export interface GitReference { fullName: string; shortName: string; kind: GitReferenceKind; + peelsToCommit: boolean; isCurrent: boolean; upstreamShortName?: string; ahead?: number; diff --git a/windows/tauri/src/features/git/utils/git-reference-actions.test.ts b/windows/tauri/src/features/git/utils/git-reference-actions.test.ts index 2b5f644e6..345a45b16 100644 --- a/windows/tauri/src/features/git/utils/git-reference-actions.test.ts +++ b/windows/tauri/src/features/git/utils/git-reference-actions.test.ts @@ -18,6 +18,7 @@ const reference = ( : `refs/tags/${shortName}`, shortName, kind, + peelsToCommit: true, isCurrent, }); diff --git a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts index 9a4730698..bd8b5ca43 100644 --- a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts +++ b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts @@ -6,6 +6,7 @@ const reference = (shortName: string): GitReference => ({ fullName: `refs/remotes/${shortName}`, shortName, kind: "remote", + peelsToCommit: true, isCurrent: false, }); @@ -30,6 +31,7 @@ describe("Git reference tree", () => { fullName: "refs/heads/main", shortName: "main", kind: "local" as const, + peelsToCommit: true, isCurrent: true, }, ]; diff --git a/windows/tauri/src/platform/core-result-adapter.history.test.ts b/windows/tauri/src/platform/core-result-adapter.history.test.ts index 898048438..e043ac5b3 100644 --- a/windows/tauri/src/platform/core-result-adapter.history.test.ts +++ b/windows/tauri/src/platform/core-result-adapter.history.test.ts @@ -13,17 +13,26 @@ describe("git history result adaptation", () => { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, behind: 3, }, + { + fullName: "refs/tags/tree-tag", + shortName: "tree-tag", + kind: "tag", + peelsToCommit: false, + isCurrent: false, + }, ], recentReferences: [ { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, @@ -52,17 +61,27 @@ describe("git history result adaptation", () => { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, behind: 3, }, + { + fullName: "refs/tags/tree-tag", + shortName: "tree-tag", + kind: "tag", + peelsToCommit: false, + isCurrent: false, + upstreamShortName: undefined, + }, ], recentReferences: [ { fullName: "refs/heads/main", shortName: "main", kind: "local", + peelsToCommit: true, isCurrent: true, upstreamShortName: "origin/main", ahead: 12, diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index dc28c6af1..f2ef61aa2 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -167,6 +167,7 @@ export function adaptCoreResult( fullName: reference.fullName, shortName: reference.shortName, kind: reference.kind, + peelsToCommit: Boolean(reference.peelsToCommit), isCurrent: Boolean(reference.isCurrent), upstreamShortName: reference.upstreamShortName ?? undefined, ahead: typeof reference.ahead === "number" ? reference.ahead : 0, @@ -178,6 +179,7 @@ export function adaptCoreResult( fullName: reference.fullName, shortName: reference.shortName, kind: reference.kind, + peelsToCommit: Boolean(reference.peelsToCommit), isCurrent: Boolean(reference.isCurrent), upstreamShortName: reference.upstreamShortName ?? undefined, ahead: typeof reference.ahead === "number" ? reference.ahead : 0, From 1823285b4dd6eb01b77cc8ff5fd654b20e72f40e Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 10:38:23 +0800 Subject: [PATCH 14/24] fix(git): restore deleted branches from commit ids --- rust/lithe-core/src/git/mod.rs | 12 ++++++++++++ rust/lithe-core/src/tests/git.rs | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 2dcd5eeb3..4270ac236 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -2991,6 +2991,18 @@ pub(super) fn write_request_reference( root: &str, request: &GitWriteRequest, ) -> Result { + // Branch restore records carry the deleted commit object ID rather than a + // live ref. Accept that exact revision only for createBranch; every other + // mutation continues to require a typed, existing Git reference. + if request.operation == "createBranch" { + if let Some(reference) = request.git_reference.as_ref() { + if reference.full_name == reference.short_name { + if let Ok(revision) = validated_revision(Some(&reference.full_name)) { + return Ok(revision); + } + } + } + } optional_write_request_reference(root, request)? .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference")) } diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 1793aacb5..37f1199f6 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1893,7 +1893,11 @@ fn git_write_records_the_deleted_branch_target() { let restore = request( "createBranch", serde_json::json!({ - "reference": deletion["data"]["branchDeletion"]["deletedTarget"], + "gitReference": { + "fullName": deletion["data"]["branchDeletion"]["deletedTarget"], + "shortName": deletion["data"]["branchDeletion"]["deletedTarget"], + "kind": "local" + }, "name": "feature/short-lived", "checkout": false }), From 31d200170b4763b49bdec2e9c02076b12a8a42e6 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 10:48:29 +0800 Subject: [PATCH 15/24] fix(build): remove duplicate run state case and bound downloads --- macos/Sources/Lithe/Views/Run/RunView.swift | 6 ------ scripts/prepare-jdk.sh | 11 ++++++++++- scripts/prepare-jdtls.sh | 11 ++++++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift index f4b238db8..3ddad98e0 100644 --- a/macos/Sources/Lithe/Views/Run/RunView.swift +++ b/macos/Sources/Lithe/Views/Run/RunView.swift @@ -207,12 +207,6 @@ struct RunView: View { return (String(localized: "Project identification failed"), message, "xmark.octagon.fill") case .idle: return nil - case .projectNotReady: - return ( - String(localized: "Project is still loading"), - String(localized: "Wait for the workspace scan to finish, then identify the project again."), - "hourglass" - ) } } diff --git a/scripts/prepare-jdk.sh b/scripts/prepare-jdk.sh index aed55c1da..86c080702 100755 --- a/scripts/prepare-jdk.sh +++ b/scripts/prepare-jdk.sh @@ -51,7 +51,16 @@ download_verified_file() { fi rm -f -- "$temporary_path" - if ! curl --fail --location --retry 3 --output "$temporary_path" "$url"; then + print -u2 -- "Downloading $description: $url" + if ! curl \ + --fail \ + --location \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 15 \ + --max-time 180 \ + --output "$temporary_path" \ + "$url"; then rm -f -- "$temporary_path" return 1 fi diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index cea744d0f..38bf3c2e2 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -80,7 +80,16 @@ download_verified_file() { fi rm -f -- "$temporary_path" - if ! curl --fail --location --retry 3 --output "$temporary_path" "$url"; then + print -u2 -- "Downloading $description: $url" + if ! curl \ + --fail \ + --location \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 15 \ + --max-time 180 \ + --output "$temporary_path" \ + "$url"; then rm -f -- "$temporary_path" return 1 fi 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 16/24] 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 fa06644d9..fe108fdd6 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 17/24] 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 cfd0acaf7..05e88ef64 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 fe108fdd6..fa06644d9 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 From c2294c1ed868d8522195ad564c1e2e9d5d494072 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Wed, 2 Sep 2026 15:49:25 +0800 Subject: [PATCH 18/24] fix(git): preserve recovery records after failed deletion --- .../Application/GitFeatureModel.swift | 10 +--- .../LitheGitModuleTests/GitModuleTests.swift | 55 +++++++++++++++++-- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 7f967b571..229872c74 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1664,15 +1664,10 @@ package final class GitFeatureModel: ObservableObject { isPerformingBranchOperation = true let result = await withGitOperation { await service.deleteBranch(reference, at: gitRepositoryRoot) } isPerformingBranchOperation = false - if let deletion = result.branchDeletion { + if result.succeeded, let deletion = result.branchDeletion { recentlyDeletedBranch = deletion - notify?( - result.succeeded - ? successfulMessage(result, fallback: "Deleted branch \(deletion.name)") - : trimmedMessage(result) - ) + notify?(successfulMessage(result, fallback: "Deleted branch \(deletion.name)")) } else { - recentlyDeletedBranch = nil notify?(result.succeeded ? "Deleted \(reference.shortName)" : trimmedMessage(result)) } await refreshGit() @@ -1754,7 +1749,6 @@ package final class GitFeatureModel: ObservableObject { recentlyDeletedTag = deletion notify?("Deleted tag \(deletion.name)") } else { - recentlyDeletedTag = nil notify?(trimmedMessage(result)) } await refreshGit() diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 219251eff..f2f5fd5ef 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -655,6 +655,50 @@ struct GitModuleTests { #expect(notifications == ["The tag 'v1.0' does not exist"]) } + @Test + func gitTagDeletionFailureKeepsThePreviousRecoveryRecord() async { + var notifications: [String] = [] + let results = GitProcessResultQueue([ + GitProcessResult( + arguments: ["tag", "-d", "v1.0"], + output: "Deleted tag 'v1.0'\n", + exitCode: 0, + tagDeletion: GitTagDeletion( + name: "v1.0", + deletedTarget: "abc123def456", + kind: .lightweight, + message: nil + ) + ), + GitProcessResult( + arguments: ["tag", "-d", "missing"], + output: "The tag 'missing' does not exist", + exitCode: 1 + ) + ]) + let (feature, _) = makeTagTestFeature( + TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []), + deleteTagResults: results + ), + onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + + for name in ["v1.0", "missing"] { + await feature.deleteTag(GitReference( + fullName: "refs/tags/\(name)", + shortName: name, + kind: .tag, + isCurrent: false, + upstreamShortName: nil + )) + } + + #expect(feature.recentlyDeletedTag?.name == "v1.0") + #expect(notifications == ["Deleted tag v1.0", "The tag 'missing' does not exist"]) + } + @Test func gitTagRestoreReplaysRecordedNameTargetAndMessage() async { var notifications: [String] = [] @@ -908,7 +952,7 @@ struct GitModuleTests { } @Test - func gitBranchDeletionFailureClearsThePreviousRecoveryRecord() async { + func gitBranchDeletionFailureKeepsThePreviousRecoveryRecord() async { var notifications: [String] = [] let results = GitProcessResultQueue([ GitProcessResult( @@ -949,7 +993,7 @@ struct GitModuleTests { upstreamShortName: nil )) - #expect(feature.recentlyDeletedBranch == nil) + #expect(feature.recentlyDeletedBranch?.name == "feature/a") #expect(notifications == ["Deleted branch feature/a", "The branch 'feature/b' does not exist"]) } @@ -2105,7 +2149,7 @@ private final class BranchCallRecorder: @unchecked Sendable { } } -/// Supplies deterministic per-call results for consecutive branch mutations. +/// Supplies deterministic per-call results for consecutive Git mutations. private final class GitProcessResultQueue: @unchecked Sendable { private let lock = NSLock() private var results: [GitProcessResult] @@ -2137,6 +2181,7 @@ private struct TestGitOperations: GitOperations { private let filesGate: GitFilesLoadGate? private let createTagResult: GitProcessResult? private let deleteTagResult: GitProcessResult? + private let deleteTagResults: GitProcessResultQueue? private let tagCallRecorder: TagCallRecorder? private let createBranchResult: GitProcessResult? private let deleteBranchResult: GitProcessResult? @@ -2158,6 +2203,7 @@ private struct TestGitOperations: GitOperations { filesGate: GitFilesLoadGate? = nil, createTagResult: GitProcessResult? = nil, deleteTagResult: GitProcessResult? = nil, + deleteTagResults: GitProcessResultQueue? = nil, tagCallRecorder: TagCallRecorder? = nil, createBranchResult: GitProcessResult? = nil, deleteBranchResult: GitProcessResult? = nil, @@ -2178,6 +2224,7 @@ private struct TestGitOperations: GitOperations { self.filesGate = filesGate self.createTagResult = createTagResult self.deleteTagResult = deleteTagResult + self.deleteTagResults = deleteTagResults self.tagCallRecorder = tagCallRecorder self.createBranchResult = createBranchResult self.deleteBranchResult = deleteBranchResult @@ -2281,6 +2328,6 @@ private struct TestGitOperations: GitOperations { } func deleteTag(named name: String, rootURL: URL) -> GitProcessResult? { tagCallRecorder?.record(TagCallRecorder.Call(name: name, revision: "", message: nil)) - return deleteTagResult + return deleteTagResults?.next() ?? deleteTagResult } } From 1b70ce8fc1a7ea6be899d501c366da6271bf094c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Wed, 2 Sep 2026 19:21:49 +0800 Subject: [PATCH 19/24] =?UTF-8?q?fix(windows):=20=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E5=A4=9A=E7=AA=97=E5=8F=A3=E8=BF=90=E8=A1=8C=E8=BE=93=E5=87=BA?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=8E=A7=E5=88=B6=E5=8F=B0=E4=B8=B2?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按窗口标签区分运行进程会话,并将 run-output/run-exit 事件定向投递到发起窗口,避免多开项目时输出混入其他窗口。 Fixes #408 Co-authored-by: Cursor --- windows/tauri/src-tauri/src/run.rs | 89 +++++++++++++++---- .../src/features/run/api/run-host-api.test.ts | 52 +++++++++++ .../src/features/run/api/run-host-api.ts | 19 +++- .../features/run/utils/run-window-context.ts | 5 ++ 4 files changed, 144 insertions(+), 21 deletions(-) create mode 100644 windows/tauri/src/features/run/api/run-host-api.test.ts create mode 100644 windows/tauri/src/features/run/utils/run-window-context.ts diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 98b053290..8e83de5c3 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -39,6 +39,12 @@ const LITHE_GITIGNORE_ENTRIES: &[&str] = &["run/local.json", "toolchains/local.j pub struct RunProcessManager; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RunSessionKey { + window_label: String, + session_id: String, +} + struct RunningSession { pid: u32, stdin: Option, @@ -50,11 +56,18 @@ impl Default for RunProcessManager { } } -fn sessions() -> &'static Mutex> { - static SESSIONS: OnceLock>> = OnceLock::new(); +fn sessions() -> &'static Mutex> { + static SESSIONS: OnceLock>> = OnceLock::new(); SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) } +fn run_session_key(window_label: &str, session_id: &str) -> RunSessionKey { + RunSessionKey { + window_label: window_label.to_string(), + session_id: session_id.to_string(), + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WriteGeneratedArgs { @@ -148,6 +161,7 @@ pub struct ResolvedLaunch { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartProcessArgs { + pub window_label: String, pub session_id: String, pub executable: String, pub arguments: Vec, @@ -261,7 +275,10 @@ pub fn run_resolve_launch(args: ResolveLaunchArgs) -> Result Result<(), String> { - stop_session(&args.session_id); + if args.window_label.trim().is_empty() { + return Err("A run process must be started from an active window.".into()); + } + stop_session(&args.window_label, &args.session_id); let mut command = command_for_executable(&args.executable, &args.arguments); command .current_dir(&args.working_directory) @@ -277,15 +294,33 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); + let session_key = run_session_key(&args.window_label, &args.session_id); sessions() .lock() .map_err(|_| "Run process state is unavailable".to_string())? - .insert(args.session_id.clone(), RunningSession { pid, stdin }); + .insert( + session_key, + RunningSession { + pid, + stdin, + }, + ); - let stdout_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stdout); - let stderr_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stderr); + let stdout_reader = spawn_output_reader( + app.clone(), + args.window_label.clone(), + args.session_id.clone(), + stdout, + ); + let stderr_reader = spawn_output_reader( + app.clone(), + args.window_label.clone(), + args.session_id.clone(), + stderr, + ); spawn_exit_waiter( app, + args.window_label, args.session_id, child, pid, @@ -296,18 +331,18 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S } #[tauri::command] -pub fn run_stop_process(session_id: String) -> Result<(), String> { - stop_session(&session_id); +pub fn run_stop_process(window_label: String, session_id: String) -> Result<(), String> { + stop_session(&window_label, &session_id); Ok(()) } #[tauri::command] -pub fn run_write_stdin(session_id: String, input: String) -> Result<(), String> { +pub fn run_write_stdin(window_label: String, session_id: String, input: String) -> Result<(), String> { let mut current = sessions() .lock() .map_err(|_| "Run process state is unavailable".to_string())?; let session = current - .get_mut(&session_id) + .get_mut(&run_session_key(&window_label, &session_id)) .ok_or_else(|| "The run process is no longer active.".to_string())?; let stdin = session .stdin @@ -1421,6 +1456,7 @@ fn runtime_version_parts(version: &str) -> Vec { fn spawn_output_reader( app: AppHandle, + window_label: String, session_id: String, stream: Option, ) -> thread::JoinHandle<()> { @@ -1434,7 +1470,8 @@ fn spawn_output_reader( if !pending.is_empty() { let chunk = decode_process_bytes(&pending); if !chunk.is_empty() { - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-output", json!({ "sessionId": session_id, "chunk": chunk }), ); @@ -1454,7 +1491,8 @@ fn spawn_output_reader( if chunk.is_empty() { continue; } - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-output", json!({ "sessionId": session_id, "chunk": chunk }), ); @@ -1467,6 +1505,7 @@ fn spawn_output_reader( fn spawn_exit_waiter( app: AppHandle, + window_label: String, session_id: String, mut child: Child, pid: u32, @@ -1481,10 +1520,11 @@ fn spawn_exit_waiter( .unwrap_or(-1); let _ = stdout_reader.join(); let _ = stderr_reader.join(); + let session_key = run_session_key(&window_label, &session_id); let stale = match sessions().lock() { - Ok(mut current) => match current.get(&session_id) { + Ok(mut current) => match current.get(&session_key) { Some(session) if session.pid == pid => { - current.remove(&session_id); + current.remove(&session_key); false } _ => true, @@ -1494,18 +1534,23 @@ fn spawn_exit_waiter( if stale { return; } - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-exit", json!({ "sessionId": session_id, "exitCode": exit_code }), ); }); } -fn stop_session(session_id: &str) { +fn stop_session(window_label: &str, session_id: &str) { let pid = sessions() .lock() .ok() - .and_then(|mut current| current.remove(session_id).map(|session| session.pid)); + .and_then(|mut current| { + current + .remove(&run_session_key(window_label, session_id)) + .map(|session| session.pid) + }); if let Some(pid) = pid { let mut command = Command::new("taskkill"); command.args(["/F", "/T", "/PID", &pid.to_string()]); @@ -1878,10 +1923,18 @@ mod tests { assert!(looks_like_real_utf8(b"[INFO] BUILD SUCCESS")); } + #[test] + fn run_session_keys_are_scoped_by_window_label() { + let left = run_session_key("project-a", "primary"); + let right = run_session_key("project-b", "primary"); + assert_ne!(left, right); + assert_eq!(left, run_session_key("project-a", "primary")); + } + #[test] fn stdin_write_rejects_an_inactive_session() { let session_id = format!("missing-{}", std::process::id()); - let error = run_write_stdin(session_id, "input\n".to_string()).unwrap_err(); + let error = run_write_stdin("main".into(), session_id, "input\n".to_string()).unwrap_err(); assert_eq!(error, "The run process is no longer active."); } diff --git a/windows/tauri/src/features/run/api/run-host-api.test.ts b/windows/tauri/src/features/run/api/run-host-api.test.ts new file mode 100644 index 000000000..a026a0227 --- /dev/null +++ b/windows/tauri/src/features/run/api/run-host-api.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const invoke = mock(async () => undefined); +const getCurrentWebviewWindow = mock(() => ({ label: "project-window" })); + +mock.module("@/platform/tauri-core", () => ({ invoke })); +mock.module("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow })); + +const { startRunProcess, stopRunProcess, writeRunStdin } = await import("../api/run-host-api"); + +describe("run host API window scoping", () => { + beforeEach(() => { + invoke.mockClear(); + getCurrentWebviewWindow.mockClear(); + }); + + test("startRunProcess includes the current window label", async () => { + await startRunProcess({ + sessionId: "primary", + executable: "go.exe", + arguments: ["run", "."], + workingDirectory: "D:\\demo", + environment: {}, + }); + + expect(invoke).toHaveBeenCalledWith("run_start_process", { + args: { + sessionId: "primary", + executable: "go.exe", + arguments: ["run", "."], + workingDirectory: "D:\\demo", + environment: {}, + windowLabel: "project-window", + }, + }); + }); + + test("stop and stdin commands include the current window label", async () => { + await stopRunProcess("primary"); + await writeRunStdin("primary", "input\n"); + + expect(invoke).toHaveBeenNthCalledWith(1, "run_stop_process", { + windowLabel: "project-window", + sessionId: "primary", + }); + expect(invoke).toHaveBeenNthCalledWith(2, "run_write_stdin", { + windowLabel: "project-window", + sessionId: "primary", + input: "input\n", + }); + }); +}); diff --git a/windows/tauri/src/features/run/api/run-host-api.ts b/windows/tauri/src/features/run/api/run-host-api.ts index 0e587b8ee..bb686ddbc 100644 --- a/windows/tauri/src/features/run/api/run-host-api.ts +++ b/windows/tauri/src/features/run/api/run-host-api.ts @@ -1,4 +1,5 @@ import { invoke } from "@/platform/tauri-core"; +import { getRunWindowLabel } from "../utils/run-window-context"; import type { GenericRuntime, GlobalToolchain, @@ -27,7 +28,11 @@ export function writeRunDocuments( } export function writeRunStdin(sessionId: string, input: string) { - return invoke("run_write_stdin", { sessionId, input }); + return invoke("run_write_stdin", { + windowLabel: getRunWindowLabel(), + sessionId, + input, + }); } export function discoverRunToolchains(root: string, selected?: GlobalToolchain) { @@ -63,9 +68,17 @@ export function startRunProcess(args: { workingDirectory: string; environment: Record; }) { - return invoke("run_start_process", { args }); + return invoke("run_start_process", { + args: { + ...args, + windowLabel: getRunWindowLabel(), + }, + }); } export function stopRunProcess(sessionId: string) { - return invoke("run_stop_process", { sessionId }); + return invoke("run_stop_process", { + windowLabel: getRunWindowLabel(), + sessionId, + }); } diff --git a/windows/tauri/src/features/run/utils/run-window-context.ts b/windows/tauri/src/features/run/utils/run-window-context.ts new file mode 100644 index 000000000..aaa3c44b5 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-window-context.ts @@ -0,0 +1,5 @@ +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +export function getRunWindowLabel(): string { + return getCurrentWebviewWindow().label; +} From 64b7acc90354b5d06a0b5da8a7f35e50fcd462c4 Mon Sep 17 00:00:00 2001 From: Lichenkang <2188718831@qq.com> Date: Wed, 2 Sep 2026 20:21:14 +0800 Subject: [PATCH 20/24] docs: add HelloGitHub recommendation badge (#414) --- README.md | 4 ++++ README.zh-CN.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 41b0bf52b..c56b5e57e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ Develop Lithe

+

+ Featured | HelloGitHub +

+

Join the Lithe QQ group 163027877 Join the Lithe WeChat group diff --git a/README.zh-CN.md b/README.zh-CN.md index 052c51766..3c5011ef4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -14,6 +14,10 @@ 如何开发

+

+ Featured | HelloGitHub +

+

加入 Lithe QQ 群 163027877 加入 Lithe 微信交流群 From bf842e07651197327fffb869b5ef77a56b700417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Wed, 2 Sep 2026 20:35:29 +0800 Subject: [PATCH 21/24] =?UTF-8?q?fix(windows):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E7=9B=91=E5=90=AC=E6=94=B9=E4=B8=BA=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E4=BD=9C=E7=94=A8=E5=9F=9F=EF=BC=8C=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E5=A4=9A=E7=AA=97=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 use-run-process-events 与 use-maven-process-events 从全局 listen 改为 getCurrentWebviewWindow().listen,避免 Tauri EventTarget::Any 在多窗下仍接收 emit_to 事件。 补充 listener 窗口作用域单测,验证不会注册全局 listen。 Co-authored-by: Cursor --- .../hooks/use-maven-process-events.test.ts | 72 +++++++++++++++++++ .../maven/hooks/use-maven-process-events.ts | 8 ++- .../run/hooks/use-run-process-events.test.ts | 68 ++++++++++++++++++ .../run/hooks/use-run-process-events.ts | 8 ++- 4 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts create mode 100644 windows/tauri/src/features/run/hooks/use-run-process-events.test.ts diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts new file mode 100644 index 000000000..91a8b98a9 --- /dev/null +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const eventHandlers = new Map void>(); +const windowListen = mock(async (event: string, handler: (event: { payload: unknown }) => void) => { + eventHandlers.set(event, handler); + return () => { + eventHandlers.delete(event); + }; +}); +const globalListen = mock(async () => () => {}); +const appendOutput = mock(() => undefined); +const finishProcess = mock(() => undefined); +const releaseMavenSessionWorkspace = mock(() => undefined); + +mock.module("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "workspace-1", + listen: windowListen, + }), +})); +mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); +mock.module("../stores/maven.store", () => ({ + mavenStoreForSession: () => ({ + getState: () => ({ + actions: { appendOutput, finishProcess }, + }), + }), + releaseMavenSessionWorkspace, +})); + +const { ensureMavenProcessListeners } = await import("./use-maven-process-events"); + +describe("maven process event listeners", () => { + beforeEach(() => { + appendOutput.mockClear(); + finishProcess.mockClear(); + releaseMavenSessionWorkspace.mockClear(); + }); + + test("registers run-output and run-exit on the current webview window", async () => { + await ensureMavenProcessListeners(); + + expect(windowListen).toHaveBeenCalledTimes(2); + expect(windowListen).toHaveBeenCalledWith("run-output", expect.any(Function)); + expect(windowListen).toHaveBeenCalledWith("run-exit", expect.any(Function)); + expect(globalListen).not.toHaveBeenCalled(); + expect(eventHandlers.has("run-output")).toBe(true); + expect(eventHandlers.has("run-exit")).toBe(true); + }); + + test("ignores non-maven session output", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "primary", chunk: "leaked\n" }, + }); + + expect(appendOutput).not.toHaveBeenCalled(); + }); + + test("routes maven session output through the maven store", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "maven:task-1", chunk: "BUILD SUCCESS\n" }, + }); + + expect(appendOutput).toHaveBeenCalledWith("maven:task-1", "BUILD SUCCESS\n"); + }); +}); diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts index 252e3c3b1..fe21994f4 100644 --- a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts @@ -1,4 +1,5 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { mavenStoreForSession, releaseMavenSessionWorkspace } from "../stores/maven.store"; interface RunOutputEvent { @@ -15,8 +16,9 @@ let outputUnlisten: UnlistenFn | undefined; let exitUnlisten: UnlistenFn | undefined; export async function ensureMavenProcessListeners(): Promise { + const currentWindow = getCurrentWebviewWindow(); if (!outputUnlisten) { - outputUnlisten = await listen("run-output", (event) => { + outputUnlisten = await currentWindow.listen("run-output", (event) => { if (!event.payload.sessionId.startsWith("maven:")) return; mavenStoreForSession(event.payload.sessionId) .getState() @@ -24,7 +26,7 @@ export async function ensureMavenProcessListeners(): Promise { }); } if (!exitUnlisten) { - exitUnlisten = await listen("run-exit", (event) => { + exitUnlisten = await currentWindow.listen("run-exit", (event) => { const sessionId = event.payload.sessionId; if (!sessionId.startsWith("maven:")) return; mavenStoreForSession(sessionId) diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts new file mode 100644 index 000000000..430d4e784 --- /dev/null +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const eventHandlers = new Map void>(); +const windowListen = mock(async (event: string, handler: (event: { payload: unknown }) => void) => { + eventHandlers.set(event, handler); + return () => { + eventHandlers.delete(event); + }; +}); +const globalListen = mock(async () => () => {}); +const appendOutput = mock(() => undefined); +const finishProcess = mock(() => undefined); +const releaseRunSessionWorkspace = mock(() => undefined); + +mock.module("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "project-window", + listen: windowListen, + }), +})); +mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); +mock.module("../stores/run.store", () => ({ + runStoreForSession: () => ({ + getState: () => ({ + actions: { appendOutput, finishProcess }, + }), + }), + releaseRunSessionWorkspace, +})); + +const { ensureRunProcessListeners } = await import("./use-run-process-events"); + +describe("run process event listeners", () => { + beforeEach(() => { + appendOutput.mockClear(); + finishProcess.mockClear(); + releaseRunSessionWorkspace.mockClear(); + }); + + test("registers run-output and run-exit on the current webview window", async () => { + await ensureRunProcessListeners(); + + expect(windowListen).toHaveBeenCalledTimes(2); + expect(windowListen).toHaveBeenCalledWith("run-output", expect.any(Function)); + expect(windowListen).toHaveBeenCalledWith("run-exit", expect.any(Function)); + expect(globalListen).not.toHaveBeenCalled(); + expect(eventHandlers.has("run-output")).toBe(true); + expect(eventHandlers.has("run-exit")).toBe(true); + }); + + test("does not register duplicate listeners on repeated setup", async () => { + const callsBefore = windowListen.mock.calls.length; + await ensureRunProcessListeners(); + expect(windowListen.mock.calls.length).toBe(callsBefore); + expect(globalListen).not.toHaveBeenCalled(); + }); + + test("routes output events through the run store for this window", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "primary", chunk: "hello\n" }, + }); + + expect(appendOutput).toHaveBeenCalledWith("primary", "hello\n"); + }); +}); diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.ts index 46bbeae65..d11ba1994 100644 --- a/windows/tauri/src/features/run/hooks/use-run-process-events.ts +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.ts @@ -1,4 +1,5 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { releaseRunSessionWorkspace, runStoreForSession } from "../stores/run.store"; interface RunOutputEvent { @@ -15,15 +16,16 @@ let outputUnlisten: UnlistenFn | undefined; let exitUnlisten: UnlistenFn | undefined; export async function ensureRunProcessListeners(): Promise { + const currentWindow = getCurrentWebviewWindow(); if (!outputUnlisten) { - outputUnlisten = await listen("run-output", (event) => { + outputUnlisten = await currentWindow.listen("run-output", (event) => { runStoreForSession(event.payload.sessionId) .getState() .actions.appendOutput(event.payload.sessionId, event.payload.chunk); }); } if (!exitUnlisten) { - exitUnlisten = await listen("run-exit", (event) => { + exitUnlisten = await currentWindow.listen("run-exit", (event) => { const sessionId = event.payload.sessionId; runStoreForSession(sessionId).getState().actions.finishProcess(sessionId, event.payload.exitCode); releaseRunSessionWorkspace(sessionId); From 72222d40b764f2a63601e99d7e862a3d3935591e Mon Sep 17 00:00:00 2001 From: Guobin Sun <151930587+Rangsh@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:33:14 +0800 Subject: [PATCH 22/24] =?UTF-8?q?feat(windows):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=98=BE=E7=A4=BA=E5=88=AB=E5=90=8D=E4=BB=A5?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E5=90=8C=E5=90=8D=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=20(#416)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(windows): 支持项目显示别名以区分同名文件夹 为工作区项目标签增加 displayAlias 字段并持久化,在标题栏、项目标签栏 及侧边栏以「文件夹名 (别名)」格式展示;用户可通过项目上下文菜单设置 别名,解决多个同名文件夹项目同时打开时难以区分路径的问题。 Closes #404 Co-authored-by: Cursor * fix(windows): 移除 project.store 中错误的自引用 import 以通过类型检查 Co-authored-by: Cursor * fix(windows): 修复 run 测试 mock 以通过 Windows CI 补充 lithe-core-client 与 tauri-core 的测试 mock 导出,并保留 run.store 的真实导出,避免同进程内后续测试加载失败。 Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: Lichenkang <2188718831@qq.com> --- .../file-system/stores/file-system.store.ts | 27 ++++++++---- .../layout/components/main-layout.tsx | 5 ++- .../components/sidebar/sidebar-projects.tsx | 12 +++++- .../src/features/run/api/run-core-api.test.ts | 3 +- .../src/features/run/api/run-host-api.test.ts | 13 ++++-- .../run/hooks/use-run-process-events.test.ts | 3 ++ .../src/features/run/stores/run.store.test.ts | 7 +--- .../window/components/project-tab-bar.test.ts | 2 +- .../window/components/project-tab-bar.tsx | 6 ++- .../title-bar/title-project-menu.tsx | 7 +++- .../prompt-project-display-alias.ts | 42 +++++++++++++++++++ .../features/window/stores/project.store.ts | 7 ++-- .../window/stores/workspace-tabs.store.ts | 15 +++++++ .../utils/project-display-label.test.ts | 20 +++++++++ .../window/utils/project-display-label.ts | 16 +++++++ windows/tauri/src/i18n/locale.ts | 5 +++ 16 files changed, 160 insertions(+), 30 deletions(-) create mode 100644 windows/tauri/src/features/window/controllers/prompt-project-display-alias.ts create mode 100644 windows/tauri/src/features/window/utils/project-display-label.test.ts create mode 100644 windows/tauri/src/features/window/utils/project-display-label.ts diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index c051d9418..a154ab221 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -43,6 +43,7 @@ import { restoreProjectUiState, } from "@/features/window/stores/workspace-ui-session"; import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { getProjectDisplayLabel } from "@/features/window/utils/project-display-label"; import { createAppWindow } from "@/features/window/utils/create-app-window"; import { serializeTerminals } from "@/features/terminal/lib/terminal-session-storage"; import { useTerminalTabsStore } from "@/features/terminal/stores/terminal-tabs.store"; @@ -641,7 +642,7 @@ const openLocalWorkspace = async ( state.isFileTreeLoading = true; }); - const projectName = getFolderName(path); + const folderName = getFolderName(path); const readDirectoryStartedAt = performance.now(); logWorkspaceOpenStep("start", "readDirectoryContents", path); @@ -649,7 +650,7 @@ const openLocalWorkspace = async ( logWorkspaceOpenStep("end", "readDirectoryContents", path, readDirectoryStartedAt); const fileTree = sortFileEntries(entries); - const wrappedFileTree = wrapWithRootFolder(fileTree, path, projectName); + const wrappedFileTree = wrapWithRootFolder(fileTree, path, folderName); if (treeState === "expand-root") { fileTreeStore.getState().actions.setExpandedPaths(new Set([path])); @@ -657,18 +658,20 @@ const openLocalWorkspace = async ( fileTreeStore.getState().actions.collapseAll(); } + const workspaceTab = useWorkspaceTabsStore + .getState() + .projectTabs.find((projectTab) => projectTab.id === workspaceId); + const displayName = workspaceTab ? getProjectDisplayLabel(workspaceTab) : folderName; + const { setRootFolderPath, setProjectName, setActiveProjectId } = projectStore.getState().actions; setRootFolderPath(path); - setProjectName(projectName); + setProjectName(displayName); if (restoreUiState) { restoreProjectUiState(path, workspaceId); } - const workspaceTab = useWorkspaceTabsStore - .getState() - .projectTabs.find((projectTab) => projectTab.id === workspaceId); setActiveProjectId(workspaceId); if (!prewarm) { useRecentFoldersStore.getState().actions.addToRecents(path, { @@ -683,7 +686,7 @@ const openLocalWorkspace = async ( state.isFileTreeLoading = false; state.files = wrappedFileTree; state.rootFolderPath = path; - state.workspaceFolders = [{ path, name: projectName, isPrimary: true }]; + state.workspaceFolders = [{ path, name: folderName, isPrimary: true }]; state.filesVersion++; state.projectFilesCache = undefined; }); @@ -1432,6 +1435,9 @@ const createFileSystemStore = (workspaceId: string): StoreApi { const isRemote = isRemoteProjectPath(project.path); const isActive = project.id === activeProjectId; + const displayName = getProjectDisplayLabel(project); return ( @@ -67,8 +71,8 @@ export function SidebarProjectDots({ )} aria-label={ isActive - ? t("titleProject.currentProject", { project: project.name }) - : t("titleProject.switchToProject", { project: project.name }) + ? t("titleProject.currentProject", { project: displayName }) + : t("titleProject.switchToProject", { project: displayName }) } aria-current={isActive ? "page" : undefined} aria-disabled={isSwitchingProject} @@ -138,6 +142,10 @@ export function SidebarProjectDots({ {t("titleProject.selectIcon")} ) : null} + void promptProjectDisplayAlias(project)}> + + {t("titleProject.setDisplayAlias")} + ({ ok: true as const, data: { document: "{}" }, })); +const cancelCoreOperation = mock(async () => true); -mock.module("@/core/lithe-core-client", () => ({ executeCore })); +mock.module("@/core/lithe-core-client", () => ({ executeCore, cancelCoreOperation })); const { createLaunchPlan, saveRunConfigurationEditorChanges } = await import("./run-core-api"); diff --git a/windows/tauri/src/features/run/api/run-host-api.test.ts b/windows/tauri/src/features/run/api/run-host-api.test.ts index a026a0227..4782e52dc 100644 --- a/windows/tauri/src/features/run/api/run-host-api.test.ts +++ b/windows/tauri/src/features/run/api/run-host-api.test.ts @@ -1,17 +1,22 @@ +import { Channel } from "@tauri-apps/api/core"; import { beforeEach, describe, expect, mock, test } from "bun:test"; const invoke = mock(async () => undefined); -const getCurrentWebviewWindow = mock(() => ({ label: "project-window" })); -mock.module("@/platform/tauri-core", () => ({ invoke })); -mock.module("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow })); +mock.module("@/platform/tauri-core", () => ({ + invoke, + Channel, + convertFileSrc: (path: string) => path, +})); +mock.module("../utils/run-window-context", () => ({ + getRunWindowLabel: () => "project-window", +})); const { startRunProcess, stopRunProcess, writeRunStdin } = await import("../api/run-host-api"); describe("run host API window scoping", () => { beforeEach(() => { invoke.mockClear(); - getCurrentWebviewWindow.mockClear(); }); test("startRunProcess includes the current window label", async () => { diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts index 430d4e784..757a4f0b4 100644 --- a/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts @@ -12,6 +12,8 @@ const appendOutput = mock(() => undefined); const finishProcess = mock(() => undefined); const releaseRunSessionWorkspace = mock(() => undefined); +const actualRunStore = await import("../stores/run.store"); + mock.module("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow: () => ({ label: "project-window", @@ -20,6 +22,7 @@ mock.module("@tauri-apps/api/webviewWindow", () => ({ })); mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); mock.module("../stores/run.store", () => ({ + ...actualRunStore, runStoreForSession: () => ({ getState: () => ({ actions: { appendOutput, finishProcess }, diff --git a/windows/tauri/src/features/run/stores/run.store.test.ts b/windows/tauri/src/features/run/stores/run.store.test.ts index f363151a1..3a315ded7 100644 --- a/windows/tauri/src/features/run/stores/run.store.test.ts +++ b/windows/tauri/src/features/run/stores/run.store.test.ts @@ -1,9 +1,6 @@ -import { describe, expect, mock, test } from "bun:test"; - -mock.module("@/platform/tauri-core", () => ({ - invoke: mock(async () => undefined), -})); +import { describe, expect, test } from "bun:test"; +// Window scoping is mocked in run-host-api.test.ts for the full `bun test src/features/run` suite. const { createRunStore } = await import("./run.store"); const { PRIMARY_SESSION_ID } = await import("../types/run.types"); diff --git a/windows/tauri/src/features/window/components/project-tab-bar.test.ts b/windows/tauri/src/features/window/components/project-tab-bar.test.ts index 4e7bd08a9..be392832c 100644 --- a/windows/tauri/src/features/window/components/project-tab-bar.test.ts +++ b/windows/tauri/src/features/window/components/project-tab-bar.test.ts @@ -14,7 +14,7 @@ test("project tab bar exposes accessible switchable and closable project tabs", expect(source).toContain("event.stopPropagation()"); expect(source).toContain("await closeProject(projectId)"); expect(source).toContain("finally"); - expect(source).toContain('t("titleProject.closeProject", { name: project.name })'); + expect(source).toContain('t("titleProject.closeProject", { name: displayName })'); expect(source).toContain("group-hover:opacity-100"); expect(source).toContain("group-focus-within:opacity-100"); expect(source).toContain( diff --git a/windows/tauri/src/features/window/components/project-tab-bar.tsx b/windows/tauri/src/features/window/components/project-tab-bar.tsx index 74e1cf859..09e15a289 100644 --- a/windows/tauri/src/features/window/components/project-tab-bar.tsx +++ b/windows/tauri/src/features/window/components/project-tab-bar.tsx @@ -5,6 +5,7 @@ import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.s import { Button } from "@/ui/button"; import { FolderIcon, XIcon as X } from "@/ui/icons"; import { cn } from "@/utils/cn"; +import { getProjectDisplayLabel } from "../utils/project-display-label"; import { getProjectTabBarItems, shouldShowProjectTabBar } from "../utils/project-tab-bar-model"; interface ProjectTabBarProps { @@ -43,7 +44,8 @@ export function ProjectTabBar({ hideWhenSingle = false }: ProjectTabBarProps) { >

{projects.map((project) => { - const closeLabel = t("titleProject.closeProject", { name: project.name }); + const displayName = getProjectDisplayLabel(project); + const closeLabel = t("titleProject.closeProject", { name: displayName }); return (
@@ -71,7 +73,7 @@ export function ProjectTabBar({ hideWhenSingle = false }: ProjectTabBarProps) { )} aria-hidden="true" /> - {project.name} + {displayName} {project.isActive ? (