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
+
+
+
+
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 @@
如何开发
+
+
+
+
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 ? (
state.handleOpenFolder);
const switchToProject = useFileSystemStore((state) => state.switchToProject);
const isSwitchingProject = useFileSystemStore((state) => state.isSwitchingProject);
- const projectLabel = activeProject?.name ?? t("projectOpen.title");
+ const projectLabel = activeProject
+ ? getProjectDisplayLabel(activeProject)
+ : t("projectOpen.title");
const [isOpen, setIsOpen] = useState(false);
const [menuNode, setMenuNode] = useState(null);
const projects = useMemo(
@@ -196,7 +199,7 @@ export function TitleProjectMenu({ onOpenProjectPicker }: TitleProjectMenuProps)
{projects.openProjects.map((project) => (
{
+ const t = createTranslator(useSettingsStore.getState().settings.displayLanguage);
+ const result = await showPromptDialog(t("titleProject.displayAliasPrompt"), {
+ defaultValue: project.displayAlias ?? "",
+ placeholder: project.path,
+ title: t("titleProject.setDisplayAlias"),
+ });
+
+ if (result === null) {
+ return;
+ }
+
+ const trimmedAlias = result.trim();
+ useWorkspaceTabsStore
+ .getState()
+ .actions.setProjectDisplayAlias(
+ project.id,
+ trimmedAlias.length > 0 ? trimmedAlias : undefined,
+ );
+
+ const activeProject = useWorkspaceTabsStore.getState().actions.getActiveProjectTab();
+ if (activeProject?.id !== project.id) {
+ return;
+ }
+
+ const updatedProject = useWorkspaceTabsStore
+ .getState()
+ .projectTabs.find((projectTab) => projectTab.id === project.id);
+ if (!updatedProject) {
+ return;
+ }
+
+ useProjectStore.getState().actions.setProjectName(getProjectDisplayLabel(updatedProject));
+}
diff --git a/windows/tauri/src/features/window/stores/project.store.ts b/windows/tauri/src/features/window/stores/project.store.ts
index 104843a66..8a57b8601 100644
--- a/windows/tauri/src/features/window/stores/project.store.ts
+++ b/windows/tauri/src/features/window/stores/project.store.ts
@@ -7,6 +7,7 @@ import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-w
import { getFolderName } from "@/utils/path-helpers";
import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store";
import { createTranslator } from "@/i18n/locale";
+import { getProjectDisplayLabel } from "../utils/project-display-label";
const getCurrentTranslator = () =>
createTranslator(useSettingsStore.getState().settings.displayLanguage);
@@ -37,13 +38,13 @@ const createProjectStore = () =>
? getCurrentTranslator()("projectPicker.remoteProjectName", {
name: connection.name,
})
- : activeTab.name;
+ : getProjectDisplayLabel(activeTab);
} catch {
- return activeTab.name;
+ return getProjectDisplayLabel(activeTab);
}
}
- return activeTab.name;
+ return getProjectDisplayLabel(activeTab);
}
const { rootFolderPath } = get();
diff --git a/windows/tauri/src/features/window/stores/workspace-tabs.store.ts b/windows/tauri/src/features/window/stores/workspace-tabs.store.ts
index 73c1de672..ca9b5ba89 100644
--- a/windows/tauri/src/features/window/stores/workspace-tabs.store.ts
+++ b/windows/tauri/src/features/window/stores/workspace-tabs.store.ts
@@ -25,6 +25,8 @@ export interface ProjectTab {
isActive: boolean;
lastOpened: number;
customIcon?: string;
+ /** User-defined label shown after the folder name to distinguish same-named projects. */
+ displayAlias?: string;
theme?: string;
}
@@ -41,6 +43,7 @@ interface WorkspaceTabsActions {
hasProjectTab: (path: string) => boolean;
renameRemoteProjectTabs: (connectionId: string, connectionName: string) => void;
setProjectIcon: (projectId: string, iconPath: string | undefined) => void;
+ setProjectDisplayAlias: (projectId: string, displayAlias: string | undefined) => void;
setProjectTheme: (projectId: string, theme: string) => void;
}
@@ -157,6 +160,18 @@ const useWorkspaceTabsStoreBase = create()(
});
},
+ setProjectDisplayAlias: (projectId: string, displayAlias: string | undefined) => {
+ set((state) => {
+ const tab = state.projectTabs.find((projectTab) => projectTab.id === projectId);
+ if (!tab) {
+ return;
+ }
+
+ const trimmedAlias = displayAlias?.trim();
+ tab.displayAlias = trimmedAlias && trimmedAlias.length > 0 ? trimmedAlias : undefined;
+ });
+ },
+
setProjectTheme: (projectId: string, theme: string) => {
set((state) => {
const tab = state.projectTabs.find((projectTab) => projectTab.id === projectId);
diff --git a/windows/tauri/src/features/window/utils/project-display-label.test.ts b/windows/tauri/src/features/window/utils/project-display-label.test.ts
new file mode 100644
index 000000000..b2cd316af
--- /dev/null
+++ b/windows/tauri/src/features/window/utils/project-display-label.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, test } from "bun:test";
+import {
+ formatProjectDisplayLabel,
+ getProjectDisplayLabel,
+} from "./project-display-label";
+
+describe("project display label", () => {
+ test("returns folder name when no alias is set", () => {
+ expect(getProjectDisplayLabel({ name: "Lithe", displayAlias: undefined })).toBe("Lithe");
+ expect(formatProjectDisplayLabel("Lithe", "")).toBe("Lithe");
+ expect(formatProjectDisplayLabel("Lithe", " ")).toBe("Lithe");
+ });
+
+ test("appends trimmed alias after the folder name", () => {
+ expect(getProjectDisplayLabel({ name: "Lithe", displayAlias: "work copy" })).toBe(
+ "Lithe (work copy)",
+ );
+ expect(formatProjectDisplayLabel("Lithe", " work copy ")).toBe("Lithe (work copy)");
+ });
+});
diff --git a/windows/tauri/src/features/window/utils/project-display-label.ts b/windows/tauri/src/features/window/utils/project-display-label.ts
new file mode 100644
index 000000000..95b27be60
--- /dev/null
+++ b/windows/tauri/src/features/window/utils/project-display-label.ts
@@ -0,0 +1,16 @@
+import type { ProjectTab } from "../stores/workspace-tabs.store";
+
+export type ProjectDisplayLabelSource = Pick;
+
+export function formatProjectDisplayLabel(name: string, displayAlias?: string): string {
+ const alias = displayAlias?.trim();
+ if (!alias) {
+ return name;
+ }
+
+ return `${name} (${alias})`;
+}
+
+export function getProjectDisplayLabel(project: ProjectDisplayLabelSource): string {
+ return formatProjectDisplayLabel(project.name, project.displayAlias);
+}
diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts
index a5f4bfb8e..0d2c02823 100644
--- a/windows/tauri/src/i18n/locale.ts
+++ b/windows/tauri/src/i18n/locale.ts
@@ -1527,6 +1527,9 @@ const catalogs = {
"titleProject.openInNewWindow": "Open in New Window",
"titleProject.closeAllProjects": "Close All Projects",
"titleProject.selectIcon": "Select Icon",
+ "titleProject.setDisplayAlias": "Set Display Alias",
+ "titleProject.displayAliasPrompt":
+ "Enter a display alias shown after the folder name to distinguish projects with the same name.",
"titleProject.removeProject": "Remove Project",
"titleProject.currentProject": "Current project {project}",
"titleProject.switchToProject": "Switch to {project}",
@@ -5491,6 +5494,8 @@ const catalogs = {
"titleProject.openInNewWindow": "在新窗口中打开",
"titleProject.closeAllProjects": "关闭所有项目",
"titleProject.selectIcon": "选择图标",
+ "titleProject.setDisplayAlias": "设置显示别名",
+ "titleProject.displayAliasPrompt": "输入显示别名,将显示在文件夹名之后,用于区分同名项目。",
"titleProject.removeProject": "移除项目",
"titleProject.currentProject": "当前项目 {project}",
"titleProject.switchToProject": "切换到 {project}",
From 9e45444038483a8ef185dee8cc4c7e5cd7d782a0 Mon Sep 17 00:00:00 2001
From: lick <2188718831@qq.com>
Date: Wed, 2 Sep 2026 22:45:16 +0800
Subject: [PATCH 23/24] fix(windows): keep run test within tauri boundary
---
windows/tauri/src/features/run/api/run-host-api.test.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
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 4782e52dc..8d3aa0197 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,11 +1,10 @@
-import { Channel } from "@tauri-apps/api/core";
import { beforeEach, describe, expect, mock, test } from "bun:test";
const invoke = mock(async () => undefined);
mock.module("@/platform/tauri-core", () => ({
invoke,
- Channel,
+ Channel: class {},
convertFileSrc: (path: string) => path,
}));
mock.module("../utils/run-window-context", () => ({
From b011f46bd141068926f8956d8fbc30f0ba534081 Mon Sep 17 00:00:00 2001
From: lick <2188718831@qq.com>
Date: Wed, 2 Sep 2026 23:32:40 +0800
Subject: [PATCH 24/24] fix(ci): skip empty Windows test report generation
---
.github/workflows/ci-windows.yml | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml
index b21c52d96..c350fd6e4 100644
--- a/.github/workflows/ci-windows.yml
+++ b/.github/workflows/ci-windows.yml
@@ -252,7 +252,10 @@ jobs:
run: ./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope WindowsRust -SuiteTimeoutSeconds 1080
- name: Generate combined Windows test report
- if: always()
+ if: >-
+ always() &&
+ (needs.changes.outputs.rust_core == 'true' ||
+ needs.changes.outputs.windows_rust == 'true')
shell: pwsh
run: node .agents/skills/write-stable-tests/scripts/generate-test-report.mjs