native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
8.1 kB · 251 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252import AppKitimport SwiftUI
struct CloneRepositoryView: View { @State private var urlString = "" @State private var locationPath = Self.defaultClonePath @State private var isCloning = false @State private var errorMessage: String? let dismiss: () -> Void let onCloned: (URL) -> Void
var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Clone") .font(.system(size: 16, weight: .semibold)) Text("Clone a remote repository into a local directory") .font(.system(size: 12.5)) .foregroundStyle(.secondary) }
Grid(alignment: .leading, verticalSpacing: 12) { GridRow { Text("URL:") .gridColumnAlignment(.trailing) TextField("Git Repository URL", text: $urlString) .textFieldStyle(.roundedBorder) } GridRow { Text("Location:") HStack(spacing: 6) { TextField("Clone destination", text: $locationPath) .textFieldStyle(.roundedBorder) Button { pickLocation() } label: { Image(systemName: "folder") .accessibilityHidden(true) } .accessibilityLabel("Choose clone destination") .help("Choose clone destination") } } }
if let errorMessage { Text(errorMessage) .font(.caption) .foregroundStyle(.red) .textSelection(.enabled) }
HStack { Spacer() Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) Button("Clone") { performClone() } .keyboardShortcut(.defaultAction) .disabled(!isValidInput || isCloning) } } .padding(24) .frame(width: 460) .onAppear { prefillFromClipboard() } .disabled(isCloning) .overlay { if isCloning { VStack(spacing: 8) { ProgressView() Text("Cloning…") .font(.callout) .foregroundStyle(.secondary) } .padding(18) .background(.regularMaterial, in: .rect(cornerRadius: 8)) } } }
private var isValidInput: Bool { !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !locationPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
private func prefillFromClipboard() { guard let content = NSPasteboard.general.string(forType: .string) else { return } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) if Self.isGitURL(trimmed) { urlString = trimmed } }
private func pickLocation() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true if panel.runModal() == .OK, let url = panel.url { locationPath = url.path } }
private func performClone() { guard let request = Self.cloneRequest(urlString: urlString, locationPath: locationPath) else { errorMessage = String(localized: "Enter a valid clone URL and destination.") return }
isCloning = true errorMessage = nil
Task { let error = await Self.runGitClone(url: request.url, destination: request.destination) isCloning = false if let error { errorMessage = error } else { dismiss() onCloned(request.destination) } } }
static func cloneRequest(urlString: String, locationPath: String) -> CloneRequest? { let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedURL.isEmpty, let normalizedLocation = PathPolicy.normalizePath(locationPath, resolvingSymlinks: false) else { return nil }
let destination = URL(fileURLWithPath: normalizedLocation, isDirectory: true) .appending(path: extractRepoName(from: trimmedURL), directoryHint: .isDirectory) return CloneRequest(url: trimmedURL, destination: destination) }
/// Returns `nil` on success, or an error message on failure. static func runGitClone(url: String, destination: URL) async -> String? { let git: GitExecutable do { git = try await GitExecutableResolver.shared.resolve(revalidate: true) } catch { return error.localizedDescription } return await withCheckedContinuation { continuation in let process = Process() process.executableURL = git.url process.environment = ProcessInfo.processInfo.environment.merging(git.environment) { _, selected in selected } process.arguments = ["clone", "--", url, destination.path] process.standardOutput = FileHandle.nullDevice let errorLogURL = FileManager.default.temporaryDirectory .appending(path: "prowl-git-clone-\(UUID().uuidString).log", directoryHint: .notDirectory)
guard FileManager.default.createFile(atPath: errorLogURL.path(percentEncoded: false), contents: nil), let errorHandle = try? FileHandle(forWritingTo: errorLogURL) else { continuation.resume(returning: String(localized: "Unable to prepare clone log")) return }
process.standardError = errorHandle
process.terminationHandler = { proc in try? errorHandle.close() defer { try? FileManager.default.removeItem(at: errorLogURL) }
if proc.terminationStatus == 0 { continuation.resume(returning: nil) } else { let data = (try? Data(contentsOf: errorLogURL)) ?? Data() let msg = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? String(localized: "Clone failed") continuation.resume(returning: msg) } }
do { try process.run() } catch { try? errorHandle.close() try? FileManager.default.removeItem(at: errorLogURL) continuation.resume(returning: error.localizedDescription) } } }
static func isGitURL(_ string: String) -> Bool { let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("git@") && trimmed.contains(":") { return true }
guard let components = URLComponents(string: trimmed), let scheme = components.scheme?.lowercased() else { return false }
if scheme == "https" || scheme == "http" { if components.path.hasSuffix(".git") { return true } let hosts = ["github.com", "gitlab.com", "bitbucket.org", "dev.azure.com", "gitee.com"] return components.host.map { hosts.contains($0.lowercased()) } ?? false } return scheme == "git" || scheme == "ssh" }
static func extractRepoName(from urlString: String) -> String { var cleaned = urlString.trimmingCharacters(in: .whitespacesAndNewlines) while cleaned.hasSuffix("/") { cleaned.removeLast() }
var name: String? if cleaned.contains(":") && !cleaned.contains("://") { let pathPart = cleaned.split(separator: ":", maxSplits: 1).last.map(String.init) ?? cleaned name = pathPart.split(separator: "/").last.map(String.init) } else { name = URLComponents(string: cleaned)?.path.split(separator: "/").last.map(String.init) ?? URL(string: cleaned)?.lastPathComponent }
var resolvedName = name ?? cleaned if resolvedName.hasSuffix(".git") { resolvedName = String(resolvedName.dropLast(4)) } return resolvedName.isEmpty ? "repository" : resolvedName }
private static var defaultClonePath: String { let home = FileManager.default.homeDirectoryForCurrentUser let developer = home.appendingPathComponent("Developer") if FileManager.default.fileExists(atPath: developer.path) { return developer.path } return home.path }}
extension CloneRepositoryView { struct CloneRequest: Equatable { let url: String let destination: URL }}