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/.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
diff --git a/README.md b/README.md
index 41b0bf52b..c56b5e57e 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,10 @@
Develop Lithe
+
+
+
+
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 @@
如何开发
+
+
+
+
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..a3969fe9b 100644
--- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
+++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
@@ -213,11 +213,20 @@ 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 ?? [] }
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/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/Services/Monitoring/LitheSignpost.swift b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift
new file mode 100644
index 000000000..817a0a0b0
--- /dev/null
+++ b/macos/Sources/Lithe/Services/Monitoring/LitheSignpost.swift
@@ -0,0 +1,27 @@
+import os
+
+enum LitheSignpost {
+ private static let signposter = OSSignposter(
+ subsystem: "com.openres.Lithe",
+ category: "Rendering"
+ )
+
+ static func begin(_ name: StaticString) -> OSSignpostIntervalState {
+ signposter.beginInterval(name)
+ }
+
+ static func end(_ name: StaticString, _ state: OSSignpostIntervalState) {
+ signposter.endInterval(name, state)
+ }
+
+ #if DEBUG
+ private static var bodyCounts: [String: Int] = [:]
+
+ static func bodyEvaluated(_ view: StaticString) {
+ bodyCounts["\(view)", default: 0] += 1
+ }
+ #else
+ @inlinable @inline(__always)
+ static func bodyEvaluated(_ view: StaticString) {}
+ #endif
+}
diff --git a/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift b/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift
new file mode 100644
index 000000000..02f765f81
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Components/LitheDragUpdateScheduler.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+/// Coalesces continuous drag updates to one delivery per main run-loop turn.
+///
+/// Held as `@State` so the bookkeeping never invalidates the host view's body.
+/// Uses `DispatchQueue.main.async` which, during `.eventTracking` mode, drains
+/// at the end of the current run-loop turn with zero added latency — unlike the
+/// 16ms `Task.sleep` pattern which adds a full frame of delay to every update.
+///
+/// `init` is `nonisolated` so that `@State` default-value initialisation —
+/// which Swift 6.2 treats as a nonisolated context — compiles without a
+/// diagnostic. All methods that access mutable state remain `@MainActor`.
+@MainActor
+final class LitheDragUpdateScheduler {
+ enum Delivery {
+ case mainRunLoopTurn
+ #if DEBUG
+ case manual
+ #endif
+ }
+
+ private var buffer = FrameCoalescedDragUpdateBuffer()
+ private var lastDeliveredValue: CGFloat?
+ private var pendingDelivery: (@MainActor () -> Void)?
+ private let delivery: Delivery
+
+ nonisolated init(delivery: Delivery = .mainRunLoopTurn) {
+ self.delivery = delivery
+ }
+
+ var pendingValue: CGFloat? { buffer.pendingValue }
+
+ func submit(
+ _ value: CGFloat,
+ minimumChange: CGFloat = 1,
+ deliver: @escaping @MainActor (CGFloat) -> Void
+ ) {
+ if let last = lastDeliveredValue, abs(value - last) < minimumChange { return }
+ guard buffer.submit(value) else { return }
+ let work: @MainActor () -> Void = { [weak self] in
+ guard let self, let value = self.buffer.takePendingValue() else { return }
+ self.lastDeliveredValue = value
+ self.pendingDelivery = nil
+ deliver(value)
+ }
+ switch delivery {
+ case .mainRunLoopTurn:
+ DispatchQueue.main.async {
+ MainActor.assumeIsolated { work() }
+ }
+ #if DEBUG
+ case .manual:
+ pendingDelivery = work
+ #endif
+ }
+ }
+
+ func cancel() {
+ buffer.cancel()
+ lastDeliveredValue = nil
+ pendingDelivery = nil
+ }
+
+ #if DEBUG
+ func flushPendingDeliveryForTesting() {
+ pendingDelivery?()
+ pendingDelivery = nil
+ }
+ #endif
+}
diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift
new file mode 100644
index 000000000..e96c315da
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneGeometry.swift
@@ -0,0 +1,36 @@
+import CoreGraphics
+
+/// Pure geometry for split-pane resize calculations.
+enum LitheSplitPaneGeometry {
+ enum Placement {
+ case leading
+ case trailing
+ }
+
+ static func resolve(
+ start: CGFloat,
+ translation: CGFloat,
+ placement: Placement,
+ minimum: CGFloat,
+ maximum: CGFloat
+ ) -> CGFloat {
+ let raw: CGFloat
+ switch placement {
+ case .leading:
+ raw = start + translation
+ case .trailing:
+ raw = start - translation
+ }
+ return clamp(raw, minimum: minimum, maximum: maximum)
+ }
+
+ static func clamp(
+ _ value: CGFloat,
+ minimum: CGFloat,
+ maximum: CGFloat
+ ) -> CGFloat {
+ let effectiveMinimum = min(minimum, maximum)
+ let effectiveMaximum = max(minimum, maximum)
+ return min(max(value, effectiveMinimum), effectiveMaximum)
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift
new file mode 100644
index 000000000..fe522b314
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Components/LitheSplitPaneView.swift
@@ -0,0 +1,139 @@
+import SwiftUI
+
+/// A two-pane split whose divider drag is confined to this container.
+///
+/// One pane has a tracked size and the other takes the remainder. The dragged
+/// size lives here rather than in the hosting feature view, so moving a divider
+/// invalidates only this container: `sized` and `flexible` were built by the
+/// host's last body pass and are the same values on every re-evaluation, which
+/// lets SwiftUI skip their bodies. That is the protection `WorkbenchWorkspaceSplitView`
+/// already had and this generalizes to the Git, Run, and test tool windows.
+struct LitheSplitPaneView: View {
+ let axis: LitheSplitAxis
+ let placement: LitheSplitPaneGeometry.Placement
+ /// The size used until the user drags, re-supplied by the host every body
+ /// pass. Hosts that derive it from live geometry keep following the window
+ /// until the first drag, matching the pre-extraction behavior.
+ let defaultSize: CGFloat
+ let minimum: CGFloat
+ let maximum: CGFloat
+ /// Minimum size reserved for the flexible pane, when the hosted content
+ /// has a product-level usability requirement of its own.
+ let flexibleMinimum: CGFloat?
+ let showsIdleDivider: Bool
+ /// Called with the final size when a drag ends. Hosts that persist the size
+ /// write it here; the container then defers to `defaultSize` again so the
+ /// persisted value is the single source of truth.
+ let onCommit: ((CGFloat) -> Void)?
+
+ private let sized: Sized
+ private let flexible: Flexible
+
+ @State private var draggedSize: CGFloat?
+ @State private var dragStart: CGFloat = 0
+
+ init(
+ axis: LitheSplitAxis,
+ placement: LitheSplitPaneGeometry.Placement,
+ defaultSize: CGFloat,
+ minimum: CGFloat,
+ maximum: CGFloat,
+ flexibleMinimum: CGFloat? = nil,
+ showsIdleDivider: Bool = true,
+ onCommit: ((CGFloat) -> Void)? = nil,
+ @ViewBuilder sized: () -> Sized,
+ @ViewBuilder flexible: () -> Flexible
+ ) {
+ self.axis = axis
+ self.placement = placement
+ self.defaultSize = defaultSize
+ self.minimum = minimum
+ self.maximum = maximum
+ self.flexibleMinimum = flexibleMinimum
+ self.showsIdleDivider = showsIdleDivider
+ self.onCommit = onCommit
+ self.sized = sized()
+ self.flexible = flexible()
+ }
+
+ private var resolvedSize: CGFloat {
+ LitheSplitPaneGeometry.clamp(
+ draggedSize ?? defaultSize,
+ minimum: minimum,
+ maximum: maximum
+ )
+ }
+
+ var body: some View {
+ let size = resolvedSize
+ if axis == .horizontal {
+ HStack(spacing: 0) { panes(size) }
+ } else {
+ VStack(spacing: 0) { panes(size) }
+ }
+ }
+
+ @ViewBuilder
+ private func panes(_ size: CGFloat) -> some View {
+ switch placement {
+ case .leading:
+ sizedPane(size)
+ handle(size)
+ flexiblePane
+ case .trailing:
+ flexiblePane
+ handle(size)
+ sizedPane(size)
+ }
+ }
+
+ @ViewBuilder
+ private func sizedPane(_ size: CGFloat) -> some View {
+ if axis == .horizontal {
+ sized.frame(width: size)
+ } else {
+ sized.frame(height: size)
+ }
+ }
+
+ @ViewBuilder
+ private var flexiblePane: some View {
+ if axis == .horizontal {
+ flexible.frame(minWidth: flexibleMinimum, maxWidth: .infinity)
+ } else {
+ flexible.frame(minHeight: flexibleMinimum, maxHeight: .infinity)
+ }
+ }
+
+ private func handle(_ size: CGFloat) -> some View {
+ SplitHandleView(
+ axis: axis,
+ showsIdleDivider: showsIdleDivider,
+ onDragStarted: { dragStart = size },
+ onDragChanged: { translation in
+ draggedSize = resolved(from: translation)
+ },
+ onDragEnded: { translation in
+ let finalSize = resolved(from: translation)
+ if let onCommit {
+ onCommit(finalSize)
+ // The host now owns the value and feeds it back as
+ // `defaultSize`; keeping a dragged size too would shadow it.
+ draggedSize = nil
+ } else {
+ draggedSize = finalSize
+ }
+ }
+ )
+ }
+
+ private func resolved(from translation: CGFloat) -> CGFloat {
+ LitheSplitPaneGeometry.resolve(
+ start: dragStart,
+ translation: translation,
+ placement: placement,
+ minimum: minimum,
+ maximum: maximum
+ )
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift b/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift
index 0a259de79..38b57cf89 100644
--- a/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift
+++ b/macos/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift
@@ -12,8 +12,7 @@ struct DiffHorizontalScroller: View {
@State private var dragStartOffset: CGFloat = 0
@State private var isDragging = false
@State private var isHovering = false
- @State private var dragUpdateBuffer = FrameCoalescedDragUpdateBuffer()
- @State private var dragUpdateTask: Task?
+ @State private var dragScheduler = LitheDragUpdateScheduler()
private var maximumOffset: CGFloat {
max(0, contentWidth - viewportWidth)
@@ -53,16 +52,18 @@ struct DiffHorizontalScroller: View {
dragStartOffset = offset
}
guard travel > 0 else { return }
- scheduleOffsetUpdate(constrained(
+ dragScheduler.submit(constrained(
dragStartOffset + (value.translation.width / travel) * maximumOffset
- ))
+ )) { nextOffset in
+ offset = nextOffset
+ }
}
.onEnded { value in
if travel > 0 {
let finalOffset = constrained(
dragStartOffset + (value.translation.width / travel) * maximumOffset
)
- cancelScheduledOffsetUpdate()
+ dragScheduler.cancel()
offset = finalOffset
}
isDragging = false
@@ -79,7 +80,7 @@ struct DiffHorizontalScroller: View {
.opacity(maximumOffset > 0.5 ? 1 : 0)
.allowsHitTesting(maximumOffset > 0.5)
.accessibilityLabel("Synchronized diff horizontal scroll")
- .onDisappear(perform: cancelScheduledOffsetUpdate)
+ .onDisappear { dragScheduler.cancel() }
.onChange(of: maximumOffset) { newMaximum in
offset = min(max(offset, 0), newMaximum)
}
@@ -88,25 +89,6 @@ struct DiffHorizontalScroller: View {
private func constrained(_ value: CGFloat) -> CGFloat {
min(max(value, 0), maximumOffset)
}
-
- private func scheduleOffsetUpdate(_ nextOffset: CGFloat) {
- guard dragUpdateBuffer.submit(nextOffset) else { return }
- dragUpdateTask = Task { @MainActor in
- try? await Task.sleep(for: .milliseconds(16))
- guard !Task.isCancelled else { return }
- let nextOffset = dragUpdateBuffer.takePendingValue()
- dragUpdateTask = nil
- if let nextOffset {
- offset = nextOffset
- }
- }
- }
-
- private func cancelScheduledOffsetUpdate() {
- dragUpdateTask?.cancel()
- dragUpdateTask = nil
- dragUpdateBuffer.cancel()
- }
}
/// Observes horizontal trackpad/wheel gestures over the diff without becoming
diff --git a/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift b/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift
index 658d5fb02..8acc20a66 100644
--- a/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift
+++ b/macos/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift
@@ -19,8 +19,7 @@ struct DiffSplitPaneView: View {
let rowOverlay: (DiffRow, DiffSide) -> RowOverlay
@State private var horizontalOffset: CGFloat = 0
- @State private var wheelUpdateBuffer = FrameCoalescedDragUpdateBuffer()
- @State private var wheelUpdateTask: Task?
+ @State private var wheelScheduler = LitheDragUpdateScheduler()
init(
displayRows: [DiffDisplayRow],
@@ -102,17 +101,24 @@ struct DiffSplitPaneView: View {
.frame(width: viewportWidth, height: minimumHeight, alignment: .topLeading)
.background {
DiffHorizontalScrollWheelMonitor { delta in
- let pendingOffset = wheelUpdateBuffer.pendingValue ?? horizontalOffset
- scheduleWheelOffsetUpdate(
- min(max(pendingOffset + delta, 0), maximumHorizontalOffset)
- )
+ // Wheel deltas are incremental, so accumulate onto the in-flight
+ // target rather than the last applied offset. minimumChange: 0
+ // keeps sub-point wheel steps from being swallowed by the
+ // deadband, matching the pre-scheduler behavior.
+ let pendingOffset = wheelScheduler.pendingValue ?? horizontalOffset
+ wheelScheduler.submit(
+ min(max(pendingOffset + delta, 0), maximumHorizontalOffset),
+ minimumChange: 0
+ ) { nextOffset in
+ horizontalOffset = nextOffset
+ }
}
}
.onChange(of: contentWidth) { _ in
- cancelScheduledWheelOffsetUpdate()
+ wheelScheduler.cancel()
horizontalOffset = min(horizontalOffset, maximumHorizontalOffset)
}
- .onDisappear(perform: cancelScheduledWheelOffsetUpdate)
+ .onDisappear { wheelScheduler.cancel() }
}
private func sideViewport(
@@ -129,25 +135,6 @@ struct DiffSplitPaneView: View {
.background(LitheTheme.editor)
}
- private func scheduleWheelOffsetUpdate(_ nextOffset: CGFloat) {
- guard wheelUpdateBuffer.submit(nextOffset) else { return }
- wheelUpdateTask = Task { @MainActor in
- try? await Task.sleep(for: .milliseconds(16))
- guard !Task.isCancelled else { return }
- let nextOffset = wheelUpdateBuffer.takePendingValue()
- wheelUpdateTask = nil
- if let nextOffset {
- horizontalOffset = nextOffset
- }
- }
- }
-
- private func cancelScheduledWheelOffsetUpdate() {
- wheelUpdateTask?.cancel()
- wheelUpdateTask = nil
- wheelUpdateBuffer.cancel()
- }
-
private var centerGutter: some View {
LitheTheme.window
.overlay(alignment: .leading) {
diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift
index ccbcfd2f7..ed077f5d5 100644
--- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift
+++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift
@@ -46,7 +46,7 @@ struct EditorAreaView: View {
@Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion
@State private var hoveredTabID: UUID?
@State private var tabDragState = EditorTabDragState.idle
- @State private var editorTabFrames: [EditorTabItem: CGRect] = [:]
+ @State private var tabFrameStore = EditorTabFrameStore()
@State private var tabDragStartFrames: [EditorTabItem: CGRect] = [:]
@State private var tabDragOffsetX: CGFloat = 0
@State private var tabReorderTarget: EditorTabReorderTarget?
@@ -59,6 +59,7 @@ struct EditorAreaView: View {
@State private var resolvedJavaDocumentIconKinds: [String: LitheIconKind] = [:]
var body: some View {
+ let _ = LitheSignpost.bodyEvaluated("EditorAreaView")
ZStack(alignment: .top) {
Group {
if model.selectedSidebar == .database {
@@ -214,7 +215,7 @@ struct EditorAreaView: View {
.coordinateSpace(name: editorTabCoordinateSpaceName)
.onPreferenceChange(EditorTabFramePreferenceKey.self) { frames in
guard tabDragState.draggedItem == nil else { return }
- editorTabFrames = frames
+ tabFrameStore.update(frames)
}
.clipped()
.animation(tabAnimation, value: model.editorTabItems)
@@ -231,14 +232,25 @@ struct EditorAreaView: View {
@ViewBuilder
private var editorTabItems: some View {
+ // Index once per pass. Scanning `openDocuments` and `terminalSessions`
+ // per tab made this quadratic, and it re-runs on every layout pass.
+ let documentIndices = Dictionary(
+ model.openDocuments.enumerated().map { ($0.element.id, $0.offset) },
+ // First match wins, matching the `firstIndex(where:)` this replaces.
+ uniquingKeysWith: { first, _ in first }
+ )
+ let sessionsByID = Dictionary(
+ model.terminalSessions.map { ($0.id, $0) },
+ uniquingKeysWith: { first, _ in first }
+ )
ForEach(model.editorTabItems) { item in
switch item {
case .document(let documentID):
- if let index = model.openDocuments.firstIndex(where: { $0.id == documentID }) {
+ if let index = documentIndices[documentID] {
editorTab(model.openDocuments[index], at: index)
}
case .terminal(let sessionID):
- if let session = model.terminalSessions.first(where: { $0.id == sessionID }) {
+ if let session = sessionsByID[sessionID] {
editorTerminalTab(session)
}
}
@@ -695,7 +707,7 @@ struct EditorAreaView: View {
}
private func beginTabDrag(_ item: EditorTabItem) {
- tabDragStartFrames = editorTabFrames
+ tabDragStartFrames = tabFrameStore.frames
tabDragOffsetX = 0
tabReorderTarget = nil
withAnimation(tabAnimation) {
@@ -798,11 +810,11 @@ struct EditorAreaView: View {
EditorTabItem.terminal($0)
}
if let activeTerminalItem,
- let sourceFrame = editorTabFrames[activeTerminalItem],
+ let sourceFrame = tabFrameStore[activeTerminalItem],
sourceFrame.contains(location) {
return nil
}
- let candidates = editorTabFrames.filter { item, _ in
+ let candidates = tabFrameStore.frames.filter { item, _ in
item != tabDragState.draggedItem && item != activeTerminalItem
}
guard let nearest = candidates.min(by: { lhs, rhs in
diff --git a/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift b/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift
new file mode 100644
index 000000000..dfefa5699
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Editor/EditorTabFrameStore.swift
@@ -0,0 +1,24 @@
+import CoreGraphics
+
+/// Where each editor tab currently sits, recorded from layout.
+///
+/// Deliberately a reference box held as `@State` rather than `@State` storage of
+/// the dictionary itself. The frames are only read when a drag or drop begins,
+/// but the preference that reports them fires on *every* layout pass — including
+/// every frame of a pane resize. Storing them as view state made each of those
+/// passes re-evaluate the whole editor area; a reference box records them
+/// without invalidating anything.
+///
+/// Follows the same pattern as `EditorViewportStore` and `LithePointerCursor`.
+@MainActor
+final class EditorTabFrameStore {
+ private(set) var frames: [EditorTabItem: CGRect] = [:]
+
+ func update(_ frames: [EditorTabItem: CGRect]) {
+ self.frames = frames
+ }
+
+ subscript(item: EditorTabItem) -> CGRect? {
+ frames[item]
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift b/macos/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift
index 5686d4dd8..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
@@ -364,6 +365,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 +375,107 @@ 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 == .local {
+ Divider()
+
+ Button("Update") {
+ dismissAndRun { Task { await model.updateCurrentBranch(reference) } }
+ }
+ .disabled(!reference.isCurrent)
+
+ Button("Push…") {
+ dismissAndRun { onPush(reference) }
+ }
+ }
+
+ if reference.kind == .local, !reference.isCurrent {
+ Divider()
+
+ Button("Delete", role: .destructive) {
+ dismissAndRun { onDelete(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 +642,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/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift
index d0d0345ec..5ba403c14 100644
--- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift
+++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift
@@ -7,16 +7,16 @@ struct ChangesSidebarView: View {
@State private var selectedTab = CommitTab.commit
@State private var trackedExpanded = true
@State private var untrackedExpanded = true
- @State private var commitAreaHeight: CGFloat = 124
- @State private var commitAreaDragStart: CGFloat = 124
@State private var stashMessage = "WIP"
@State private var includeUntracked = true
@State private var selectedStash: GitStash?
@State private var selectedShelf: GitShelfEntry?
@State private var pendingDropStash: GitStash?
@State private var pendingDropShelf: GitShelfEntry?
+ @State private var sectionsCache = GitChangeSectionsCache()
var body: some View {
+ let _ = LitheSignpost.bodyEvaluated("ChangesSidebarView")
VStack(spacing: 0) {
tabHeader
Rectangle().fill(LitheTheme.divider).frame(height: 1)
@@ -159,43 +159,26 @@ struct ChangesSidebarView: View {
minimumCommitHeight,
availableCommitHeight
)
- let resolvedCommitHeight = constrained(
- commitAreaHeight,
- minimum: minimumCommitHeight,
- maximum: maximumCommitHeight
- )
VStack(spacing: 0) {
commitToolbar
Rectangle().fill(LitheTheme.divider).frame(height: 1)
- changeList
- .frame(minHeight: minimumListHeight)
- SplitHandleView(
+
+ LitheSplitPaneView(
axis: .vertical,
- onDragStarted: {
- commitAreaDragStart = resolvedCommitHeight
- },
- onDragChanged: { translation in
- commitAreaHeight = constrained(
- commitAreaDragStart - translation,
- minimum: minimumCommitHeight,
- maximum: maximumCommitHeight
- )
- },
- onDragEnded: { translation in
- commitAreaHeight = constrained(
- commitAreaDragStart - translation,
- minimum: minimumCommitHeight,
- maximum: maximumCommitHeight
- )
- }
+ placement: .trailing,
+ defaultSize: Self.defaultCommitAreaHeight,
+ minimum: minimumCommitHeight,
+ maximum: maximumCommitHeight,
+ sized: { commitArea },
+ flexible: { changeList.frame(minHeight: minimumListHeight) }
)
- commitArea
- .frame(height: resolvedCommitHeight)
}
}
}
+ private static let defaultCommitAreaHeight: CGFloat = 124
+
private var shelfContent: some View {
VStack(spacing: 0) {
HStack(spacing: 6) {
@@ -792,17 +775,25 @@ struct ChangesSidebarView: View {
.padding(24)
}
+ /// All four sections come from one pass over `gitChanges`; see
+ /// `GitChangeSectionsCache`.
+ private var changeSections: GitChangeSectionsCache.Sections {
+ sectionsCache.sections(
+ changes: model.gitChanges,
+ conflictFilterPaths: model.gitConflictFilterPaths
+ )
+ }
+
private var trackedChanges: [GitChange] {
- displayedChanges.filter { $0.kind != .added }
+ changeSections.tracked
}
private var addedChanges: [GitChange] {
- displayedChanges.filter { $0.kind == .added }
+ changeSections.added
}
private var displayedChanges: [GitChange] {
- guard !model.gitConflictFilterPaths.isEmpty else { return model.gitChanges }
- return model.gitChanges.filter { model.gitConflictFilterPaths.contains($0.path) }
+ changeSections.displayed
}
private func isEffectivelyStaged(_ change: GitChange) -> Bool {
@@ -819,7 +810,7 @@ struct ChangesSidebarView: View {
}
private var stagedChanges: [GitChange] {
- model.gitChanges.filter(\.isStaged)
+ changeSections.staged
}
private var canCommit: Bool {
diff --git a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift
new file mode 100644
index 000000000..832c0626f
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift
@@ -0,0 +1,66 @@
+import LitheGitModule
+
+/// Splits the working-tree changes into the sections the sidebar renders, once
+/// per change of the underlying list.
+///
+/// `displayedChanges` was recomputed by `trackedChanges`, `addedChanges`, and
+/// the empty-state check, so one body pass filtered the whole change list
+/// several times over and then partitioned it twice more.
+///
+/// A reference box in `@State`, like `EditorViewportStore`, so caching cannot
+/// invalidate the view that reads it.
+@MainActor
+final class GitChangeSectionsCache {
+ struct Sections {
+ /// All changes, minus anything hidden by an active conflict filter.
+ let displayed: [GitChange]
+ let tracked: [GitChange]
+ let added: [GitChange]
+ let staged: [GitChange]
+ }
+
+ private var cachedChanges: [GitChange] = []
+ private var cachedFilterPaths: Set = []
+ private var cached: Sections?
+
+ func sections(
+ changes: [GitChange],
+ conflictFilterPaths: Set
+ ) -> Sections {
+ if let cached, cachedChanges == changes, cachedFilterPaths == conflictFilterPaths {
+ return cached
+ }
+
+ var displayed: [GitChange] = []
+ var tracked: [GitChange] = []
+ var added: [GitChange] = []
+ var staged: [GitChange] = []
+ displayed.reserveCapacity(changes.count)
+
+ for change in changes {
+ // `staged` intentionally ignores the conflict filter, matching the
+ // commit-affordance checks that read it.
+ if change.isStaged { staged.append(change) }
+ guard conflictFilterPaths.isEmpty || conflictFilterPaths.contains(change.path) else {
+ continue
+ }
+ displayed.append(change)
+ if change.kind == .added {
+ added.append(change)
+ } else {
+ tracked.append(change)
+ }
+ }
+
+ let sections = Sections(
+ displayed: displayed,
+ tracked: tracked,
+ added: added,
+ staged: staged
+ )
+ cachedChanges = changes
+ cachedFilterPaths = conflictFilterPaths
+ cached = sections
+ return sections
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift b/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift
new file mode 100644
index 000000000..501b6a99c
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Git/GitCurrentReferenceCache.swift
@@ -0,0 +1,25 @@
+import LitheGitModule
+
+/// Caches the "current" reference lookup against the reference list it came from.
+///
+/// `GitLogView` asks for the current branch from more than twenty places in a
+/// single body pass, and each ask was a linear scan of every branch, remote
+/// branch, and tag in the repository.
+///
+/// A reference box in `@State`, like `EditorViewportStore`, so caching cannot
+/// invalidate the view that reads it.
+@MainActor
+final class GitCurrentReferenceCache {
+ private var cachedReferences: [GitReference] = []
+ private var cachedResult: GitReference?
+ private var hasCached = false
+
+ func reference(in references: [GitReference]) -> GitReference? {
+ if hasCached, cachedReferences == references { return cachedResult }
+ let result = references.first(where: \.isCurrent)
+ cachedReferences = references
+ cachedResult = result
+ hasCached = true
+ return result
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Git/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..a41d3adac 100644
--- a/macos/Sources/Lithe/Views/Git/GitLogView.swift
+++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift
@@ -11,16 +11,16 @@ struct GitLogView: View {
@State private var tagsExpanded = true
@State private var collapsedReferenceGroups: Set = []
@State private var collapsedFileGroups: Set = []
- @State private var referencePaneWidth: CGFloat = 260
- @State private var referencePaneDragStart: CGFloat = 260
- @State private var detailPaneWidth: CGFloat = 350
- @State private var detailPaneDragStart: CGFloat = 350
- @State private var filesPaneHeight: CGFloat?
- @State private var filesPaneDragStart: CGFloat = 0
+ @State private var localReferenceRows: [GitReferenceRow] = []
+ @State private var remoteReferenceRows: [GitReferenceRow] = []
+ @State private var tagReferenceRows: [GitReferenceRow] = []
+ @State private var currentReferenceCache = GitCurrentReferenceCache()
@State private var branchDialogRequest: GitBranchDialogRequest?
+ @State private var 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
@@ -67,97 +67,13 @@ 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)
- )
- 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) {
+ .task(id: model.gitCommitsVersion) {
let commits = model.gitCommits
let updatedLayout = await Task.detached(priority: .userInitiated) {
GitGraphLayoutService.layout(commits: commits)
@@ -165,13 +81,21 @@ struct GitLogView: View {
guard model.gitCommits == commits else { return }
graphLayout = updatedLayout
}
+ // The three section arrays are derived, not user state. Rebuilding them
+ // here rather than in `body` keeps the flattening off the render path
+ // while still reacting to both inputs it depends on.
+ .task(id: referenceRowsTaskIdentity) {
+ rebuildReferenceRows()
+ }
.task(id: gitLogFilterTaskIdentity) {
do {
try await Task.sleep(for: .milliseconds(180))
} catch {
return
}
- await model.applyGitLogFilter(gitLogQuery)
+ // `Date()` is captured here — once, at the moment the debounced
+ // task fires — so date-range boundaries are stable for this query.
+ await model.applyGitLogFilter(gitLogQuery(now: Date()))
}
.onChange(of: model.gitRepositoryRoot) { _ in
selectedGitLogAuthor = nil
@@ -302,6 +226,98 @@ 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
+ GitLogThreePaneLayout(
+ availableWidth: geometry.size.width,
+ referencePane: { referencePane },
+ commitPane: { commitPane },
+ detailPane: { detailPane }
+ )
+ }
+ }
+
+ /// 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 {
@@ -575,11 +591,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) {
@@ -643,6 +665,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) {
@@ -716,8 +780,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 +801,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)
}
)
}
@@ -932,6 +1021,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)
+ }
+ }
}
}
@@ -1070,40 +1173,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)
}
@@ -1282,15 +1363,27 @@ struct GitLogView: View {
},
onReset: { commit in
pendingOperation.wrappedValue = GitCommitOperationRequest(kind: .reset, commit: commit)
+ },
+ onCreateTag: { commit in
+ tagDialogRequest = GitTagDialogRequest(commit: commit)
}
)
}
private var visibleCommitHashes: Set? {
- guard !gitLogQuery.isEmpty else { return nil }
+ guard hasActiveGitLogFilter else { return nil }
return model.gitLogMatchedCommitHashes
}
+ /// True when any filter is active, without calling `Date()`. Used to decide
+ /// whether to show the filtered commit subset or the full log.
+ private var hasActiveGitLogFilter: Bool {
+ !model.gitLogSearchQuery.isEmpty
+ || selectedGitLogAuthor != nil
+ || selectedGitLogDatePreset != .anyTime
+ || !gitLogPathFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
private var gitLogFilterTaskIdentity: GitLogFilterTaskIdentity {
GitLogFilterTaskIdentity(
searchQuery: model.gitLogSearchQuery,
@@ -1301,14 +1394,16 @@ struct GitLogView: View {
)
}
- private var gitLogQuery: GitLogQuery {
+ /// Builds the filter query with a caller-supplied `now`, so `Date()` is
+ /// only called once at the task execution site rather than on every body pass.
+ private func gitLogQuery(now: Date) -> GitLogQuery {
let path = gitLogPathFilter.trimmingCharacters(in: .whitespacesAndNewlines)
let query = GitLogQuery.parse(model.gitLogSearchQuery).addingStructuredFilters(
currentUserOnly: selectedGitLogAuthor == .currentUser,
exactAuthor: selectedGitLogAuthor?.exactAuthor,
paths: path.isEmpty ? [] : [path]
)
- return selectedGitLogDatePreset.applying(to: query, now: Date())
+ return selectedGitLogDatePreset.applying(to: query, now: now)
}
private var gitLogAuthorOptions: [GitLogAuthorOption] {
@@ -1625,8 +1720,39 @@ struct GitLogView: View {
return components.suffix(2).joined(separator: "/")
}
+ /// Asked for from many places in one body pass, so the linear scan is
+ /// memoized against the reference list it came from.
private var currentReference: GitReference? {
- model.gitReferences.first(where: \.isCurrent)
+ currentReferenceCache.reference(in: model.gitReferences)
+ }
+
+ /// Both inputs the flattened rows depend on. `gitReferences` is compared by
+ /// value because it is small and changes rarely; the collapse set changes
+ /// only on an explicit disclosure toggle.
+ private var referenceRowsTaskIdentity: GitReferenceRowsIdentity {
+ GitReferenceRowsIdentity(
+ references: model.gitReferences,
+ collapsedGroups: collapsedReferenceGroups
+ )
+ }
+
+ private func rebuildReferenceRows() {
+ let references = model.gitReferences
+ localReferenceRows = GitReferenceRowsBuilder.rows(
+ from: references.filter { $0.kind == .local },
+ kind: .local,
+ collapsedGroups: collapsedReferenceGroups
+ )
+ remoteReferenceRows = GitReferenceRowsBuilder.rows(
+ from: references.filter { $0.kind == .remote },
+ kind: .remote,
+ collapsedGroups: collapsedReferenceGroups
+ )
+ tagReferenceRows = GitReferenceRowsBuilder.rows(
+ from: references.filter { $0.kind == .tag },
+ kind: .tag,
+ collapsedGroups: collapsedReferenceGroups
+ )
}
private func referenceIcon(_ reference: GitReference) -> String {
@@ -1784,72 +1910,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)
@@ -2055,6 +2115,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.
@@ -2485,3 +2644,293 @@ struct GitCheckoutConflictDialog: View {
dismiss()
}
}
+
+// MARK: - Git Reference Row Actions & View
+
+/// Combined `.task(id:)` key for the flattened reference rows, so the rows are
+/// rebuilt when either the references or the collapse state changes.
+private struct GitReferenceRowsIdentity: Equatable {
+ let references: [GitReference]
+ let collapsedGroups: Set
+}
+
+private struct GitReferenceRowActions {
+ let select: (GitReference) -> Void
+ let toggleGroup: (String) -> Void
+ let newBranch: (GitReference) -> Void
+ let renameBranch: (GitReference) -> Void
+ let showDiffWithWorkingTree: (GitReference) -> Void
+ let compareWithCurrent: (GitReference) -> Void
+ let compareWithSelectedSource: (GitReference) -> Void
+ let selectForCompare: (GitReference) -> Void
+ let comparisonSourceName: String?
+ let checkout: (GitReference) -> Void
+ let updateCurrentBranch: (GitReference) -> Void
+ let push: (GitReference) -> Void
+ let branchOperation: (GitBranchOperationKind, GitReference) -> Void
+}
+
+private struct GitReferenceRowView: View, Equatable {
+ let row: GitReferenceRow
+ let isSelected: Bool
+ let isPerformingBranchOperation: Bool
+ let currentReferenceID: String?
+ let comparisonSourceID: String?
+ let actions: GitReferenceRowActions
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.row == rhs.row
+ && lhs.isSelected == rhs.isSelected
+ && lhs.isPerformingBranchOperation == rhs.isPerformingBranchOperation
+ && lhs.currentReferenceID == rhs.currentReferenceID
+ && lhs.comparisonSourceID == rhs.comparisonSourceID
+ }
+
+ var body: some View {
+ switch row.content {
+ case .group(let key, let isCollapsed):
+ groupRow(key: key, isCollapsed: isCollapsed)
+ case .reference(let reference):
+ referenceRow(reference)
+ }
+ }
+
+ private func groupRow(key: String, isCollapsed: Bool) -> some View {
+ Button {
+ actions.toggleGroup(key)
+ } label: {
+ HStack(spacing: 7) {
+ Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
+ .font(.system(size: 8, weight: .bold))
+ .frame(width: 10)
+ Image(systemName: "folder")
+ .font(.system(size: 12))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(row.name)
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ Spacer(minLength: 8)
+ }
+ .padding(.leading, CGFloat(row.depth * 16))
+ .padding(.trailing, 8)
+ .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading)
+ .contentShape(Rectangle())
+ .litheRowHover(cornerRadius: 4)
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ }
+
+ private func referenceRow(_ reference: GitReference) -> some View {
+ Button {
+ actions.select(reference)
+ } label: {
+ HStack(spacing: 7) {
+ LitheSystemIcon(systemImage: referenceIcon(reference), size: 14)
+ .foregroundStyle(reference.kind == .tag ? LitheTheme.warning : LitheTheme.secondaryText)
+ .frame(width: 16)
+ Text(row.name)
+ .font(.system(size: 13))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ if reference.isCurrent {
+ Image(systemName: "checkmark")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(LitheTheme.accent)
+ }
+ Spacer(minLength: 8)
+ }
+ .padding(.leading, CGFloat(row.depth * 16))
+ .padding(.trailing, 8)
+ .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading)
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ .contentShape(Rectangle())
+ .litheRowHover(
+ isActive: isSelected,
+ cornerRadius: 4,
+ activeBackground: LitheTheme.subtleSelection
+ )
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ .contextMenu {
+ Button("New Branch from '\(reference.shortName)'…") {
+ actions.newBranch(reference)
+ }
+
+ Button("Show Diff with Working Tree") {
+ actions.showDiffWithWorkingTree(reference)
+ }
+
+ if let currentReferenceID, currentReferenceID != reference.id {
+ Button("Compare with Current Branch") {
+ actions.compareWithCurrent(reference)
+ }
+ }
+
+ if let comparisonSourceID, comparisonSourceID != reference.id,
+ let sourceName = actions.comparisonSourceName {
+ Button("Compare '\(sourceName)' with '\(reference.shortName)'") {
+ actions.compareWithSelectedSource(reference)
+ }
+ } else {
+ Button("Select for Compare") {
+ actions.selectForCompare(reference)
+ }
+ }
+
+ if !reference.isCurrent {
+ Divider()
+
+ Button("Checkout") {
+ actions.checkout(reference)
+ }
+ .disabled(isPerformingBranchOperation)
+
+ if reference.kind != .tag {
+ Button("Checkout and Rebase onto Current Branch") {
+ actions.branchOperation(.checkoutAndRebase, reference)
+ }
+ .disabled(isPerformingBranchOperation)
+
+ Button("Merge into Current Branch") {
+ actions.branchOperation(.merge, reference)
+ }
+ .disabled(isPerformingBranchOperation)
+
+ Button("Rebase Current Branch onto…") {
+ actions.branchOperation(.rebase, reference)
+ }
+ .disabled(isPerformingBranchOperation)
+ }
+ }
+
+ if reference.kind == .remote {
+ Divider()
+
+ Button("Pull with Rebase") {
+ actions.branchOperation(.pullRebase, reference)
+ }
+ .disabled(isPerformingBranchOperation)
+
+ Button("Pull with Merge") {
+ actions.branchOperation(.pullMerge, reference)
+ }
+ .disabled(isPerformingBranchOperation)
+ }
+
+ if reference.kind == .local {
+ Divider()
+
+ Button("Update") {
+ actions.updateCurrentBranch(reference)
+ }
+ .disabled(!reference.isCurrent || isPerformingBranchOperation)
+
+ Button("Push…") {
+ actions.push(reference)
+ }
+ .disabled(isPerformingBranchOperation)
+
+ if !reference.isCurrent {
+ Button("Delete Branch", role: .destructive) {
+ actions.branchOperation(.delete, reference)
+ }
+ .disabled(isPerformingBranchOperation)
+ }
+
+ Divider()
+
+ Button("Rename…") {
+ actions.renameBranch(reference)
+ }
+ .disabled(isPerformingBranchOperation)
+ }
+ }
+ }
+
+ private func referenceIcon(_ reference: GitReference) -> String {
+ switch reference.kind {
+ case .local: "point.3.connected.trianglepath.dotted"
+ case .remote: "cloud"
+ case .tag: "tag"
+ }
+ }
+}
+
+// MARK: - Git Log Three-Pane Layout
+
+private enum GitLogThreePaneMetrics {
+ static let minimumReferencePaneWidth: CGFloat = 180
+ static let minimumCommitPaneWidth: CGFloat = 340
+ static let minimumDetailPaneWidth: CGFloat = 250
+}
+
+private struct GitLogThreePaneLayout: View {
+ let availableWidth: CGFloat
+ private let referencePane: ReferencePane
+ private let commitPane: CommitPane
+ private let detailPane: DetailPane
+
+ init(
+ availableWidth: CGFloat,
+ @ViewBuilder referencePane: () -> ReferencePane,
+ @ViewBuilder commitPane: () -> CommitPane,
+ @ViewBuilder detailPane: () -> DetailPane
+ ) {
+ self.availableWidth = availableWidth
+ self.referencePane = referencePane()
+ self.commitPane = commitPane()
+ self.detailPane = detailPane()
+ }
+
+ private var referencePaneMaximum: CGFloat {
+ max(
+ GitLogThreePaneMetrics.minimumReferencePaneWidth,
+ min(
+ availableWidth * 0.35,
+ availableWidth
+ - (SplitHandleView.thickness * 2)
+ - GitLogThreePaneMetrics.minimumCommitPaneWidth
+ - GitLogThreePaneMetrics.minimumDetailPaneWidth
+ )
+ )
+ }
+
+ private var detailPaneMaximum: CGFloat {
+ max(
+ GitLogThreePaneMetrics.minimumDetailPaneWidth,
+ min(
+ availableWidth * 0.5,
+ availableWidth
+ - (SplitHandleView.thickness * 2)
+ - GitLogThreePaneMetrics.minimumCommitPaneWidth
+ - referencePaneMaximum
+ )
+ )
+ }
+
+ var body: some View {
+ LitheSplitPaneView(
+ axis: .horizontal,
+ placement: .leading,
+ defaultSize: 220,
+ minimum: GitLogThreePaneMetrics.minimumReferencePaneWidth,
+ maximum: referencePaneMaximum,
+ flexibleMinimum: GitLogThreePaneMetrics.minimumCommitPaneWidth,
+ sized: { referencePane },
+ flexible: {
+ LitheSplitPaneView(
+ axis: .horizontal,
+ placement: .trailing,
+ defaultSize: 350,
+ minimum: GitLogThreePaneMetrics.minimumDetailPaneWidth,
+ maximum: detailPaneMaximum,
+ sized: { detailPane },
+ flexible: { commitPane }
+ )
+ }
+ )
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
new file mode 100644
index 000000000..5b114f3d1
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Git/GitReferenceRows.swift
@@ -0,0 +1,162 @@
+import Foundation
+import LitheGitModule
+
+/// One visible line of the Git log's reference tree.
+///
+/// The tree used to render as a recursive `-> AnyView` function, which erased
+/// every level's type, blocked `LazyVStack`, and forced the whole tree to
+/// re-evaluate whenever `GitLogView` re-ran. Flattening to rows makes each line
+/// independently comparable and lazily rendered.
+struct GitReferenceRow: Identifiable, Equatable {
+ enum Content: Equatable {
+ /// A branch, remote branch, or tag the user can act on.
+ case reference(GitReference)
+ /// A path segment shared by several references, such as `feature` in
+ /// `feature/a` and `feature/b`. `key` is the collapse-state key.
+ case group(key: String, isCollapsed: Bool)
+ }
+
+ let id: String
+ /// The last path component, which is what the row displays.
+ let name: String
+ /// Nesting level, used only for the row's leading indent.
+ let depth: Int
+ let content: Content
+}
+
+/// Flattens references into the visible rows of one section, in render order.
+enum GitReferenceRowsBuilder {
+ /// - Parameters:
+ /// - references: Already filtered to a single `kind` by the caller.
+ /// - collapsedGroups: Keys of groups whose children are hidden.
+ static func rows(
+ from references: [GitReference],
+ kind: GitReferenceKind,
+ collapsedGroups: Set
+ ) -> [GitReferenceRow] {
+ var rows: [GitReferenceRow] = []
+ append(
+ GitReferenceTreeNode.build(from: references),
+ kind: kind,
+ depth: 0,
+ collapsedGroups: collapsedGroups,
+ into: &rows
+ )
+ return rows
+ }
+
+ private static func append(
+ _ nodes: [GitReferenceTreeNode],
+ kind: GitReferenceKind,
+ depth: Int,
+ collapsedGroups: Set,
+ into rows: inout [GitReferenceRow]
+ ) {
+ for node in nodes {
+ // A node can be both: `feature` may be a branch and also the prefix
+ // of `feature/x`, in which case it emits a reference row and a group
+ // row at the same depth.
+ if let reference = node.reference {
+ rows.append(
+ GitReferenceRow(
+ id: "reference:" + node.path,
+ name: node.name,
+ depth: depth,
+ content: .reference(reference)
+ )
+ )
+ }
+
+ guard !node.children.isEmpty else { continue }
+ let key = "\(kind.rawValue):\(node.path)"
+ let isCollapsed = collapsedGroups.contains(key)
+ rows.append(
+ GitReferenceRow(
+ id: "group:" + node.path,
+ name: node.name,
+ depth: depth,
+ content: .group(key: key, isCollapsed: isCollapsed)
+ )
+ )
+
+ guard !isCollapsed else { continue }
+ append(
+ node.children,
+ kind: kind,
+ depth: depth + 1,
+ collapsedGroups: collapsedGroups,
+ into: &rows
+ )
+ }
+ }
+}
+
+/// Intermediate tree used only to group references by their `/`-separated path
+/// before flattening.
+struct GitReferenceTreeNode: Identifiable {
+ let path: String
+ let name: String
+ let reference: GitReference?
+ let children: [GitReferenceTreeNode]
+
+ var id: String { path }
+
+ static func build(from references: [GitReference]) -> [GitReferenceTreeNode] {
+ let root = MutableGitReferenceTreeNode(name: "", path: "")
+
+ for reference in references {
+ let components = reference.shortName
+ .split(separator: "/")
+ .map(String.init)
+ guard !components.isEmpty else { continue }
+
+ var node = root
+ var pathComponents: [String] = []
+ for component in components {
+ pathComponents.append(component)
+ if node.children[component] == nil {
+ node.children[component] = MutableGitReferenceTreeNode(
+ name: component,
+ path: pathComponents.joined(separator: "/")
+ )
+ }
+ node = node.children[component]!
+ }
+ node.reference = reference
+ }
+
+ return makeNodes(from: root)
+ }
+
+ private static func makeNodes(from node: MutableGitReferenceTreeNode) -> [GitReferenceTreeNode] {
+ node.children.values
+ .map { child in
+ GitReferenceTreeNode(
+ path: child.path,
+ name: child.name,
+ reference: child.reference,
+ children: makeNodes(from: child)
+ )
+ }
+ // Leaves before folders, then natural order, so the list is stable
+ // across rebuilds of the unordered child dictionary.
+ .sorted { lhs, rhs in
+ if (lhs.reference != nil) != (rhs.reference != nil) {
+ return lhs.reference != nil
+ }
+ return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
+ }
+ }
+}
+
+private final class MutableGitReferenceTreeNode {
+ let name: String
+ let path: String
+ var reference: GitReference?
+ var children: [String: MutableGitReferenceTreeNode] = [:]
+
+ init(name: String, path: String) {
+ self.name = name
+ self.path = path
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift
index 1d6f8460a..88d5857e8 100644
--- a/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift
+++ b/macos/Sources/Lithe/Views/Language/LanguageTestsView.swift
@@ -30,42 +30,27 @@ struct LanguageTestsView: View {
minimumListWidth,
geometry.size.width - SplitHandleView.thickness - minimumContentWidth
)
- let resolvedListWidth = min(
- max(liveItemListWidth ?? CGFloat(itemListWidth), minimumListWidth),
- maximumListWidth
- )
- HStack(spacing: 0) {
- if isItemListCollapsed {
+ if isItemListCollapsed {
+ HStack(spacing: 0) {
collapsedItemListBar
.frame(width: 32)
Rectangle()
.fill(LitheTheme.divider)
.frame(width: 1)
- } else {
- testItemList
- .frame(width: resolvedListWidth)
- SplitHandleView(
- axis: .horizontal,
- onDragStarted: { itemListDragStart = resolvedListWidth },
- onDragChanged: { translation in
- liveItemListWidth = min(
- max(itemListDragStart + translation, minimumListWidth),
- maximumListWidth
- )
- },
- onDragEnded: { translation in
- let finalWidth = min(
- max(itemListDragStart + translation, minimumListWidth),
- maximumListWidth
- )
- itemListWidth = Double(finalWidth)
- liveItemListWidth = nil
- }
- )
+ selectedTestContent
}
-
- selectedTestContent
+ } else {
+ LitheSplitPaneView(
+ axis: .horizontal,
+ placement: .leading,
+ defaultSize: CGFloat(itemListWidth),
+ minimum: minimumListWidth,
+ maximum: maximumListWidth,
+ onCommit: { itemListWidth = Double($0) },
+ sized: { testItemList },
+ flexible: { selectedTestContent }
+ )
}
}
}
diff --git a/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift b/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift
new file mode 100644
index 000000000..71d19a3b6
--- /dev/null
+++ b/macos/Sources/Lithe/Views/Run/RunConfigurationTokenCache.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+/// Parses the comma- and newline-separated token lists the Run tool window keeps
+/// in `@AppStorage`, memoized on the raw string.
+///
+/// The stored strings are re-parsed on every access, and `isPinned` is called
+/// inside a filter over every configuration, so a body pass re-split the whole
+/// list once per configuration. Caching on the raw value turns that O(n·m) back
+/// into O(m) while keeping `@AppStorage` the source of truth.
+///
+/// Held as a reference box in `@State` (like `EditorViewportStore`) so caching
+/// never invalidates the view that reads it.
+@MainActor
+final class RunConfigurationTokenCache {
+ private var lastRawValue: String?
+ private var lastTokens: Set = []
+
+ /// - Parameter separator: `,` for collapsed executions, `\n` for pin tokens.
+ func tokens(from rawValue: String, separator: Character) -> Set {
+ if lastRawValue == rawValue { return lastTokens }
+ let tokens = Set(rawValue.split(separator: separator).map(String.init))
+ lastRawValue = rawValue
+ lastTokens = tokens
+ return tokens
+ }
+}
diff --git a/macos/Sources/Lithe/Views/Run/RunView.swift b/macos/Sources/Lithe/Views/Run/RunView.swift
index f4b238db8..94fdbbf99 100644
--- a/macos/Sources/Lithe/Views/Run/RunView.swift
+++ b/macos/Sources/Lithe/Views/Run/RunView.swift
@@ -9,13 +9,16 @@ struct RunView: View {
@AppStorage("lithe.run.pinnedConfigurationIDs") private var pinnedConfigurationTokens = ""
@AppStorage("lithe.run.configurationListWidth") private var configurationListWidth = 230.0
@AppStorage("lithe.run.configurationListCollapsed") private var isConfigurationListCollapsed = false
- @State private var liveConfigurationListWidth: CGFloat?
- @State private var configurationListDragStart: CGFloat = 230
+ /// Separate caches: the two raw strings change independently, and one box
+ /// memoizes a single raw value.
+ @State private var collapsedExecutionCache = RunConfigurationTokenCache()
+ @State private var pinnedConfigurationCache = RunConfigurationTokenCache()
/// The configuration whose editor popover is open. Held separately from the list
/// selection so opening an editor does not switch which log is shown.
@State private var editingConfigurationID: String?
var body: some View {
+ let _ = LitheSignpost.bodyEvaluated("RunView")
VStack(spacing: 0) {
toolWindowHeader
@@ -48,48 +51,26 @@ struct RunView: View {
minimumListWidth,
min(420, geometry.size.width - SplitHandleView.thickness - minimumContentWidth)
)
- let resolvedListWidth = constrained(
- liveConfigurationListWidth ?? CGFloat(configurationListWidth),
- minimum: minimumListWidth,
- maximum: maximumListWidth
- )
-
- HStack(spacing: 0) {
- if isConfigurationListCollapsed {
+ if isConfigurationListCollapsed {
+ HStack(spacing: 0) {
collapsedConfigurationListBar
.frame(width: 32)
Rectangle()
.fill(LitheTheme.divider)
.frame(width: 1)
- } else {
- moduleSessionList
- .frame(width: resolvedListWidth)
-
- SplitHandleView(
- axis: .horizontal,
- onDragStarted: {
- configurationListDragStart = resolvedListWidth
- },
- onDragChanged: { translation in
- liveConfigurationListWidth = constrained(
- configurationListDragStart + translation,
- minimum: minimumListWidth,
- maximum: maximumListWidth
- )
- },
- onDragEnded: { translation in
- let finalWidth = constrained(
- configurationListDragStart + translation,
- minimum: minimumListWidth,
- maximum: maximumListWidth
- )
- configurationListWidth = Double(finalWidth)
- liveConfigurationListWidth = nil
- }
- )
+ selectedConfigurationContent
}
-
- selectedConfigurationContent
+ } else {
+ LitheSplitPaneView(
+ axis: .horizontal,
+ placement: .leading,
+ defaultSize: CGFloat(configurationListWidth),
+ minimum: minimumListWidth,
+ maximum: maximumListWidth,
+ onCommit: { configurationListWidth = Double($0) },
+ sized: { moduleSessionList },
+ flexible: { selectedConfigurationContent }
+ )
}
}
}
@@ -207,12 +188,6 @@ struct RunView: View {
return (String(localized: "Project identification failed"), message, "xmark.octagon.fill")
case .idle:
return nil
- case .projectNotReady:
- return (
- String(localized: "Project is still loading"),
- String(localized: "Wait for the workspace scan to finish, then identify the project again."),
- "hourglass"
- )
}
}
@@ -434,11 +409,11 @@ struct RunView: View {
}
private var collapsedExecutions: Set {
- Set(collapsedExecutionIDs.split(separator: ",").map(String.init))
+ collapsedExecutionCache.tokens(from: collapsedExecutionIDs, separator: ",")
}
private var pinnedConfigurationTokenSet: Set {
- Set(pinnedConfigurationTokens.split(separator: "\n").map(String.init))
+ pinnedConfigurationCache.tokens(from: pinnedConfigurationTokens, separator: "\n")
}
private func pinToken(for configuration: RunConfiguration) -> String {
@@ -888,10 +863,6 @@ struct RunView: View {
return trimmed.isEmpty ? nil : trimmed
}
- private func constrained(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat {
- min(max(value, minimum), maximum)
- }
-
private func sectionHeader(_ execution: RunConfigurationExecution, count: Int) -> some View {
Button {
toggleCollapsed(execution)
diff --git a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift
index f0d598ce7..d909c208b 100644
--- a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift
+++ b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift
@@ -21,6 +21,8 @@ struct SplitHandleView: View {
@State private var isHovering = false
@State private var isDragging = false
+ @State private var dragScheduler = LitheDragUpdateScheduler()
+ @State private var cursor = SplitHandleCursor()
init(
axis: LitheSplitAxis,
@@ -40,37 +42,54 @@ struct SplitHandleView: View {
self.onDragEnded = onDragEnded
}
- @ViewBuilder
var body: some View {
- if axis == .horizontal {
- handleSurface.frame(maxHeight: .infinity)
- } else {
- handleSurface.frame(maxWidth: .infinity)
- }
- }
-
- private var handleSurface: some View {
ZStack {
trackBackground
+ Color.clear
dividerLine
- SplitHandleInteractionView(
- axis: axis,
- onHoverChanged: { isHovering = $0 },
- onDragStateChanged: { isDragging = $0 },
- onDragStarted: onDragStarted,
- onDragChanged: onDragChanged,
- onDragEnded: onDragEnded
- )
- .frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(
width: axis == .horizontal ? Self.thickness : nil,
height: axis == .vertical ? Self.thickness : nil
)
.contentShape(Rectangle())
+ .gesture(
+ // The handle moves with the resized pane, so local coordinates create a
+ // feedback loop where translation jumps as the coordinate origin moves.
+ DragGesture(minimumDistance: 0, coordinateSpace: .global)
+ .onChanged { value in
+ if !isDragging {
+ isDragging = true
+ cursor.update(isResizing: true, cursor: resizeCursor)
+ onDragStarted()
+ }
+ let currentTranslation = axis == .horizontal ? value.translation.width : value.translation.height
+ // Pointer devices can deliver substantially more events than
+ // the display can present. The scheduler keeps only the
+ // newest translation for the next run-loop turn and applies
+ // the sub-point deadband, so no per-event @State is written.
+ dragScheduler.submit(currentTranslation) { translation in
+ onDragChanged(translation)
+ }
+ }
+ .onEnded { value in
+ let finalTranslation = axis == .horizontal
+ ? value.translation.width
+ : value.translation.height
+ dragScheduler.cancel()
+ isDragging = false
+ cursor.update(isResizing: isHovering, cursor: resizeCursor)
+ onDragEnded(finalTranslation)
+ }
+ )
+ .onHover { isInside in
+ guard isInside != isHovering else { return }
+ isHovering = isInside
+ cursor.update(isResizing: isInside || isDragging, cursor: resizeCursor)
+ }
.onDisappear {
- isHovering = false
- isDragging = false
+ dragScheduler.cancel()
+ cursor.update(isResizing: false, cursor: resizeCursor)
}
.help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize")
.accessibilityLabel(axis == .horizontal ? "Horizontal pane resize handle" : "Vertical pane resize handle")
@@ -98,7 +117,7 @@ struct SplitHandleView: View {
@ViewBuilder
private var dividerLine: some View {
let isHighlighted = isHovering || isDragging
- if showsIdleDivider {
+ if showsIdleDivider || isHighlighted {
let color = isHighlighted ? LitheTheme.accent : LitheTheme.divider
if axis == .horizontal {
@@ -115,183 +134,22 @@ struct SplitHandleView: View {
}
}
-}
-
-private struct SplitHandleInteractionView: NSViewRepresentable {
- let axis: LitheSplitAxis
- let onHoverChanged: (Bool) -> Void
- let onDragStateChanged: (Bool) -> Void
- let onDragStarted: () -> Void
- let onDragChanged: (CGFloat) -> Void
- let onDragEnded: (CGFloat) -> Void
-
- func makeNSView(context: Context) -> SplitHandleInteractionNSView {
- SplitHandleInteractionNSView(
- axis: axis,
- onHoverChanged: onHoverChanged,
- onDragStateChanged: onDragStateChanged,
- onDragStarted: onDragStarted,
- onDragChanged: onDragChanged,
- onDragEnded: onDragEnded
- )
- }
-
- func updateNSView(_ nsView: SplitHandleInteractionNSView, context: Context) {
- nsView.update(
- axis: axis,
- onHoverChanged: onHoverChanged,
- onDragStateChanged: onDragStateChanged,
- onDragStarted: onDragStarted,
- onDragChanged: onDragChanged,
- onDragEnded: onDragEnded
- )
- }
-
- static func dismantleNSView(_ nsView: SplitHandleInteractionNSView, coordinator: ()) {
- nsView.cancelInteraction()
- }
-}
-
-private final class SplitHandleInteractionNSView: NSView {
- private var axis: LitheSplitAxis
- private var onHoverChanged: (Bool) -> Void
- private var onDragStateChanged: (Bool) -> Void
- private var onDragStarted: () -> Void
- private var onDragChanged: (CGFloat) -> Void
- private var onDragEnded: (CGFloat) -> Void
- private var trackingArea: NSTrackingArea?
- private var dragStartInWindow: NSPoint?
- private var isDragging = false
- private var isInside = false
-
- init(
- axis: LitheSplitAxis,
- onHoverChanged: @escaping (Bool) -> Void,
- onDragStateChanged: @escaping (Bool) -> Void,
- onDragStarted: @escaping () -> Void,
- onDragChanged: @escaping (CGFloat) -> Void,
- onDragEnded: @escaping (CGFloat) -> Void
- ) {
- self.axis = axis
- self.onHoverChanged = onHoverChanged
- self.onDragStateChanged = onDragStateChanged
- self.onDragStarted = onDragStarted
- self.onDragChanged = onDragChanged
- self.onDragEnded = onDragEnded
- super.init(frame: .zero)
- wantsLayer = true
- layer?.backgroundColor = NSColor.clear.cgColor
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func updateTrackingAreas() {
- if let trackingArea {
- removeTrackingArea(trackingArea)
- }
- let area = NSTrackingArea(
- rect: bounds,
- options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- addTrackingArea(area)
- trackingArea = area
- super.updateTrackingAreas()
- }
-
- override func resetCursorRects() {
- super.resetCursorRects()
- addCursorRect(bounds, cursor: resizeCursor)
- }
-
- override func cursorUpdate(with event: NSEvent) {
- resizeCursor.set()
- }
-
- override func mouseEntered(with event: NSEvent) {
- isInside = true
- onHoverChanged(true)
- resizeCursor.set()
- }
-
- override func mouseExited(with event: NSEvent) {
- isInside = false
- onHoverChanged(false)
- if !isDragging {
- NSCursor.arrow.set()
- }
- }
-
- override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
- true
- }
-
- override func mouseDown(with event: NSEvent) {
- isDragging = true
- dragStartInWindow = event.locationInWindow
- onDragStateChanged(true)
- resizeCursor.set()
- onDragStarted()
- }
-
- override func mouseDragged(with event: NSEvent) {
- guard let dragStartInWindow else { return }
- onDragChanged(translation(from: dragStartInWindow, to: event.locationInWindow))
- }
-
- override func mouseUp(with event: NSEvent) {
- guard let dragStartInWindow else { return }
- onDragEnded(translation(from: dragStartInWindow, to: event.locationInWindow))
- self.dragStartInWindow = nil
- isDragging = false
- onDragStateChanged(false)
- (isInside ? resizeCursor : NSCursor.arrow).set()
- }
-
- func update(
- axis: LitheSplitAxis,
- onHoverChanged: @escaping (Bool) -> Void,
- onDragStateChanged: @escaping (Bool) -> Void,
- onDragStarted: @escaping () -> Void,
- onDragChanged: @escaping (CGFloat) -> Void,
- onDragEnded: @escaping (CGFloat) -> Void
- ) {
- self.axis = axis
- self.onHoverChanged = onHoverChanged
- self.onDragStateChanged = onDragStateChanged
- self.onDragStarted = onDragStarted
- self.onDragChanged = onDragChanged
- self.onDragEnded = onDragEnded
- window?.invalidateCursorRects(for: self)
- }
-
- func cancelInteraction() {
- dragStartInWindow = nil
- if isDragging {
- isDragging = false
- onDragStateChanged(false)
- }
- if isInside {
- isInside = false
- onHoverChanged(false)
- }
- NSCursor.arrow.set()
- }
-
private var resizeCursor: NSCursor {
axis == .horizontal ? .resizeLeftRight : .resizeUpDown
}
+}
- private func translation(from start: NSPoint, to current: NSPoint) -> CGFloat {
- axis == .horizontal ? current.x - start.x : start.y - current.y
- }
+private final class SplitHandleCursor {
+ private var isResizing = false
- deinit {
- if isDragging || isInside {
- NSCursor.arrow.set()
+ @MainActor
+ func update(isResizing newValue: Bool, cursor: NSCursor) {
+ guard newValue != isResizing else { return }
+ isResizing = newValue
+ if newValue {
+ cursor.push()
+ } else {
+ NSCursor.pop()
}
}
}
diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift
index e750e2912..a451135fb 100644
--- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift
+++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift
@@ -25,6 +25,38 @@ struct WorkbenchModuleUIRegistry {
let isVisible: @MainActor (AppModel) -> Bool
let isSelected: @MainActor (AppModel) -> Bool
let content: @MainActor (AppModel) -> AnyView
+ /// Distinguishes states the erased content cannot express by value —
+ /// chiefly "still loading" versus a specific attached feature object.
+ let contentIdentity: @MainActor (AppModel) -> AnyHashable
+
+ init(
+ id: String,
+ ideaAssetPath: String?,
+ isVisible: @escaping @MainActor (AppModel) -> Bool,
+ isSelected: @escaping @MainActor (AppModel) -> Bool,
+ content: @escaping @MainActor (AppModel) -> AnyView,
+ // Renderers whose content depends on nothing but the hosted view's
+ // own observation of AppModel are always interchangeable.
+ contentIdentity: @escaping @MainActor (AppModel) -> AnyHashable = { _ in 0 }
+ ) {
+ self.id = id
+ self.ideaAssetPath = ideaAssetPath
+ self.isVisible = isVisible
+ self.isSelected = isSelected
+ self.content = content
+ self.contentIdentity = contentIdentity
+ }
+
+ /// Identity for renderers built from an optional feature object. The
+ /// loading placeholder and each distinct feature instance must compare
+ /// differently, or switching workspaces would keep showing the previous
+ /// workspace's feature.
+ static func featureIdentity(_ feature: AnyObject?) -> AnyHashable {
+ guard let feature else { return AnyHashable(Self.loadingIdentity) }
+ return AnyHashable(ObjectIdentifier(feature))
+ }
+
+ private static let loadingIdentity = "module-loading"
}
struct Registration {
@@ -100,14 +132,24 @@ struct WorkbenchModuleUIRegistry {
func selectedToolContent(
from contributions: [ModuleContribution],
model: AppModel
- ) -> AnyView {
+ ) -> ModuleToolContent {
for contribution in contributions {
guard let renderer = renderer(for: contribution), renderer.isSelected(model) else { continue }
- return renderer.content(model)
+ return ModuleToolContent(
+ rendererID: renderer.id,
+ contentIdentity: renderer.contentIdentity(model),
+ content: renderer.content(model)
+ )
}
- return AnyView(Self.moduleLoadingView)
+ return ModuleToolContent(
+ rendererID: Self.loadingRendererID,
+ contentIdentity: 0,
+ content: AnyView(Self.moduleLoadingView)
+ )
}
+ private static let loadingRendererID = "workbench.module-loading"
+
static var moduleLoadingView: some View {
VStack(spacing: 8) {
ProgressView()
@@ -119,3 +161,24 @@ struct WorkbenchModuleUIRegistry {
.background(LitheTheme.editor)
}
}
+
+/// Restores a comparable identity to a tool window that the registry had to
+/// erase to `AnyView`.
+///
+/// `AnyView` defeats SwiftUI's value comparison, so every `WorkbenchView.body`
+/// pass — including one per frame while a divider is dragged — forced the whole
+/// bottom tool window to re-evaluate. The hosted views observe `AppModel`
+/// themselves and stay live when this one is skipped, so comparing which
+/// renderer is showing (and which feature it was built from) is sufficient.
+/// This mirrors `GitGraphRowView`, whose `==` deliberately ignores its actions.
+struct ModuleToolContent: View, Equatable {
+ let rendererID: String
+ let contentIdentity: AnyHashable
+ let content: AnyView
+
+ static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.rendererID == rhs.rendererID && lhs.contentIdentity == rhs.contentIdentity
+ }
+
+ var body: some View { content }
+}
diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
index 2ed1e92ae..05e88ef64 100644
--- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
+++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift
@@ -4,6 +4,7 @@ import LitheGitModule
enum WorkbenchLayoutMetrics {
static let rightActivityBarWidth: CGFloat = 40
+ static let rightActivityBarDividerWidth: CGFloat = 1
static let workspaceTrailingInset = rightActivityBarWidth
}
@@ -18,11 +19,9 @@ private enum ActivityBarMetrics {
}
private enum WorkbenchWorkspaceMetrics {
- static let paneInset: CGFloat = 0
+ static let paneInset: CGFloat = 6
static let paneSpacing: CGFloat = 6
static let paneCornerRadius: CGFloat = 10
- static let minimumTopPaneHeight: CGFloat = 220
- static let changesMinimumTopPaneHeight: CGFloat = 332
}
private enum WorkbenchPopoverLayoutMetrics {
@@ -82,6 +81,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
@@ -91,15 +91,21 @@ struct WorkbenchView: View {
@State private var isBackgroundPickerPresented = false
var body: some View {
+ let _ = LitheSignpost.bodyEvaluated("WorkbenchView")
VStack(spacing: 0) {
topBar
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
if projectSessions.openProjects.count > 1 {
projectTabBar
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
}
HStack(spacing: 0) {
activityBar
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(width: 1)
workspaceArea
.padding(.trailing, WorkbenchLayoutMetrics.workspaceTrailingInset)
}
@@ -108,6 +114,7 @@ struct WorkbenchView: View {
rightHoverRegion
}
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
statusBar
}
.background {
@@ -278,6 +285,31 @@ struct WorkbenchView: View {
}
)
}
+ .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."
+ )
+ }
.overlayPreferenceValue(ProjectSwitcherButtonBoundsPreferenceKey.self) { bounds in
GeometryReader { geometry in
if isProjectSwitcherPresented, let bounds {
@@ -298,53 +330,17 @@ struct WorkbenchView: View {
}
}
}
- .overlay(alignment: .bottomTrailing) {
- if !model.activeNotifications.isEmpty {
- VStack(alignment: .trailing, spacing: 8) {
- ForEach(model.activeNotifications) { notification in
- HStack(alignment: .center, spacing: 10) {
- Image(systemName: "info.circle.fill")
- .font(.system(size: 14))
- .foregroundStyle(LitheTheme.accent)
-
- Text(LocalizedStringKey(notification.message))
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(LitheTheme.primaryText)
- .fixedSize(horizontal: false, vertical: true)
-
- Spacer(minLength: 4)
-
- Button {
- model.dismissNotification(notification.id)
- } label: {
- Image(systemName: "xmark")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(LitheTheme.tertiaryText)
- }
- .buttonStyle(.plain)
- .frame(width: 16, height: 16)
- .contentShape(Rectangle())
- .litheRowHover(cornerRadius: LitheTheme.Metrics.cornerRadius, animation: nil)
- .accessibilityLabel("Dismiss notification")
- }
- .padding(.leading, 12)
- .padding(.trailing, 6)
- .padding(.vertical, 10)
- .frame(minWidth: 280, maxWidth: 360, alignment: .topLeading)
- .background(LitheTheme.notificationBackground)
- .clipShape(RoundedRectangle(cornerRadius: 7))
- .contentShape(RoundedRectangle(cornerRadius: 7))
- .onContinuousHover(coordinateSpace: .local) { phase in
- if case .active = phase {
- NSCursor.arrow.set()
- }
- }
- }
- }
- .contentShape(Rectangle())
- .onHover { model.setNotificationStackHovered($0) }
- .padding(.trailing, WorkbenchLayoutMetrics.rightActivityBarWidth + 12)
- .padding(.bottom, 38)
+ .overlay(alignment: .bottom) {
+ if let message = model.notificationMessage {
+ Text(LocalizedStringKey(message))
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .padding(.horizontal, 14)
+ .frame(height: 34)
+ .background(LitheTheme.raised)
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+ .shadow(color: .black.opacity(0.35), radius: 12, y: 4)
+ .padding(.bottom, 38)
}
}
.overlay {
@@ -401,6 +397,46 @@ struct WorkbenchView: View {
}
}
}
+ .overlay(alignment: .bottomTrailing) {
+ if !model.activeNotifications.isEmpty {
+ VStack(alignment: .trailing, spacing: 8) {
+ ForEach(model.activeNotifications) { notification in
+ HStack(alignment: .center, spacing: 10) {
+ Image(systemName: "info.circle.fill")
+ .font(.system(size: 14))
+ .foregroundStyle(LitheTheme.accent)
+ Text(LocalizedStringKey(notification.message))
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer(minLength: 4)
+ Button {
+ model.dismissNotification(notification.id)
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ }
+ .buttonStyle(.plain)
+ .frame(width: 16, height: 16)
+ .contentShape(Rectangle())
+ .litheRowHover(cornerRadius: LitheTheme.Metrics.cornerRadius, animation: nil)
+ .accessibilityLabel("Dismiss notification")
+ }
+ .padding(.leading, 12)
+ .padding(.trailing, 6)
+ .padding(.vertical, 10)
+ .frame(minWidth: 280, maxWidth: 360, alignment: .topLeading)
+ .background(LitheTheme.notificationBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+ .contentShape(RoundedRectangle(cornerRadius: 7))
+ }
+ }
+ .onHover { model.setNotificationStackHovered($0) }
+ .padding(.trailing, WorkbenchLayoutMetrics.rightActivityBarWidth + 12)
+ .padding(.bottom, 38)
+ }
+ }
.frame(height: LitheTheme.Metrics.tabHeight + 4)
.background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.toolHeader)
}
@@ -518,6 +554,11 @@ struct WorkbenchView: View {
value: .bounds
) { $0 }
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(width: 1, height: 20)
+ .padding(.horizontal, 5)
+
Button {
updateSwitcherPresentation(
project: false,
@@ -678,6 +719,10 @@ struct WorkbenchView: View {
updateSwitcherPresentation(branch: false)
pendingTopBarPushReference = reference
},
+ onDelete: { reference in
+ updateSwitcherPresentation(branch: false)
+ pendingTopBarDeleteReference = reference
+ },
onNewBranch: { reference in
updateSwitcherPresentation(branch: false)
newBranchReference = reference
@@ -1102,6 +1147,7 @@ struct WorkbenchView: View {
from: model.rightSidebarContributions,
model: model
)
+ .equatable()
.environmentObject(linuxDoWebSession)
.frame(width: rightSidebarWidth)
.frame(maxHeight: .infinity)
@@ -1134,6 +1180,9 @@ struct WorkbenchView: View {
}
}
}
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(width: 1)
pluginActivityBar
}
.fixedSize(horizontal: true, vertical: false)
@@ -1239,26 +1288,22 @@ struct WorkbenchView: View {
WorkbenchWorkspaceSplitView(
sidebarWidth: sidebarWidth,
topPaneHeight: topPaneHeight,
- sidebarPaneBackground: model.selectedSidebar == .changes
- ? LitheTheme.toolHeader
- : LitheTheme.editor,
- minimumTopPaneHeight: model.selectedSidebar == .changes
- ? WorkbenchWorkspaceMetrics.changesMinimumTopPaneHeight
- : WorkbenchWorkspaceMetrics.minimumTopPaneHeight,
isBottomToolVisible: isBottomToolVisible,
- onSidebarWidthCommitted: { width in
- sidebarWidth = width
- saveLayout(sidebarWidth: width, topPaneHeight: topPaneHeight)
- },
- onTopPaneHeightCommitted: { height in
- topPaneHeight = height
- saveLayout(sidebarWidth: sidebarWidth, topPaneHeight: height)
- },
+ actions: WorkbenchWorkspaceSplitActions(
+ onSidebarWidthCommitted: { width in
+ sidebarWidth = width
+ saveLayout(sidebarWidth: width, topPaneHeight: topPaneHeight)
+ },
+ onTopPaneHeightCommitted: { height in
+ topPaneHeight = height
+ saveLayout(sidebarWidth: sidebarWidth, topPaneHeight: height)
+ },
+ onBottomToolMinimize: {
+ model.closeGitLog()
+ }
+ ),
showsBottomToolMinimize: model.isGitLogVisible,
hasWorkbenchBackground: model.workbenchBackgroundFeature.hasImage,
- onBottomToolMinimize: {
- model.closeGitLog()
- },
sidebar: {
activeSidebar(projectTreeRowHeight: settings.projectTreeRowHeight)
},
@@ -1289,6 +1334,7 @@ struct WorkbenchView: View {
from: model.activityBarContributions,
model: model
)
+ .equatable()
}
}
}
@@ -1524,6 +1570,10 @@ private struct WorkbenchNotificationCenterView: View {
.padding(.horizontal, 14)
.frame(height: 38)
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(height: 1)
+
if model.notifications.isEmpty {
VStack(spacing: 8) {
Image(systemName: "bell")
@@ -1580,17 +1630,24 @@ private struct WorkbenchNotificationCenterView: View {
}
}
+/// The callbacks the workspace split view hands back to the workbench.
+///
+/// Grouped into one value, following `GitGraphRowActions`, so the split view
+/// carries a single stored property instead of three freshly allocated escaping
+/// closures per parent body pass.
+private struct WorkbenchWorkspaceSplitActions {
+ let onSidebarWidthCommitted: (CGFloat) -> Void
+ let onTopPaneHeightCommitted: (CGFloat) -> Void
+ let onBottomToolMinimize: () -> Void
+}
+
private struct WorkbenchWorkspaceSplitView: View {
let sidebarWidth: CGFloat
let topPaneHeight: CGFloat?
- let sidebarPaneBackground: Color
- let minimumTopPaneHeight: CGFloat
let isBottomToolVisible: Bool
- let onSidebarWidthCommitted: (CGFloat) -> Void
- let onTopPaneHeightCommitted: (CGFloat) -> Void
+ let actions: WorkbenchWorkspaceSplitActions
let showsBottomToolMinimize: Bool
let hasWorkbenchBackground: Bool
- let onBottomToolMinimize: () -> Void
let sidebar: Sidebar
let editor: Editor
let bottomTool: BottomTool
@@ -1603,28 +1660,20 @@ private struct WorkbenchWorkspaceSplitView Void,
- onTopPaneHeightCommitted: @escaping (CGFloat) -> Void,
+ actions: WorkbenchWorkspaceSplitActions,
showsBottomToolMinimize: Bool,
hasWorkbenchBackground: Bool,
- onBottomToolMinimize: @escaping () -> Void,
@ViewBuilder sidebar: () -> Sidebar,
@ViewBuilder editor: () -> Editor,
@ViewBuilder bottomTool: () -> BottomTool
) {
self.sidebarWidth = sidebarWidth
self.topPaneHeight = topPaneHeight
- self.sidebarPaneBackground = sidebarPaneBackground
- self.minimumTopPaneHeight = minimumTopPaneHeight
self.isBottomToolVisible = isBottomToolVisible
- self.onSidebarWidthCommitted = onSidebarWidthCommitted
- self.onTopPaneHeightCommitted = onTopPaneHeightCommitted
+ self.actions = actions
self.showsBottomToolMinimize = showsBottomToolMinimize
self.hasWorkbenchBackground = hasWorkbenchBackground
- self.onBottomToolMinimize = onBottomToolMinimize
self.sidebar = sidebar()
self.editor = editor()
self.bottomTool = bottomTool()
@@ -1634,12 +1683,12 @@ private struct WorkbenchWorkspaceSplitView some View {
+ SplitHandleView(
+ axis: .horizontal,
+ showsIdleDivider: false,
+ onDragStarted: {
+ sidebarDragStart = resolvedSidebarWidth
+ },
+ onDragChanged: { translation in
+ liveSidebarWidth = constrained(
+ sidebarDragStart + translation,
+ minimum: minimumSidebarWidth,
+ maximum: maximumSidebarWidth
+ )
+ },
+ onDragEnded: { translation in
+ let finalWidth = constrained(
+ sidebarDragStart + translation,
+ minimum: minimumSidebarWidth,
+ maximum: maximumSidebarWidth
+ )
+ liveSidebarWidth = finalWidth
+ actions.onSidebarWidthCommitted(finalWidth)
+ }
+ )
+ .padding(.top, WorkbenchWorkspaceMetrics.paneInset)
+ .padding(.bottom, bottomInset)
+ .offset(
+ x: WorkbenchWorkspaceMetrics.paneInset
+ + resolvedSidebarWidth
+ + WorkbenchWorkspaceMetrics.paneSpacing / 2
+ - SplitHandleView.thickness / 2
+ )
+ }
+
+ private func topPaneResizeHandle(
+ resolvedTopPaneHeight: CGFloat,
+ minimumTopPaneHeight: CGFloat,
+ maximumTopPaneHeight: CGFloat
+ ) -> some View {
+ SplitHandleView(
+ axis: .vertical,
+ showsIdleDivider: false,
+ onDragStarted: {
+ topPaneDragStart = resolvedTopPaneHeight
+ },
+ onDragChanged: { translation in
+ liveTopPaneHeight = constrained(
+ topPaneDragStart + translation,
+ minimum: minimumTopPaneHeight,
+ maximum: maximumTopPaneHeight
+ )
+ },
+ onDragEnded: { translation in
+ let finalHeight = constrained(
+ topPaneDragStart + translation,
+ minimum: minimumTopPaneHeight,
+ maximum: maximumTopPaneHeight
+ )
+ liveTopPaneHeight = finalHeight
+ actions.onTopPaneHeightCommitted(finalHeight)
+ }
+ )
+ .padding(.horizontal, WorkbenchWorkspaceMetrics.paneInset)
+ .offset(
+ y: resolvedTopPaneHeight
+ + WorkbenchWorkspaceMetrics.paneSpacing / 2
+ - SplitHandleView.thickness / 2
+ )
+ }
+
private func constrained(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat {
min(max(value, minimum), maximum)
}
@@ -1845,13 +1917,26 @@ private struct WorkbenchPaneChromeModifier: ViewModifier {
@ViewBuilder
func body(content: Content) -> some View {
if roundsCorners {
+ // Four fixed-size corner notches instead of one pane-sized even-odd
+ // fill. The notch geometry only depends on the corner radius, so it
+ // is built once and merely repositioned while a pane resizes, rather
+ // than re-tessellating a full-pane vector path every frame. Absolute
+ // positioning (not leading/trailing alignment) keeps the notches on
+ // the same physical corners the previous fill used.
content
.background(background)
.overlay {
- WorkbenchPaneCornerCutouts(
- cornerRadius: WorkbenchWorkspaceMetrics.paneCornerRadius
- )
- .fill(surrounding, style: FillStyle(eoFill: true))
+ GeometryReader { proxy in
+ let radius = WorkbenchWorkspaceMetrics.paneCornerRadius
+ let half = radius / 2
+ ZStack {
+ notch(.topLeading).position(x: half, y: half)
+ notch(.topTrailing).position(x: proxy.size.width - half, y: half)
+ notch(.bottomLeading).position(x: half, y: proxy.size.height - half)
+ notch(.bottomTrailing)
+ .position(x: proxy.size.width - half, y: proxy.size.height - half)
+ }
+ }
.allowsHitTesting(false)
.accessibilityHidden(true)
}
@@ -1859,21 +1944,135 @@ private struct WorkbenchPaneChromeModifier: ViewModifier {
content.background(background)
}
}
-}
-private struct WorkbenchPaneCornerCutouts: Shape {
- let cornerRadius: CGFloat
+ private func notch(_ corner: WorkbenchPaneCornerGeometry.Corner) -> some View {
+ WorkbenchPaneCornerNotch(corner: corner)
+ .fill(surrounding)
+ .frame(
+ width: WorkbenchWorkspaceMetrics.paneCornerRadius,
+ height: WorkbenchWorkspaceMetrics.paneCornerRadius
+ )
+ }
+}
+/// One corner of the gap between a pane's square bounds and its rounded
+/// silhouette, painted in the surrounding color so the pane reads as rounded
+/// without clipping the AppKit-backed content inside it.
+///
+/// The path is a compile-time constant: the radius is fixed, so every instance
+/// reuses the same geometry and resizing a pane only moves it.
+private struct WorkbenchPaneCornerNotch: Shape {
+ let corner: WorkbenchPaneCornerGeometry.Corner
+
+ /// Ignores `rect` because the caller always frames this at exactly
+ /// `paneCornerRadius` square; honoring an arbitrary rect would mean
+ /// rebuilding the path on every layout, which is the cost being removed.
func path(in rect: CGRect) -> Path {
+ WorkbenchPaneCornerGeometry.path(for: corner)
+ }
+}
+
+/// Pure geometry for the four pane corner notches, separated from the `Shape`
+/// so the arc direction can be verified without rendering.
+enum WorkbenchPaneCornerGeometry {
+ enum Corner: CaseIterable {
+ case topLeading
+ case topTrailing
+ case bottomLeading
+ case bottomTrailing
+ }
+
+ /// The notch path in a `radius`-square box, cached per corner.
+ static func path(for corner: Corner) -> Path {
+ paths[corner] ?? Path()
+ }
+
+ private static let radius = WorkbenchWorkspaceMetrics.paneCornerRadius
+
+ private static let paths: [Corner: Path] = Dictionary(
+ uniqueKeysWithValues: Corner.allCases.map { ($0, makePath(for: $0, radius: radius)) }
+ )
+
+ static func makePath(for corner: Corner, radius: CGFloat) -> Path {
+ // The arc is centered on the box corner diagonally opposite the pane
+ // corner being rounded, so it stays tangent to both pane edges.
+ let center: CGPoint
+ let start: CGPoint
+ let end: CGPoint
+ switch corner {
+ case .topLeading:
+ center = CGPoint(x: radius, y: radius)
+ start = CGPoint(x: radius, y: 0)
+ end = CGPoint(x: 0, y: radius)
+ case .topTrailing:
+ center = CGPoint(x: 0, y: radius)
+ start = CGPoint(x: 0, y: 0)
+ end = CGPoint(x: radius, y: radius)
+ case .bottomLeading:
+ center = CGPoint(x: radius, y: 0)
+ start = CGPoint(x: radius, y: radius)
+ end = CGPoint(x: 0, y: 0)
+ case .bottomTrailing:
+ center = CGPoint(x: 0, y: 0)
+ start = CGPoint(x: 0, y: radius)
+ end = CGPoint(x: radius, y: 0)
+ }
+
+ // Quarter arc as a cubic Bézier. Building it from the two tangent points
+ // rather than sweep angles keeps the direction unambiguous in SwiftUI's
+ // y-down space, where `clockwise:` reads inverted.
+ let handle = radius * 0.5522847498307936
+ let startTangent = unitTangent(from: center, through: start, toward: end)
+ let endTangent = unitTangent(from: center, through: end, toward: start)
+
var path = Path()
- path.addRect(rect)
- path.addRoundedRect(
- in: rect,
- cornerSize: CGSize(width: cornerRadius, height: cornerRadius),
- style: .continuous
+ path.move(to: paneCorner(for: corner, radius: radius))
+ path.addLine(to: start)
+ path.addCurve(
+ to: end,
+ control1: CGPoint(
+ x: start.x + startTangent.dx * handle,
+ y: start.y + startTangent.dy * handle
+ ),
+ control2: CGPoint(
+ x: end.x + endTangent.dx * handle,
+ y: end.y + endTangent.dy * handle
+ )
)
+ path.closeSubpath()
return path
}
+
+ /// The square corner the notch fills in, in box-local coordinates.
+ private static func paneCorner(for corner: Corner, radius: CGFloat) -> CGPoint {
+ switch corner {
+ case .topLeading: CGPoint(x: 0, y: 0)
+ case .topTrailing: CGPoint(x: radius, y: 0)
+ case .bottomLeading: CGPoint(x: 0, y: radius)
+ case .bottomTrailing: CGPoint(x: radius, y: radius)
+ }
+ }
+
+ /// Unit tangent to the circle at `point`, oriented so the arc sweeps toward
+ /// `destination` along the 90-degree side.
+ private static func unitTangent(
+ from center: CGPoint,
+ through point: CGPoint,
+ toward destination: CGPoint
+ ) -> CGVector {
+ let radial = CGVector(dx: point.x - center.x, dy: point.y - center.y)
+ // Rotating the radius by 90 degrees gives the tangent; the sign that
+ // points at the other endpoint is the one that sweeps the minor arc.
+ let candidate = CGVector(dx: -radial.dy, dy: radial.dx)
+ let towardDestination = CGVector(
+ dx: destination.x - point.x,
+ dy: destination.y - point.y
+ )
+ let alignment = candidate.dx * towardDestination.dx + candidate.dy * towardDestination.dy
+ let length = max(hypot(radial.dx, radial.dy), 0.0001)
+ let sign: CGFloat = alignment >= 0 ? 1 : -1
+ return CGVector(dx: sign * candidate.dx / length, dy: sign * candidate.dy / length)
+ }
}
private struct WorkbenchBackgroundImageView: View {
@@ -1885,15 +2084,14 @@ private struct WorkbenchBackgroundImageView: View {
ZStack {
LitheTheme.window
- GeometryReader { geometry in
- if let image {
- Image(nsImage: image)
- .resizable()
- .scaledToFill()
- .frame(width: geometry.size.width, height: geometry.size.height)
- .clipped()
- .opacity(opacity)
- }
+ if let image {
+ // Fill and clip at the container instead of measuring with a
+ // GeometryReader, so a window resize no longer re-evaluates a
+ // geometry closure just to restate the size the layout offers.
+ Image(nsImage: image)
+ .resizable()
+ .scaledToFill()
+ .opacity(opacity)
}
// Preserve the source image's colour while keeping text legible.
@@ -1902,7 +2100,11 @@ private struct WorkbenchBackgroundImageView: View {
// effect at the full 100% setting.
(colorScheme == .dark ? Color.black.opacity(0.46) : Color.white.opacity(0.25))
}
- .compositingGroup()
+ .clipped()
+ // Deliberately not a compositing group: no group-wide opacity or blend
+ // mode is applied here, so flattening these layers offscreen changed
+ // nothing visually while forcing the whole window to recomposite on
+ // every resize.
.allowsHitTesting(false)
}
}
diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift
index 3067d8872..229872c74 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.
@@ -43,7 +51,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?
@@ -170,6 +198,8 @@ package final class GitFeatureModel: ObservableObject {
pendingConflictRollback = nil
pendingStashRestoreConflict = nil
isStashRestoreConflictNoticeVisible = false
+ recentlyDeletedTag = nil
+ recentlyDeletedBranch = nil
gitConflictFilterPaths = []
requestedStashReference = nil
deferredSavedChanges = nil
@@ -1634,10 +1664,129 @@ 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 result.succeeded, let deletion = result.branchDeletion {
+ recentlyDeletedBranch = deletion
+ notify?(successfulMessage(result, fallback: "Deleted branch \(deletion.name)"))
+ } else {
+ 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 {
+ 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..f2f5fd5ef 100644
--- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift
+++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift
@@ -441,9 +441,653 @@ 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 gitTagDeletionFailureKeepsThePreviousRecoveryRecord() async {
+ var notifications: [String] = []
+ let results = GitProcessResultQueue([
+ GitProcessResult(
+ arguments: ["tag", "-d", "v1.0"],
+ output: "Deleted tag 'v1.0'\n",
+ exitCode: 0,
+ tagDeletion: GitTagDeletion(
+ name: "v1.0",
+ deletedTarget: "abc123def456",
+ kind: .lightweight,
+ message: nil
+ )
+ ),
+ GitProcessResult(
+ arguments: ["tag", "-d", "missing"],
+ output: "The tag 'missing' does not exist",
+ exitCode: 1
+ )
+ ])
+ let (feature, _) = makeTagTestFeature(
+ TestGitOperations(
+ snapshotValue: GitSnapshot(repositoryRoot: URL(fileURLWithPath: "/workspace"), branch: "main", changes: []),
+ deleteTagResults: results
+ ),
+ onNotify: { notifications.append($0) }
+ )
+ await feature.refreshGit()
+
+ for name in ["v1.0", "missing"] {
+ await feature.deleteTag(GitReference(
+ fullName: "refs/tags/\(name)",
+ shortName: name,
+ kind: .tag,
+ isCurrent: false,
+ upstreamShortName: nil
+ ))
+ }
+
+ #expect(feature.recentlyDeletedTag?.name == "v1.0")
+ #expect(notifications == ["Deleted tag v1.0", "The tag 'missing' does not exist"])
+ }
+
+ @Test
+ func gitTagRestoreReplaysRecordedNameTargetAndMessage() async {
+ var notifications: [String] = []
+ 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 gitBranchDeletionFailureKeepsThePreviousRecoveryRecord() 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?.name == "feature/a")
+ #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 +2100,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 Git 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 +2179,14 @@ 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 deleteTagResults: GitProcessResultQueue?
+ 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 +2200,15 @@ 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,
+ deleteTagResults: GitProcessResultQueue? = nil,
+ tagCallRecorder: TagCallRecorder? = nil,
+ createBranchResult: GitProcessResult? = nil,
+ deleteBranchResult: GitProcessResult? = nil,
+ deleteBranchResults: GitProcessResultQueue? = nil,
+ branchCallRecorder: BranchCallRecorder? = nil
) {
self.snapshotValue = snapshotValue
self.comparisonValue = comparisonValue
@@ -1496,6 +2222,14 @@ private struct TestGitOperations: GitOperations {
self.runGate = runGate
self.filesRecorder = filesRecorder
self.filesGate = filesGate
+ self.createTagResult = createTagResult
+ self.deleteTagResult = deleteTagResult
+ self.deleteTagResults = deleteTagResults
+ 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 +2274,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 +2322,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 deleteTagResults?.next() ?? deleteTagResult
+ }
}
diff --git a/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift
new file mode 100644
index 000000000..6928b550f
--- /dev/null
+++ b/macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift
@@ -0,0 +1,118 @@
+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 source(at relativePath: String) throws -> String {
+ let repositoryRoot = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ return try String(
+ contentsOf: repositoryRoot.appendingPathComponent(relativePath),
+ 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
+ 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.. 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)
+ }
+ }
+}
diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs
index 4f5328cd3..4270ac236 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 Result {
+ // Branch restore records carry the deleted commit object ID rather than a
+ // live ref. Accept that exact revision only for createBranch; every other
+ // mutation continues to require a typed, existing Git reference.
+ if request.operation == "createBranch" {
+ if let Some(reference) = request.git_reference.as_ref() {
+ if reference.full_name == reference.short_name {
+ if let Ok(revision) = validated_revision(Some(&reference.full_name)) {
+ return Ok(revision);
+ }
+ }
+ }
+ }
optional_write_request_reference(root, request)?
.ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Missing Git reference"))
}
@@ -3139,6 +3227,8 @@ fn successful_git_result() -> GitCommandResponse {
invocations: Vec::new(),
operation_error: None,
stash_restore: None,
+ tag_deletion: None,
+ branch_deletion: None,
warnings: Vec::new(),
}
}
@@ -3739,6 +3829,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