-
Notifications
You must be signed in to change notification settings - Fork 0
Welcome: Clone Repository quick action #166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } | ||
| ), | ||
| 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) | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
|
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() | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.