diff --git a/supacode/Features/Repositories/Views/AddToProwlView.swift b/supacode/Features/Repositories/Views/AddToProwlView.swift index a09b5a60..a71e0b9d 100644 --- a/supacode/Features/Repositories/Views/AddToProwlView.swift +++ b/supacode/Features/Repositories/Views/AddToProwlView.swift @@ -3,13 +3,26 @@ import SwiftUI struct AddToProwlView: View { let onBrowse: () -> Void + let onCloneCompleted: (URL) -> Void let onWorkspace: () -> Void let onDrop: ([URL]) -> Void @Environment(\.dismiss) private var dismiss @State private var isDragTargeted = false @State private var isWorkspaceHovered = false + @State private var showCloneForm = false var body: some View { + if showCloneForm { + CloneRepositoryView { clonedURL in + dismiss() + onCloneCompleted(clonedURL) + } + } else { + mainContent + } + } + + private var mainContent: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 11) { Image(nsImage: NSApp.applicationIconImage) @@ -49,52 +62,52 @@ struct AddToProwlView: View { } private var dropZone: some View { - Button { - dismiss() - onBrowse() - } label: { - VStack(spacing: 2) { - Image(systemName: "folder.badge.plus") - .font(.system(size: 28)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(Color.accentColor) - .frame(width: 52, height: 52) - .background( - Color.accentColor.opacity(isDragTargeted ? 0.2 : 0.1), - in: .rect(cornerRadius: 14) - ) - .scaleEffect(isDragTargeted ? 1.06 : 1) - - Text(isDragTargeted ? "Release to add" : "Drag a repo here") - .font(.system(size: 15, weight: .semibold)) - .padding(.top, 10) - - Text("a Git repository or any folder — opens one project root") - .font(.system(size: 12)) - .foregroundStyle(.secondary) + VStack(spacing: 2) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 28)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(Color.accentColor) + .frame(width: 52, height: 52) + .background( + Color.accentColor.opacity(isDragTargeted ? 0.2 : 0.1), + in: .rect(cornerRadius: 14) + ) + .scaleEffect(isDragTargeted ? 1.06 : 1) + + Text(isDragTargeted ? "Release to add" : "Drag a repo here") + .font(.system(size: 15, weight: .semibold)) + .padding(.top, 10) + + Text("a Git repository or any folder — opens one project root") + .font(.system(size: 12)) + .foregroundStyle(.secondary) - Text("Browse…") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.white) - .padding(.vertical, 5) - .padding(.horizontal, 13) - .background(Color.accentColor, in: .rect(cornerRadius: 7)) - .padding(.top, 14) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 26) - .padding(.horizontal, 20) - .background(.quaternary.opacity(0.3), in: .rect(cornerRadius: 15)) - .overlay { - RoundedRectangle(cornerRadius: 15) - .strokeBorder( - isDragTargeted ? Color.accentColor : Color.secondary.opacity(0.3), - style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]) - ) + HStack(spacing: 8) { + Button("Browse…") { + dismiss() + onBrowse() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Button("Clone…") { + showCloneForm = true + } + .controlSize(.small) } - .contentShape(.rect(cornerRadius: 15)) + .padding(.top, 14) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 26) + .padding(.horizontal, 20) + .background(.quaternary.opacity(0.3), in: .rect(cornerRadius: 15)) + .overlay { + RoundedRectangle(cornerRadius: 15) + .strokeBorder( + isDragTargeted ? Color.accentColor : Color.secondary.opacity(0.3), + style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]) + ) } - .buttonStyle(.plain) .dropDestination(for: URL.self) { urls, _ in let fileURLs = urls.filter(\.isFileURL) guard !fileURLs.isEmpty else { return false } diff --git a/supacode/Features/Repositories/Views/CloneRepositoryView.swift b/supacode/Features/Repositories/Views/CloneRepositoryView.swift new file mode 100644 index 00000000..cf4ea7cf --- /dev/null +++ b/supacode/Features/Repositories/Views/CloneRepositoryView.swift @@ -0,0 +1,189 @@ +import AppKit +import SwiftUI + +struct CloneRepositoryView: View { + @State private var urlString = "" + @State private var locationPath = Self.defaultClonePath + @State private var isCloning = false + @State private var errorMessage: String? + @Environment(\.dismiss) private var dismiss + 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") + } + .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() } + .opacity(isCloning ? 0 : 1) + .overlay { + if isCloning { + VStack(spacing: 8) { + ProgressView() + Text("Cloning…") + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + + private var isValidInput: Bool { + !urlString.trimmingCharacters(in: .whitespaces).isEmpty + && !locationPath.trimmingCharacters(in: .whitespaces).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() { + let repoName = Self.extractRepoName(from: urlString) + let destination = URL(fileURLWithPath: locationPath).appendingPathComponent(repoName, isDirectory: true) + let url = urlString + + isCloning = true + errorMessage = nil + + Task { + let error = await Self.runGitClone(url: url, destination: destination) + isCloning = false + if let error { + errorMessage = error + } else { + dismiss() + onCloned(destination) + } + } + } + + /// Returns `nil` on success, or an error message on failure. + static func runGitClone(url: String, destination: URL) async -> String? { + await withCheckedContinuation { continuation in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = ["clone", "--", url, destination.path] + let errorPipe = Pipe() + process.standardError = errorPipe + process.standardOutput = FileHandle.nullDevice + + process.terminationHandler = { proc in + if proc.terminationStatus == 0 { + continuation.resume(returning: nil) + } else { + let data = errorPipe.fileHandleForReading.readDataToEndOfFile() + let msg = + String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "Clone failed" + continuation.resume(returning: msg) + } + } + + do { + try process.run() + } catch { + continuation.resume(returning: error.localizedDescription) + } + } + } + + static func isGitURL(_ string: String) -> Bool { + if string.hasPrefix("https://") || string.hasPrefix("http://") { + if string.hasSuffix(".git") { return true } + let hosts = ["github.com", "gitlab.com", "bitbucket.org", "dev.azure.com", "gitee.com"] + return hosts.contains { string.contains($0) } + } + if string.hasPrefix("git@") && string.contains(":") { return true } + if string.hasPrefix("git://") || string.hasPrefix("ssh://") { return true } + return false + } + + static func extractRepoName(from urlString: String) -> String { + let cleaned = urlString.hasSuffix("/") ? String(urlString.dropLast()) : urlString + var name: String + if cleaned.contains(":") && !cleaned.contains("://") { + name = + cleaned.components(separatedBy: "/").last + ?? cleaned.components(separatedBy: ":").last ?? cleaned + } else { + name = URL(string: cleaned)?.lastPathComponent ?? cleaned + } + if name.hasSuffix(".git") { + name = String(name.dropLast(4)) + } + return name.isEmpty ? "repository" : name + } + + 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 + } +} diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index 7c1ffc68..55498954 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -22,6 +22,9 @@ struct EmptyStateView: View { onBrowse: { store.send(.setOpenPanelPresented(true)) }, + onCloneCompleted: { url in + store.send(.repositoryManagement(.openRepositories([url]))) + }, onWorkspace: { store.send(.workspaceCreation(.promptRequested)) }, diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 6f4a8cc5..02fb568d 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -208,6 +208,9 @@ struct SidebarListView: View { onBrowse: { store.send(.setOpenPanelPresented(true)) }, + onCloneCompleted: { url in + store.send(.repositoryManagement(.openRepositories([url]))) + }, onWorkspace: { store.send(.workspaceCreation(.promptRequested)) },