From 776eee7b293e86d1dbbf0c5f6201e9e10ff0af5b Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 17 Apr 2026 15:28:14 +0300 Subject: [PATCH] Welcome: Clone Repository quick action Add a third Quick Action on the Welcome screen that opens a sheet to clone a git repository. URL field + destination folder (NSOpenPanel) + Clone/Cancel buttons with a progress indicator while running and an alert on failure. Clone runs via Process on /usr/bin/git with user's existing credentials (SSH/credential helper); task cancellation terminates the subprocess. On success the cloned folder is added to recent projects and opened via the existing projectSelected delegate. Closes #149 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Relay/Welcome/CloneRepositorySheet.swift | 147 +++++++++++ MacApp/Relay/Welcome/GitCloneClient.swift | 216 ++++++++++++++++ MacApp/Relay/Welcome/WelcomeFeature.swift | 154 +++++++++++- MacApp/Relay/Welcome/WelcomeView.swift | 30 ++- MacApp/RelayTests/WelcomeFeatureTests.swift | 233 ++++++++++++++++++ 5 files changed, 773 insertions(+), 7 deletions(-) create mode 100644 MacApp/Relay/Welcome/CloneRepositorySheet.swift create mode 100644 MacApp/Relay/Welcome/GitCloneClient.swift diff --git a/MacApp/Relay/Welcome/CloneRepositorySheet.swift b/MacApp/Relay/Welcome/CloneRepositorySheet.swift new file mode 100644 index 0000000..544a495 --- /dev/null +++ b/MacApp/Relay/Welcome/CloneRepositorySheet.swift @@ -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 + 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) + } + } +} diff --git a/MacApp/Relay/Welcome/GitCloneClient.swift b/MacApp/Relay/Welcome/GitCloneClient.swift new file mode 100644 index 0000000..b75f9cf --- /dev/null +++ b/MacApp/Relay/Welcome/GitCloneClient.swift @@ -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( + 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() + } + } +} diff --git a/MacApp/Relay/Welcome/WelcomeFeature.swift b/MacApp/Relay/Welcome/WelcomeFeature.swift index 2f7531b..af1de22 100644 --- a/MacApp/Relay/Welcome/WelcomeFeature.swift +++ b/MacApp/Relay/Welcome/WelcomeFeature.swift @@ -3,14 +3,16 @@ import Foundation // MARK: - WelcomeFeature -/// TCA reducer backing the Welcome screen (Recent Projects + Open folder). +/// TCA reducer backing the Welcome screen (Recent Projects + Open folder + +/// Clone Repository). /// -/// Owns the list of recent projects, the search filter, and emits -/// `delegate(.projectSelected)` when the user picks a folder to open, or +/// Owns the list of recent projects, the search filter, and the transient +/// Clone Repository sheet state. Emits `delegate(.projectSelected)` when the +/// user picks a folder to open or finishes cloning, or /// `delegate(.manageServersRequested)` when the user taps the "Manage Servers" /// quick action. Opening the OS file picker is delegated to the view layer — /// the reducer only reacts to the already-selected URL via -/// ``Action/folderSelected(_:)``. +/// ``Action/folderSelected(_:)`` / ``Action/cloneDestinationSelected(_:)``. @Reducer struct WelcomeFeature { @@ -19,6 +21,25 @@ struct WelcomeFeature { /// Default limit for the recent projects fetch. static let recentLimit = 20 + // MARK: - Clone sub-state + + /// Transient state of the Clone Repository sheet. Non-nil iff the sheet + /// is visible. Persists the typed URL, chosen destination parent folder, + /// in-flight progress, and the last error to surface in an alert. + struct CloneState: Equatable { + var urlString: String = "" + var destinationDirectory: URL? + var isCloning: Bool = false + var errorMessage: String? + + /// True when the Clone button in the sheet should be enabled. + var isCloneButtonEnabled: Bool { + !isCloning + && destinationDirectory != nil + && !urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + // MARK: - State @ObservableState @@ -31,12 +52,17 @@ struct WelcomeFeature { /// `contains`). var searchText: String = "" + /// Clone Repository sheet state. Non-nil while the sheet is open. + var clone: CloneState? + init( recentProjects: [ProjectInfo] = [], - searchText: String = "" + searchText: String = "", + clone: CloneState? = nil ) { self.recentProjects = recentProjects self.searchText = searchText + self.clone = clone } /// Projects matching the current `searchText`. When the query is empty, @@ -85,6 +111,36 @@ struct WelcomeFeature { /// User tapped the "Manage Servers" quick action. case manageServersTapped + // MARK: Clone Repository actions + + /// User tapped the "Clone Repository" quick action — opens the sheet. + case cloneRepositoryTapped + + /// User dismissed the clone sheet (Cancel or close) without clicking + /// the Clone button during a successful clone. Cancels in-flight work. + case cloneSheetDismissed + + /// User edited the repository URL field inside the sheet. + case cloneURLChanged(String) + + /// View layer delivered the destination folder picked via `NSOpenPanel`. + /// `nil` means the panel was cancelled — state is left untouched. + case cloneDestinationSelected(URL?) + + /// User tapped the Clone button inside the sheet. Kicks off the + /// subprocess and moves the sheet into its `isCloning` state. + case cloneStarted + + /// Internal: clone subprocess terminated. + case cloneCompleted(CloneCompletion) + + /// User tapped Cancel while the clone was running. Terminates the + /// subprocess via effect cancellation. + case cloneCancelled + + /// User dismissed the error alert. + case cloneErrorDismissed + /// Delegated events intended for the parent reducer. case delegate(Delegate) @@ -95,11 +151,26 @@ struct WelcomeFeature { /// User requested to open the server management sheet. case manageServersRequested } + + /// Sendable/Equatable wrapper around the clone result so the whole + /// `Action` enum stays `Equatable`-friendly. + @CasePathable + enum CloneCompletion: Sendable, Equatable { + case success(URL) + case failure(CloneError) + } + } + + // MARK: - Cancellation IDs + + private enum CancelID: Hashable { + case clone } // MARK: - Dependencies @Dependency(\.projectRepository) var projectRepository + @Dependency(\.gitCloneClient) var gitCloneClient // MARK: - Body @@ -151,6 +222,79 @@ struct WelcomeFeature { case .manageServersTapped: return .send(.delegate(.manageServersRequested)) + case .cloneRepositoryTapped: + if state.clone == nil { + state.clone = CloneState() + } + return .none + + case .cloneSheetDismissed: + state.clone = nil + return .cancel(id: CancelID.clone) + + case let .cloneURLChanged(text): + state.clone?.urlString = text + // Typing into the URL field clears any stale error. + state.clone?.errorMessage = nil + return .none + + case let .cloneDestinationSelected(url): + guard let url else { return .none } + state.clone?.destinationDirectory = url + state.clone?.errorMessage = nil + return .none + + case .cloneStarted: + guard var clone = state.clone else { return .none } + let trimmedURL = clone.urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedURL.isEmpty else { + clone.errorMessage = CloneError.invalidURL.errorDescription + state.clone = clone + return .none + } + guard let destination = clone.destinationDirectory else { + clone.errorMessage = CloneError.destinationMissing.errorDescription + state.clone = clone + return .none + } + clone.isCloning = true + clone.errorMessage = nil + state.clone = clone + + return .run { [gitCloneClient] send in + do { + let clonedURL = try await gitCloneClient.clone(trimmedURL, destination) + await send(.cloneCompleted(.success(clonedURL))) + } catch let error as CloneError { + await send(.cloneCompleted(.failure(error))) + } catch { + await send(.cloneCompleted(.failure( + .processFailure(exitCode: -1, stderr: error.localizedDescription) + ))) + } + } + .cancellable(id: CancelID.clone, cancelInFlight: true) + + case let .cloneCompleted(.success(url)): + state.clone = nil + return .run { send in + await projectRepository.addProject(url) + await send(.delegate(.projectSelected(url))) + } + + case let .cloneCompleted(.failure(error)): + state.clone?.isCloning = false + state.clone?.errorMessage = error.errorDescription + return .none + + case .cloneCancelled: + state.clone?.isCloning = false + return .cancel(id: CancelID.clone) + + case .cloneErrorDismissed: + state.clone?.errorMessage = nil + return .none + case .delegate: return .none } diff --git a/MacApp/Relay/Welcome/WelcomeView.swift b/MacApp/Relay/Welcome/WelcomeView.swift index 84e3a41..d761fb4 100644 --- a/MacApp/Relay/Welcome/WelcomeView.swift +++ b/MacApp/Relay/Welcome/WelcomeView.swift @@ -6,8 +6,8 @@ import SwiftUI /// Приветственный экран с двухколоночным layout: /// слева — Recent Projects + search, справа — Quick Actions (Open Project, -/// Manage Servers) и app identity. Показывается при запуске если нет -/// открытого проекта. +/// Clone Repository, Manage Servers) и app identity. Показывается при запуске +/// если нет открытого проекта. struct WelcomeView: View { @Bindable var store: StoreOf @@ -25,6 +25,20 @@ struct WelcomeView: View { .onAppear { store.send(.onAppear) } + .sheet( + isPresented: Binding( + get: { store.clone != nil }, + set: { isPresented in + if !isPresented { + store.send(.cloneSheetDismissed) + } + } + ) + ) { + if let clone = store.clone { + CloneRepositorySheet(store: store, clone: clone) + } + } } // MARK: - Left panel (projects) @@ -130,6 +144,18 @@ struct WelcomeView: View { .accessibilityLabel("Open a project folder") .keyboardShortcut("o", modifiers: .command) + Button { + store.send(.cloneRepositoryTapped) + } label: { + Label("Clone Repository\u{2026}", systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityLabel("Clone a git repository") + .keyboardShortcut("c", modifiers: [.command, .shift]) + Button { store.send(.manageServersTapped) } label: { diff --git a/MacApp/RelayTests/WelcomeFeatureTests.swift b/MacApp/RelayTests/WelcomeFeatureTests.swift index 3e42621..a273136 100644 --- a/MacApp/RelayTests/WelcomeFeatureTests.swift +++ b/MacApp/RelayTests/WelcomeFeatureTests.swift @@ -141,4 +141,237 @@ struct WelcomeFeatureTests { state.searchText = "zzz" #expect(state.filteredProjects.isEmpty) } + + // MARK: - Clone Repository — sheet presentation + + @Test + func cloneRepositoryTappedOpensSheet() async { + let store = TestStore(initialState: WelcomeFeature.State()) { + WelcomeFeature() + } + + await store.send(.cloneRepositoryTapped) { + $0.clone = WelcomeFeature.CloneState() + } + } + + @Test + func cloneSheetDismissedClearsState() async { + let initial = WelcomeFeature.CloneState( + urlString: "git@github.com:foo/bar.git", + destinationDirectory: URL(filePath: "/tmp"), + isCloning: false, + errorMessage: nil + ) + + let store = TestStore( + initialState: WelcomeFeature.State(clone: initial) + ) { + WelcomeFeature() + } + + await store.send(.cloneSheetDismissed) { + $0.clone = nil + } + } + + // MARK: - Clone Repository — form updates + + @Test + func cloneURLChangedUpdatesState() async { + let store = TestStore( + initialState: WelcomeFeature.State(clone: WelcomeFeature.CloneState()) + ) { + WelcomeFeature() + } + + await store.send(.cloneURLChanged("https://example.com/repo.git")) { + $0.clone?.urlString = "https://example.com/repo.git" + } + } + + @Test + func cloneDestinationSelectedUpdatesState() async { + let dest = URL(filePath: "/Users/me/Projects") + let store = TestStore( + initialState: WelcomeFeature.State(clone: WelcomeFeature.CloneState()) + ) { + WelcomeFeature() + } + + await store.send(.cloneDestinationSelected(dest)) { + $0.clone?.destinationDirectory = dest + } + } + + @Test + func cloneDestinationSelectedNilIsNoOp() async { + let store = TestStore( + initialState: WelcomeFeature.State(clone: WelcomeFeature.CloneState()) + ) { + WelcomeFeature() + } + + await store.send(.cloneDestinationSelected(nil)) + } + + // MARK: - Clone Repository — cloneStarted + + @Test + func cloneStartedValidatesURL() async { + let initial = WelcomeFeature.CloneState( + urlString: " ", + destinationDirectory: URL(filePath: "/tmp") + ) + let store = TestStore( + initialState: WelcomeFeature.State(clone: initial) + ) { + WelcomeFeature() + } + + await store.send(.cloneStarted) { + $0.clone?.errorMessage = CloneError.invalidURL.errorDescription + } + } + + @Test + func cloneStartedValidatesDestination() async { + let initial = WelcomeFeature.CloneState( + urlString: "git@github.com:foo/bar.git", + destinationDirectory: nil + ) + let store = TestStore( + initialState: WelcomeFeature.State(clone: initial) + ) { + WelcomeFeature() + } + + await store.send(.cloneStarted) { + $0.clone?.errorMessage = CloneError.destinationMissing.errorDescription + } + } + + @Test + func cloneStartedSuccessDelegatesAndAddsProject() async { + let destination = URL(filePath: "/tmp") + let clonedURL = URL(filePath: "/tmp/bar") + let added = LockIsolated<[URL]>([]) + let initial = WelcomeFeature.CloneState( + urlString: "git@github.com:foo/bar.git", + destinationDirectory: destination + ) + + let store = TestStore( + initialState: WelcomeFeature.State(clone: initial) + ) { + WelcomeFeature() + } withDependencies: { + $0.gitCloneClient.clone = { @Sendable url, dest in + #expect(url == "git@github.com:foo/bar.git") + #expect(dest == destination) + return clonedURL + } + $0.projectRepository.addProject = { @Sendable picked in + added.withValue { $0.append(picked) } + } + } + + await store.send(.cloneStarted) { + $0.clone?.isCloning = true + } + await store.receive(\.cloneCompleted.success) { + $0.clone = nil + } + await store.receive(\.delegate.projectSelected) + + #expect(added.value == [clonedURL]) + } + + @Test + func cloneStartedFailureSetsErrorMessage() async { + let initial = WelcomeFeature.CloneState( + urlString: "git@github.com:foo/bar.git", + destinationDirectory: URL(filePath: "/tmp") + ) + let error = CloneError.authenticationRequired + + let store = TestStore( + initialState: WelcomeFeature.State(clone: initial) + ) { + WelcomeFeature() + } withDependencies: { + $0.gitCloneClient.clone = { @Sendable _, _ in + throw error + } + } + + await store.send(.cloneStarted) { + $0.clone?.isCloning = true + } + await store.receive(\.cloneCompleted.failure) { + $0.clone?.isCloning = false + $0.clone?.errorMessage = error.errorDescription + } + } + + // MARK: - Clone Repository — cancel + + @Test + func cloneCancelledClearsIsCloning() async { + let initial = WelcomeFeature.CloneState( + urlString: "git@github.com:foo/bar.git", + destinationDirectory: URL(filePath: "/tmp"), + isCloning: true + ) + let store = TestStore( + initialState: WelcomeFeature.State(clone: initial) + ) { + WelcomeFeature() + } + + await store.send(.cloneCancelled) { + $0.clone?.isCloning = false + } + } + + @Test + func cloneErrorDismissedClearsMessage() async { + let initial = WelcomeFeature.CloneState( + urlString: "git@github.com:foo/bar.git", + destinationDirectory: URL(filePath: "/tmp"), + errorMessage: "boom" + ) + let store = TestStore( + initialState: WelcomeFeature.State(clone: initial) + ) { + WelcomeFeature() + } + + await store.send(.cloneErrorDismissed) { + $0.clone?.errorMessage = nil + } + } + + // MARK: - CloneState.isCloneButtonEnabled + + @Test + func cloneButtonEnabledRequiresURLDestinationAndIdle() { + var state = WelcomeFeature.CloneState() + #expect(!state.isCloneButtonEnabled) + + state.urlString = "git@github.com:foo/bar.git" + #expect(!state.isCloneButtonEnabled) + + state.destinationDirectory = URL(filePath: "/tmp") + #expect(state.isCloneButtonEnabled) + + state.urlString = " " + #expect(!state.isCloneButtonEnabled) + + state.urlString = "git@github.com:foo/bar.git" + state.isCloning = true + #expect(!state.isCloneButtonEnabled) + } + + // MARK: - GitCloneRunner.derivedFolderName (exposed via extension if needed) }