Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions MacApp/Relay/Welcome/CloneRepositorySheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import AppKit
import ComposableArchitecture
import SwiftUI

// MARK: - CloneRepositorySheet

/// Modal content for the "Clone Repository" quick action. Owns its own
/// progress/error rendering and forwards user intents back to the enclosing
/// ``WelcomeFeature`` store.
struct CloneRepositorySheet: View {

let store: StoreOf<WelcomeFeature>
let clone: WelcomeFeature.CloneState

var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text("Clone Repository")
.font(.title2)
.fontWeight(.semibold)
Text("Provide a git URL and a destination folder. Authentication uses your existing SSH keys or credential helper.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}

Divider()

// Repository URL
VStack(alignment: .leading, spacing: 6) {
Text("Repository URL")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.textCase(.uppercase)

TextField(
"git@github.com:org/repo.git or https://…",
text: Binding(
get: { clone.urlString },
set: { store.send(.cloneURLChanged($0)) }
)
)
.textFieldStyle(.roundedBorder)
.disabled(clone.isCloning)
.accessibilityLabel("Repository URL")
}

// Destination folder
VStack(alignment: .leading, spacing: 6) {
Text("Destination")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
.textCase(.uppercase)

HStack(spacing: 8) {
Text(clone.destinationDirectory?.path ?? "No folder selected")
.font(.body)
.foregroundStyle(clone.destinationDirectory == nil ? .secondary : .primary)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)

Button("Choose\u{2026}", action: chooseDestination)
.disabled(clone.isCloning)
.accessibilityLabel("Choose destination folder")
}
.padding(10)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(6)
}

// Progress
if clone.isCloning {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Cloning repository\u{2026}")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.transition(.opacity)
}

Spacer(minLength: 0)

// Buttons
HStack {
Spacer()
Button("Cancel", action: cancel)
.keyboardShortcut(.cancelAction)

Button(clone.isCloning ? "Cloning\u{2026}" : "Clone") {
store.send(.cloneStarted)
}
.buttonStyle(.borderedProminent)
.disabled(!clone.isCloneButtonEnabled)
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(minWidth: 480, idealWidth: 520)
.alert(
"Clone Failed",
isPresented: Binding(
get: { clone.errorMessage != nil },
set: { isPresented in
if !isPresented {
store.send(.cloneErrorDismissed)
}
}
Comment thread
kirich1409 marked this conversation as resolved.
),
presenting: clone.errorMessage
) { _ in
Button("OK", role: .cancel) {
store.send(.cloneErrorDismissed)
}
} message: { message in
Text(message)
}
}

// MARK: - Actions

private func chooseDestination() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.title = "Choose Destination Folder"
panel.prompt = "Choose"

guard panel.runModal() == .OK else { return }
store.send(.cloneDestinationSelected(panel.url))
}

private func cancel() {
if clone.isCloning {
store.send(.cloneCancelled)
} else {
store.send(.cloneSheetDismissed)
}
}
}
216 changes: 216 additions & 0 deletions MacApp/Relay/Welcome/GitCloneClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import ComposableArchitecture
import Foundation

// MARK: - GitCloneClient

/// TCA dependency that performs `git clone` via ``Process`` while staying
/// cancellable — terminating the subprocess when the surrounding Swift task
/// is cancelled (e.g. via TCA's `.cancel(id:)`).
///
/// The live implementation relies on the user's existing git credentials
/// (SSH agent, credential helper). No explicit credential management.
struct GitCloneClient: Sendable {

/// Clones `repositoryURL` into a new folder under `destinationDirectory`.
/// Returns the resulting project directory URL.
var clone: @Sendable (_ repositoryURL: String, _ destinationDirectory: URL) async throws -> URL
}

// MARK: - CloneError

/// Errors surfaced to the user from the clone flow.
enum CloneError: Error, Equatable, LocalizedError {

case invalidURL
case destinationMissing
case targetAlreadyExists(URL)
case authenticationRequired
case processFailure(exitCode: Int32, stderr: String)
case cancelled

var errorDescription: String? {
switch self {
case .invalidURL:
return "Enter a git repository URL."
case .destinationMissing:
return "Choose a destination folder."
case let .targetAlreadyExists(url):
return "A folder named '\(url.lastPathComponent)' already exists in the destination."
case .authenticationRequired:
return "Authentication failed. Check your SSH keys or credential helper."
case let .processFailure(_, stderr):
let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "git clone failed." : trimmed
case .cancelled:
return "Clone was cancelled."
}
}
}

// MARK: - DependencyKey

extension GitCloneClient: DependencyKey {

static var liveValue: GitCloneClient {
GitCloneClient(
clone: { repositoryURL, destinationDirectory in
try await GitCloneRunner.run(
repositoryURL: repositoryURL,
destinationDirectory: destinationDirectory
)
}
)
}

static let testValue = GitCloneClient(
clone: { _, _ in
throw CloneError.processFailure(exitCode: -1, stderr: "unimplemented")
}
)
}

// MARK: - DependencyValues

extension DependencyValues {
var gitCloneClient: GitCloneClient {
get { self[GitCloneClient.self] }
set { self[GitCloneClient.self] = newValue }
}
}

// MARK: - GitCloneRunner

/// Runs `git clone` as a subprocess and propagates task cancellation to
/// the underlying ``Process`` via ``Process/terminate()``.
private enum GitCloneRunner {

static func run(
repositoryURL: String,
destinationDirectory: URL
) async throws -> URL {
let trimmedURL = repositoryURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedURL.isEmpty else { throw CloneError.invalidURL }

let targetDirectoryURL = destinationDirectory.appendingPathComponent(
derivedFolderName(from: trimmedURL),
isDirectory: true
)

if FileManager.default.fileExists(atPath: targetDirectoryURL.path) {
throw CloneError.targetAlreadyExists(targetDirectoryURL)
}

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/git")
process.arguments = [
"clone",
"--progress",
trimmedURL,
targetDirectoryURL.path
]
process.currentDirectoryURL = destinationDirectory

let stderrPipe = Pipe()
process.standardError = stderrPipe
// Discard stdout — git progress goes to stderr anyway.
process.standardOutput = Pipe()

let processBox = ProcessBox(process: process)

return try await withTaskCancellationHandler(
operation: {
try await withCheckedThrowingContinuation { continuation in
process.terminationHandler = { proc in
let data = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let stderr = String(data: data, encoding: .utf8) ?? ""

if processBox.wasCancelled {
continuation.resume(throwing: CloneError.cancelled)
return
}

if proc.terminationStatus == 0 {
continuation.resume(returning: targetDirectoryURL)
} else {
continuation.resume(throwing: classify(
exitCode: proc.terminationStatus,
stderr: stderr
))
}
}

do {
try process.run()
} catch {
continuation.resume(throwing: CloneError.processFailure(
Comment thread
kirich1409 marked this conversation as resolved.
exitCode: -1,
stderr: error.localizedDescription
))
}
}
},
onCancel: {
processBox.cancel()
}
)
}

/// Derives the target folder name the way `git clone` would without an
/// explicit path: last path component, stripping a trailing `.git` suffix.
static func derivedFolderName(from url: String) -> String {
var trimmed = url
if trimmed.hasSuffix("/") { trimmed.removeLast() }

let lastSlash = trimmed.lastIndex(of: "/") ?? trimmed.lastIndex(of: ":")
let tail: String
if let lastSlash {
tail = String(trimmed[trimmed.index(after: lastSlash)...])
} else {
tail = trimmed
}

return tail.hasSuffix(".git") ? String(tail.dropLast(4)) : tail
}

private static func classify(exitCode: Int32, stderr: String) -> CloneError {
let lowered = stderr.lowercased()
if lowered.contains("authentication failed")
|| lowered.contains("permission denied")
|| lowered.contains("could not read username")
|| lowered.contains("repository not found") {
return .authenticationRequired
}
return .processFailure(exitCode: exitCode, stderr: stderr)
}
}

// MARK: - ProcessBox

/// Thread-safe wrapper around a ``Process`` so the cancellation handler can
/// safely terminate it and flag the termination as user-initiated.
private final class ProcessBox: @unchecked Sendable {

private let lock = NSLock()
private let process: Process
private var cancelled = false

init(process: Process) {
self.process = process
}

var wasCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return cancelled
}

func cancel() {
lock.lock()
cancelled = true
let isRunning = process.isRunning
lock.unlock()
if isRunning {
process.terminate()
}
}
}
Loading
Loading