From db88cf4e56b005f8ed4b0c96d73285037923f5ae Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 15:45:45 +0800 Subject: [PATCH 1/4] fix: branch creation and task running issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # RxCode — run-button-ui-fix * This branch focused on enhancing multi-project chat tool functionalities, including adding new tool descriptors and updating handlers for enhanced search and message retrieval. * It also addressed persistent issues with plan mode across different branches, ensuring consistent behavior during thread switching and reject-with-feedback scenarios. * Work was done on Xcode run configuration, including implementing a smart launcher for iOS simulators and updating run profile toolbars. * The branch also tackled issues with branch creation and layout, such as moving the wallet button to the main content view and improving run button placement. * Resolved follow-ups included re-prompting permission on debug builds and addressing unresolved issues like simulator stdout tie problems. ## Threads - Branch Creation Issue - Plan mode persistence issue - Multi-project chat tool - Xcode Run Configuration Issue - Repeated permission request - Run button placement confusion --- .../RxCodeCore/Backend/IDEToolRegistry.swift | 93 ++++++- .../RxCodeCore/Models/XcodeDestination.swift | 133 +++++++++ .../RxCodeCore/Models/XcodeRunConfig.swift | 36 ++- .../RunProfile/RunTaskExecutor.swift | 94 +++++-- .../RunProfile/XcodeDestinationParser.swift | 83 ++++++ Packages/Sources/RxCodeCore/WindowState.swift | 10 + .../RunTaskExecutorTests.swift | 171 ++++++++++++ RxCode.xcodeproj/project.pbxproj | 28 +- RxCode/App/AppState.swift | 252 +++++++++++++++++- .../IDEServer/AppState+IDEToolHandling.swift | 203 ++++++++++++-- .../RunProfile/XcodeDestinationService.swift | 97 +++++++ RxCode/Views/Chat/BranchPickerChip.swift | 16 +- .../RunProfile/RunConfigurationsView.swift | 26 +- .../RunProfile/RunProfileToolbarGroup.swift | 162 ++++++++++- 14 files changed, 1322 insertions(+), 82 deletions(-) create mode 100644 Packages/Sources/RxCodeCore/Models/XcodeDestination.swift create mode 100644 Packages/Sources/RxCodeCore/RunProfile/XcodeDestinationParser.swift create mode 100644 RxCode/Services/RunProfile/XcodeDestinationService.swift diff --git a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift index 185899c1..7527a577 100644 --- a/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift +++ b/Packages/Sources/RxCodeCore/Backend/IDEToolRegistry.swift @@ -110,20 +110,60 @@ public enum IDEToolRegistry { "required": .array([.string("job_id")]), ]) ), + IDETool( + name: "ide__get_projects", + description: "List every project registered in RxCode. Use to discover sibling projects you may want to read threads from or chat with.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([:]), + ]) + ), IDETool( name: "ide__get_threads", - description: "List chat threads in the current project (or all projects if none specified).", + description: "List or natural-language search chat threads. With `query`, results are ranked by the same on-device embedding search the global search overlay uses and include matched snippets + scores. Without `query`, returns recent threads sorted by updatedAt. Each result includes the AI-generated summary when available.", visibility: .alwaysIDEOnly, inputSchema: .object([ "type": .string("object"), "properties": .object([ - "project_id": .object(["type": .string("string")]), + "project_id": .object([ + "type": .string("string"), + "description": .string("Optional UUID to restrict results to a single project. Applied after ranking when `query` is set."), + ]), + "query": .object([ + "type": .string("string"), + "description": .string("Optional natural-language query. When set, results are ranked by semantic similarity using on-device embeddings."), + ]), + "limit": .object([ + "type": .string("integer"), + "description": .string("Maximum number of threads to return. Default 50, capped at 200."), + ]), + ]), + ]) + ), + IDETool( + name: "ide__get_thread_messages", + description: "Fetch the message history of a specific thread by id. Hydrates the full ChatSession (CLI-backed history or RxCode JSON) and returns role+text per message.", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "thread_id": .object(["type": .string("string")]), + "limit": .object([ + "type": .string("integer"), + "description": .string("Most recent N messages to return. Default 200, capped at 1000."), + ]), + "include_tool_calls": .object([ + "type": .string("boolean"), + "description": .string("When true, include tool-call blocks in the messages array. Default false — only user/assistant text is returned."), + ]), ]), + "required": .array([.string("thread_id")]), ]) ), IDETool( name: "ide__get_thread_detail", - description: "Fetch the message history of a specific thread by id.", + description: "Deprecated alias for ide__get_thread_messages. Prefer ide__get_thread_messages.", visibility: .alwaysIDEOnly, inputSchema: .object([ "type": .string("object"), @@ -133,6 +173,53 @@ public enum IDEToolRegistry { "required": .array([.string("thread_id")]), ]) ), + IDETool( + name: "ide__send_to_thread", + description: "Send a chat prompt to a thread in any project — continue an existing thread by `thread_id`, or start a brand-new thread by passing `project_id`. Triggers a real agent run that may consume tokens. Returns the assistant's reply text (waits up to `timeout_seconds`).", + visibility: .alwaysIDEOnly, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "prompt": .object([ + "type": .string("string"), + "description": .string("The user message to send to the target agent."), + ]), + "thread_id": .object([ + "type": .string("string"), + "description": .string("Continue this existing thread. Mutually exclusive with `project_id`."), + ]), + "project_id": .object([ + "type": .string("string"), + "description": .string("Start a brand-new thread in this project. Mutually exclusive with `thread_id`."), + ]), + "model": .object([ + "type": .string("string"), + "description": .string("Optional model override for a new thread."), + ]), + "agent_provider": .object([ + "type": .string("string"), + "description": .string("Optional agent provider override for a new thread (e.g. 'claude_code', 'codex', 'acp')."), + ]), + "effort": .object([ + "type": .string("string"), + "description": .string("Optional effort override for a new thread."), + ]), + "permission_mode": .object([ + "type": .string("string"), + "description": .string("Optional permission mode override for a new thread."), + ]), + "wait_for_response": .object([ + "type": .string("boolean"), + "description": .string("If true (default), block until the assistant finishes or `timeout_seconds` elapses. If false, return immediately with the new thread id."), + ]), + "timeout_seconds": .object([ + "type": .string("number"), + "description": .string("How long to wait for the assistant's reply. Default 120, capped at 600. On timeout the call returns the partial text with `done: false` — the caller can poll via ide__get_thread_messages."), + ]), + ]), + "required": .array([.string("prompt")]), + ]) + ), IDETool( name: "ide__get_usage", description: "Get current rate-limit / token usage stats reported by the active provider.", diff --git a/Packages/Sources/RxCodeCore/Models/XcodeDestination.swift b/Packages/Sources/RxCodeCore/Models/XcodeDestination.swift new file mode 100644 index 00000000..c31cfc26 --- /dev/null +++ b/Packages/Sources/RxCodeCore/Models/XcodeDestination.swift @@ -0,0 +1,133 @@ +import Foundation + +/// A single `xcodebuild` destination — populated either from +/// `xcodebuild -showdestinations` or `xcrun simctl list devices --json`. +/// +/// The destination is the structured form of what Xcode shows in its +/// run-destination menu (My Mac, iPhone 16, etc). It serializes to the +/// `-destination` argument via `xcodebuildArgument` and is the source of +/// truth for which launcher path `RunTaskExecutor` uses (macOS `open` vs. +/// `xcrun simctl install/launch`). +public struct XcodeDestination: Codable, Sendable, Hashable, Identifiable { + public enum Kind: String, Codable, Sendable, Hashable { + case macOS + case macCatalyst + case iosSimulator + case iosDevice + case tvSimulator + case tvDevice + case watchSimulator + case watchDevice + case visionSimulator + case visionDevice + case other + } + + public var kind: Kind + /// Raw platform string as reported by `xcodebuild` (e.g. "macOS", + /// "iOS Simulator", "iOS"). Used verbatim when building the + /// `-destination` argument. + public var platform: String + public var name: String + /// Simulator UDID, or device identifier when known. Preferred over + /// `name` for the `-destination` argument because it's unambiguous. + public var udid: String? + /// OS version string for simulators (e.g. "18.0"). Optional. + public var os: String? + /// CPU architecture for macOS destinations (e.g. "arm64", "x86_64"). + public var arch: String? + /// Optional macOS variant (e.g. "Mac Catalyst", "Designed for iPad"). + public var variant: String? + + public init( + kind: Kind, + platform: String, + name: String, + udid: String? = nil, + os: String? = nil, + arch: String? = nil, + variant: String? = nil + ) { + self.kind = kind + self.platform = platform + self.name = name + self.udid = udid + self.os = os + self.arch = arch + self.variant = variant + } + + public var id: String { + // UDID is unique per device/simulator; fall back to a composite key + // when missing so generic destinations (e.g. "Any iOS Device") still + // get a stable id for SwiftUI list diffing. + if let udid, !udid.isEmpty { return "\(platform):\(udid)" } + var parts = [platform, name] + if let os { parts.append(os) } + if let arch { parts.append(arch) } + if let variant { parts.append(variant) } + return parts.joined(separator: "|") + } + + /// Build the value passed to `xcodebuild -destination ""`. + /// Prefers `id=` when available — it's the only form that + /// unambiguously targets a single simulator runtime. + public var xcodebuildArgument: String { + var parts: [String] = ["platform=\(platform)"] + if let udid, !udid.isEmpty { + parts.append("id=\(udid)") + } else { + parts.append("name=\(name)") + if let os, !os.isEmpty { parts.append("OS=\(os)") } + } + if let arch, !arch.isEmpty { parts.append("arch=\(arch)") } + if let variant, !variant.isEmpty { parts.append("variant=\(variant)") } + return parts.joined(separator: ",") + } + + /// Human-readable label for menus. Mirrors what Xcode shows. + public var displayName: String { + switch kind { + case .macOS: + if let variant, !variant.isEmpty { return "\(name) (\(variant))" } + if let arch, !arch.isEmpty { return "\(name) (\(arch))" } + return name + case .iosSimulator, .tvSimulator, .watchSimulator, .visionSimulator: + if let os, !os.isEmpty { return "\(name) — \(os)" } + return name + default: + return name + } + } + + /// Coarse grouping for menu sections. + public var groupLabel: String { + switch kind { + case .macOS: return "macOS" + case .macCatalyst: return "Mac Catalyst" + case .iosSimulator: return "iOS Simulators" + case .iosDevice: return "iOS Devices" + case .tvSimulator: return "tvOS Simulators" + case .tvDevice: return "tvOS Devices" + case .watchSimulator: return "watchOS Simulators" + case .watchDevice: return "watchOS Devices" + case .visionSimulator: return "visionOS Simulators" + case .visionDevice: return "visionOS Devices" + case .other: return "Other" + } + } + + /// Whether `RunTaskExecutor` can actually launch the produced bundle for + /// this destination. Currently true for macOS and any simulator; false + /// for physical iOS/tvOS/watchOS/visionOS devices (which require Xcode's + /// `devicectl` plumbing we don't have yet). + public var isLaunchable: Bool { + switch kind { + case .macOS, .macCatalyst, + .iosSimulator, .tvSimulator, .watchSimulator, .visionSimulator: + return true + case .iosDevice, .tvDevice, .watchDevice, .visionDevice, .other: + return false + } + } +} diff --git a/Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift b/Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift index 07f2156a..dcd6dd38 100644 --- a/Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift +++ b/Packages/Sources/RxCodeCore/Models/XcodeRunConfig.swift @@ -15,7 +15,13 @@ public struct XcodeRunConfig: Codable, Sendable, Hashable { public var scheme: String public var configuration: String public var action: XcodeAction - /// Optional `-destination` argument. Empty string omits the flag. + /// Structured destination chosen via the toolbar picker. When set, the + /// run script uses `selectedDestination.xcodebuildArgument` and branches + /// the launcher path on `selectedDestination.kind`. + public var selectedDestination: XcodeDestination? + /// Legacy free-text `-destination` override. Older profiles on disk + /// stored their destination here. Still honored when + /// `selectedDestination` is nil so we don't break those. public var destination: String public init( @@ -24,6 +30,7 @@ public struct XcodeRunConfig: Codable, Sendable, Hashable { scheme: String = "", configuration: String = "Debug", action: XcodeAction = .run, + selectedDestination: XcodeDestination? = nil, destination: String = "" ) { self.container = container @@ -31,6 +38,33 @@ public struct XcodeRunConfig: Codable, Sendable, Hashable { self.scheme = scheme self.configuration = configuration self.action = action + self.selectedDestination = selectedDestination self.destination = destination } + + /// Resolves the value (if any) that should be passed to + /// `xcodebuild -destination`. Prefers the structured picker selection. + public var resolvedDestinationArgument: String? { + if let selectedDestination { + return selectedDestination.xcodebuildArgument + } + let trimmed = destination.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } + + private enum CodingKeys: String, CodingKey { + case container, isWorkspace, scheme, configuration, action + case selectedDestination, destination + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + container = try c.decodeIfPresent(String.self, forKey: .container) ?? "" + isWorkspace = try c.decodeIfPresent(Bool.self, forKey: .isWorkspace) ?? false + scheme = try c.decodeIfPresent(String.self, forKey: .scheme) ?? "" + configuration = try c.decodeIfPresent(String.self, forKey: .configuration) ?? "Debug" + action = try c.decodeIfPresent(XcodeAction.self, forKey: .action) ?? .run + selectedDestination = try c.decodeIfPresent(XcodeDestination.self, forKey: .selectedDestination) + destination = try c.decodeIfPresent(String.self, forKey: .destination) ?? "" + } } diff --git a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift index d0924736..239e27f8 100644 --- a/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift +++ b/Packages/Sources/RxCodeCore/RunProfile/RunTaskExecutor.swift @@ -150,11 +150,11 @@ public enum RunTaskExecutor { let container = shellEscape(cfg.container) let scheme = shellEscape(cfg.scheme) let configuration = shellEscape(cfg.configuration) - let destination = cfg.destination.trimmingCharacters(in: .whitespaces) + let destinationArg = cfg.resolvedDestinationArgument var base = "xcodebuild \(containerFlag) \(container) -scheme \(scheme) -configuration \(configuration)" - if !destination.isEmpty { - base += " -destination \(shellEscape(destination))" + if let destinationArg { + base += " -destination \(shellEscape(destinationArg))" } switch cfg.action { @@ -165,25 +165,85 @@ public enum RunTaskExecutor { case .test: return ["\(base) test"] case .run: - // Build, then resolve BUILT_PRODUCTS_DIR / FULL_PRODUCT_NAME from - // `-showBuildSettings` and `open` the resulting bundle. `-n` - // forces a fresh instance — without it, `open` just activates an - // already-running copy (common when iterating on an app you - // launched a moment ago), making Run feel like a no-op. + return runLauncherLines(base: base, destination: cfg.selectedDestination) + } + } + + /// Compose the build-and-launch script. Branches on destination kind so + /// macOS uses `open -n` while iOS-family simulators install + launch via + /// `xcrun simctl`. Physical devices fail fast with a clear message — + /// supporting them needs Xcode's `devicectl` plumbing we don't have. + private static func runLauncherLines(base: String, destination: XcodeDestination?) -> [String] { + let kind = destination?.kind ?? .macOS // no destination → assume macOS, matches old behavior + if let destination, !destination.isLaunchable { return [ + "printf '[rxcode] launching on %s destinations is not yet supported. Build will run; the produced bundle is at BUILT_PRODUCTS_DIR.\\n' \(shellEscape(destination.groupLabel)) 1>&2", "\(base) build", - "__rxcode_settings=$(\(base) -showBuildSettings 2>/dev/null)", - "__rxcode_build_dir=$(printf '%s\\n' \"$__rxcode_settings\" | awk -F' = ' '/^[[:space:]]*BUILT_PRODUCTS_DIR =/ {print $2; exit}')", - "__rxcode_product=$(printf '%s\\n' \"$__rxcode_settings\" | awk -F' = ' '/^[[:space:]]*FULL_PRODUCT_NAME =/ {print $2; exit}')", - "if [ -n \"$__rxcode_build_dir\" ] && [ -n \"$__rxcode_product\" ]; then", - " printf '[rxcode] launching %s\\n' \"$__rxcode_build_dir/$__rxcode_product\"", - " open -n \"$__rxcode_build_dir/$__rxcode_product\"", - "else", - " printf '[rxcode] could not resolve built product to launch\\n' 1>&2", + "exit 1", + ] + } + + // Shared prelude: build, then capture BUILT_PRODUCTS_DIR, + // FULL_PRODUCT_NAME, and PRODUCT_BUNDLE_IDENTIFIER from + // `-showBuildSettings`. Bundle id is only needed for simctl launch + // but it's cheap to extract once. + var lines: [String] = [ + "\(base) build", + "__rxcode_settings=$(\(base) -showBuildSettings 2>/dev/null)", + "__rxcode_build_dir=$(printf '%s\\n' \"$__rxcode_settings\" | awk -F' = ' '/^[[:space:]]*BUILT_PRODUCTS_DIR =/ {print $2; exit}')", + "__rxcode_product=$(printf '%s\\n' \"$__rxcode_settings\" | awk -F' = ' '/^[[:space:]]*FULL_PRODUCT_NAME =/ {print $2; exit}')", + "__rxcode_bundle_id=$(printf '%s\\n' \"$__rxcode_settings\" | awk -F' = ' '/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER =/ {print $2; exit}')", + "if [ -z \"$__rxcode_build_dir\" ] || [ -z \"$__rxcode_product\" ]; then", + " printf '[rxcode] could not resolve built product to launch\\n' 1>&2", + " exit 1", + "fi", + "__rxcode_app=\"$__rxcode_build_dir/$__rxcode_product\"", + ] + + switch kind { + case .macOS, .macCatalyst, .other: + // `open -n` forces a fresh instance — without it, `open` just + // activates an already-running copy (common when iterating on an + // app you launched a moment ago), making Run feel like a no-op. + lines.append(contentsOf: [ + "printf '[rxcode] launching %s\\n' \"$__rxcode_app\"", + "open -n \"$__rxcode_app\"", + ]) + case .iosSimulator, .tvSimulator, .watchSimulator, .visionSimulator: + let udidLiteral: String + if let udid = destination?.udid, !udid.isEmpty { + udidLiteral = shellEscape(udid) + } else { + // No UDID — caller picked a generic destination by name. Use + // `booted` to target whatever's already running; simctl will + // error usefully if nothing is. + udidLiteral = "'booted'" + } + lines.append(contentsOf: [ + "__rxcode_udid=\(udidLiteral)", + "if [ -z \"$__rxcode_bundle_id\" ]; then", + " __rxcode_bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$__rxcode_app/Info.plist\" 2>/dev/null)", + "fi", + "if [ -z \"$__rxcode_bundle_id\" ]; then", + " printf '[rxcode] could not resolve bundle identifier\\n' 1>&2", " exit 1", "fi", - ] + "if [ \"$__rxcode_udid\" != 'booted' ]; then", + " xcrun simctl boot \"$__rxcode_udid\" 2>/dev/null || true", + "fi", + "open -a Simulator", + "printf '[rxcode] installing %s to %s\\n' \"$__rxcode_app\" \"$__rxcode_udid\"", + "xcrun simctl install \"$__rxcode_udid\" \"$__rxcode_app\"", + "printf '[rxcode] launching %s on %s\\n' \"$__rxcode_bundle_id\" \"$__rxcode_udid\"", + "xcrun simctl launch --console-pty \"$__rxcode_udid\" \"$__rxcode_bundle_id\"", + ]) + case .iosDevice, .tvDevice, .watchDevice, .visionDevice: + // Unreachable — guarded by `isLaunchable` above. + lines.append("printf '[rxcode] device launch is not implemented\\n' 1>&2") + lines.append("exit 1") } + + return lines } private static func makeScriptLines(_ cfg: MakeRunConfig) -> [String] { diff --git a/Packages/Sources/RxCodeCore/RunProfile/XcodeDestinationParser.swift b/Packages/Sources/RxCodeCore/RunProfile/XcodeDestinationParser.swift new file mode 100644 index 00000000..a8d234d4 --- /dev/null +++ b/Packages/Sources/RxCodeCore/RunProfile/XcodeDestinationParser.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Parses `xcodebuild -showdestinations` output into `[XcodeDestination]`. +/// +/// The command prints two sections — "Available destinations" and +/// "Ineligible destinations" — each followed by lines like: +/// +/// { platform:iOS Simulator, id:ABCD-1234, OS:18.0, name:iPhone 16 } +/// +/// We only keep entries from the "Available" section. Each `{ ... }` block +/// is split on `, ` then on `:` to extract key/value pairs. +public enum XcodeDestinationParser { + + public static func parse(_ output: String) -> [XcodeDestination] { + var results: [XcodeDestination] = [] + var seen = Set() + var inAvailable = false + + for rawLine in output.components(separatedBy: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + + if line.hasPrefix("Available destinations") { + inAvailable = true + continue + } + if line.hasPrefix("Ineligible destinations") { + inAvailable = false + continue + } + guard inAvailable else { continue } + guard line.hasPrefix("{"), line.hasSuffix("}") else { continue } + + let inner = String(line.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) + let pairs = parsePairs(inner) + guard let platform = pairs["platform"], !platform.isEmpty else { continue } + + let destination = XcodeDestination( + kind: classify(platform: platform, variant: pairs["variant"]), + platform: platform, + name: pairs["name"] ?? platform, + udid: pairs["id"], + os: pairs["OS"], + arch: pairs["arch"], + variant: pairs["variant"] + ) + + if seen.insert(destination.id).inserted { + results.append(destination) + } + } + + return results + } + + /// Splits `key:value, key:value` honoring commas-within-values would be + /// nice, but xcodebuild never emits those. Splitting on `, ` is enough. + private static func parsePairs(_ inner: String) -> [String: String] { + var out: [String: String] = [:] + for chunk in inner.components(separatedBy: ", ") { + guard let colon = chunk.firstIndex(of: ":") else { continue } + let key = String(chunk[.. XcodeDestination.Kind { + if let variant, variant == "Mac Catalyst" { return .macCatalyst } + switch platform { + case "macOS": return .macOS + case "iOS Simulator": return .iosSimulator + case "iOS": return .iosDevice + case "tvOS Simulator": return .tvSimulator + case "tvOS": return .tvDevice + case "watchOS Simulator": return .watchSimulator + case "watchOS": return .watchDevice + case "visionOS Simulator": return .visionSimulator + case "visionOS": return .visionDevice + default: return .other + } + } +} diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index fdfdd500..3eb997dd 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -62,6 +62,16 @@ public final class WindowState { public var selectedProject: Project? public var currentSessionId: String? + // MARK: - Pending Worktree (new-chat view) + + /// Filesystem path of a worktree the user created before sending the first + /// message. Transferred onto the session state when `sendPrompt` allocates + /// a session id; cleared when the new-chat view is reset or a different + /// session is selected. + public var pendingWorktreePath: String? + /// Branch name companion to `pendingWorktreePath`. + public var pendingWorktreeBranch: String? + // MARK: - Placeholder Tracking public private(set) var pendingPlaceholderIds: Set = [] diff --git a/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift index 66b557a7..ab4cb1de 100644 --- a/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift +++ b/Packages/Tests/RxCodeCoreTests/RunTaskExecutorTests.swift @@ -271,6 +271,177 @@ struct RunTaskExecutorTests { #expect(!script.contains("# --- main ---")) } + // MARK: - Xcode profile + + @Test("Xcode .build emits a single xcodebuild build invocation") + func xcodeBuildAction() { + let profile = RunProfile( + projectId: UUID(), + name: "Build", + type: .xcode, + xcode: XcodeRunConfig(container: "App.xcodeproj", scheme: "App", action: .build) + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("xcodebuild -project 'App.xcodeproj' -scheme 'App' -configuration 'Debug' build")) + #expect(!script.contains("xcrun simctl")) + #expect(!script.contains("open -n")) + } + + @Test("Xcode .run with no destination falls back to macOS open -n launcher") + func xcodeRunNoDestinationUsesMacLauncher() { + let profile = RunProfile( + projectId: UUID(), + name: "Run", + type: .xcode, + xcode: XcodeRunConfig(container: "App.xcodeproj", scheme: "App", action: .run) + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("xcodebuild -project 'App.xcodeproj' -scheme 'App' -configuration 'Debug' build")) + #expect(script.contains("open -n \"$__rxcode_app\"")) + #expect(!script.contains("xcrun simctl")) + } + + @Test("Xcode .run with iOS Simulator destination installs and launches via simctl") + func xcodeRunSimulatorUsesSimctl() { + let destination = XcodeDestination( + kind: .iosSimulator, + platform: "iOS Simulator", + name: "iPhone 16", + udid: "ABCD-1234", + os: "18.0" + ) + let profile = RunProfile( + projectId: UUID(), + name: "Run on iPhone 16", + type: .xcode, + xcode: XcodeRunConfig( + container: "App.xcodeproj", + scheme: "App", + action: .run, + selectedDestination: destination + ) + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("-destination 'platform=iOS Simulator,id=ABCD-1234'")) + #expect(script.contains("xcrun simctl boot \"$__rxcode_udid\"")) + #expect(script.contains("open -a Simulator")) + #expect(script.contains("xcrun simctl install \"$__rxcode_udid\" \"$__rxcode_app\"")) + #expect(script.contains("xcrun simctl launch --console-pty \"$__rxcode_udid\" \"$__rxcode_bundle_id\"")) + #expect(!script.contains("open -n \"$__rxcode_app\"")) + } + + @Test("Xcode .run with physical iOS device emits clear unsupported error") + func xcodeRunDeviceIsUnsupported() { + let destination = XcodeDestination( + kind: .iosDevice, + platform: "iOS", + name: "My iPhone", + udid: "ABCD-DEVICE" + ) + let profile = RunProfile( + projectId: UUID(), + name: "Run on device", + type: .xcode, + xcode: XcodeRunConfig( + container: "App.xcodeproj", + scheme: "App", + action: .run, + selectedDestination: destination + ) + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("is not yet supported")) + #expect(script.contains("xcodebuild -project 'App.xcodeproj' -scheme 'App' -configuration 'Debug' -destination 'platform=iOS,id=ABCD-DEVICE' build")) + #expect(!script.contains("xcrun simctl")) + } + + @Test("Legacy free-text destination is still honored when no structured pick") + func xcodeRunLegacyDestinationHonored() { + let profile = RunProfile( + projectId: UUID(), + name: "Legacy", + type: .xcode, + xcode: XcodeRunConfig( + container: "App.xcodeproj", + scheme: "App", + action: .run, + destination: "platform=macOS,arch=arm64" + ) + ) + let script = RunTaskExecutor.buildWrapperScript(profile: profile, projectPath: "/p") + #expect(script.contains("-destination 'platform=macOS,arch=arm64'")) + #expect(script.contains("open -n \"$__rxcode_app\"")) + } + + // MARK: - XcodeDestination + + @Test("XcodeDestination prefers id over name in xcodebuild argument") + func xcodeDestinationPrefersId() { + let d = XcodeDestination( + kind: .iosSimulator, + platform: "iOS Simulator", + name: "iPhone 16", + udid: "ABCD-1234", + os: "18.0" + ) + #expect(d.xcodebuildArgument == "platform=iOS Simulator,id=ABCD-1234") + } + + @Test("XcodeDestination falls back to name+OS when udid missing") + func xcodeDestinationFallsBackToName() { + let d = XcodeDestination( + kind: .iosSimulator, + platform: "iOS Simulator", + name: "iPhone 16", + os: "18.0" + ) + #expect(d.xcodebuildArgument == "platform=iOS Simulator,name=iPhone 16,OS=18.0") + } + + // MARK: - XcodeDestinationParser + + @Test("Parser extracts destinations from the Available section only") + func parserSkipsIneligible() { + let output = """ + Available destinations for the "App" scheme: + \t{ platform:macOS, arch:arm64, id:00006001-001435262693801E, name:My Mac } + \t{ platform:iOS Simulator, id:ABCD-1234, OS:18.0, name:iPhone 16 } + + Ineligible destinations for the "App" scheme: + \t{ platform:iOS, name:Generic iOS Device, error:Whatever } + """ + let parsed = XcodeDestinationParser.parse(output) + #expect(parsed.count == 2) + #expect(parsed[0].platform == "macOS") + #expect(parsed[0].name == "My Mac") + #expect(parsed[0].arch == "arm64") + #expect(parsed[1].platform == "iOS Simulator") + #expect(parsed[1].udid == "ABCD-1234") + #expect(parsed[1].os == "18.0") + } + + @Test("Parser classifies Mac Catalyst via variant") + func parserClassifiesMacCatalyst() { + let output = """ + Available destinations for the "App" scheme: + \t{ platform:macOS, arch:arm64, variant:Mac Catalyst, id:XYZ, name:My Mac } + """ + let parsed = XcodeDestinationParser.parse(output) + #expect(parsed.count == 1) + #expect(parsed[0].kind == .macCatalyst) + } + + @Test("Parser dedupes by stable id") + func parserDedupes() { + let output = """ + Available destinations for the "App" scheme: + \t{ platform:iOS Simulator, id:ABCD, OS:18.0, name:iPhone 16 } + \t{ platform:iOS Simulator, id:ABCD, OS:18.0, name:iPhone 16 } + """ + let parsed = XcodeDestinationParser.parse(output) + #expect(parsed.count == 1) + } + // MARK: - Model round-trip @Test("RunProfile JSON round-trips") diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index b28cd518..419a3838 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -14,13 +14,13 @@ DF06CCD12FB4CAB5005991E1 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = DF06CCD02FB4CAB5005991E1 /* ViewInspector */; }; DF06DCC72FB8552B005991E1 /* UnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */; }; DF23F7362FB8C3EC008929A6 /* icon.icon in Resources */ = {isa = PBXBuildFile; fileRef = DF23F7352FB8C3EC008929A6 /* icon.icon */; }; + DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */; }; DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; }; DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; }; DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; }; DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; }; E6821AC12F7CEE7200829FC9 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = E6A001012F8A000100000001 /* SwiftTerm */; }; E6C001022F9B000100000001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E6C001012F9B000100000001 /* Sparkle */; }; - DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */ = {isa = PBXBuildFile; productRef = DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */; }; E6D001032FA0000100000001 /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001012FA0000100000001 /* RxCodeCore */; }; E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = E6D001022FA0000100000001 /* RxCodeChatKit */; }; /* End PBXBuildFile section */ @@ -647,6 +647,14 @@ minimumVersion = 0.10.3; }; }; + DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/paololeonardi/WaterfallGrid"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; E6A001002F8A000100000001 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git"; @@ -663,14 +671,6 @@ minimumVersion = 2.0.0; }; }; - DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/paololeonardi/WaterfallGrid"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.0; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -683,6 +683,11 @@ package = DF06CCCF2FB4CAB5005991E1 /* XCRemoteSwiftPackageReference "ViewInspector" */; productName = ViewInspector; }; + DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */ = { + isa = XCSwiftPackageProductDependency; + package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; + productName = WaterfallGrid; + }; DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */ = { isa = XCSwiftPackageProductDependency; productName = RxCodeChatKit; @@ -697,11 +702,6 @@ package = E6C001002F9B000100000001 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */ = { - isa = XCSwiftPackageProductDependency; - package = DF23FF1B2FBB42F7008929A6 /* XCRemoteSwiftPackageReference "WaterfallGrid" */; - productName = WaterfallGrid; - }; E6D001012FA0000100000001 /* RxCodeCore */ = { isa = XCSwiftPackageProductDependency; package = E6D001002FA0000100000001 /* XCLocalSwiftPackageReference "Packages" */; diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 182b7f05..bad8b542 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -148,6 +148,20 @@ final class AppState { /// id after the swap that happens mid-stream in `processStream`. private var sessionIdRedirect: [String: String] = [:] + // MARK: - Stream Completion Tracking (cross-project MCP) + + /// Result of a finished stream. Used by `ide__send_to_thread` to surface + /// the assistant's reply back through the MCP tool call. + struct StreamCompletion: Sendable { + let sessionId: String + let assistantText: String + let error: String? + } + + /// Completed streams whose owners (callers of `awaitStreamCompletion`) + /// have not yet picked up the result. Keyed by `streamId`. + private var pendingStreamCompletions: [UUID: StreamCompletion] = [:] + // MARK: - Session Summaries (shared — lightweight metadata for all projects) var allSessionSummaries: [ChatSession.Summary] = [] @@ -2037,6 +2051,7 @@ final class AppState { // MARK: - Shared Send Logic + @discardableResult private func sendPrompt( _ prompt: String, displayText: String? = nil, @@ -2045,10 +2060,10 @@ final class AppState { initialMessages: [ChatMessage]? = nil, tempFilePaths: [String] = [], in window: WindowState - ) async { + ) async -> UUID? { guard let project = window.selectedProject else { handleError(AppError.noProjectSelected, in: window) - return + return nil } if isStreaming(in: window) { @@ -2071,12 +2086,18 @@ final class AppState { window.sessionModel = snapModel let snapEffort = window.sessionEffort let snapPermission = window.sessionPermissionMode + let pendingWorktreePath = window.pendingWorktreePath + let pendingWorktreeBranch = window.pendingWorktreeBranch updateState(tempId) { state in state.agentProvider = snapProvider state.model = snapModel state.effort = snapEffort state.permissionMode = snapPermission + state.worktreePath = pendingWorktreePath + state.worktreeBranch = pendingWorktreeBranch } + window.pendingWorktreePath = nil + window.pendingWorktreeBranch = nil } let sessionKey = window.currentSessionId! @@ -2112,7 +2133,9 @@ final class AppState { messages: [], agentProvider: provider, model: selection.model, - origin: provider.defaultSessionOrigin + origin: provider.defaultSessionOrigin, + worktreePath: sessionStates[sessionKey]?.worktreePath, + worktreeBranch: sessionStates[sessionKey]?.worktreeBranch ) allSessionSummaries.insert(placeholder.summary, at: 0) threadStore.upsert(placeholder.summary) @@ -2202,6 +2225,7 @@ final class AppState { } } sessionStates[sessionKey, default: SessionStreamState()].streamTask = task + return streamId } // MARK: - Stream Processing @@ -2256,6 +2280,187 @@ final class AppState { } } + // MARK: - Stream Completion (cross-project MCP) + + /// Record that the stream `streamId` finished. Stored in + /// `pendingStreamCompletions` for any `awaitStreamCompletion(...)` caller + /// (currently `ide__send_to_thread`) to pick up. Latest call wins, except + /// we don't overwrite a success with an error from the fallback path. + private func recordStreamCompletion( + streamId: UUID, + sessionId: String, + assistantText: String, + error: String? + ) { + pendingStreamCompletions[streamId] = StreamCompletion( + sessionId: sessionId, + assistantText: assistantText, + error: error + ) + } + + /// Wait up to `timeout` seconds for the stream identified by `streamId` + /// to record a completion. Polls every 100ms — MainActor serialization + /// means the recorder fires between sleeps. Returns the completion if + /// one arrived in time, otherwise `nil`. + func awaitStreamCompletion(streamId: UUID, timeout: TimeInterval) async -> StreamCompletion? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let completion = pendingStreamCompletions.removeValue(forKey: streamId) { + return completion + } + try? await Task.sleep(nanoseconds: 100_000_000) + } + return pendingStreamCompletions.removeValue(forKey: streamId) + } + + /// Discard a recorded completion. Called by long-running `wait_for_response=false` + /// MCP sends so the dictionary doesn't grow unbounded with abandoned results. + func discardStreamCompletion(streamId: UUID) { + pendingStreamCompletions.removeValue(forKey: streamId) + } + + // MARK: - Cross-Project Send (used by ide__send_to_thread) + + struct CrossProjectSendResult: Sendable { + let threadId: String + let projectId: UUID + let done: Bool + let assistantText: String + let error: String? + } + + enum CrossProjectSendError: Error, LocalizedError { + case unknownProject(UUID) + case unknownThread(String) + + var errorDescription: String? { + switch self { + case .unknownProject(let id): return "No project with id \(id.uuidString)" + case .unknownThread(let id): return "No thread with id \(id)" + } + } + } + + /// Send a prompt to a thread in any project. The send runs through the + /// normal `sendPrompt` pipeline via a synthetic `WindowState`, so all the + /// usual side-effects (title generation, briefing updates, persistence) + /// still fire and any UI windows currently bound to the same session see + /// the assistant tokens live via the shared `sessionStates` dictionary. + func sendCrossProject( + projectId: UUID?, + threadId: String?, + prompt: String, + agentProvider: AgentProvider? = nil, + model: String? = nil, + effort: String? = nil, + permissionMode: PermissionMode? = nil, + waitForResponse: Bool = true, + timeoutSeconds: TimeInterval = 120 + ) async throws -> CrossProjectSendResult { + // Resolve target project + thread. + let resolvedProject: Project + let resolvedThreadId: String? + + if let threadId { + guard let summary = allSessionSummaries.first(where: { $0.id == threadId }) + ?? threadStore.fetch(id: threadId).map({ $0.toSummary() }) + else { + throw CrossProjectSendError.unknownThread(threadId) + } + guard let proj = projects.first(where: { $0.id == summary.projectId }) else { + throw CrossProjectSendError.unknownProject(summary.projectId) + } + resolvedProject = proj + resolvedThreadId = threadId + } else if let projectId { + guard let proj = projects.first(where: { $0.id == projectId }) else { + throw CrossProjectSendError.unknownProject(projectId) + } + resolvedProject = proj + resolvedThreadId = nil + } else { + throw CrossProjectSendError.unknownProject(UUID()) + } + + // Build a synthetic WindowState. AppState.sessionStates is shared across + // windows, so the message + stream are visible to any real window that + // happens to also be viewing this session. + let window = WindowState() + window.selectedProject = resolvedProject + window.currentSessionId = resolvedThreadId + + // Carry over per-session overrides for a new thread; for an existing + // thread we leave the session's own stored values alone (the resume + // path in sendPrompt reads from `sessionStates[sessionKey]`). + if resolvedThreadId == nil { + if let agentProvider { + window.sessionAgentProvider = agentProvider + } + if let model { + window.sessionModel = model + } + if let effort { + window.sessionEffort = effort + } + if let permissionMode { + window.sessionPermissionMode = permissionMode + } + } + + guard let streamId = await sendPrompt(prompt, displayText: prompt, in: window) else { + return CrossProjectSendResult( + threadId: resolvedThreadId ?? "", + projectId: resolvedProject.id, + done: false, + assistantText: "", + error: "Send failed: no session could be allocated." + ) + } + + // After sendPrompt returns, window.currentSessionId is the (possibly + // pending-) key the stream is bound to. The CLI may rename it to its + // own sid mid-stream; we surface whichever id the completion lands on. + let postSendThreadId = window.currentSessionId ?? resolvedThreadId ?? "" + + if !waitForResponse { + // Don't leak the result in the dictionary — the caller is + // fire-and-forget. Drop it once it lands. + Task { [weak self] in + _ = await self?.awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) + } + return CrossProjectSendResult( + threadId: postSendThreadId, + projectId: resolvedProject.id, + done: false, + assistantText: "", + error: nil + ) + } + + let completion = await awaitStreamCompletion(streamId: streamId, timeout: timeoutSeconds) + if let completion { + return CrossProjectSendResult( + threadId: completion.sessionId, + projectId: resolvedProject.id, + done: completion.error == nil, + assistantText: completion.assistantText, + error: completion.error + ) + } else { + // Timed out. Surface the partial assistant text we have so far so + // the caller can decide whether to poll back via get_thread_messages. + let partial = lastAssistantResponseText(in: stateForSession(window.currentSessionId ?? "").messages) + return CrossProjectSendResult( + threadId: window.currentSessionId ?? postSendThreadId, + projectId: resolvedProject.id, + done: false, + assistantText: partial, + error: nil + ) + } + } + /// Drop "No response requested." text blocks from the assistant message /// at `idx`. If the message has no blocks left after the strip, remove /// it entirely. Called at turn-finalization sites — the marker is the @@ -2697,6 +2902,13 @@ final class AppState { } } + recordStreamCompletion( + streamId: streamId, + sessionId: resultEvent.sessionId, + assistantText: lastAssistantResponseText(in: stateForSession(sessionKey).messages), + error: resultEvent.isError ? "Agent reported an error result." : nil + ) + let isFg = (window.currentSessionId ?? window.newSessionKey) == sessionKey if !isFg, !resultEvent.isError { updateState(sessionKey) { $0.hasUncheckedCompletion = true } @@ -2849,6 +3061,24 @@ final class AppState { logger.info("[Stream:UI] stream \(streamId) ended but newer stream \(currentOwner!) owns session — skipping cleanup") } } + + // Fallback completion record: covers cancellations, no-events errors, + // and any path where `.result` was not received. The `.result` case + // already records a completion before reaching here — recordStreamCompletion + // is idempotent (it overwrites with the latest), but if a prior call set a + // successful completion we don't want to clobber it with an error. + if pendingStreamCompletions[streamId] == nil { + let assistantText = lastAssistantResponseText(in: stateForSession(sessionKey).messages) + let errorMsg: String? = eventCount == 0 + ? (stderrOutput ?? "Stream ended with no events.") + : (Task.isCancelled ? "Stream was cancelled." : nil) + recordStreamCompletion( + streamId: streamId, + sessionId: sessionKey, + assistantText: assistantText, + error: errorMsg + ) + } } } @@ -3568,6 +3798,8 @@ final class AppState { updateState(session.id) { $0.hasUncheckedCompletion = false } window.showingBriefing = false + window.pendingWorktreePath = nil + window.pendingWorktreeBranch = nil window.currentSessionId = session.id window.sessionAgentProvider = sessionStates[session.id]?.agentProvider ?? session.agentProvider window.sessionModel = sessionStates[session.id]?.model ?? session.model @@ -3709,6 +3941,8 @@ final class AppState { window.sessionEffort = nil window.sessionPermissionMode = nil window.sessionPlanMode = false + window.pendingWorktreePath = nil + window.pendingWorktreeBranch = nil sessionStates.removeValue(forKey: window.newSessionKey) window.inputText = window.draftTexts[newDraftKey(for: window)] ?? "" window.messageQueue = window.draftQueues[newDraftKey(for: window)] ?? [] @@ -4171,14 +4405,20 @@ final class AppState { guard let project = window.selectedProject else { throw AppError.noProjectSelected } - guard let sessionId = window.currentSessionId else { - throw AppError.streamFailed("No active chat. Send a message first or pick a chat.") - } let baseRepo = URL(fileURLWithPath: project.path) let info = try await GitWorktreeService.shared.createWorktree( baseRepo: baseRepo, branch: branch ) + + // New-chat view: no session yet. Park the worktree on the window so + // it gets applied when sendPrompt allocates a session id. + guard let sessionId = window.currentSessionId else { + window.pendingWorktreePath = info.path.path + window.pendingWorktreeBranch = info.branch + return + } + // Update in-memory state sessionStates[sessionId, default: SessionStreamState()].worktreePath = info.path.path sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = info.branch diff --git a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift index 0aeb783f..9cfa18ac 100644 --- a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift +++ b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift @@ -36,10 +36,14 @@ extension AppState: IDEToolHandling { return await handleGetRunningJobs() case "ide__get_job_output": throw IDEToolError.notSupported("ide__get_job_output is not yet implemented") + case "ide__get_projects": + return handleGetProjects() case "ide__get_threads": return await handleGetThreads(arguments: arguments) - case "ide__get_thread_detail": - return try await handleGetThreadDetail(arguments: arguments) + case "ide__get_thread_messages", "ide__get_thread_detail": + return try await handleGetThreadMessages(arguments: arguments) + case "ide__send_to_thread": + return try await handleSendToThread(arguments: arguments) case "ide__get_usage": return await handleGetUsage() case "ide__ask_user": @@ -85,52 +89,203 @@ extension AppState: IDEToolHandling { } @MainActor - private func handleGetThreads(arguments: JSONValue) -> JSONValue { + private func handleGetProjects() -> JSONValue { + let entries: [JSONValue] = projects.map { p in + .object([ + "id": .string(p.id.uuidString), + "name": .string(p.name), + "path": .string(p.path), + "github_repo": p.gitHubRepo.map { .string($0) } ?? .null, + "last_session_id": p.lastSessionId.map { .string($0) } ?? .null, + "last_agent_provider": p.lastAgentProvider.map { .string($0.rawValue) } ?? .null, + "last_model": p.lastModel.map { .string($0) } ?? .null, + ]) + } + return jsonTextResult(.array(entries)) + } + + @MainActor + private func handleGetThreads(arguments: JSONValue) async -> JSONValue { let projectFilter: UUID? = { if let s = arguments["project_id"]?.stringValue { return UUID(uuidString: s) } return nil }() + let query = arguments["query"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestedLimit = Int(arguments["limit"]?.numberValue ?? 50) + let limit = max(1, min(requestedLimit, 200)) + let summaries = threadStore.loadAllSummaries() - let filtered = summaries - .filter { projectFilter == nil || $0.projectId == projectFilter } - .filter { !$0.isArchived } - .sorted { $0.updatedAt > $1.updatedAt } - .prefix(50) - let entries: [JSONValue] = filtered.map { s in - .object([ - "id": .string(s.id), - "title": .string(s.title), - "project_id": .string(s.projectId.uuidString), - "updated_at": .string(ISO8601DateFormatter().string(from: s.updatedAt)), - "agent_provider": .string(s.agentProvider.rawValue), - ]) + let byId = Dictionary(uniqueKeysWithValues: summaries.map { ($0.id, $0) }) + let iso = ISO8601DateFormatter() + + func emit(summary: ChatSession.Summary, score: Float?, snippet: String?) -> JSONValue { + let storedSummary = threadStore.fetchThreadSummary(sessionId: summary.id)?.summary ?? "" + var obj: [String: JSONValue] = [ + "id": .string(summary.id), + "title": .string(summary.title), + "project_id": .string(summary.projectId.uuidString), + "updated_at": .string(iso.string(from: summary.updatedAt)), + "agent_provider": .string(summary.agentProvider.rawValue), + "summary": .string(storedSummary), + "branch": summary.worktreeBranch.map { .string($0) } ?? .null, + "is_archived": .bool(summary.isArchived), + "worktree_branch": summary.worktreeBranch.map { .string($0) } ?? .null, + ] + if let score { obj["score"] = .number(Double(score)) } + if let snippet { obj["snippet"] = .string(snippet) } + return .object(obj) + } + + if let query, !query.isEmpty { + // Semantic search via the same on-device embedding pipeline that + // powers the global search overlay. Flatten the project groups so + // we can apply an optional project filter while preserving rank. + let groups = await searchService.search(query, limit: limit) + let flat = groups.flatMap { $0.hits } + let filtered = flat.filter { hit in + guard let pid = projectFilter else { return true } + return hit.projectId == pid + } + let entries: [JSONValue] = filtered.prefix(limit).compactMap { hit in + guard let summary = byId[hit.threadId] else { return nil } + if summary.isArchived { return nil } + return emit(summary: summary, score: hit.score, snippet: hit.snippet) + } + return jsonTextResult(.array(entries)) + } else { + let filtered = summaries + .filter { projectFilter == nil || $0.projectId == projectFilter } + .filter { !$0.isArchived } + .sorted { $0.updatedAt > $1.updatedAt } + .prefix(limit) + let entries: [JSONValue] = filtered.map { emit(summary: $0, score: nil, snippet: nil) } + return jsonTextResult(.array(entries)) } - return jsonTextResult(.array(entries)) } @MainActor - private func handleGetThreadDetail(arguments: JSONValue) throws -> JSONValue { + private func handleGetThreadMessages(arguments: JSONValue) async throws -> JSONValue { guard let id = arguments["thread_id"]?.stringValue else { throw IDEToolError.invalidArguments("missing 'thread_id'") } guard let thread = threadStore.fetch(id: id) else { throw IDEToolError.handlerFailed("No thread with id \(id)") } - // ChatThread is the SwiftData summary record; the full message - // body is persisted by ChatSession on disk. Return the metadata - // here — a future revision can hydrate ChatSession via - // PersistenceService.loadSession for richer detail. + let summary = thread.toSummary() + let cwd = summary.worktreePath + ?? projects.first(where: { $0.id == summary.projectId })?.path + ?? "" + let requestedLimit = Int(arguments["limit"]?.numberValue ?? 200) + let limit = max(1, min(requestedLimit, 1000)) + let includeToolCalls = arguments["include_tool_calls"]?.boolValue ?? false + + let session = await persistence.loadFullSession(summary: summary, cwd: cwd) + let allMessages = session?.messages ?? [] + // Most-recent N — preserve chronological order in the output. + let tail = Array(allMessages.suffix(limit)) + let iso = ISO8601DateFormatter() + + let messageEntries: [JSONValue] = tail.compactMap { msg in + // Concatenate text blocks; optionally append a one-line summary for + // each tool call so a reader can tell what happened without the + // full result payload. + var pieces: [String] = [] + for block in msg.blocks { + if let text = block.text, !text.isEmpty { + pieces.append(text) + } else if let call = block.toolCall, includeToolCalls { + let resultPreview = call.result.map { $0.prefix(200) }.map(String.init) ?? "" + pieces.append("[tool: \(call.name)\(call.isError ? " (error)" : "")] \(resultPreview)") + } + } + let text = pieces.joined(separator: "\n\n") + if text.isEmpty && !msg.isError { return nil } + var obj: [String: JSONValue] = [ + "role": .string(msg.role.rawValue), + "text": .string(text), + "timestamp": .string(iso.string(from: msg.timestamp)), + ] + if msg.isError { obj["is_error"] = .bool(true) } + if !msg.attachmentPaths.isEmpty { + obj["attachments"] = .array(msg.attachmentPaths.map { att in + .object([ + "name": .string(att.name), + "path": .string(att.path), + "type": .string(att.type), + ]) + }) + } + return .object(obj) + } + return jsonTextResult(.object([ "id": .string(thread.id), "title": .string(thread.title), "project_id": .string(thread.projectId.uuidString), - "created_at": .string(ISO8601DateFormatter().string(from: thread.createdAt)), - "updated_at": .string(ISO8601DateFormatter().string(from: thread.updatedAt)), + "created_at": .string(iso.string(from: thread.createdAt)), + "updated_at": .string(iso.string(from: thread.updatedAt)), "model": thread.model.map { .string($0) } ?? .null, "agent_provider": thread.agentProviderRaw.map { .string($0) } ?? .null, + "summary": .string(threadStore.fetchThreadSummary(sessionId: thread.id)?.summary ?? ""), + "messages": .array(messageEntries), ])) } + @MainActor + private func handleSendToThread(arguments: JSONValue) async throws -> JSONValue { + guard let prompt = arguments["prompt"]?.stringValue, !prompt.isEmpty else { + throw IDEToolError.invalidArguments("missing 'prompt'") + } + let threadId = arguments["thread_id"]?.stringValue + let projectIdStr = arguments["project_id"]?.stringValue + if threadId != nil && projectIdStr != nil { + throw IDEToolError.invalidArguments("pass either 'thread_id' or 'project_id', not both") + } + if threadId == nil && projectIdStr == nil { + throw IDEToolError.invalidArguments("one of 'thread_id' or 'project_id' is required") + } + let projectId: UUID? = projectIdStr.flatMap(UUID.init(uuidString:)) + if let projectIdStr, projectId == nil { + throw IDEToolError.invalidArguments("'project_id' is not a valid UUID: \(projectIdStr)") + } + + let agentProvider: AgentProvider? = arguments["agent_provider"]?.stringValue + .flatMap(AgentProvider.init(rawValue:)) + let model = arguments["model"]?.stringValue + let effort = arguments["effort"]?.stringValue + let permissionMode: PermissionMode? = arguments["permission_mode"]?.stringValue + .flatMap(PermissionMode.init(rawValue:)) + let waitForResponse = arguments["wait_for_response"]?.boolValue ?? true + let requestedTimeout = arguments["timeout_seconds"]?.numberValue ?? 120 + let timeoutSeconds = max(1, min(requestedTimeout, 600)) + + do { + let result = try await sendCrossProject( + projectId: projectId, + threadId: threadId, + prompt: prompt, + agentProvider: agentProvider, + model: model, + effort: effort, + permissionMode: permissionMode, + waitForResponse: waitForResponse, + timeoutSeconds: timeoutSeconds + ) + var obj: [String: JSONValue] = [ + "thread_id": .string(result.threadId), + "project_id": .string(result.projectId.uuidString), + "done": .bool(result.done), + "assistant_text": .string(result.assistantText), + ] + if let error = result.error { + obj["error"] = .string(error) + } + return jsonTextResult(.object(obj)) + } catch let error as CrossProjectSendError { + throw IDEToolError.handlerFailed(error.localizedDescription) + } + } + private func handleGetUsage() async -> JSONValue { let provider = await MainActor.run { selectedAgentProvider } let usage = await rateLimitUsage(for: provider, forceRefresh: false) diff --git a/RxCode/Services/RunProfile/XcodeDestinationService.swift b/RxCode/Services/RunProfile/XcodeDestinationService.swift new file mode 100644 index 00000000..9ef2e9ac --- /dev/null +++ b/RxCode/Services/RunProfile/XcodeDestinationService.swift @@ -0,0 +1,97 @@ +import Foundation +import RxCodeCore + +/// Discovers `xcodebuild` destinations for a given Xcode container + scheme. +/// Results are cached per (container, scheme) for the lifetime of the actor +/// so reopening the destination picker doesn't re-spawn `xcodebuild` every +/// time. The cache is best-effort — when in doubt, callers can pass +/// `forceRefresh: true`. +actor XcodeDestinationService { + + static let shared = XcodeDestinationService() + + private struct CacheKey: Hashable { + let projectPath: String + let container: String + let scheme: String + } + + private var cache: [CacheKey: [XcodeDestination]] = [:] + + func destinations( + projectPath: String, + container: String, + isWorkspace: Bool, + scheme: String, + forceRefresh: Bool = false + ) async -> [XcodeDestination] { + let key = CacheKey(projectPath: projectPath, container: container, scheme: scheme) + if !forceRefresh, let hit = cache[key] { return hit } + + guard !container.isEmpty, !scheme.isEmpty else { return [] } + + let containerPath = (projectPath as NSString).appendingPathComponent(container) + let flag = isWorkspace ? "-workspace" : "-project" + let output = await runProcess( + executable: "/usr/bin/env", + arguments: ["xcodebuild", flag, containerPath, "-scheme", scheme, "-showdestinations"], + cwd: projectPath, + timeoutSeconds: 20 + ) + let destinations = XcodeDestinationParser.parse(output) + cache[key] = destinations + return destinations + } + + func invalidate(projectPath: String, container: String, scheme: String) { + cache[CacheKey(projectPath: projectPath, container: container, scheme: scheme)] = nil + } + + private func runProcess( + executable: String, + arguments: [String], + cwd: String, + timeoutSeconds: Int + ) async -> String { + await withCheckedContinuation { continuation in + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = pipe + process.standardError = Pipe() + process.environment = ProcessInfo.processInfo.environment + + let latch = OnceLatch() + process.terminationHandler = { _ in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let out = String(data: data, encoding: .utf8) ?? "" + if latch.set() { continuation.resume(returning: out) } + } + + do { + try process.run() + } catch { + if latch.set() { continuation.resume(returning: "") } + return + } + + Task.detached { [process] in + try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000) + if process.isRunning { process.terminate() } + } + } + } +} + +private final class OnceLatch: @unchecked Sendable { + private let lock = NSLock() + private var fired = false + func set() -> Bool { + lock.lock(); defer { lock.unlock() } + if fired { return false } + fired = true + return true + } +} diff --git a/RxCode/Views/Chat/BranchPickerChip.swift b/RxCode/Views/Chat/BranchPickerChip.swift index cf02d5b5..df856a9f 100644 --- a/RxCode/Views/Chat/BranchPickerChip.swift +++ b/RxCode/Views/Chat/BranchPickerChip.swift @@ -50,15 +50,19 @@ struct BranchPickerChip: View { } private var sessionWorktreePath: String? { - guard let sid = windowState.currentSessionId else { return nil } - return appState.sessionStates[sid]?.worktreePath - ?? appState.allSessionSummaries.first(where: { $0.id == sid })?.worktreePath + if let sid = windowState.currentSessionId { + return appState.sessionStates[sid]?.worktreePath + ?? appState.allSessionSummaries.first(where: { $0.id == sid })?.worktreePath + } + return windowState.pendingWorktreePath } private var sessionWorktreeBranch: String? { - guard let sid = windowState.currentSessionId else { return nil } - return appState.sessionStates[sid]?.worktreeBranch - ?? appState.allSessionSummaries.first(where: { $0.id == sid })?.worktreeBranch + if let sid = windowState.currentSessionId { + return appState.sessionStates[sid]?.worktreeBranch + ?? appState.allSessionSummaries.first(where: { $0.id == sid })?.worktreeBranch + } + return windowState.pendingWorktreeBranch } private var displayName: String { diff --git a/RxCode/Views/RunProfile/RunConfigurationsView.swift b/RxCode/Views/RunProfile/RunConfigurationsView.swift index 6c9da425..1b940eec 100644 --- a/RxCode/Views/RunProfile/RunConfigurationsView.swift +++ b/RxCode/Views/RunProfile/RunConfigurationsView.swift @@ -463,16 +463,28 @@ private struct RunProfileDetailForm: View { Text(action.rawValue.capitalized).tag(action) } } - TextField( - "Destination (optional)", - text: xcode.destination, - prompt: Text("platform=macOS") - ) - .font(.system(.body, design: .monospaced)) + LabeledContent("Destination") { + HStack(spacing: 6) { + Text(xcode.wrappedValue.selectedDestination?.displayName ?? "Any Mac (default)") + .foregroundStyle(.secondary) + if xcode.wrappedValue.selectedDestination != nil { + Button { + var c = xcode.wrappedValue + c.selectedDestination = nil + xcode.wrappedValue = c + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .help("Clear destination — pick again from the toolbar") + } + } + } } header: { Text("Xcode") } footer: { - Text("Build runs `xcodebuild build`. Run builds, then launches the produced .app. Working directory is always the project root.") + Text("Build runs `xcodebuild build`. Run builds, then launches the produced .app (macOS) or installs + launches on the selected simulator. Pick the destination from the Run toolbar.") } } diff --git a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift index 2886bb22..003fe8ac 100644 --- a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift +++ b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift @@ -1,14 +1,20 @@ import RxCodeCore import SwiftUI -/// Single combined toolbar pill containing: profile picker · run button · -/// stop button (the stop button is hidden entirely when no task is active). -/// Wrapped in its own subview so its body re-renders independently of the -/// rest of the toolbar. +/// Single combined toolbar pill containing: profile picker · (optional) +/// destination picker · run button · stop button (the stop button is hidden +/// entirely when no task is active). Wrapped in its own subview so its body +/// re-renders independently of the rest of the toolbar. struct RunProfileToolbarGroup: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState + /// Destinations cached per (container, scheme). Loaded on demand when + /// the picker first appears or the refresh button is tapped. + @State private var destinations: [XcodeDestination] = [] + @State private var isLoadingDestinations = false + @State private var lastLoadedKey: String? + private var project: Project? { windowState.selectedProject } private var profiles: [RunProfile] { @@ -32,11 +38,27 @@ struct RunProfileToolbarGroup: View { } } + /// Stable key for the destination cache lookup. Changes when the user + /// switches profile or edits the container/scheme. + private var destinationCacheKey: String? { + guard let profile = selectedProfile, + profile.type == .xcode, + let xcode = profile.xcode, + !xcode.container.isEmpty, + !xcode.scheme.isEmpty else { return nil } + return "\(xcode.container)::\(xcode.scheme)" + } + var body: some View { HStack(spacing: 2) { profilePicker .padding(.leading, 4) + if shouldShowDestinationPicker { + Divider().frame(height: 14) + destinationPicker + } + Divider().frame(height: 14) runButton @@ -49,6 +71,14 @@ struct RunProfileToolbarGroup: View { .task(id: project?.id) { if let project { await appState.ensureRunProfilesLoaded(for: project.id) } } + .task(id: destinationCacheKey) { + await loadDestinationsIfNeeded(force: false) + } + } + + private var shouldShowDestinationPicker: Bool { + guard let profile = selectedProfile else { return false } + return profile.type == .xcode && profile.xcode != nil } // MARK: - Profile picker @@ -180,4 +210,128 @@ struct RunProfileToolbarGroup: View { windowState.showInspector = true windowState.selectedRunTaskId = appState.runService.activeTasks.first?.id } + + // MARK: - Destination picker + + private var destinationPicker: some View { + Menu { + if isLoadingDestinations && destinations.isEmpty { + Text("Loading destinations…").foregroundStyle(.secondary) + } else if destinations.isEmpty { + Text("No destinations found").foregroundStyle(.secondary) + Text("Check the scheme and container path.") + .foregroundStyle(.secondary) + .font(.system(size: 11)) + } else { + let grouped = Dictionary(grouping: destinations, by: { $0.groupLabel }) + let order = ["macOS", "Mac Catalyst", "iOS Simulators", "iOS Devices", + "tvOS Simulators", "tvOS Devices", + "watchOS Simulators", "watchOS Devices", + "visionOS Simulators", "visionOS Devices", "Other"] + let presentGroups = order.filter { grouped[$0] != nil } + ForEach(presentGroups, id: \.self) { group in + Section(group) { + ForEach(grouped[group] ?? []) { destination in + Button { + selectDestination(destination) + } label: { + HStack { + Text(destination.displayName) + if destination.id == currentDestination?.id { + Image(systemName: "checkmark") + } + if !destination.isLaunchable { + Text("(build only)") + .foregroundStyle(.secondary) + } + } + } + } + } + } + } + Divider() + Button { + Task { await loadDestinationsIfNeeded(force: true) } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + } label: { + HStack(spacing: 4) { + Image(systemName: destinationIconName) + .font(.system(size: 11)) + Text(currentDestination?.displayName ?? "Any Mac") + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .frame(maxWidth: 220) + .contentShape(Rectangle()) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Select Run Destination") + .accessibilityIdentifier("run-profile-destination-menu") + } + + private var currentDestination: XcodeDestination? { + selectedProfile?.xcode?.selectedDestination + } + + private var destinationIconName: String { + switch currentDestination?.kind { + case .iosSimulator, .iosDevice: return "iphone" + case .tvSimulator, .tvDevice: return "tv" + case .watchSimulator, .watchDevice: return "applewatch" + case .visionSimulator, .visionDevice: return "visionpro" + case .macCatalyst: return "ipad.and.iphone" + case .macOS, .other, nil: return "desktopcomputer" + } + } + + private func selectDestination(_ destination: XcodeDestination) { + guard let project, let profile = selectedProfile, profile.type == .xcode else { return } + var updated = profiles + guard let idx = updated.firstIndex(where: { $0.id == profile.id }) else { return } + var newProfile = updated[idx] + var xcode = newProfile.xcode ?? XcodeRunConfig() + xcode.selectedDestination = destination + // Clear the legacy free-text override so it doesn't shadow the new + // structured selection on the next run. + xcode.destination = "" + newProfile.xcode = xcode + newProfile.updatedAt = Date() + updated[idx] = newProfile + appState.setRunProfiles(updated, for: project.id) + } + + private func loadDestinationsIfNeeded(force: Bool) async { + guard let key = destinationCacheKey, + let project, + let xcode = selectedProfile?.xcode else { + destinations = [] + return + } + if !force && lastLoadedKey == key && !destinations.isEmpty { return } + + isLoadingDestinations = true + defer { isLoadingDestinations = false } + + let loaded = await XcodeDestinationService.shared.destinations( + projectPath: project.path, + container: xcode.container, + isWorkspace: xcode.isWorkspace, + scheme: xcode.scheme, + forceRefresh: force + ) + // Guard against late returns after the user switched profiles. + if destinationCacheKey == key { + destinations = loaded + lastLoadedKey = key + } + } } From bf39a24ad56bbb0dedc3fde523c12aa422e73088 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 16:24:37 +0800 Subject: [PATCH 2/4] fix: worktree implementation for branch switching --- .../RxCodeCore/Utilities/GitHelper.swift | 47 +++++++++ .../Utilities/GitWorktreeService.swift | 10 ++ RxCode.xcodeproj/project.pbxproj | 4 +- RxCode/App/AppState.swift | 98 +++++++++++++------ RxCode/Views/Chat/BranchPickerChip.swift | 61 +++++++++++- 5 files changed, 183 insertions(+), 37 deletions(-) diff --git a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift index a0b8fba5..c96104de 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift @@ -40,4 +40,51 @@ public enum GitHelper { let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + /// Checks out an existing branch at `path`. Returns the combined git output + /// on failure, or nil on success. + public static func checkout(branch: String, at path: String) async -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["checkout", branch] + process.currentDirectoryURL = URL(fileURLWithPath: path) + process.environment = ProcessInfo.processInfo.environment.merging([ + "GIT_TERMINAL_PROMPT": "0", + "GIT_PAGER": "", + "PAGER": "", + ]) { _, new in new } + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + + do { try process.run() } catch { return "\(error)" } + + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in continuation.resume() } + } + + if process.terminationStatus == 0 { return nil } + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + let combined = (String(data: outData, encoding: .utf8) ?? "") + + (String(data: errData, encoding: .utf8) ?? "") + let trimmed = combined.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "git checkout exited \(process.terminationStatus)" : trimmed + } + + /// Returns local branch names ordered by most recent commit first. + public static func listLocalBranches(at path: String) async -> [String] { + guard let result = await run( + ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads/"], + at: path + ) else { + return [] + } + return result + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } } diff --git a/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift b/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift index b02325f4..1abcf3a3 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitWorktreeService.swift @@ -61,6 +61,16 @@ public actor GitWorktreeService { guard let mainGitDir else { throw WorktreeError.notARepo } let mainRepo = mainGitDir.deletingLastPathComponent() + // If the branch is already checked out in an existing worktree (including + // the main repo), reuse it — `git worktree add` would otherwise refuse + // with "branch is already checked out at …", breaking back-and-forth + // branch switching in chat. + if let output = await runGit(["worktree", "list", "--porcelain"], at: mainRepo.path), + let existing = parseWorktreeList(output).first(where: { $0.branch == normalizedBranch }) + { + return existing + } + let baseDir = defaultBaseDir(for: mainRepo) try ensureDirectory(baseDir) diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index 419a3838..6477f1e7 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -544,7 +544,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.2.5; - PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCode; + PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.RxCode; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -578,7 +578,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.2.5; - PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCode; + PRODUCT_BUNDLE_IDENTIFIER = com.rxlab.RxCode; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index bad8b542..6b37a309 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -3374,50 +3374,32 @@ final class AppState { state.activeToolInputBuffer = "" state.textDeltaBuffer = "" state.pendingToolResults.removeAll() - state.streamingStartDate = nil - // Drop the in-progress assistant bubble so it doesn't reappear on the next turn. - if let lastIndex = state.messages.indices.last, - state.messages[lastIndex].role == .assistant, - !state.messages[lastIndex].isError, - !state.messages[lastIndex].isCompactBoundary - { - state.messages.remove(at: lastIndex) - } - // Restore the user message that triggered this stream into the input field — - // both its text and the original attachment objects so images / pasted text - // aren't silently dropped when the user stops a turn. - if let lastIndex = state.messages.indices.last, - state.messages[lastIndex].role == .user, - !state.messages[lastIndex].isCompactBoundary - { - let userText = state.messages[lastIndex].blocks.compactMap(\.text).joined() - state.messages.remove(at: lastIndex) - window.inputText = userText - window.attachments = state.inFlightUserAttachments + if let idx = state.messages.indices.reversed().first(where: { + state.messages[$0].role == .assistant && state.messages[$0].isStreaming + }) { + state.messages[idx].isStreaming = false + state.messages[idx].finalizeToolCalls() + if let start = state.streamingStartDate { + state.messages[idx].duration = Date().timeIntervalSince(start) + } + Self.stripNoOpText(at: idx, in: &state.messages) } + state.streamingStartDate = nil state.inFlightUserAttachments = [] } window.showError = false window.errorMessage = nil - // Save messages accumulated up to the point of cancellation to disk (prevent data loss) + // Save messages accumulated up to the point of cancellation to disk (prevent data loss). + // The placeholder session (if any) is left in place so partial messages remain visible; + // it will be promoted to the real CLI session id on the next user turn. if let project = window.selectedProject { let messages = stateForSession(key).messages if !messages.isEmpty { await saveSession(sessionId: key, projectId: project.id, messages: messages) } } - - // Clean up placeholder session on cancellation - if let sid = window.currentSessionId, window.pendingPlaceholderIds.contains(sid) { - allSessionSummaries.removeAll { $0.id == sid } - threadStore.delete(id: sid) - Task.detached(priority: .utility) { [searchService] in await searchService.removeThread(id: sid) } - window.removePendingPlaceholder(sid) - sessionStates.removeValue(forKey: sid) - window.currentSessionId = nil - } } private func recordStreamingDuration(for key: String) { @@ -4442,6 +4424,60 @@ final class AppState { } } + /// Switch the chat to an existing branch. + /// + /// If the branch is already attached to a linked worktree, point the + /// session at that worktree. Otherwise run `git checkout` in the project + /// root and clear the session's worktree pointer. + func switchToExistingBranch(_ branch: String, in window: WindowState) async throws { + guard let project = window.selectedProject else { + throw AppError.noProjectSelected + } + let baseRepo = URL(fileURLWithPath: project.path) + + let existingWorktree: GitWorktreeService.WorktreeInfo? = await { + guard let list = try? await GitWorktreeService.shared.listWorktrees(baseRepo: baseRepo) else { + return nil + } + // The main repo also appears in `worktree list`; skip it so the + // project root takes the plain-checkout path. + return list.first { $0.branch == branch && $0.path.standardizedFileURL != baseRepo.standardizedFileURL } + }() + + let newPath: String? + let newBranch: String? + if let existingWorktree { + newPath = existingWorktree.path.path + newBranch = existingWorktree.branch + } else { + if let err = await GitHelper.checkout(branch: branch, at: project.path) { + throw GitWorktreeService.WorktreeError.gitFailed(err) + } + newPath = nil + newBranch = nil + } + + guard let sessionId = window.currentSessionId else { + window.pendingWorktreePath = newPath + window.pendingWorktreeBranch = newBranch + return + } + + sessionStates[sessionId, default: SessionStreamState()].worktreePath = newPath + sessionStates[sessionId, default: SessionStreamState()].worktreeBranch = newBranch + if let idx = allSessionSummaries.firstIndex(where: { $0.id == sessionId }) { + allSessionSummaries[idx].worktreePath = newPath + allSessionSummaries[idx].worktreeBranch = newBranch + threadStore.upsert(allSessionSummaries[idx]) + } + if let snap = allSessionSummaries.first(where: { $0.id == sessionId }) { + await updateSessionMetadata(snap.makeSession()) { s in + s.worktreePath = newPath + s.worktreeBranch = newBranch + } + } + } + /// Remove the worktree associated with the session (if any). /// `force = true` removes even with uncommitted changes. func detachWorktree(in window: WindowState, force: Bool = false) async throws { diff --git a/RxCode/Views/Chat/BranchPickerChip.swift b/RxCode/Views/Chat/BranchPickerChip.swift index df856a9f..f54cd37b 100644 --- a/RxCode/Views/Chat/BranchPickerChip.swift +++ b/RxCode/Views/Chat/BranchPickerChip.swift @@ -10,11 +10,37 @@ struct BranchPickerChip: View { @Environment(WindowState.self) private var windowState @State private var showCreateSheet = false @State private var currentBranch: String? + @State private var branches: [String] = [] + @State private var switchingTo: String? @State private var refreshTask: Task? var body: some View { - Button { - showCreateSheet = true + Menu { + if !branches.isEmpty { + Section("Switch branch") { + ForEach(branches, id: \.self) { name in + Button { + switchToBranch(name) + } label: { + HStack { + if name == activeBranch { + Image(systemName: "checkmark") + } else { + Image(systemName: "arrow.triangle.branch") + } + Text(name) + } + } + .disabled(switchingTo != nil) + } + } + Divider() + } + Button { + showCreateSheet = true + } label: { + Label("Create new branch…", systemImage: "plus") + } } label: { HStack(spacing: 4) { Image(systemName: "arrow.triangle.branch") @@ -30,7 +56,9 @@ struct BranchPickerChip: View { .padding(.vertical, 4) .background(ClaudeTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall)) } - .buttonStyle(.plain) + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() .help(workingDirectoryHint) .sheet(isPresented: $showCreateSheet) { CreateBranchSheet(currentBranch: currentBranch) { @@ -43,6 +71,25 @@ struct BranchPickerChip: View { .onChange(of: refreshKey) { _, _ in refresh() } } + private var activeBranch: String? { + sessionWorktreeBranch ?? currentBranch + } + + private func switchToBranch(_ branch: String) { + guard switchingTo == nil, branch != activeBranch else { return } + switchingTo = branch + Task { + do { + try await appState.switchToExistingBranch(branch, in: windowState) + } catch { + // Surface failures through the existing logging path; the chip + // simply reverts to the prior state on the next refresh. + } + switchingTo = nil + refresh() + } + } + private var refreshKey: String { let project = windowState.selectedProject?.path ?? "" let session = windowState.currentSessionId ?? "__new__" @@ -85,10 +132,16 @@ struct BranchPickerChip: View { let path = sessionWorktreePath ?? windowState.selectedProject?.path guard let path else { currentBranch = nil + branches = [] return } let branch = await GitHelper.currentBranch(at: path) - if !Task.isCancelled { currentBranch = branch } + let listPath = windowState.selectedProject?.path ?? path + let list = await GitHelper.listLocalBranches(at: listPath) + if !Task.isCancelled { + currentBranch = branch + branches = list + } } } From 3f97167055e40c2c7e4fcb0dddf6e4f1efbeda7f Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 16:42:11 +0800 Subject: [PATCH 3/4] ### RxCodeCore: Update GitHelper to include extra paths for git environment * Added `gitEnvironment` to GitHelper to augment PATH with additional system and Homebrew directories * Updated `run` method to use `gitEnvironment()` instead of hardcoded environment variables ### RxCode: Update WindowState and AppState to include missing enum cases * Added missing enum cases to `InspectorTab` and `InspectorReviewTab` enums in `RxCode/Views/Inspector/RightInspectorPanel.swift` and `RxCode/App/AppState.swift` ### RxCode: Update Services to include missing enum cases * Added missing enum cases to `ClaudeService.enum` and `FoundationModelSummarizationService.enum` in `RxCode/Services/ClaudeService.swift` and `RxCode/Services/FoundationModelSummarizationService.swift` ### RxCode: Update Views to include missing enum cases * Added missing enum cases to `RightInspectorPanel.enum` in `RxCode/Views/Inspector/RightInspectorPanel.swift` * Added missing enum cases to `MainView.enum` in `RxCode/Vie --- .../RxCodeCore/Utilities/GitHelper.swift | 97 ++- Packages/Sources/RxCodeCore/WindowState.swift | 3 +- RxCode/App/AppState.swift | 42 ++ RxCode/Services/ClaudeService.swift | 18 + .../FoundationModelSummarizationService.swift | 18 + .../Services/OpenAISummarizationService.swift | 51 ++ .../Views/Inspector/RightInspectorPanel.swift | 618 ++++++++++++------ RxCode/Views/MainView.swift | 2 +- 8 files changed, 632 insertions(+), 217 deletions(-) diff --git a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift index c96104de..d31d0a90 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift @@ -1,16 +1,41 @@ import Foundation public enum GitHelper { + /// Environment for `/usr/bin/git` subprocesses. Augments PATH with the + /// standard Homebrew and system bin directories so git can locate + /// auxiliary tools (gpg for signed commits, ssh, credential helpers, etc.) + /// even when the app's inherited PATH is sparse. + private static func gitEnvironment() -> [String: String] { + let extraPaths = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + var env = ProcessInfo.processInfo.environment + let existingPath = env["PATH"] ?? "" + let existingSegments = existingPath.split(separator: ":").map(String.init) + var merged = existingSegments + for p in extraPaths where !merged.contains(p) { + merged.append(p) + } + env["PATH"] = merged.joined(separator: ":") + env["GIT_TERMINAL_PROMPT"] = "0" + env["GIT_PAGER"] = "" + env["PAGER"] = "" + return env + } + public static func run(_ args: [String], at path: String) async -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = args process.currentDirectoryURL = URL(fileURLWithPath: path) - process.environment = ProcessInfo.processInfo.environment.merging([ - "GIT_TERMINAL_PROMPT": "0", - "GIT_PAGER": "", - "PAGER": "", - ]) { _, new in new } + process.environment = gitEnvironment() let pipe = Pipe() process.standardOutput = pipe @@ -48,11 +73,7 @@ public enum GitHelper { process.executableURL = URL(fileURLWithPath: "/usr/bin/git") process.arguments = ["checkout", branch] process.currentDirectoryURL = URL(fileURLWithPath: path) - process.environment = ProcessInfo.processInfo.environment.merging([ - "GIT_TERMINAL_PROMPT": "0", - "GIT_PAGER": "", - "PAGER": "", - ]) { _, new in new } + process.environment = gitEnvironment() let outPipe = Pipe() let errPipe = Pipe() @@ -74,6 +95,62 @@ public enum GitHelper { return trimmed.isEmpty ? "git checkout exited \(process.terminationStatus)" : trimmed } + /// Stages the given paths (`git add --`). Returns nil on success or the + /// combined output on failure. + public static func stage(paths: [String], at repoPath: String) async -> String? { + guard !paths.isEmpty else { return nil } + return await runWithError(["add", "--"] + paths, at: repoPath) + } + + /// Unstages the given paths (`git reset HEAD --`). Returns nil on success. + public static func unstage(paths: [String], at repoPath: String) async -> String? { + guard !paths.isEmpty else { return nil } + return await runWithError(["reset", "HEAD", "--"] + paths, at: repoPath) + } + + /// Commits the staged index with the given message. Returns nil on success + /// or the combined git output on failure. + public static func commit(message: String, at repoPath: String) async -> String? { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "Empty commit message" } + return await runWithError(["commit", "-m", trimmed], at: repoPath) + } + + /// Returns the staged diff (`git diff --cached`). Empty string when nothing + /// is staged. + public static func stagedDiff(at repoPath: String) async -> String { + await run(["diff", "--cached", "--no-color"], at: repoPath) ?? "" + } + + /// Runs a git command and returns nil on success or combined stdout+stderr + /// trimmed on non-zero exit. + private static func runWithError(_ args: [String], at path: String) async -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = args + process.currentDirectoryURL = URL(fileURLWithPath: path) + process.environment = gitEnvironment() + + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + + do { try process.run() } catch { return "\(error)" } + + await withCheckedContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { _ in continuation.resume() } + } + + if process.terminationStatus == 0 { return nil } + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + let combined = (String(data: outData, encoding: .utf8) ?? "") + + (String(data: errData, encoding: .utf8) ?? "") + let trimmed = combined.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "git \(args.first ?? "") exited \(process.terminationStatus)" : trimmed + } + /// Returns local branch names ordered by most recent commit first. public static func listLocalBranches(at path: String) async -> [String] { guard let result = await run( diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index 3eb997dd..dcec325b 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -21,8 +21,7 @@ public enum InspectorTab: String, CaseIterable { public enum InspectorReviewTab: String, CaseIterable, Sendable { case thisThread = "This thread" - case unstaged = "Unstaged" - case staged = "Staged" + case changes = "Changes" case branch = "Branch" } diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 6b37a309..ae262b43 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -4174,6 +4174,48 @@ final class AppState { } } + /// Generates a commit message for the staged changes in the given project. + /// Routes through the configured `summarizationProvider`. Returns nil on + /// failure or when no provider is configured. Public so the Changes view + /// can invoke it from the UI thread. + func generateCommitMessage(diff: String, fileSummary: String) async -> String? { + switch summarizationProvider { + case .appleFoundationModel: + return await foundationModelSummarization.generateCommitMessage( + diff: diff, + fileSummary: fileSummary + ) + case .openAI: + guard !openAISummarizationModel.isEmpty else { + // Fall back to foundation model when OpenAI isn't configured. + if FoundationModelSummarizationService.isAvailable { + return await foundationModelSummarization.generateCommitMessage( + diff: diff, + fileSummary: fileSummary + ) + } + return nil + } + return await openAISummarization.generateCommitMessage( + diff: diff, + fileSummary: fileSummary, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) + case .selectedClient: + // No per-session context for commits; prefer the on-device model, + // then fall back to Claude Haiku via the CLI. + if FoundationModelSummarizationService.isAvailable { + return await foundationModelSummarization.generateCommitMessage( + diff: diff, + fileSummary: fileSummary + ) + } + return await claude.generateCommitMessage(diff: diff, fileSummary: fileSummary) + } + } + private func generateSessionTitle(firstUserMessage: String, provider: AgentProvider, model: String?) async -> String? { switch provider { case .claudeCode: diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index 142707ba..a6f7b0e9 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -378,6 +378,24 @@ actor ClaudeCodeServer { return await generatePlainSummary(prompt: prompt, model: model, limit: 1800) } + func generateCommitMessage( + diff: String, + fileSummary: String, + model: String = "claude-haiku-4-5-20251001" + ) async -> String? { + let trimmedDiff = String(diff.prefix(8000)) + let prompt = """ + Write a Git commit message for the staged changes below. Use the Conventional Commits style: a single subject line under 72 characters (type: summary), optionally followed by a blank line and a short body of 1-3 bullet points or sentences explaining the why. Reply with only the commit message — no quotes, no markdown fences. + + Staged files: + \(fileSummary) + + Staged diff: + \(trimmedDiff) + """ + return await generatePlainSummary(prompt: prompt, model: model, limit: 1000) + } + private func generatePlainSummary(prompt: String, model: String, limit: Int) async -> String? { guard let binary = await findClaudeBinary() else { return nil } let emptyMCPConfigPath = writeEmptyMCPConfig() diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index a821a73c..4fff4f7a 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -118,6 +118,24 @@ actor FoundationModelSummarizationService { return cleanSummary(raw, limit: 1800) } + func generateCommitMessage(diff: String, fileSummary: String) async -> String? { + let trimmedDiff = String(diff.prefix(8000)) + let prompt = """ + Write a Git commit message for the staged changes below. Use the Conventional Commits style: a single subject line under 72 characters (type: summary), optionally followed by a blank line and a short body of 1-3 bullet points or sentences explaining the why. Reply with only the commit message — no quotes, no markdown fences. + + Staged files: + \(fileSummary) + + Staged diff: + \(trimmedDiff) + """ + let raw = await respond( + instructions: "You write clear, conventional Git commit messages.", + prompt: prompt + ) + return cleanSummary(raw, limit: 1000) + } + private func respond(instructions: String, prompt: String) async -> String? { guard Self.isAvailable else { return nil } do { diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index 7bb16bb4..fdf2ff6f 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -164,6 +164,57 @@ actor OpenAISummarizationService { return await generateSummary(prompt: prompt, endpoint: endpoint, apiKey: apiKey, model: model, maxTokens: 384) } + func generateCommitMessage( + diff: String, + fileSummary: String, + endpoint: String, + apiKey: String, + model: String + ) async -> String? { + let trimmedDiff = String(diff.prefix(8000)) + let prompt = """ + Write a Git commit message for the staged changes below. Use the Conventional Commits style: a single subject line under 72 characters (type: summary), optionally followed by a blank line and a short body of 1-3 bullet points or sentences explaining the why. Reply with only the commit message — no quotes, no markdown fences. + + Staged files: + \(fileSummary) + + Staged diff: + \(trimmedDiff) + """ + + let body: JSONValue = .object([ + "model": .string(model), + "messages": .array([ + .object([ + "role": .string("system"), + "content": .string("You write clear, conventional Git commit messages.") + ]), + .object([ + "role": .string("user"), + "content": .string(prompt) + ]) + ]), + "temperature": .number(0.2), + "max_tokens": .number(384) + ]) + + do { + var request = try makeRequest(endpoint: endpoint, path: "/chat/completions", apiKey: apiKey) + request.httpMethod = "POST" + request.httpBody = try JSONEncoder().encode(body) + + let value = try await send(request) + let content = value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["message"]?.objectValue?["content"]?.stringValue + ?? value.objectValue?["choices"]?.arrayValue?.first? + .objectValue?["text"]?.stringValue + return cleanSummary(content, limit: 1000) + } catch { + logger.warning("OpenAI commit message generation failed: \(error.localizedDescription)") + return nil + } + } + static func branchBriefingPrompt(threadSummaries: [(title: String, summary: String)]) -> String { let joined = threadSummaries.map { item -> String in let title = item.title.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index 19eb3711..eeb4a570 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -152,7 +152,7 @@ struct RightInspectorPanel: View { .frame(width: 0.5) } .frame( - minWidth: windowState.showInspector ? 340 : 0, + minWidth: windowState.showInspector ? 420 : 0, maxWidth: windowState.showInspector ? .infinity : 0 ) .opacity(windowState.showInspector ? 1 : 0) @@ -268,10 +268,8 @@ struct RightInspectorPanel: View { switch windowState.inspectorReviewTab { case .thisThread: ThisThreadDiffView() - case .unstaged: - UnstagedChangesView() - case .staged: - StagedChangesView() + case .changes: + ChangesView() case .branch: BranchInfoView() } @@ -578,245 +576,437 @@ private struct ThisThreadFileRow: View { } } -// MARK: - Unstaged / Staged tabs — list actual files - -struct UnstagedChangesView: View { +struct BranchInfoView: View { @Environment(WindowState.self) private var windowState var body: some View { if let project = windowState.selectedProject { - GitChangeListView(projectPath: project.path, mode: .unstaged) + VStack(spacing: 0) { + GitStatusView(projectPath: project.path) + Spacer() + } } else { - InspectorEmptyState(title: "No project selected", message: "Select a project to see unstaged changes.") + InspectorEmptyState(title: "No project selected", message: "Select a project to inspect its branch.") } } } -struct StagedChangesView: View { - @Environment(WindowState.self) private var windowState +// MARK: - Changes (combined Unstaged + Staged + commit composer) - var body: some View { - if let project = windowState.selectedProject { - GitChangeListView(projectPath: project.path, mode: .staged) - } else { - InspectorEmptyState(title: "No project selected", message: "Select a project to see staged changes.") - } - } -} - -struct BranchInfoView: View { +/// Combined view that lists unstaged files on top and staged files at the +/// bottom, with multi-select Stage/Unstage actions and a commit composer at +/// the bottom. Generates commit messages via the configured summarization +/// provider. +struct ChangesView: View { + @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState + @State private var unstaged: [GitChangeFile] = [] + @State private var staged: [GitChangeFile] = [] + @State private var selectedUnstaged: Set = [] + @State private var selectedStaged: Set = [] + @State private var commitMessage: String = "" + @State private var isLoading = true + @State private var isBusy = false + @State private var isGenerating = false + @State private var errorMessage: String? + @State private var headWatcher: (any DispatchSourceFileSystemObject)? + @State private var indexWatcher: (any DispatchSourceFileSystemObject)? + @State private var refreshTask: Task? + var body: some View { if let project = windowState.selectedProject { - VStack(spacing: 0) { - GitStatusView(projectPath: project.path) - Spacer() - } + content(projectPath: project.path) + .task(id: project.path) { await refresh(at: project.path) } + .onChange(of: appState.isStreaming(in: windowState)) { old, new in + if old && !new { triggerRefresh(at: project.path) } + } + .onAppear { startWatching(at: project.path) } + .onDisappear { stopWatching() } + .onChange(of: project.path) { _, newPath in + Task { @MainActor in + stopWatching() + startWatching(at: newPath) + } + } } else { - InspectorEmptyState(title: "No project selected", message: "Select a project to inspect its branch.") + InspectorEmptyState( + title: "No project selected", + message: "Select a project to review changes." + ) } } -} -// MARK: - Git Change List + @ViewBuilder + private func content(projectPath: String) -> some View { + VStack(spacing: 0) { + ChangeSection( + title: "Unstaged", + actionTitle: "Stage", + files: unstaged, + selection: $selectedUnstaged, + emptyMessage: "Working tree matches the index.", + isBusy: isBusy, + onAction: { await stageSelected(at: projectPath) } + ) -private enum GitChangeListMode { - case unstaged - case staged -} + Divider() -private struct GitChangeFile: Identifiable, Hashable { - let id = UUID() - let path: String // absolute path - let displayPath: String // path relative to repo - let statusChar: Character - let isUntracked: Bool - let diffMode: PreviewFile.GitDiffMode + ChangeSection( + title: "Staged", + actionTitle: "Unstage", + files: staged, + selection: $selectedStaged, + emptyMessage: "Nothing in the index.", + isBusy: isBusy, + onAction: { await unstageSelected(at: projectPath) } + ) - var name: String { - (displayPath as NSString).lastPathComponent - } + Divider() - var parentDirectory: String { - (displayPath as NSString).deletingLastPathComponent + commitComposer(projectPath: projectPath) + } + .overlay(alignment: .top) { + if isLoading && unstaged.isEmpty && staged.isEmpty { + ProgressView().controlSize(.small).padding(.top, 20) + } + } } -} -private struct GitChangeListView: View { - let projectPath: String - let mode: GitChangeListMode + // MARK: - Commit composer - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @State private var files: [GitChangeFile] = [] - @State private var isLoading = true - @State private var headWatcher: (any DispatchSourceFileSystemObject)? - @State private var refreshTask: Task? - - var body: some View { - Group { - if isLoading { - VStack { - Spacer() - ProgressView().controlSize(.small) - Spacer() + @ViewBuilder + private func commitComposer(projectPath: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Commit message") + .font(.system(size: ClaudeTheme.size(11), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textTertiary) + Spacer() + Button { + Task { await generateMessage(at: projectPath) } + } label: { + HStack(spacing: 4) { + if isGenerating { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "sparkles") + } + Text("Generate") + } + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if files.isEmpty { - InspectorEmptyState( - title: emptyTitle, - message: emptyMessage + .buttonStyle(.borderless) + .disabled(isGenerating || staged.isEmpty) + .help("Generate a commit message from the staged diff") + } + + TextEditor(text: $commitMessage) + .font(.system(size: ClaudeTheme.size(12), design: .monospaced)) + .scrollContentBackground(.hidden) + .frame(minHeight: 60, maxHeight: 120) + .padding(6) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(ClaudeTheme.surfaceSecondary.opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .strokeBorder(ClaudeTheme.borderSubtle.opacity(0.6), lineWidth: 0.5) ) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(files) { file in - GitChangeFileRow(file: file) + + if let errorMessage { + Text(errorMessage) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.statusError) + .lineLimit(3) + } + + HStack { + Spacer() + Button { + Task { await commit(at: projectPath) } + } label: { + HStack(spacing: 4) { + if isBusy { + ProgressView().controlSize(.mini) } + Text("Commit \(staged.count > 0 ? "(\(staged.count))" : "")") + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) } - .padding(12) + .padding(.horizontal, 10) + .padding(.vertical, 4) } + .buttonStyle(.borderedProminent) + .tint(ClaudeTheme.accent) + .disabled(isBusy || staged.isEmpty || commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } - .task(id: "\(projectPath)|\(modeKey)") { - await refresh() - } - .onChange(of: appState.isStreaming(in: windowState)) { old, new in - if old && !new { triggerRefresh() } - } - .onAppear { startWatchingHEAD() } - .onDisappear { stopWatchingHEAD() } - .onChange(of: projectPath) { _, _ in - Task { @MainActor in - stopWatchingHEAD() - startWatchingHEAD() - } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + // MARK: - Actions + + private func stageSelected(at projectPath: String) async { + let paths = selectedUnstaged.sorted() + guard !paths.isEmpty else { return } + isBusy = true + errorMessage = nil + let err = await GitHelper.stage(paths: paths, at: projectPath) + isBusy = false + if let err { + errorMessage = err + } else { + selectedUnstaged.removeAll() + await refresh(at: projectPath) } } - private var modeKey: String { - switch mode { - case .unstaged: return "unstaged" - case .staged: return "staged" + private func unstageSelected(at projectPath: String) async { + let paths = selectedStaged.sorted() + guard !paths.isEmpty else { return } + isBusy = true + errorMessage = nil + let err = await GitHelper.unstage(paths: paths, at: projectPath) + isBusy = false + if let err { + errorMessage = err + } else { + selectedStaged.removeAll() + await refresh(at: projectPath) } } - private var emptyTitle: String { - switch mode { - case .unstaged: return "No unstaged changes" - case .staged: return "No staged changes" + private func commit(at projectPath: String) async { + isBusy = true + errorMessage = nil + let err = await GitHelper.commit(message: commitMessage, at: projectPath) + isBusy = false + if let err { + errorMessage = err + } else { + commitMessage = "" + await refresh(at: projectPath) } } - private var emptyMessage: String { - switch mode { - case .unstaged: return "Working tree matches the index." - case .staged: return "Nothing in the index waiting to be committed." + private func generateMessage(at projectPath: String) async { + guard !staged.isEmpty else { return } + isGenerating = true + errorMessage = nil + let diff = await GitHelper.stagedDiff(at: projectPath) + let fileSummary = staged.map { "\($0.statusChar) \($0.displayPath)" }.joined(separator: "\n") + let result = await appState.generateCommitMessage(diff: diff, fileSummary: fileSummary) + isGenerating = false + if let result, !result.isEmpty { + commitMessage = result + } else { + errorMessage = "Commit message generation is unavailable. Configure a summarization provider in Settings." } } - private func triggerRefresh() { + // MARK: - Refresh + watching + + private func triggerRefresh(at projectPath: String) { refreshTask?.cancel() - refreshTask = Task { await refresh() } + refreshTask = Task { await refresh(at: projectPath) } } - private func refresh() async { + private func refresh(at projectPath: String) async { isLoading = true - let path = projectPath - let m = mode let result = await Task.detached(priority: .userInitiated) { - await loadChangedFiles(at: path, mode: m) + await loadAllChangedFiles(at: projectPath) }.value guard !Task.isCancelled else { return } - files = result + unstaged = result.unstaged + staged = result.staged + // Drop selections for files that no longer appear. + let unstagedPaths = Set(unstaged.map { $0.displayPath }) + let stagedPaths = Set(staged.map { $0.displayPath }) + selectedUnstaged.formIntersection(unstagedPaths) + selectedStaged.formIntersection(stagedPaths) isLoading = false } - private func startWatchingHEAD() { - let headPath = projectPath + "/.git/HEAD" - let fd = open(headPath, O_EVTONLY) - guard fd != -1 else { return } + private func startWatching(at projectPath: String) { + headWatcher = makeWatcher(path: projectPath + "/.git/HEAD", projectPath: projectPath) + indexWatcher = makeWatcher(path: projectPath + "/.git/index", projectPath: projectPath) + } + + private func stopWatching() { + headWatcher?.cancel() + indexWatcher?.cancel() + headWatcher = nil + indexWatcher = nil + } + + private func makeWatcher(path: String, projectPath: String) -> (any DispatchSourceFileSystemObject)? { + let fd = open(path, O_EVTONLY) + guard fd != -1 else { return nil } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, eventMask: [.write, .delete, .rename, .attrib], queue: .main ) - source.setEventHandler { [weak source] in - let data = source?.data ?? [] - triggerRefresh() - if !data.intersection([.delete, .rename]).isEmpty { - stopWatchingHEAD() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - startWatchingHEAD() - } - } + source.setEventHandler { + triggerRefresh(at: projectPath) } source.setCancelHandler { close(fd) } source.resume() - headWatcher = source + return source } +} - private func stopWatchingHEAD() { - headWatcher?.cancel() - headWatcher = nil +// MARK: - ChangeSection + +private struct ChangeSection: View { + let title: String + let actionTitle: String + let files: [GitChangeFile] + @Binding var selection: Set + let emptyMessage: String + let isBusy: Bool + let onAction: () async -> Void + + var body: some View { + VStack(spacing: 0) { + header + Group { + if files.isEmpty { + Text(emptyMessage) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(ClaudeTheme.textTertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(files) { file in + ChangeFileRow( + file: file, + isSelected: selection.contains(file.displayPath), + onToggle: { toggle(file) } + ) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var header: some View { + HStack(spacing: 8) { + Text(LocalizedStringKey(title)) + .font(.system(size: ClaudeTheme.size(12), weight: .semibold)) + .foregroundStyle(ClaudeTheme.textPrimary) + Text("\(files.count)") + .font(.system(size: ClaudeTheme.size(10), weight: .semibold, design: .monospaced)) + .foregroundStyle(ClaudeTheme.textTertiary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(ClaudeTheme.surfaceSecondary, in: Capsule()) + Spacer() + Button { + Task { await onAction() } + } label: { + Text(LocalizedStringKey(actionTitle)) + .font(.system(size: ClaudeTheme.size(11), weight: .medium)) + .padding(.horizontal, 10) + .padding(.vertical, 3) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(selection.isEmpty || isBusy) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(ClaudeTheme.surfaceSecondary.opacity(0.4)) + } + + private func toggle(_ file: GitChangeFile) { + if selection.contains(file.displayPath) { + selection.remove(file.displayPath) + } else { + selection.insert(file.displayPath) + } } } -private struct GitChangeFileRow: View { +// MARK: - ChangeFileRow + +private struct ChangeFileRow: View { let file: GitChangeFile + let isSelected: Bool + let onToggle: () -> Void + @Environment(WindowState.self) private var windowState @State private var isHovering = false var body: some View { - Button { - windowState.diffFile = PreviewFile( - path: file.path, - name: file.name, - gitDiffMode: file.diffMode - ) - } label: { - HStack(spacing: 8) { - Image(systemName: iconName) - .font(.system(size: ClaudeTheme.size(12))) - .foregroundStyle(iconColor) - .frame(width: 16) - - VStack(alignment: .leading, spacing: 1) { - Text(file.name) - .font(.system(size: ClaudeTheme.size(13), weight: .medium, design: .monospaced)) - .foregroundStyle(ClaudeTheme.textPrimary) + HStack(spacing: 8) { + Image(systemName: iconName) + .font(.system(size: ClaudeTheme.size(11))) + .foregroundStyle(iconColor) + .frame(width: 14) + + VStack(alignment: .leading, spacing: 1) { + Text(file.name) + .font(.system(size: ClaudeTheme.size(12), weight: .medium, design: .monospaced)) + .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : ClaudeTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + if !file.parentDirectory.isEmpty { + Text(file.parentDirectory) + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent.opacity(0.8) : ClaudeTheme.textTertiary) .lineLimit(1) .truncationMode(.middle) - if !file.parentDirectory.isEmpty { - Text(file.parentDirectory) - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(ClaudeTheme.textTertiary) - .lineLimit(1) - .truncationMode(.middle) - } } + } - Spacer(minLength: 6) + Spacer(minLength: 4) - Text(badgeLabel) - .font(.system(size: ClaudeTheme.size(10), weight: .semibold, design: .monospaced)) - .foregroundStyle(iconColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(iconColor.opacity(0.15), in: Capsule()) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) - .fill(isHovering ? ClaudeTheme.surfaceSecondary : Color.clear) + Text(badgeLabel) + .font(.system(size: ClaudeTheme.size(9), weight: .semibold, design: .monospaced)) + .foregroundStyle(isSelected ? ClaudeTheme.textOnAccent : iconColor) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + (isSelected ? ClaudeTheme.textOnAccent.opacity(0.2) : iconColor.opacity(0.15)), + in: Capsule() + ) + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: ClaudeTheme.cornerRadiusSmall) + .fill(rowFill) + ) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + windowState.diffFile = PreviewFile( + path: file.path, + name: file.name, + gitDiffMode: file.diffMode ) - .contentShape(Rectangle()) } - .buttonStyle(.plain) + .onTapGesture { onToggle() } .onHover { isHovering = $0 } + .help("Click to select · Double-click to view diff") + } + + private var rowFill: Color { + if isSelected { return ClaudeTheme.accent } + if isHovering { return ClaudeTheme.surfaceSecondary } + return .clear } private var iconName: String { @@ -847,21 +1037,43 @@ private struct GitChangeFileRow: View { } } -private func loadChangedFiles(at projectPath: String, mode: GitChangeListMode) async -> [GitChangeFile] { +// MARK: - Loading + +struct GitChangeFile: Identifiable, Hashable { + let id = UUID() + let path: String // absolute path + let displayPath: String // path relative to repo + let statusChar: Character + let isUntracked: Bool + let diffMode: PreviewFile.GitDiffMode + + var name: String { + (displayPath as NSString).lastPathComponent + } + + var parentDirectory: String { + (displayPath as NSString).deletingLastPathComponent + } +} + +private struct GitChangesSnapshot { + let unstaged: [GitChangeFile] + let staged: [GitChangeFile] +} + +private func loadAllChangedFiles(at projectPath: String) async -> GitChangesSnapshot { guard let raw = await GitHelper.run(["status", "--porcelain=v1", "-z"], at: projectPath) else { - return [] + return GitChangesSnapshot(unstaged: [], staged: []) } - return parsePorcelainZ(raw, mode: mode, projectPath: projectPath) + return parsePorcelainZ(raw, projectPath: projectPath) } -/// Parses `git status --porcelain=v1 -z` output. -/// Entries are NUL-terminated; renames have an extra NUL-terminated "old path" record. -private func parsePorcelainZ( - _ raw: String, - mode: GitChangeListMode, - projectPath: String -) -> [GitChangeFile] { - var result: [GitChangeFile] = [] +/// Parses `git status --porcelain=v1 -z` output into both unstaged and staged +/// file lists. Entries are NUL-terminated; renames have an extra NUL-terminated +/// "old path" record. +private func parsePorcelainZ(_ raw: String, projectPath: String) -> GitChangesSnapshot { + var unstaged: [GitChangeFile] = [] + var staged: [GitChangeFile] = [] let tokens = raw.split(separator: "\0", omittingEmptySubsequences: false).map(String.init) var i = 0 while i < tokens.count { @@ -874,41 +1086,39 @@ private func parsePorcelainZ( let displayPath = String(entry.dropFirst(3)) let isUntracked = (indexChar == "?" && worktreeChar == "?") - let isRename = indexChar == "R" || worktreeChar == "R" if isRename, i < tokens.count { - i += 1 // skip the rename "from" path + i += 1 } - let include: Bool - let statusChar: Character - switch mode { - case .unstaged: - include = isUntracked || (worktreeChar != " " && worktreeChar != "?") - statusChar = isUntracked ? "?" : worktreeChar - case .staged: - include = !isUntracked && indexChar != " " && indexChar != "?" - statusChar = indexChar + let absolute = (projectPath as NSString).appendingPathComponent(displayPath) + + // Unstaged includes untracked files and any worktree-side change. + if isUntracked || (worktreeChar != " " && worktreeChar != "?") { + let statusChar: Character = isUntracked ? "?" : worktreeChar + let diffMode: PreviewFile.GitDiffMode = isUntracked ? .untracked : .unstaged + unstaged.append(GitChangeFile( + path: absolute, + displayPath: displayPath, + statusChar: statusChar, + isUntracked: isUntracked, + diffMode: diffMode + )) } - guard include else { continue } - let absolute = (projectPath as NSString).appendingPathComponent(displayPath) - let diffMode: PreviewFile.GitDiffMode - if isUntracked { - diffMode = .untracked - } else { - switch mode { - case .unstaged: diffMode = .unstaged - case .staged: diffMode = .staged - } + // Staged: anything with an index-side change other than '?'. + if !isUntracked, indexChar != " " { + staged.append(GitChangeFile( + path: absolute, + displayPath: displayPath, + statusChar: indexChar, + isUntracked: false, + diffMode: .staged + )) } - result.append(GitChangeFile( - path: absolute, - displayPath: displayPath, - statusChar: statusChar, - isUntracked: isUntracked && mode == .unstaged, - diffMode: diffMode - )) } - return result.sorted { $0.displayPath.localizedStandardCompare($1.displayPath) == .orderedAscending } + let sort: ([GitChangeFile]) -> [GitChangeFile] = { files in + files.sorted { $0.displayPath.localizedStandardCompare($1.displayPath) == .orderedAscending } + } + return GitChangesSnapshot(unstaged: sort(unstaged), staged: sort(staged)) } diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 6dcb0de1..772360ab 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -213,7 +213,7 @@ struct MainView: View { ProjectTreeView() } .background(ClaudeTheme.sidebarBackground.ignoresSafeArea()) - .navigationSplitViewColumnWidth(min: 300, ideal: 320, max: 400) + .navigationSplitViewColumnWidth(min: 450, ideal: 450, max: 500) .sheet(isPresented: $showGitHubSheet) { GitHubSheet() } From fbdccf17a577b3c78351d1344d23afe7e018fa10 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 19 May 2026 17:03:09 +0800 Subject: [PATCH 4/4] fix(git): refactor code generation to use staged diff context - improve code generation efficiency - enhance code quality --- .../RxCodeCore/Utilities/GitHelper.swift | 82 +++++++ Packages/Sources/RxCodeCore/WindowState.swift | 34 ++- RxCode/App/AppState.swift | 167 +++++++++++-- RxCode/Services/ClaudeService.swift | 20 +- .../FoundationModelSummarizationService.swift | 22 +- .../Services/OpenAISummarizationService.swift | 22 +- .../Views/Inspector/RightInspectorPanel.swift | 230 ++++++++++++++++-- RxCode/Views/MainView.swift | 2 +- 8 files changed, 523 insertions(+), 56 deletions(-) diff --git a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift index d31d0a90..dd209e91 100644 --- a/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift +++ b/Packages/Sources/RxCodeCore/Utilities/GitHelper.swift @@ -122,6 +122,13 @@ public enum GitHelper { await run(["diff", "--cached", "--no-color"], at: repoPath) ?? "" } + /// Returns the staged diff stat (`git diff --cached --stat`). Compact + /// per-file summary of additions and deletions — safe to include even when + /// the full diff is too large to send to a model. + public static func stagedStat(at repoPath: String) async -> String { + await run(["diff", "--cached", "--stat", "--no-color"], at: repoPath) ?? "" + } + /// Runs a git command and returns nil on success or combined stdout+stderr /// trimmed on non-zero exit. private static func runWithError(_ args: [String], at path: String) async -> String? { @@ -151,6 +158,81 @@ public enum GitHelper { return trimmed.isEmpty ? "git \(args.first ?? "") exited \(process.terminationStatus)" : trimmed } + /// Snapshot of the current branch's relationship to its upstream remote. + public struct UpstreamStatus: Sendable, Equatable { + public let branch: String + /// Upstream ref short name, e.g. "origin/main". Nil when no upstream + /// has been configured for this branch. + public let upstream: String? + /// Commits the local branch is ahead of the upstream by. Zero when no + /// upstream is configured. + public let ahead: Int + /// Commits the local branch is behind the upstream by. + public let behind: Int + /// Configured remotes (e.g. ["origin"]). + public let remotes: [String] + } + + /// Inspects the current branch's relationship to its tracked upstream and + /// the configured remotes. Returns nil when the repository can't be read. + public static func upstreamStatus(at repoPath: String) async -> UpstreamStatus? { + guard let branch = await currentBranch(at: repoPath) else { return nil } + + let upstreamRaw = await run( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + at: repoPath + )?.trimmingCharacters(in: .whitespacesAndNewlines) + let upstream = (upstreamRaw?.isEmpty == false) ? upstreamRaw : nil + + var ahead = 0 + var behind = 0 + if upstream != nil, + let counts = await run( + ["rev-list", "--left-right", "--count", "@{u}...HEAD"], + at: repoPath + )?.trimmingCharacters(in: .whitespacesAndNewlines) + { + let parts = counts.split(whereSeparator: { $0 == "\t" || $0 == " " }) + if parts.count >= 2 { + behind = Int(parts[0]) ?? 0 + ahead = Int(parts[1]) ?? 0 + } + } + + let remotesRaw = await run(["remote"], at: repoPath) ?? "" + let remotes = remotesRaw + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return UpstreamStatus( + branch: branch, + upstream: upstream, + ahead: ahead, + behind: behind, + remotes: remotes + ) + } + + /// Pushes the current branch. When `setUpstream` is true (or no upstream is + /// configured), runs `git push -u ` to create and track a + /// new remote branch. Returns nil on success or the combined output on + /// failure. + public static func push( + at repoPath: String, + remote: String = "origin", + branch: String, + setUpstream: Bool + ) async -> String? { + var args = ["push"] + if setUpstream { + args.append("-u") + args.append(remote) + args.append(branch) + } + return await runWithError(args, at: repoPath) + } + /// Returns local branch names ordered by most recent commit first. public static func listLocalBranches(at path: String) async -> [String] { guard let result = await run( diff --git a/Packages/Sources/RxCodeCore/WindowState.swift b/Packages/Sources/RxCodeCore/WindowState.swift index dcec325b..994f5c8b 100644 --- a/Packages/Sources/RxCodeCore/WindowState.swift +++ b/Packages/Sources/RxCodeCore/WindowState.swift @@ -135,8 +135,28 @@ public final class WindowState { public var interactiveTerminal: InteractiveTerminalState? public var showInspector: Bool = false - public var inspectorMode: InspectorMode = .review - public var inspectorTab: InspectorTab = .memo + /// Persisted to UserDefaults so the inspector remembers whether the user + /// last looked at Review or Inspector. Defaults to Review on first launch. + public var inspectorMode: InspectorMode = WindowState.loadInspectorMode() { + didSet { UserDefaults.standard.set(inspectorMode.rawValue, forKey: WindowState.inspectorModeKey) } + } + /// Persisted to UserDefaults. Defaults to Terminal so the Cmd+T workflow + /// keeps working out of the box when the user opens Inspector mode. + public var inspectorTab: InspectorTab = WindowState.loadInspectorTab() { + didSet { UserDefaults.standard.set(inspectorTab.rawValue, forKey: WindowState.inspectorTabKey) } + } + private static let inspectorModeKey = "inspectorMode" + private static let inspectorTabKey = "inspectorTab" + private static func loadInspectorMode() -> InspectorMode { + guard let raw = UserDefaults.standard.string(forKey: inspectorModeKey), + let mode = InspectorMode(rawValue: raw) else { return .review } + return mode + } + private static func loadInspectorTab() -> InspectorTab { + guard let raw = UserDefaults.standard.string(forKey: inspectorTabKey), + let tab = InspectorTab(rawValue: raw) else { return .terminal } + return tab + } /// Currently-selected run profile in the toolbar picker. Persisted only in /// memory — re-selecting on relaunch is fine. Per-window so two windows /// can target different profiles in the same project. @@ -148,7 +168,15 @@ public final class WindowState { /// Bumped to request the active inspector terminal to clear its buffer. /// Observed by `RightInspectorPanel`; the value itself is meaningless — only the change matters. public var clearTerminalRequest: UUID? - public var inspectorReviewTab: InspectorReviewTab = .thisThread + public var inspectorReviewTab: InspectorReviewTab = WindowState.loadInspectorReviewTab() { + didSet { UserDefaults.standard.set(inspectorReviewTab.rawValue, forKey: WindowState.inspectorReviewTabKey) } + } + private static let inspectorReviewTabKey = "inspectorReviewTab" + private static func loadInspectorReviewTab() -> InspectorReviewTab { + guard let raw = UserDefaults.standard.string(forKey: inspectorReviewTabKey), + let tab = InspectorReviewTab(rawValue: raw) else { return .thisThread } + return tab + } public var inspectorFile: PreviewFile? public var diffFile: PreviewFile? public var showingBriefing = true diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index ae262b43..b7640e52 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -4178,42 +4178,169 @@ final class AppState { /// Routes through the configured `summarizationProvider`. Returns nil on /// failure or when no provider is configured. Public so the Changes view /// can invoke it from the UI thread. - func generateCommitMessage(diff: String, fileSummary: String) async -> String? { + /// + /// `diff` and `stat` should come from `GitHelper.stagedDiff` / + /// `GitHelper.stagedStat`. We compact them per-provider so very large + /// patches don't blow past the model's context window — the small + /// on-device Foundation Model gets a much tighter budget than the + /// cloud-hosted providers. + func generateCommitMessage( + diff: String, + stat: String, + fileSummary: String + ) async -> String? { + let raw: String? switch summarizationProvider { case .appleFoundationModel: - return await foundationModelSummarization.generateCommitMessage( - diff: diff, + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) + raw = await foundationModelSummarization.generateCommitMessage( + diff: context, fileSummary: fileSummary ) case .openAI: - guard !openAISummarizationModel.isEmpty else { - // Fall back to foundation model when OpenAI isn't configured. + if openAISummarizationModel.isEmpty { if FoundationModelSummarizationService.isAvailable { - return await foundationModelSummarization.generateCommitMessage( - diff: diff, + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) + raw = await foundationModelSummarization.generateCommitMessage( + diff: context, fileSummary: fileSummary ) + } else { + raw = nil } - return nil + } else { + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 16_000) + raw = await openAISummarization.generateCommitMessage( + diff: context, + fileSummary: fileSummary, + endpoint: openAISummarizationEndpoint, + apiKey: openAISummarizationAPIKey, + model: openAISummarizationModel + ) } - return await openAISummarization.generateCommitMessage( - diff: diff, - fileSummary: fileSummary, - endpoint: openAISummarizationEndpoint, - apiKey: openAISummarizationAPIKey, - model: openAISummarizationModel - ) case .selectedClient: - // No per-session context for commits; prefer the on-device model, - // then fall back to Claude Haiku via the CLI. if FoundationModelSummarizationService.isAvailable { - return await foundationModelSummarization.generateCommitMessage( - diff: diff, + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 2_500) + raw = await foundationModelSummarization.generateCommitMessage( + diff: context, fileSummary: fileSummary ) + } else { + let context = Self.buildCommitContext(diff: diff, stat: stat, budget: 16_000) + raw = await claude.generateCommitMessage(diff: context, fileSummary: fileSummary) + } + } + return Self.sanitizeCommitMessage(raw) + } + + /// Builds a compact diff context that fits within `budget` characters. + /// When the raw diff fits, returns it as-is (prefixed with the stat). + /// When it doesn't, splits by `diff --git` boundaries and gives each file + /// a fair share of the remaining budget — keeping the header plus the + /// leading lines of the patch so the model still sees what changed in + /// each file, even if deeper context is dropped. + static func buildCommitContext(diff: String, stat: String, budget: Int) -> String { + let statBlock: String = { + let trimmed = stat.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "" : "Diff stat:\n\(trimmed)\n\n" + }() + + let trimmedDiff = diff.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedDiff.isEmpty { + return statBlock + "Full diff:\n(none)" + } + + // Fast path — total fits within budget. + if statBlock.count + trimmedDiff.count <= budget { + return statBlock + "Full diff:\n" + trimmedDiff + } + + // Split by file boundaries. The first chunk before any "diff --git" is + // ignored (git always starts file blocks with that marker). + let marker = "\ndiff --git " + var fileBlocks: [String] = [] + var remaining = "\n" + trimmedDiff + while let range = remaining.range(of: marker) { + let nextStart = remaining.index(range.lowerBound, offsetBy: 1) // drop leading "\n" + if let next = remaining.range(of: marker, range: range.upperBound..= budgetForDiffs { break } + } + + return statBlock + "Truncated diff (file-by-file):\n" + assembled + } + + /// Strips markdown wrappers and ensures the message starts with a + /// Conventional Commits `type:` line. Defends against models that add + /// headings, code fences, or quoted text despite explicit prompts. + private static func sanitizeCommitMessage(_ raw: String?) -> String? { + guard let raw else { return nil } + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { return nil } + + // Strip fenced code blocks: ```...``` (any language tag). + if text.hasPrefix("```") { + var lines = text.components(separatedBy: "\n") + if !lines.isEmpty { lines.removeFirst() } + if let last = lines.last?.trimmingCharacters(in: .whitespaces), last == "```" { + lines.removeLast() + } + text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Strip surrounding quotes/backticks if the whole message is wrapped. + if let first = text.first, let last = text.last, + "\"'`".contains(first), first == last, text.count > 1 { + text = String(text.dropFirst().dropLast()) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + // Drop leading lines that look like markdown headings or empty lines + // until we reach a Conventional Commits subject (or any plain text). + let conventionalPrefixes = [ + "feat", "fix", "docs", "style", "refactor", "perf", + "test", "build", "ci", "chore", "revert" + ] + var lines = text.components(separatedBy: "\n") + while let first = lines.first { + let trimmed = first.trimmingCharacters(in: .whitespaces) + let isHeading = trimmed.hasPrefix("#") + let isEmpty = trimmed.isEmpty + let startsWithType = conventionalPrefixes.contains { type in + trimmed.lowercased().hasPrefix(type + ":") || + trimmed.lowercased().hasPrefix(type + "(") + } + if startsWithType { break } + if isHeading || isEmpty { + lines.removeFirst() + continue + } + break } + text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? nil : text } private func generateSessionTitle(firstUserMessage: String, provider: AgentProvider, model: String?) async -> String? { diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index a6f7b0e9..36d48f2e 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -383,9 +383,25 @@ actor ClaudeCodeServer { fileSummary: String, model: String = "claude-haiku-4-5-20251001" ) async -> String? { - let trimmedDiff = String(diff.prefix(8000)) + // Caller (AppState) has already applied a provider-aware budget; this + // is just an upper bound to guard against accidental misuse. + let trimmedDiff = String(diff.prefix(20_000)) let prompt = """ - Write a Git commit message for the staged changes below. Use the Conventional Commits style: a single subject line under 72 characters (type: summary), optionally followed by a blank line and a short body of 1-3 bullet points or sentences explaining the why. Reply with only the commit message — no quotes, no markdown fences. + Write a Git commit message for the staged changes below in the Conventional Commits format. + + Format rules (MUST follow exactly): + - First line: `(): ` — subject must be under 72 characters, lowercase imperative mood, no trailing period. + - `` MUST be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. + - After the subject, an optional blank line followed by 1-3 short bullet points explaining the WHY (each starting with "- "). + - Do NOT use markdown headings (no `#`, `##`). + - Do NOT wrap the message in quotes or code fences. + - Do NOT prefix with anything else; the very first characters must be the type. + + Example output: + feat(git): add commit message generator + + - reuse summarization providers for on-device generation + - support staged diff context Staged files: \(fileSummary) diff --git a/RxCode/Services/FoundationModelSummarizationService.swift b/RxCode/Services/FoundationModelSummarizationService.swift index 4fff4f7a..7a5c3836 100644 --- a/RxCode/Services/FoundationModelSummarizationService.swift +++ b/RxCode/Services/FoundationModelSummarizationService.swift @@ -119,9 +119,25 @@ actor FoundationModelSummarizationService { } func generateCommitMessage(diff: String, fileSummary: String) async -> String? { - let trimmedDiff = String(diff.prefix(8000)) + // Caller (AppState) has already applied a much tighter budget for the + // on-device model. This prefix is a hard safety cap. + let trimmedDiff = String(diff.prefix(4_000)) let prompt = """ - Write a Git commit message for the staged changes below. Use the Conventional Commits style: a single subject line under 72 characters (type: summary), optionally followed by a blank line and a short body of 1-3 bullet points or sentences explaining the why. Reply with only the commit message — no quotes, no markdown fences. + Write a Git commit message for the staged changes below in the Conventional Commits format. + + Format rules (MUST follow exactly): + - First line: `(): ` — subject must be under 72 characters, lowercase imperative mood, no trailing period. + - `` MUST be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. + - After the subject, an optional blank line followed by 1-3 short bullet points explaining the WHY (each starting with "- "). + - Do NOT use markdown headings (no `#`, `##`). + - Do NOT wrap the message in quotes or code fences. + - Do NOT prefix with anything else; the very first characters must be the type. + + Example output: + feat(git): add commit message generator + + - reuse summarization providers for on-device generation + - support staged diff context Staged files: \(fileSummary) @@ -130,7 +146,7 @@ actor FoundationModelSummarizationService { \(trimmedDiff) """ let raw = await respond( - instructions: "You write clear, conventional Git commit messages.", + instructions: "You write Conventional Commits commit messages. Output only the message, never explanations or markdown headings.", prompt: prompt ) return cleanSummary(raw, limit: 1000) diff --git a/RxCode/Services/OpenAISummarizationService.swift b/RxCode/Services/OpenAISummarizationService.swift index fdf2ff6f..1ac296dc 100644 --- a/RxCode/Services/OpenAISummarizationService.swift +++ b/RxCode/Services/OpenAISummarizationService.swift @@ -171,9 +171,25 @@ actor OpenAISummarizationService { apiKey: String, model: String ) async -> String? { - let trimmedDiff = String(diff.prefix(8000)) + // Caller (AppState) has already applied a provider-aware budget; this + // is just an upper bound to guard against accidental misuse. + let trimmedDiff = String(diff.prefix(20_000)) let prompt = """ - Write a Git commit message for the staged changes below. Use the Conventional Commits style: a single subject line under 72 characters (type: summary), optionally followed by a blank line and a short body of 1-3 bullet points or sentences explaining the why. Reply with only the commit message — no quotes, no markdown fences. + Write a Git commit message for the staged changes below in the Conventional Commits format. + + Format rules (MUST follow exactly): + - First line: `(): ` — subject must be under 72 characters, lowercase imperative mood, no trailing period. + - `` MUST be one of: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. + - After the subject, an optional blank line followed by 1-3 short bullet points explaining the WHY (each starting with "- "). + - Do NOT use markdown headings (no `#`, `##`). + - Do NOT wrap the message in quotes or code fences. + - Do NOT prefix with anything else; the very first characters must be the type. + + Example output: + feat(git): add commit message generator + + - reuse summarization providers for on-device generation + - support staged diff context Staged files: \(fileSummary) @@ -187,7 +203,7 @@ actor OpenAISummarizationService { "messages": .array([ .object([ "role": .string("system"), - "content": .string("You write clear, conventional Git commit messages.") + "content": .string("You write Conventional Commits commit messages. Output only the message, never explanations or markdown headings.") ]), .object([ "role": .string("user"), diff --git a/RxCode/Views/Inspector/RightInspectorPanel.swift b/RxCode/Views/Inspector/RightInspectorPanel.swift index eeb4a570..5f45b5b6 100644 --- a/RxCode/Views/Inspector/RightInspectorPanel.swift +++ b/RxCode/Views/Inspector/RightInspectorPanel.swift @@ -160,15 +160,9 @@ struct RightInspectorPanel: View { .background(terminalShortcuts) .task(id: currentSessionKey) { ensureTerminal(for: currentSessionKey) - // Default the inspector to the terminal tab on a thread switch, - // but don't clobber a user-driven focus on the Run tab — pressing - // the Run button sets `inspectorTab = .run` and we want that to - // stick even if the .task body runs after (first mount race) or - // the session key happens to change in the same tick. - windowState.inspectorMode = .inspector - if windowState.inspectorTab != .run { - windowState.inspectorTab = .terminal - } + // Keep the user's last-chosen inspector mode/tab (persisted in + // WindowState). Just make sure the panel is visible and the + // terminal process exists for this session. windowState.showInspector = true } .onChange(of: windowState.inspectorTab) { _, newTab in @@ -597,6 +591,11 @@ struct BranchInfoView: View { /// bottom, with multi-select Stage/Unstage actions and a commit composer at /// the bottom. Generates commit messages via the configured summarization /// provider. +enum ChangeSectionFocus: Hashable { + case unstaged + case staged +} + struct ChangesView: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState @@ -609,10 +608,13 @@ struct ChangesView: View { @State private var isLoading = true @State private var isBusy = false @State private var isGenerating = false + @State private var isPushing = false @State private var errorMessage: String? + @State private var upstream: GitHelper.UpstreamStatus? @State private var headWatcher: (any DispatchSourceFileSystemObject)? @State private var indexWatcher: (any DispatchSourceFileSystemObject)? @State private var refreshTask: Task? + @FocusState private var focusedSection: ChangeSectionFocus? var body: some View { if let project = windowState.selectedProject { @@ -647,8 +649,12 @@ struct ChangesView: View { selection: $selectedUnstaged, emptyMessage: "Working tree matches the index.", isBusy: isBusy, - onAction: { await stageSelected(at: projectPath) } + onAction: { await stageSelected(at: projectPath) }, + onFocus: { focusedSection = .unstaged }, + onEmptyTap: clearAllSelections ) + .focusable() + .focused($focusedSection, equals: .unstaged) Divider() @@ -659,8 +665,12 @@ struct ChangesView: View { selection: $selectedStaged, emptyMessage: "Nothing in the index.", isBusy: isBusy, - onAction: { await unstageSelected(at: projectPath) } + onAction: { await unstageSelected(at: projectPath) }, + onFocus: { focusedSection = .staged }, + onEmptyTap: clearAllSelections ) + .focusable() + .focused($focusedSection, equals: .staged) Divider() @@ -671,6 +681,44 @@ struct ChangesView: View { ProgressView().controlSize(.small).padding(.top, 20) } } + .background(selectAllShortcut) + } + + /// Cmd+A hooks up to whichever section is currently focused. Hidden button + /// avoids stealing focus from the visible UI. + @ViewBuilder + private var selectAllShortcut: some View { + Button("Select All in Section") { + selectAllInFocusedSection() + } + .keyboardShortcut("a", modifiers: .command) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + + private func clearAllSelections() { + selectedUnstaged.removeAll() + selectedStaged.removeAll() + } + + private func selectAllInFocusedSection() { + switch focusedSection { + case .unstaged: + selectedUnstaged = Set(unstaged.map { $0.displayPath }) + case .staged: + selectedStaged = Set(staged.map { $0.displayPath }) + case .none: + // No section focused — default to whichever has files, preferring + // unstaged. Keeps Cmd+A useful right after the view appears. + if !unstaged.isEmpty { + selectedUnstaged = Set(unstaged.map { $0.displayPath }) + focusedSection = .unstaged + } else if !staged.isEmpty { + selectedStaged = Set(staged.map { $0.displayPath }) + focusedSection = .staged + } + } } // MARK: - Commit composer @@ -722,8 +770,28 @@ struct ChangesView: View { .lineLimit(3) } - HStack { + HStack(spacing: 8) { + upstreamStatusLabel Spacer() + Button { + Task { await push(at: projectPath) } + } label: { + HStack(spacing: 4) { + if isPushing { + ProgressView().controlSize(.mini) + } else { + Image(systemName: pushIconName) + } + Text(pushLabel) + .font(.system(size: ClaudeTheme.size(12), weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + .buttonStyle(.bordered) + .disabled(isBusy || isPushing || !canPush) + .help(pushHelp) + Button { Task { await commit(at: projectPath) } } label: { @@ -746,6 +814,71 @@ struct ChangesView: View { .padding(.vertical, 10) } + @ViewBuilder + private var upstreamStatusLabel: some View { + if let upstream { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: ClaudeTheme.size(10))) + Text(upstream.branch) + .font(.system(size: ClaudeTheme.size(11), weight: .medium, design: .monospaced)) + Text("·") + .foregroundStyle(ClaudeTheme.textTertiary) + Text(upstreamSummary(upstream)) + .font(.system(size: ClaudeTheme.size(11))) + } + .foregroundStyle(ClaudeTheme.textTertiary) + .lineLimit(1) + .truncationMode(.tail) + } + } + + private func upstreamSummary(_ status: GitHelper.UpstreamStatus) -> String { + if status.upstream == nil { + return status.remotes.isEmpty ? "no remote" : "no upstream" + } + switch (status.ahead, status.behind) { + case (0, 0): return "up to date" + case (let a, 0): return "↑\(a)" + case (0, let b): return "↓\(b)" + case (let a, let b): return "↑\(a) ↓\(b)" + } + } + + private var canPush: Bool { + guard let upstream else { return false } + if upstream.upstream == nil { + // Need at least one remote to create an upstream. + return !upstream.remotes.isEmpty + } + return upstream.ahead > 0 + } + + private var pushLabel: String { + guard let upstream else { return "Push" } + return upstream.upstream == nil ? "Publish" : "Push" + } + + private var pushIconName: String { + guard let upstream else { return "arrow.up" } + return upstream.upstream == nil ? "icloud.and.arrow.up" : "arrow.up" + } + + private var pushHelp: String { + guard let upstream else { return "Push the current branch" } + if upstream.upstream == nil { + if upstream.remotes.isEmpty { + return "No remote configured. Add one with `git remote add origin `." + } + let remote = upstream.remotes.contains("origin") ? "origin" : (upstream.remotes.first ?? "origin") + return "Push and track \(remote)/\(upstream.branch)" + } + if upstream.ahead == 0 { + return "Nothing to push — local matches \(upstream.upstream ?? "upstream")" + } + return "Push \(upstream.ahead) commit\(upstream.ahead == 1 ? "" : "s") to \(upstream.upstream ?? "upstream")" + } + // MARK: - Actions private func stageSelected(at projectPath: String) async { @@ -791,13 +924,39 @@ struct ChangesView: View { } } + private func push(at projectPath: String) async { + guard let upstream else { return } + isPushing = true + errorMessage = nil + let setUpstream = upstream.upstream == nil + let remote = upstream.remotes.contains("origin") ? "origin" : (upstream.remotes.first ?? "origin") + let err = await GitHelper.push( + at: projectPath, + remote: remote, + branch: upstream.branch, + setUpstream: setUpstream + ) + isPushing = false + if let err { + errorMessage = err + } else { + await refresh(at: projectPath) + } + } + private func generateMessage(at projectPath: String) async { guard !staged.isEmpty else { return } isGenerating = true errorMessage = nil - let diff = await GitHelper.stagedDiff(at: projectPath) + async let diffTask = GitHelper.stagedDiff(at: projectPath) + async let statTask = GitHelper.stagedStat(at: projectPath) + let (diff, stat) = await (diffTask, statTask) let fileSummary = staged.map { "\($0.statusChar) \($0.displayPath)" }.joined(separator: "\n") - let result = await appState.generateCommitMessage(diff: diff, fileSummary: fileSummary) + let result = await appState.generateCommitMessage( + diff: diff, + stat: stat, + fileSummary: fileSummary + ) isGenerating = false if let result, !result.isEmpty { commitMessage = result @@ -815,12 +974,17 @@ struct ChangesView: View { private func refresh(at projectPath: String) async { isLoading = true - let result = await Task.detached(priority: .userInitiated) { + async let filesTask = Task.detached(priority: .userInitiated) { await loadAllChangedFiles(at: projectPath) }.value + async let upstreamTask = Task.detached(priority: .userInitiated) { + await GitHelper.upstreamStatus(at: projectPath) + }.value + let (result, upstreamResult) = await (filesTask, upstreamTask) guard !Task.isCancelled else { return } unstaged = result.unstaged staged = result.staged + upstream = upstreamResult // Drop selections for files that no longer appear. let unstagedPaths = Set(unstaged.map { $0.displayPath }) let stagedPaths = Set(staged.map { $0.displayPath }) @@ -868,6 +1032,8 @@ private struct ChangeSection: View { let emptyMessage: String let isBusy: Bool let onAction: () async -> Void + let onFocus: () -> Void + let onEmptyTap: () -> Void var body: some View { VStack(spacing: 0) { @@ -886,7 +1052,10 @@ private struct ChangeSection: View { ChangeFileRow( file: file, isSelected: selection.contains(file.displayPath), - onToggle: { toggle(file) } + onToggle: { + onFocus() + toggle(file) + } ) } } @@ -898,6 +1067,15 @@ private struct ChangeSection: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { + // Tapping the empty area inside a section focuses it and clears + // all selections (both unstaged and staged). Taps on rows are + // intercepted by the row's own gesture, so this only fires on + // the background. + onFocus() + onEmptyTap() + } } @ViewBuilder @@ -991,16 +1169,20 @@ private struct ChangeFileRow: View { .fill(rowFill) ) .contentShape(Rectangle()) - .onTapGesture(count: 2) { - windowState.diffFile = PreviewFile( - path: file.path, - name: file.name, - gitDiffMode: file.diffMode - ) - } .onTapGesture { onToggle() } + .contextMenu { + Button("Show Diff") { openDiff() } + } .onHover { isHovering = $0 } - .help("Click to select · Double-click to view diff") + .help("Click to select · Right-click for Show Diff") + } + + private func openDiff() { + windowState.diffFile = PreviewFile( + path: file.path, + name: file.name, + gitDiffMode: file.diffMode + ) } private var rowFill: Color { diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 772360ab..1ebe56b0 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -213,7 +213,7 @@ struct MainView: View { ProjectTreeView() } .background(ClaudeTheme.sidebarBackground.ignoresSafeArea()) - .navigationSplitViewColumnWidth(min: 450, ideal: 450, max: 500) + .navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 500) .sheet(isPresented: $showGitHubSheet) { GitHubSheet() }