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

+

+ Featured | HelloGitHub +

+

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

+

+ Featured | HelloGitHub +

+

加入 Lithe QQ 群 163027877 加入 Lithe 微信交流群 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, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + format!("{target}^{{commit}}"), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes one tag and returns a structured deletion record so the host can +/// offer a restore. The probes run before the deletion because `git tag -d` +/// diagnostics are localized prose that cannot be mapped to stable errors. +fn delete_tag(root: &str, value: Option<&str>) -> Result { + let name = validated_tag_name(value)?; + let reference = format!("refs/tags/{name}"); + let expected_object = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + ) + })?; + let object_type = execute_git( + root, + &["cat-file".into(), "-t".into(), expected_object.clone()], + None, + )?; + if object_type.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The tag '{name}' does not exist"), + )); + } + let is_annotated = object_type.stdout.trim() == "tag"; + let mut message = None; + if is_annotated { + let tag_object = execute_git( + root, + &["cat-file".into(), "tag".into(), expected_object.clone()], + None, + )?; + if tag_object.exit_code == 0 { + message = annotation_message_from_tag_object(&tag_object.stdout); + } + } + // Peel the ref to a commit so pre-existing tree/blob tags cannot produce + // a recovery record that violates the restore contract. + let peeled = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + format!("{expected_object}^{{commit}}"), + ], + None, + )?; + if peeled.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve tag target '{name}'"), + ) + .with_details(peeled.output)); + } + let mut result = delete_ref_if_unchanged(root, &reference, &expected_object)?; + if result.exit_code == 0 { + result.tag_deletion = Some(GitTagDeletionResponse { + name, + deleted_target: peeled.stdout.trim().to_string(), + kind: if is_annotated { + "annotated" + } else { + "lightweight" + } + .to_string(), + message, + }); + } + Ok(result) +} + +/// Deletes one local branch and returns a structured deletion record so the +/// host can offer a restore. The commit is resolved before the deletion +/// because `git branch -d` diagnostics are localized prose. +fn delete_branch(root: &str, branch: &str) -> Result { + let reference = format!("refs/heads/{branch}"); + let target = resolve_ref_object(root, &reference)?.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' does not exist"), + ) + })?; + ensure_branch_is_safely_deletable(root, branch, &reference, &target)?; + let mut result = delete_ref_if_unchanged(root, &reference, &target)?; + if result.exit_code == 0 { + result.branch_deletion = Some(GitBranchDeletionResponse { + name: branch.to_string(), + deleted_target: target, + }); + if let Err(error) = remove_branch_config(root, branch) { + // The ref mutation already committed. Preserve recovery data and + // report configuration cleanup as a diagnosable partial success. + result.warnings.push(GitOperationWarning::new( + "branch_config_cleanup_failed", + &error.message, + error.details, + )); + } + } + Ok(result) +} + +/// Resolves an exact refname to its current unpeeled object id. +fn resolve_ref_object(root: &str, reference: &str) -> Result, CoreError> { + let probe = execute_git( + root, + &[ + "rev-parse".into(), + "--verify".into(), + "--quiet".into(), + reference.to_string(), + ], + None, + )?; + Ok((probe.exit_code == 0).then(|| probe.stdout.trim().to_string())) +} + +/// Deletes a ref only when it still points at the object observed by the +/// caller. `update-ref` performs the comparison and mutation under the same +/// ref lock, closing the probe-then-mutate race. +fn delete_ref_if_unchanged( + root: &str, + reference: &str, + expected_object: &str, +) -> Result { + let mut result = execute_git( + root, + &[ + "update-ref".into(), + "-d".into(), + reference.to_string(), + expected_object.to_string(), + ], + None, + )?; + if result.exit_code != 0 { + result.operation_error = Some( + CoreError::new( + ErrorCode::InvalidRequest, + format!("The Git reference '{reference}' changed before it could be deleted"), + ) + .with_details(result.output.clone()), + ); + } + Ok(result) +} + +/// Preserves `git branch -d` safety before the atomic ref mutation: a branch +/// must not be checked out in any worktree and must be merged into its valid +/// upstream, or into HEAD when it has no usable upstream. +fn ensure_branch_is_safely_deletable( + root: &str, + branch: &str, + reference: &str, + target: &str, +) -> Result<(), CoreError> { + let worktrees = execute_git( + root, + &["worktree".into(), "list".into(), "--porcelain".into()], + None, + )?; + if worktrees.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Could not inspect Git worktrees") + .with_details(worktrees.output), + ); + } + if worktrees + .stdout + .lines() + .any(|line| line == format!("branch {reference}")) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is checked out in a worktree"), + )); + } + + let upstream = execute_git( + root, + &[ + "for-each-ref".into(), + "--format=%(upstream)".into(), + reference.to_string(), + ], + None, + )?; + if upstream.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect the branch upstream", + ) + .with_details(upstream.output)); + } + let upstream = upstream.stdout.trim(); + let merge_target = if !upstream.is_empty() && resolved_commit_target(root, upstream)?.is_some() + { + upstream + } else { + "HEAD" + }; + let merged = execute_git( + root, + &[ + "merge-base".into(), + "--is-ancestor".into(), + target.to_string(), + merge_target.to_string(), + ], + None, + )?; + match merged.exit_code { + 0 => Ok(()), + 1 => Err(CoreError::new( + ErrorCode::InvalidRequest, + format!("The branch '{branch}' is not fully merged"), + )), + _ => Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not verify whether branch '{branch}' is merged"), + ) + .with_details(merged.output)), + } +} + +/// Removes branch-local configuration after the ref has been deleted, matching +/// the metadata cleanup performed by `git branch -d`. +fn remove_branch_config(root: &str, branch: &str) -> Result<(), CoreError> { + let listing = capture_git_with_options( + root, + &[ + "config".into(), + "--name-only".into(), + "--get-regexp".into(), + "^branch\\.".into(), + ], + None, + false, + )?; + if listing.exit_code == 1 { + return Ok(()); + } + if listing.exit_code != 0 { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not inspect branch configuration", + ) + .with_details(String::from_utf8_lossy(&listing.stderr))); + } + let prefix = format!("branch.{branch}."); + if !String::from_utf8_lossy(&listing.stdout) + .lines() + .any(|key| key.starts_with(&prefix)) + { + return Ok(()); + } + let cleanup = capture_git_with_options( + root, + &[ + "config".into(), + "--remove-section".into(), + format!("branch.{branch}"), + ], + None, + false, + )?; + if cleanup.exit_code == 0 { + return Ok(()); + } + Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not remove configuration for deleted branch '{branch}'"), + ) + .with_details(String::from_utf8_lossy(&cleanup.stderr))) +} + +/// Extracts the annotation message from a raw tag object byte-for-byte, so a +/// restored tag keeps the original message including CRLF line endings and +/// trailing newlines. Only the signature block is cut, by locating its first +/// line in the raw content; the signature belongs to the previous tagger and +/// a restored tag would be signed separately. +fn annotation_message_from_tag_object(raw: &str) -> Option { + let (_, message) = raw.split_once("\n\n")?; + let mut offset = 0; + for line in message.split_inclusive('\n') { + let without_eol = line.trim_end_matches(['\r', '\n']); + if without_eol.starts_with("-----BEGIN ") && without_eol.ends_with("SIGNATURE-----") { + return Some(message[..offset].to_string()); + } + offset += line.len(); + } + Some(message.to_string()) +} + fn local_branch_name(reference: &str) -> Result { let branch = reference .strip_prefix("refs/heads/") @@ -3807,6 +4263,8 @@ fn failed_git_result(error: CoreError) -> GitCommandResponse { invocations: Vec::new(), operation_error: Some(error), stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), } } @@ -4812,7 +5270,7 @@ fn switch_validated_reference( fn parse_reference(line: &str) -> Option { let columns = line.split('\t').collect::>(); - if columns.len() < 4 || columns[1].ends_with("/HEAD") { + if columns.len() < 8 || columns[1].ends_with("/HEAD") { return None; } let kind = if columns[0].starts_with("refs/heads/") { @@ -4841,6 +5299,7 @@ fn parse_reference(line: &str) -> Option { // `fullName` and always exposes the namespace-relative short name. short_name: short_name.to_string(), kind: kind.to_string(), + peels_to_commit: kind != "tag" || columns[6] == "commit" || columns[7] == "commit", is_current: columns[2].trim() == "*", upstream_short_name, ahead, @@ -5596,8 +6055,9 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { #[cfg(test)] mod tests { use super::{ - line_similarity, pair_diff_entries, parse_diff, structured_diff_from_output, DiffEntry, - GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, + annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_diff, + structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, + GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitPushPreviewResponse, @@ -5605,6 +6065,47 @@ mod tests { }; use serde_json::Value; + #[test] + fn tag_annotation_parser_preserves_crlf_and_trailing_blank_lines() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "details\r\n", + "\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\ndetails\r\n\r\n") + ); + } + + #[test] + fn tag_annotation_parser_removes_only_the_signature_block() { + let raw = concat!( + "object abc123\n", + "type commit\n", + "tag v1.0\n", + "tagger Lithe Test 0 +0000\n", + "\n", + "release\r\n", + "\r\n", + "-----BEGIN PGP SIGNATURE-----\r\n", + "signature-data\r\n", + "-----END PGP SIGNATURE-----\r\n" + ); + + assert_eq!( + annotation_message_from_tag_object(raw).as_deref(), + Some("release\r\n\r\n") + ); + } + #[cfg(target_os = "windows")] #[test] fn background_git_processes_do_not_create_windows_console() { @@ -5656,6 +6157,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; @@ -5835,6 +6338,8 @@ mod tests { ], operation_error: None, stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; @@ -5855,6 +6360,7 @@ mod tests { full_name: "refs/heads/feature/recent".into(), short_name: "feature/recent".into(), kind: "local".into(), + peels_to_commit: true, is_current: true, upstream_short_name: None, ahead: 0, @@ -5864,6 +6370,7 @@ mod tests { full_name: "refs/heads/main".into(), short_name: "main".into(), kind: "local".into(), + peels_to_commit: true, is_current: false, upstream_short_name: Some("origin/main".into()), ahead: 2, @@ -5966,6 +6473,8 @@ mod tests { "Invalid Git reference", )), stash_restore: None, + tag_deletion: None, + branch_deletion: None, warnings: Vec::new(), }; diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index eac88a016..bb5ef2fb3 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -407,6 +407,9 @@ pub struct GitReferenceResponse { pub short_name: String, /// Reference category: local branch, remote branch, or tag. pub kind: String, + /// Whether the reference resolves to a commit and therefore supports + /// commit-only mutations such as restorable tag deletion. + pub peels_to_commit: bool, pub is_current: bool, pub upstream_short_name: Option, /// Commits present only on this local branch compared with its upstream. diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index f32fe04d7..37f1199f6 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1512,6 +1512,483 @@ fn git_write_executes_checkout_preflight_clone_and_validation() { fs::remove_dir_all(root).expect("temporary repository should be removable"); } +#[test] +fn git_write_creates_deletes_and_records_tags() { + let root = temporary_root("git-tags"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + let head = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + // A lightweight tag points straight at the target commit and never + // carries the structured deletion record. + let lightweight = request( + "createTag", + serde_json::json!({"name": "v1.0", "revision": "HEAD"}), + ); + assert_eq!(lightweight["ok"], true, "{lightweight:?}"); + assert!(lightweight["data"].get("tagDeletion").is_none()); + assert_eq!( + String::from_utf8_lossy(&run(&["rev-parse", "refs/tags/v1.0"]).stdout).trim(), + head + ); + + // An annotation creates a tag object whose CRLF and trailing blank lines + // must survive the later delete/restore round trip byte for byte. + let annotated = request( + "createTag", + serde_json::json!({ + "name": "v2.0", + "revision": "HEAD", + "message": "release\r\n\r\nsecond paragraph\r\n\r\n" + }), + ); + assert_eq!(annotated["ok"], true, "{annotated:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["cat-file", "-t", "refs/tags/v2.0"]).stdout).trim(), + "tag" + ); + let annotated_tag_object = + String::from_utf8_lossy(&run(&["rev-parse", "refs/tags/v2.0"]).stdout) + .trim() + .to_string(); + + // An empty name is a missing required field. The format matrix below is + // shared with the macOS dialog through `tag-names.json`, so both sides + // reject exactly the same names: `git check-ref-format` refname rules, + // per-component checks such as `foo/.bar`, and the command-line guards. + // Each rejection must happen before any subprocess so the error stays a + // plain invalid_request envelope. + let tag_names: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/tag-names.json" + ))) + .expect("tag name fixture should be valid JSON"); + let empty = request( + "createTag", + serde_json::json!({"name": "", "revision": "HEAD"}), + ); + assert_eq!(empty["ok"], false, "{empty:?}"); + assert_eq!(empty["error"]["code"], "invalid_request"); + assert_eq!(empty["error"]["message"], "Missing or invalid Git tag name"); + for name in tag_names["invalid"].as_array().expect("invalid list") { + let name = name.as_str().expect("invalid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], false, "name {name:?}: {response:?}"); + assert_eq!( + response["error"]["code"], "invalid_request", + "name {name:?}" + ); + } + for name in tag_names["valid"].as_array().expect("valid list") { + let name = name.as_str().expect("valid name should be a string"); + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": "HEAD"}), + ); + assert_eq!(response["ok"], true, "name {name:?}: {response:?}"); + } + + // A duplicate name is a structured operation error so callers can show it + // in place instead of parsing Git's stderr. + let duplicate = request( + "createTag", + serde_json::json!({"name": "v1.0", "revision": "HEAD"}), + ); + assert_eq!(duplicate["ok"], true, "{duplicate:?}"); + assert_eq!( + duplicate["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + duplicate["data"]["operationError"]["message"], + "A tag named 'v1.0' already exists" + ); + + let unresolvable = request( + "createTag", + serde_json::json!({"name": "v3.0", "revision": "no-such-revision"}), + ); + assert_eq!(unresolvable["ok"], true, "{unresolvable:?}"); + assert_eq!( + unresolvable["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + unresolvable["data"]["operationError"]["message"], + "Could not resolve tag target 'no-such-revision'" + ); + + // Tree and blob revisions are valid Git objects but not valid targets for + // this commit-only contract. Neither rejection may leave a tag behind. + let tree = String::from_utf8_lossy(&run(&["rev-parse", "HEAD^{tree}"]).stdout) + .trim() + .to_string(); + let blob = String::from_utf8_lossy(&run(&["hash-object", "example.txt"]).stdout) + .trim() + .to_string(); + for (name, revision) in [("tree-target", tree), ("blob-target", blob)] { + let response = request( + "createTag", + serde_json::json!({"name": name, "revision": revision}), + ); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!( + response["data"]["operationError"]["message"], + format!("Could not resolve tag target '{revision}'") + ); + assert_ne!( + run(&[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/tags/{name}") + ]) + .status + .code(), + Some(0) + ); + } + + // Deleting a lightweight tag reports the commit it pointed at. + let delete_lightweight = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(delete_lightweight["ok"], true, "{delete_lightweight:?}"); + assert_eq!(delete_lightweight["data"]["exitCode"], 0); + assert_eq!(delete_lightweight["data"]["tagDeletion"]["name"], "v1.0"); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["kind"], + "lightweight" + ); + assert_eq!( + delete_lightweight["data"]["tagDeletion"]["deletedTarget"], + head + ); + assert!(delete_lightweight["data"]["invocations"] + .as_array() + .expect("tag deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/tags/v1.0", head]) + })); + assert!(delete_lightweight["data"]["tagDeletion"] + .get("message") + .is_none()); + assert!( + run(&["rev-parse", "--verify", "--quiet", "refs/tags/v1.0"]) + .status + .code() + != Some(0) + ); + + // Deleting an annotated tag carries the peeled commit and the original + // annotation so a restore can rebuild the message byte for byte. + let delete_annotated = request("deleteTag", serde_json::json!({"name": "v2.0"})); + assert_eq!(delete_annotated["ok"], true, "{delete_annotated:?}"); + let deletion = &delete_annotated["data"]["tagDeletion"]; + assert_eq!(deletion["name"], "v2.0"); + assert_eq!(deletion["kind"], "annotated"); + assert_eq!( + deletion["message"], + "release\r\n\r\nsecond paragraph\r\n\r\n" + ); + assert_eq!(deletion["deletedTarget"], head); + assert!(delete_annotated["data"]["invocations"] + .as_array() + .expect("annotated tag deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/tags/v2.0", annotated_tag_object]) + })); + + // Deleting a missing tag fails with the stable not-exist message. + let missing = request("deleteTag", serde_json::json!({"name": "v1.0"})); + assert_eq!(missing["ok"], true, "{missing:?}"); + assert_eq!(missing["data"]["operationError"]["code"], "invalid_request"); + assert_eq!( + missing["data"]["operationError"]["message"], + "The tag 'v1.0' does not exist" + ); + + // Restoring reuses createTag with the recorded target and message, so the + // rebuilt annotation must match the original message exactly. + let restore = request( + "createTag", + serde_json::json!({ + "name": deletion["name"], + "revision": deletion["deletedTarget"], + "message": deletion["message"] + }), + ); + assert_eq!(restore["ok"], true, "{restore:?}"); + let restored_object = run(&["cat-file", "tag", "refs/tags/v2.0"]); + assert!(restored_object + .stdout + .ends_with(b"\n\nrelease\r\n\r\nsecond paragraph\r\n\r\n")); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_write_records_the_deleted_branch_target() { + let root = temporary_root("git-branch-delete"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["branch", "feature/short-lived"]).status.success()); + assert!(run(&[ + "config", + "branch.feature/short-lived.description", + "temporary" + ]) + .status + .success()); + let head = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + // A successful branch deletion carries the commit the branch pointed at so + // the host can offer a restore, exactly like the tag deletion record. + let deletion = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(deletion["ok"], true, "{deletion:?}"); + assert_eq!(deletion["data"]["exitCode"], 0); + assert_eq!( + deletion["data"]["branchDeletion"]["name"], "feature/short-lived", + "{deletion:?}" + ); + assert_eq!(deletion["data"]["branchDeletion"]["deletedTarget"], head); + assert!(deletion["data"]["invocations"] + .as_array() + .expect("branch deletion invocations should be an array") + .iter() + .any(|invocation| { + invocation["arguments"] + == serde_json::json!(["update-ref", "-d", "refs/heads/feature/short-lived", head]) + })); + assert!(deletion["data"].get("tagDeletion").is_none()); + assert_ne!( + run(&["config", "--get", "branch.feature/short-lived.description"]) + .status + .code(), + Some(0), + "branch-local configuration should be removed with the ref" + ); + + // Deleting a missing branch fails with the stable not-exist message. + let missing = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(missing["ok"], true, "{missing:?}"); + assert_eq!(missing["data"]["operationError"]["code"], "invalid_request"); + assert_eq!( + missing["data"]["operationError"]["message"], + "The branch 'feature/short-lived' does not exist" + ); + + // Restoring replays createBranch against the recorded commit. + let restore = request( + "createBranch", + serde_json::json!({ + "gitReference": { + "fullName": deletion["data"]["branchDeletion"]["deletedTarget"], + "shortName": deletion["data"]["branchDeletion"]["deletedTarget"], + "kind": "local" + }, + "name": "feature/short-lived", + "checkout": false + }), + ); + assert_eq!(restore["ok"], true, "{restore:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["rev-parse", "refs/heads/feature/short-lived"]).stdout) + .trim(), + head + ); + + // A config lock can make metadata cleanup fail after the expected-OID ref + // deletion has already succeeded. The response must still carry recovery + // data so hosts can offer Restore alongside the cleanup warning. + assert!(run(&["branch", "feature/config-locked"]).status.success()); + assert!(run(&[ + "config", + "branch.feature/config-locked.description", + "temporary" + ]) + .status + .success()); + let config_lock = root.join(".git/config.lock"); + fs::write(&config_lock, "locked\n").expect("config lock should be creatable"); + let cleanup_warning = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/config-locked"}), + ); + fs::remove_file(config_lock).expect("config lock should be removable"); + assert_eq!(cleanup_warning["ok"], true, "{cleanup_warning:?}"); + assert!(cleanup_warning["data"].get("operationError").is_none()); + assert_eq!( + cleanup_warning["data"]["warnings"][0]["code"], + "branch_config_cleanup_failed" + ); + assert_eq!( + cleanup_warning["data"]["branchDeletion"]["name"], "feature/config-locked", + "{cleanup_warning:?}" + ); + assert_eq!( + cleanup_warning["data"]["branchDeletion"]["deletedTarget"], + head + ); + assert_ne!( + run(&[ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/config-locked" + ]) + .status + .code(), + Some(0) + ); + + // Atomic deletion must retain the safety contract of `git branch -d` and + // refuse a branch whose tip is not merged into its upstream or HEAD. + assert!(run(&["switch", "-q", "feature/short-lived"]) + .status + .success()); + fs::write(root.join("feature.txt"), "unmerged\n").expect("file should be writable"); + assert!(run(&["add", "feature.txt"]).status.success()); + assert!(run(&["commit", "-qm", "unmerged branch work"]) + .status + .success()); + assert!(run(&["switch", "-q", "main"]).status.success()); + let unmerged = request( + "deleteBranch", + serde_json::json!({"reference": "refs/heads/feature/short-lived"}), + ); + assert_eq!(unmerged["ok"], true, "{unmerged:?}"); + assert_eq!( + unmerged["data"]["operationError"]["code"], + "invalid_request" + ); + assert_eq!( + unmerged["data"]["operationError"]["message"], + "The branch 'feature/short-lived' is not fully merged" + ); + assert!(run(&[ + "show-ref", + "--verify", + "--quiet", + "refs/heads/feature/short-lived" + ]) + .status + .success()); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + #[test] fn git_write_rolls_back_large_selected_path_set_without_command_line_overflow() { let root = temporary_root("git-write-large-rollback"); @@ -2317,6 +2794,16 @@ fn git_history_returns_references_and_commit_graph_fields() { assert!(run(&["add", "example.txt"]).status.success()); assert!(run(&["commit", "-qm", "initial"]).status.success()); + assert!(run(&["tag", "commit-tag", "HEAD"]).status.success()); + let tree = String::from_utf8_lossy(&run(&["rev-parse", "HEAD^{tree}"]).stdout) + .trim() + .to_string(); + assert!(run(&["tag", "tree-tag", &tree]).status.success()); + let blob = String::from_utf8_lossy(&run(&["hash-object", "example.txt"]).stdout) + .trim() + .to_string(); + assert!(run(&["tag", "blob-tag", &blob]).status.success()); + let commit_hash = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) .trim() .to_string(); @@ -2415,6 +2902,19 @@ fn git_history_returns_references_and_commit_graph_fields() { .expect("references should be an array") .iter() .any(|reference| reference["kind"] == "local")); + for (name, expected) in [ + ("commit-tag", true), + ("tree-tag", false), + ("blob-tag", false), + ] { + let reference = response["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .find(|reference| reference["shortName"] == name) + .expect("tag reference should be present"); + assert_eq!(reference["peelsToCommit"], expected, "tag {name}"); + } fs::remove_dir_all(root).expect("temporary workspace should be removable"); } diff --git a/scripts/prepare-jdk.sh b/scripts/prepare-jdk.sh index aed55c1da..86c080702 100755 --- a/scripts/prepare-jdk.sh +++ b/scripts/prepare-jdk.sh @@ -51,7 +51,16 @@ download_verified_file() { fi rm -f -- "$temporary_path" - if ! curl --fail --location --retry 3 --output "$temporary_path" "$url"; then + print -u2 -- "Downloading $description: $url" + if ! curl \ + --fail \ + --location \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 15 \ + --max-time 180 \ + --output "$temporary_path" \ + "$url"; then rm -f -- "$temporary_path" return 1 fi diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index cea744d0f..38bf3c2e2 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -80,7 +80,16 @@ download_verified_file() { fi rm -f -- "$temporary_path" - if ! curl --fail --location --retry 3 --output "$temporary_path" "$url"; then + print -u2 -- "Downloading $description: $url" + if ! curl \ + --fail \ + --location \ + --retry 3 \ + --retry-all-errors \ + --connect-timeout 15 \ + --max-time 180 \ + --output "$temporary_path" \ + "$url"; then rm -f -- "$temporary_path" return 1 fi diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 665fc1ff5..8034b542c 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -250,7 +250,7 @@ response retains the invocation trace and includes the failure as `renameBranch`, `setUpstream`, `unsetUpstream`, `deleteBranch`, `merge`, `rebase`, `createWorktree`, `fetch`, `pull`, `push`, `checkout`, `checkoutAndRebase`, `checkoutRevision`, `clone`, `stashPush`, `stashApply`, `stashPop`, `stashDrop`, `deleteRemoteBranch`, `operationContinue`, -`operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, +`operationAbort`, `operationSkip`, `createTag`, and `deleteTag`. Optional fields are `paths`, `reference`, `referenceKind`, `gitReference`, `revision`, `revisions`, `name`, `message`, `remote`, `destination`, `mode`, `includeUntracked`, `checkout`, `amend`, `force`, `pushTags`, `expectedPush`, and `autoStash`. @@ -367,6 +367,38 @@ operation, a target outside the current branch's first-parent chain, a rewrite range containing a merge commit, or any rewritten commit reachable from `refs/remotes`. +`createTag` uses `name` for the new tag, `revision` as its target commit or +revision, and an optional `message`: when the field is present (including an +empty value), it creates an annotated tag (`git tag -a`); an absent field +creates a lightweight tag. UI callers trim new user-entered messages. Core +passes the supplied annotation with verbatim cleanup so restore preserves +CRLF and trailing blank lines, and an explicit empty value preserves an empty +annotated tag. Tag names must satisfy the `git check-ref-format` refname rules and must not +begin with a dash; `shared/fixtures/git/tag-names.json` pins the boundary cases +for Core and host-side validation. Before invoking Git, `createTag` probes the +repository so a duplicate tag (`A tag named '' already exists`) and an unresolvable +non-commit target (`Could not resolve tag target ''`) fail with stable +`invalid_request` messages instead of localized Git output. `deleteTag` uses +`name` and removes `refs/tags/`; a missing tag fails with +`The tag '' does not exist`. On success the response carries a +structured `tagDeletion` record — `{ "name": string, "deletedTarget": string, +"kind": "lightweight" | "annotated", "message": string? }` — where +`deletedTarget` is the peeled commit the deleted ref resolved to and +`message` is the original annotation with its line breaks preserved. Hosts +can rebuild the tag by replaying `createTag` with `name`, `deletedTarget`, +and `message`; the tagger identity and timestamp are intentionally not +preserved. Deletion supplies the observed unpeeled object ID to `update-ref`, +so a concurrent force-update fails atomically instead of deleting new state and +returning a stale recovery target. `deleteBranch` applies the same expected-OID +guard after checking the branch is fully merged and not checked out, then +on success, carries a structured `branchDeletion` record — +`{ "name": string, "deletedTarget": string }` — so hosts can offer to +recreate the branch at its previous commit; a missing branch fails with +`The branch '' does not exist`. If the ref deletion succeeds but branch +configuration cleanup fails, the response contains both `branchDeletion` and a +`branch_config_cleanup_failed` warning; hosts must preserve the Restore action while surfacing the +cleanup diagnostic. + `operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata to select the active merge, rebase, cherry-pick, or revert instead of accepting an operation kind from the caller. Continue is rejected while conflicted paths @@ -442,6 +474,8 @@ and the optional effective `userName` and `userEmail` from repository Git configuration. Commit parents are explicit so clients can render merge topology without re-parsing Git output. The identity fields let clients implement a stable `me` filter without guessing from recent commits. +Each reference includes `peelsToCommit`; hosts use it to disable commit-only +actions for legal tree/blob tags before the user reaches a failing mutation. Each local reference with an upstream also returns numeric `ahead` and `behind` counts against that fetched remote-tracking reference. References without an upstream, remote references, and tags return zero for both fields. diff --git a/shared/fixtures/git/history-response-v1.json b/shared/fixtures/git/history-response-v1.json index 595d3d0c1..a5e303309 100644 --- a/shared/fixtures/git/history-response-v1.json +++ b/shared/fixtures/git/history-response-v1.json @@ -4,6 +4,7 @@ "fullName": "refs/heads/feature/recent", "shortName": "feature/recent", "kind": "local", + "peelsToCommit": true, "isCurrent": true, "upstreamShortName": null, "ahead": 0, @@ -13,6 +14,7 @@ "fullName": "refs/heads/main", "shortName": "main", "kind": "local", + "peelsToCommit": true, "isCurrent": false, "upstreamShortName": "origin/main", "ahead": 2, @@ -24,6 +26,7 @@ "fullName": "refs/heads/feature/recent", "shortName": "feature/recent", "kind": "local", + "peelsToCommit": true, "isCurrent": true, "upstreamShortName": null, "ahead": 0, @@ -33,6 +36,7 @@ "fullName": "refs/heads/main", "shortName": "main", "kind": "local", + "peelsToCommit": true, "isCurrent": false, "upstreamShortName": "origin/main", "ahead": 2, diff --git a/shared/fixtures/git/tag-names.json b/shared/fixtures/git/tag-names.json new file mode 100644 index 000000000..2aa2589dd --- /dev/null +++ b/shared/fixtures/git/tag-names.json @@ -0,0 +1,35 @@ +{ + "protocolVersion": 1, + "valid": [ + "v1", + "v1.0.0", + "release-2026.08", + "feature/x", + "a/b/c", + "v1_rc1" + ], + "invalid": [ + "", + " ", + "a..b", + "-v1", + "a b", + "v~1", + "a:b", + "a?b", + "a*b", + "a[b", + "a\\b", + "@{x", + "a//b", + "a.lock", + ".hidden", + "@", + "head/", + "x/../y", + "foo/.bar", + "foo/bar.lock", + "a/.b/c", + "x/./y" + ] +} diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index e6b3cf62e..ba85a07b3 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -133,6 +133,51 @@ "message": "Save work", "includeUntracked": true } + }, + { + "operation": "createTag", + "payload": { + "name": "v1.0.0", + "revision": "2f1c9a4" + } + }, + { + "operation": "createTag", + "payload": { + "name": "v2.0.0", + "revision": "8e4b2d7", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteTag", + "payload": { + "name": "v2.0.0" + } + }, + { + "operation": "deleteBranch", + "payload": { + "reference": "refs/heads/feature/pending" + } + } + ], + "responses": [ + { + "operation": "deleteTag", + "tagDeletion": { + "name": "v2.0.0", + "deletedTarget": "8e4b2d7bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1", + "kind": "annotated", + "message": "Release 2.0.0" + } + }, + { + "operation": "deleteBranch", + "branchDeletion": { + "name": "feature/pending", + "deletedTarget": "2f1c9a4bb019f0e5a3c6f1a2d4e5b6c7d8e9f0a1" + } } ], "invalidRequests": [ @@ -151,6 +196,14 @@ }, "errorCode": "invalid_request" }, + { + "operation": "createTag", + "payload": { + "name": "bad tag name", + "revision": "HEAD" + }, + "errorCode": "invalid_request" + }, { "operation": "pull", "payload": { diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 98b053290..8e83de5c3 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -39,6 +39,12 @@ const LITHE_GITIGNORE_ENTRIES: &[&str] = &["run/local.json", "toolchains/local.j pub struct RunProcessManager; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct RunSessionKey { + window_label: String, + session_id: String, +} + struct RunningSession { pid: u32, stdin: Option, @@ -50,11 +56,18 @@ impl Default for RunProcessManager { } } -fn sessions() -> &'static Mutex> { - static SESSIONS: OnceLock>> = OnceLock::new(); +fn sessions() -> &'static Mutex> { + static SESSIONS: OnceLock>> = OnceLock::new(); SESSIONS.get_or_init(|| Mutex::new(HashMap::new())) } +fn run_session_key(window_label: &str, session_id: &str) -> RunSessionKey { + RunSessionKey { + window_label: window_label.to_string(), + session_id: session_id.to_string(), + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WriteGeneratedArgs { @@ -148,6 +161,7 @@ pub struct ResolvedLaunch { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartProcessArgs { + pub window_label: String, pub session_id: String, pub executable: String, pub arguments: Vec, @@ -261,7 +275,10 @@ pub fn run_resolve_launch(args: ResolveLaunchArgs) -> Result Result<(), String> { - stop_session(&args.session_id); + if args.window_label.trim().is_empty() { + return Err("A run process must be started from an active window.".into()); + } + stop_session(&args.window_label, &args.session_id); let mut command = command_for_executable(&args.executable, &args.arguments); command .current_dir(&args.working_directory) @@ -277,15 +294,33 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S let stdin = child.stdin.take(); let stdout = child.stdout.take(); let stderr = child.stderr.take(); + let session_key = run_session_key(&args.window_label, &args.session_id); sessions() .lock() .map_err(|_| "Run process state is unavailable".to_string())? - .insert(args.session_id.clone(), RunningSession { pid, stdin }); + .insert( + session_key, + RunningSession { + pid, + stdin, + }, + ); - let stdout_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stdout); - let stderr_reader = spawn_output_reader(app.clone(), args.session_id.clone(), stderr); + let stdout_reader = spawn_output_reader( + app.clone(), + args.window_label.clone(), + args.session_id.clone(), + stdout, + ); + let stderr_reader = spawn_output_reader( + app.clone(), + args.window_label.clone(), + args.session_id.clone(), + stderr, + ); spawn_exit_waiter( app, + args.window_label, args.session_id, child, pid, @@ -296,18 +331,18 @@ pub fn run_start_process(app: AppHandle, args: StartProcessArgs) -> Result<(), S } #[tauri::command] -pub fn run_stop_process(session_id: String) -> Result<(), String> { - stop_session(&session_id); +pub fn run_stop_process(window_label: String, session_id: String) -> Result<(), String> { + stop_session(&window_label, &session_id); Ok(()) } #[tauri::command] -pub fn run_write_stdin(session_id: String, input: String) -> Result<(), String> { +pub fn run_write_stdin(window_label: String, session_id: String, input: String) -> Result<(), String> { let mut current = sessions() .lock() .map_err(|_| "Run process state is unavailable".to_string())?; let session = current - .get_mut(&session_id) + .get_mut(&run_session_key(&window_label, &session_id)) .ok_or_else(|| "The run process is no longer active.".to_string())?; let stdin = session .stdin @@ -1421,6 +1456,7 @@ fn runtime_version_parts(version: &str) -> Vec { fn spawn_output_reader( app: AppHandle, + window_label: String, session_id: String, stream: Option, ) -> thread::JoinHandle<()> { @@ -1434,7 +1470,8 @@ fn spawn_output_reader( if !pending.is_empty() { let chunk = decode_process_bytes(&pending); if !chunk.is_empty() { - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-output", json!({ "sessionId": session_id, "chunk": chunk }), ); @@ -1454,7 +1491,8 @@ fn spawn_output_reader( if chunk.is_empty() { continue; } - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-output", json!({ "sessionId": session_id, "chunk": chunk }), ); @@ -1467,6 +1505,7 @@ fn spawn_output_reader( fn spawn_exit_waiter( app: AppHandle, + window_label: String, session_id: String, mut child: Child, pid: u32, @@ -1481,10 +1520,11 @@ fn spawn_exit_waiter( .unwrap_or(-1); let _ = stdout_reader.join(); let _ = stderr_reader.join(); + let session_key = run_session_key(&window_label, &session_id); let stale = match sessions().lock() { - Ok(mut current) => match current.get(&session_id) { + Ok(mut current) => match current.get(&session_key) { Some(session) if session.pid == pid => { - current.remove(&session_id); + current.remove(&session_key); false } _ => true, @@ -1494,18 +1534,23 @@ fn spawn_exit_waiter( if stale { return; } - let _ = app.emit( + let _ = app.emit_to( + &window_label, "run-exit", json!({ "sessionId": session_id, "exitCode": exit_code }), ); }); } -fn stop_session(session_id: &str) { +fn stop_session(window_label: &str, session_id: &str) { let pid = sessions() .lock() .ok() - .and_then(|mut current| current.remove(session_id).map(|session| session.pid)); + .and_then(|mut current| { + current + .remove(&run_session_key(window_label, session_id)) + .map(|session| session.pid) + }); if let Some(pid) = pid { let mut command = Command::new("taskkill"); command.args(["/F", "/T", "/PID", &pid.to_string()]); @@ -1878,10 +1923,18 @@ mod tests { assert!(looks_like_real_utf8(b"[INFO] BUILD SUCCESS")); } + #[test] + fn run_session_keys_are_scoped_by_window_label() { + let left = run_session_key("project-a", "primary"); + let right = run_session_key("project-b", "primary"); + assert_ne!(left, right); + assert_eq!(left, run_session_key("project-a", "primary")); + } + #[test] fn stdin_write_rejects_an_inactive_session() { let session_id = format!("missing-{}", std::process::id()); - let error = run_write_stdin(session_id, "input\n".to_string()).unwrap_err(); + let error = run_write_stdin("main".into(), session_id, "input\n".to_string()).unwrap_err(); assert_eq!(error, "The run process is no longer active."); } diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index c051d9418..a154ab221 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -43,6 +43,7 @@ import { restoreProjectUiState, } from "@/features/window/stores/workspace-ui-session"; import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { getProjectDisplayLabel } from "@/features/window/utils/project-display-label"; import { createAppWindow } from "@/features/window/utils/create-app-window"; import { serializeTerminals } from "@/features/terminal/lib/terminal-session-storage"; import { useTerminalTabsStore } from "@/features/terminal/stores/terminal-tabs.store"; @@ -641,7 +642,7 @@ const openLocalWorkspace = async ( state.isFileTreeLoading = true; }); - const projectName = getFolderName(path); + const folderName = getFolderName(path); const readDirectoryStartedAt = performance.now(); logWorkspaceOpenStep("start", "readDirectoryContents", path); @@ -649,7 +650,7 @@ const openLocalWorkspace = async ( logWorkspaceOpenStep("end", "readDirectoryContents", path, readDirectoryStartedAt); const fileTree = sortFileEntries(entries); - const wrappedFileTree = wrapWithRootFolder(fileTree, path, projectName); + const wrappedFileTree = wrapWithRootFolder(fileTree, path, folderName); if (treeState === "expand-root") { fileTreeStore.getState().actions.setExpandedPaths(new Set([path])); @@ -657,18 +658,20 @@ const openLocalWorkspace = async ( fileTreeStore.getState().actions.collapseAll(); } + const workspaceTab = useWorkspaceTabsStore + .getState() + .projectTabs.find((projectTab) => projectTab.id === workspaceId); + const displayName = workspaceTab ? getProjectDisplayLabel(workspaceTab) : folderName; + const { setRootFolderPath, setProjectName, setActiveProjectId } = projectStore.getState().actions; setRootFolderPath(path); - setProjectName(projectName); + setProjectName(displayName); if (restoreUiState) { restoreProjectUiState(path, workspaceId); } - const workspaceTab = useWorkspaceTabsStore - .getState() - .projectTabs.find((projectTab) => projectTab.id === workspaceId); setActiveProjectId(workspaceId); if (!prewarm) { useRecentFoldersStore.getState().actions.addToRecents(path, { @@ -683,7 +686,7 @@ const openLocalWorkspace = async ( state.isFileTreeLoading = false; state.files = wrappedFileTree; state.rootFolderPath = path; - state.workspaceFolders = [{ path, name: projectName, isPrimary: true }]; + state.workspaceFolders = [{ path, name: folderName, isPrimary: true }]; state.filesVersion++; state.projectFilesCache = undefined; }); @@ -1432,6 +1435,9 @@ const createFileSystemStore = (workspaceId: string): StoreApi { fullName: "refs/remotes/origin/feature/orders", shortName: "origin/feature/orders", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -109,6 +110,7 @@ describe("Git branch reference mutations", () => { fullName: "refs/remotes/origin/main", shortName: "origin/main", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; await setBranchUpstream("C:/repo", "main", upstream); diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts index 3bf873322..23ab10c65 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.test.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -42,6 +42,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -123,6 +124,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -169,6 +171,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -200,6 +203,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; @@ -231,6 +235,7 @@ describe("Git integration state", () => { fullName: "refs/remotes/origin/feature/demo", shortName: "origin/feature/demo", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; diff --git a/windows/tauri/src/features/git/api/git-push-api.test.ts b/windows/tauri/src/features/git/api/git-push-api.test.ts index 950369083..56a6cf913 100644 --- a/windows/tauri/src/features/git/api/git-push-api.test.ts +++ b/windows/tauri/src/features/git/api/git-push-api.test.ts @@ -46,6 +46,7 @@ describe("Git push API", () => { fullName: "refs/heads/feature/push", shortName: "feature/push", kind: "local", + peelsToCommit: true, isCurrent: true, }); diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index e4828ca34..8c6d1acc7 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -112,6 +112,7 @@ describe("Git remote Pull API", () => { fullName: "refs/remotes/team/origin/feature/orders", shortName: "team/origin/feature/orders", kind: "remote", + peelsToCommit: true, isCurrent: false, }; await deleteRemoteBranch("C:/repo", reference); diff --git a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts index 7a8588550..86b7be15b 100644 --- a/windows/tauri/src/features/git/api/git-worktrees-api.test.ts +++ b/windows/tauri/src/features/git/api/git-worktrees-api.test.ts @@ -23,6 +23,7 @@ describe("Git reference worktrees", () => { fullName: "refs/remotes/origin/feature/orders", shortName: "origin/feature/orders", kind: "remote" as const, + peelsToCommit: true, isCurrent: false, }; diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index ecea27d99..11c081e69 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -34,6 +34,7 @@ export interface GitReference { fullName: string; shortName: string; kind: GitReferenceKind; + peelsToCommit: boolean; isCurrent: boolean; upstreamShortName?: string; ahead?: number; diff --git a/windows/tauri/src/features/git/utils/git-reference-actions.test.ts b/windows/tauri/src/features/git/utils/git-reference-actions.test.ts index 2b5f644e6..345a45b16 100644 --- a/windows/tauri/src/features/git/utils/git-reference-actions.test.ts +++ b/windows/tauri/src/features/git/utils/git-reference-actions.test.ts @@ -18,6 +18,7 @@ const reference = ( : `refs/tags/${shortName}`, shortName, kind, + peelsToCommit: true, isCurrent, }); diff --git a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts index 9a4730698..bd8b5ca43 100644 --- a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts +++ b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts @@ -6,6 +6,7 @@ const reference = (shortName: string): GitReference => ({ fullName: `refs/remotes/${shortName}`, shortName, kind: "remote", + peelsToCommit: true, isCurrent: false, }); @@ -30,6 +31,7 @@ describe("Git reference tree", () => { fullName: "refs/heads/main", shortName: "main", kind: "local" as const, + peelsToCommit: true, isCurrent: true, }, ]; diff --git a/windows/tauri/src/features/layout/components/main-layout.tsx b/windows/tauri/src/features/layout/components/main-layout.tsx index d4e040095..cb2a68d80 100644 --- a/windows/tauri/src/features/layout/components/main-layout.tsx +++ b/windows/tauri/src/features/layout/components/main-layout.tsx @@ -16,6 +16,7 @@ import { isWslPath } from "@/features/wsl/utils/wsl-path"; import { useTerminalStore } from "@/features/terminal/stores/terminal.store"; import { useMenuEventsWrapper } from "@/features/window/hooks/use-menu-events-wrapper"; import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { getProjectDisplayLabel } from "@/features/window/utils/project-display-label"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { toast } from "sonner"; import { useTranslation } from "@/i18n/locale-provider"; @@ -185,7 +186,9 @@ export function MainLayout() { } useWorkspaceTabsStore.getState().actions.removeProjectTab(activeTab.id); - toast.warning(t("fileSystem.removedMissingProject", { name: activeTab.name })); + toast.warning( + t("fileSystem.removedMissingProject", { name: getProjectDisplayLabel(activeTab) }), + ); } }; diff --git a/windows/tauri/src/features/layout/components/sidebar/sidebar-projects.tsx b/windows/tauri/src/features/layout/components/sidebar/sidebar-projects.tsx index 1aedb2fcc..0799c4193 100644 --- a/windows/tauri/src/features/layout/components/sidebar/sidebar-projects.tsx +++ b/windows/tauri/src/features/layout/components/sidebar/sidebar-projects.tsx @@ -1,7 +1,9 @@ import { useState } from "react"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import ProjectIconPicker from "@/features/window/components/project-icon-picker"; +import { promptProjectDisplayAlias } from "@/features/window/controllers/prompt-project-display-alias"; import type { ProjectTab } from "@/features/window/stores/workspace-tabs.store"; +import { getProjectDisplayLabel } from "@/features/window/utils/project-display-label"; import { createAppWindow } from "@/features/window/utils/create-app-window"; import { ContextMenu, @@ -15,6 +17,7 @@ import { FolderOpenIcon, ImageIcon, OpenExternalIcon, + PencilSimpleLineIcon, TrashIcon, WindowExpandIcon, } from "@/ui/icons"; @@ -55,6 +58,7 @@ export function SidebarProjectDots({ {projects.map((project) => { const isRemote = isRemoteProjectPath(project.path); const isActive = project.id === activeProjectId; + const displayName = getProjectDisplayLabel(project); return ( @@ -67,8 +71,8 @@ export function SidebarProjectDots({ )} aria-label={ isActive - ? t("titleProject.currentProject", { project: project.name }) - : t("titleProject.switchToProject", { project: project.name }) + ? t("titleProject.currentProject", { project: displayName }) + : t("titleProject.switchToProject", { project: displayName }) } aria-current={isActive ? "page" : undefined} aria-disabled={isSwitchingProject} @@ -138,6 +142,10 @@ export function SidebarProjectDots({ {t("titleProject.selectIcon")} ) : null} + void promptProjectDisplayAlias(project)}> + + {t("titleProject.setDisplayAlias")} + void>(); +const windowListen = mock(async (event: string, handler: (event: { payload: unknown }) => void) => { + eventHandlers.set(event, handler); + return () => { + eventHandlers.delete(event); + }; +}); +const globalListen = mock(async () => () => {}); +const appendOutput = mock(() => undefined); +const finishProcess = mock(() => undefined); +const releaseMavenSessionWorkspace = mock(() => undefined); + +mock.module("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "workspace-1", + listen: windowListen, + }), +})); +mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); +mock.module("../stores/maven.store", () => ({ + mavenStoreForSession: () => ({ + getState: () => ({ + actions: { appendOutput, finishProcess }, + }), + }), + releaseMavenSessionWorkspace, +})); + +const { ensureMavenProcessListeners } = await import("./use-maven-process-events"); + +describe("maven process event listeners", () => { + beforeEach(() => { + appendOutput.mockClear(); + finishProcess.mockClear(); + releaseMavenSessionWorkspace.mockClear(); + }); + + test("registers run-output and run-exit on the current webview window", async () => { + await ensureMavenProcessListeners(); + + expect(windowListen).toHaveBeenCalledTimes(2); + expect(windowListen).toHaveBeenCalledWith("run-output", expect.any(Function)); + expect(windowListen).toHaveBeenCalledWith("run-exit", expect.any(Function)); + expect(globalListen).not.toHaveBeenCalled(); + expect(eventHandlers.has("run-output")).toBe(true); + expect(eventHandlers.has("run-exit")).toBe(true); + }); + + test("ignores non-maven session output", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "primary", chunk: "leaked\n" }, + }); + + expect(appendOutput).not.toHaveBeenCalled(); + }); + + test("routes maven session output through the maven store", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "maven:task-1", chunk: "BUILD SUCCESS\n" }, + }); + + expect(appendOutput).toHaveBeenCalledWith("maven:task-1", "BUILD SUCCESS\n"); + }); +}); diff --git a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts index 252e3c3b1..fe21994f4 100644 --- a/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts +++ b/windows/tauri/src/features/maven/hooks/use-maven-process-events.ts @@ -1,4 +1,5 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { mavenStoreForSession, releaseMavenSessionWorkspace } from "../stores/maven.store"; interface RunOutputEvent { @@ -15,8 +16,9 @@ let outputUnlisten: UnlistenFn | undefined; let exitUnlisten: UnlistenFn | undefined; export async function ensureMavenProcessListeners(): Promise { + const currentWindow = getCurrentWebviewWindow(); if (!outputUnlisten) { - outputUnlisten = await listen("run-output", (event) => { + outputUnlisten = await currentWindow.listen("run-output", (event) => { if (!event.payload.sessionId.startsWith("maven:")) return; mavenStoreForSession(event.payload.sessionId) .getState() @@ -24,7 +26,7 @@ export async function ensureMavenProcessListeners(): Promise { }); } if (!exitUnlisten) { - exitUnlisten = await listen("run-exit", (event) => { + exitUnlisten = await currentWindow.listen("run-exit", (event) => { const sessionId = event.payload.sessionId; if (!sessionId.startsWith("maven:")) return; mavenStoreForSession(sessionId) diff --git a/windows/tauri/src/features/run/api/run-core-api.test.ts b/windows/tauri/src/features/run/api/run-core-api.test.ts index 456e31395..72adb72c1 100644 --- a/windows/tauri/src/features/run/api/run-core-api.test.ts +++ b/windows/tauri/src/features/run/api/run-core-api.test.ts @@ -5,8 +5,9 @@ const executeCore = mock(async () => ({ ok: true as const, data: { document: "{}" }, })); +const cancelCoreOperation = mock(async () => true); -mock.module("@/core/lithe-core-client", () => ({ executeCore })); +mock.module("@/core/lithe-core-client", () => ({ executeCore, cancelCoreOperation })); const { createLaunchPlan, saveRunConfigurationEditorChanges } = await import("./run-core-api"); diff --git a/windows/tauri/src/features/run/api/run-host-api.test.ts b/windows/tauri/src/features/run/api/run-host-api.test.ts new file mode 100644 index 000000000..8d3aa0197 --- /dev/null +++ b/windows/tauri/src/features/run/api/run-host-api.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const invoke = mock(async () => undefined); + +mock.module("@/platform/tauri-core", () => ({ + invoke, + Channel: class {}, + convertFileSrc: (path: string) => path, +})); +mock.module("../utils/run-window-context", () => ({ + getRunWindowLabel: () => "project-window", +})); + +const { startRunProcess, stopRunProcess, writeRunStdin } = await import("../api/run-host-api"); + +describe("run host API window scoping", () => { + beforeEach(() => { + invoke.mockClear(); + }); + + test("startRunProcess includes the current window label", async () => { + await startRunProcess({ + sessionId: "primary", + executable: "go.exe", + arguments: ["run", "."], + workingDirectory: "D:\\demo", + environment: {}, + }); + + expect(invoke).toHaveBeenCalledWith("run_start_process", { + args: { + sessionId: "primary", + executable: "go.exe", + arguments: ["run", "."], + workingDirectory: "D:\\demo", + environment: {}, + windowLabel: "project-window", + }, + }); + }); + + test("stop and stdin commands include the current window label", async () => { + await stopRunProcess("primary"); + await writeRunStdin("primary", "input\n"); + + expect(invoke).toHaveBeenNthCalledWith(1, "run_stop_process", { + windowLabel: "project-window", + sessionId: "primary", + }); + expect(invoke).toHaveBeenNthCalledWith(2, "run_write_stdin", { + windowLabel: "project-window", + sessionId: "primary", + input: "input\n", + }); + }); +}); diff --git a/windows/tauri/src/features/run/api/run-host-api.ts b/windows/tauri/src/features/run/api/run-host-api.ts index 0e587b8ee..bb686ddbc 100644 --- a/windows/tauri/src/features/run/api/run-host-api.ts +++ b/windows/tauri/src/features/run/api/run-host-api.ts @@ -1,4 +1,5 @@ import { invoke } from "@/platform/tauri-core"; +import { getRunWindowLabel } from "../utils/run-window-context"; import type { GenericRuntime, GlobalToolchain, @@ -27,7 +28,11 @@ export function writeRunDocuments( } export function writeRunStdin(sessionId: string, input: string) { - return invoke("run_write_stdin", { sessionId, input }); + return invoke("run_write_stdin", { + windowLabel: getRunWindowLabel(), + sessionId, + input, + }); } export function discoverRunToolchains(root: string, selected?: GlobalToolchain) { @@ -63,9 +68,17 @@ export function startRunProcess(args: { workingDirectory: string; environment: Record; }) { - return invoke("run_start_process", { args }); + return invoke("run_start_process", { + args: { + ...args, + windowLabel: getRunWindowLabel(), + }, + }); } export function stopRunProcess(sessionId: string) { - return invoke("run_stop_process", { sessionId }); + return invoke("run_stop_process", { + windowLabel: getRunWindowLabel(), + sessionId, + }); } diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts new file mode 100644 index 000000000..757a4f0b4 --- /dev/null +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const eventHandlers = new Map void>(); +const windowListen = mock(async (event: string, handler: (event: { payload: unknown }) => void) => { + eventHandlers.set(event, handler); + return () => { + eventHandlers.delete(event); + }; +}); +const globalListen = mock(async () => () => {}); +const appendOutput = mock(() => undefined); +const finishProcess = mock(() => undefined); +const releaseRunSessionWorkspace = mock(() => undefined); + +const actualRunStore = await import("../stores/run.store"); + +mock.module("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "project-window", + listen: windowListen, + }), +})); +mock.module("@tauri-apps/api/event", () => ({ listen: globalListen })); +mock.module("../stores/run.store", () => ({ + ...actualRunStore, + runStoreForSession: () => ({ + getState: () => ({ + actions: { appendOutput, finishProcess }, + }), + }), + releaseRunSessionWorkspace, +})); + +const { ensureRunProcessListeners } = await import("./use-run-process-events"); + +describe("run process event listeners", () => { + beforeEach(() => { + appendOutput.mockClear(); + finishProcess.mockClear(); + releaseRunSessionWorkspace.mockClear(); + }); + + test("registers run-output and run-exit on the current webview window", async () => { + await ensureRunProcessListeners(); + + expect(windowListen).toHaveBeenCalledTimes(2); + expect(windowListen).toHaveBeenCalledWith("run-output", expect.any(Function)); + expect(windowListen).toHaveBeenCalledWith("run-exit", expect.any(Function)); + expect(globalListen).not.toHaveBeenCalled(); + expect(eventHandlers.has("run-output")).toBe(true); + expect(eventHandlers.has("run-exit")).toBe(true); + }); + + test("does not register duplicate listeners on repeated setup", async () => { + const callsBefore = windowListen.mock.calls.length; + await ensureRunProcessListeners(); + expect(windowListen.mock.calls.length).toBe(callsBefore); + expect(globalListen).not.toHaveBeenCalled(); + }); + + test("routes output events through the run store for this window", () => { + const outputHandler = eventHandlers.get("run-output"); + expect(outputHandler).toBeDefined(); + + outputHandler?.({ + payload: { sessionId: "primary", chunk: "hello\n" }, + }); + + expect(appendOutput).toHaveBeenCalledWith("primary", "hello\n"); + }); +}); diff --git a/windows/tauri/src/features/run/hooks/use-run-process-events.ts b/windows/tauri/src/features/run/hooks/use-run-process-events.ts index 46bbeae65..d11ba1994 100644 --- a/windows/tauri/src/features/run/hooks/use-run-process-events.ts +++ b/windows/tauri/src/features/run/hooks/use-run-process-events.ts @@ -1,4 +1,5 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { releaseRunSessionWorkspace, runStoreForSession } from "../stores/run.store"; interface RunOutputEvent { @@ -15,15 +16,16 @@ let outputUnlisten: UnlistenFn | undefined; let exitUnlisten: UnlistenFn | undefined; export async function ensureRunProcessListeners(): Promise { + const currentWindow = getCurrentWebviewWindow(); if (!outputUnlisten) { - outputUnlisten = await listen("run-output", (event) => { + outputUnlisten = await currentWindow.listen("run-output", (event) => { runStoreForSession(event.payload.sessionId) .getState() .actions.appendOutput(event.payload.sessionId, event.payload.chunk); }); } if (!exitUnlisten) { - exitUnlisten = await listen("run-exit", (event) => { + exitUnlisten = await currentWindow.listen("run-exit", (event) => { const sessionId = event.payload.sessionId; runStoreForSession(sessionId).getState().actions.finishProcess(sessionId, event.payload.exitCode); releaseRunSessionWorkspace(sessionId); diff --git a/windows/tauri/src/features/run/stores/run.store.test.ts b/windows/tauri/src/features/run/stores/run.store.test.ts index f363151a1..3a315ded7 100644 --- a/windows/tauri/src/features/run/stores/run.store.test.ts +++ b/windows/tauri/src/features/run/stores/run.store.test.ts @@ -1,9 +1,6 @@ -import { describe, expect, mock, test } from "bun:test"; - -mock.module("@/platform/tauri-core", () => ({ - invoke: mock(async () => undefined), -})); +import { describe, expect, test } from "bun:test"; +// Window scoping is mocked in run-host-api.test.ts for the full `bun test src/features/run` suite. const { createRunStore } = await import("./run.store"); const { PRIMARY_SESSION_ID } = await import("../types/run.types"); diff --git a/windows/tauri/src/features/run/utils/run-window-context.ts b/windows/tauri/src/features/run/utils/run-window-context.ts new file mode 100644 index 000000000..aaa3c44b5 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-window-context.ts @@ -0,0 +1,5 @@ +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +export function getRunWindowLabel(): string { + return getCurrentWebviewWindow().label; +} diff --git a/windows/tauri/src/features/window/components/project-tab-bar.test.ts b/windows/tauri/src/features/window/components/project-tab-bar.test.ts index 4e7bd08a9..be392832c 100644 --- a/windows/tauri/src/features/window/components/project-tab-bar.test.ts +++ b/windows/tauri/src/features/window/components/project-tab-bar.test.ts @@ -14,7 +14,7 @@ test("project tab bar exposes accessible switchable and closable project tabs", expect(source).toContain("event.stopPropagation()"); expect(source).toContain("await closeProject(projectId)"); expect(source).toContain("finally"); - expect(source).toContain('t("titleProject.closeProject", { name: project.name })'); + expect(source).toContain('t("titleProject.closeProject", { name: displayName })'); expect(source).toContain("group-hover:opacity-100"); expect(source).toContain("group-focus-within:opacity-100"); expect(source).toContain( diff --git a/windows/tauri/src/features/window/components/project-tab-bar.tsx b/windows/tauri/src/features/window/components/project-tab-bar.tsx index 74e1cf859..09e15a289 100644 --- a/windows/tauri/src/features/window/components/project-tab-bar.tsx +++ b/windows/tauri/src/features/window/components/project-tab-bar.tsx @@ -5,6 +5,7 @@ import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.s import { Button } from "@/ui/button"; import { FolderIcon, XIcon as X } from "@/ui/icons"; import { cn } from "@/utils/cn"; +import { getProjectDisplayLabel } from "../utils/project-display-label"; import { getProjectTabBarItems, shouldShowProjectTabBar } from "../utils/project-tab-bar-model"; interface ProjectTabBarProps { @@ -43,7 +44,8 @@ export function ProjectTabBar({ hideWhenSingle = false }: ProjectTabBarProps) { >

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