From d251c67bafa3b7d2e21248fe1cf0d330b16aee3f Mon Sep 17 00:00:00 2001 From: MikotoZero Date: Tue, 16 Jun 2026 15:56:53 +0800 Subject: [PATCH] Improve workspace creation and child repo flows --- README.md | 5 +- docs/components/repositories-and-worktrees.md | 4 +- docs/components/workspaces.md | 47 +- .../Clients/Terminal/TerminalClient.swift | 1 + supacode/Domain/ProjectWorkspace.swift | 105 +++- .../RepositoriesFeature+CoreReducer.swift | 70 ++- ...epositoriesFeature+RepositoryLoading.swift | 3 + ...sitoriesFeature+RepositoryManagement.swift | 2 + .../RepositoriesFeature+Selection.swift | 1 + ...epositoriesFeature+WorkspaceChildren.swift | 5 + ...epositoriesFeature+WorktreeLifecycle.swift | 2 + .../Reducer/RepositoriesFeature.swift | 8 +- .../WorkspaceCreationPromptFeature.swift | 227 ++++++-- .../Repositories/Views/EmptyStateView.swift | 31 +- .../Views/RepositorySectionView.swift | 15 +- .../Repositories/Views/SidebarListView.swift | 53 +- .../Views/WorkspaceChildRowsView.swift | 91 ++-- .../Views/WorkspaceCreationPromptView.swift | 415 ++++++++++----- .../Views/WorkspaceDetailView.swift | 7 +- .../Repositories/Views/WorktreeRow.swift | 69 ++- .../Views/RepositorySettingsView.swift | 20 +- .../WorktreeTerminalManager.swift | 2 + .../WorktreeTerminalState+Surfaces.swift | 2 + .../Models/WorktreeTerminalState.swift | 64 ++- supacodeTests/ProjectWorkspaceTests.swift | 71 ++- supacodeTests/RepositoriesFeatureTests.swift | 487 +++++++++++++++--- 26 files changed, 1414 insertions(+), 393 deletions(-) diff --git a/README.md b/README.md index 736cd549..80d0ee0a 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,9 @@ A personal fork of [Supacode](https://github.com/supabitapp/supacode), built on ```bash make build-ghostty-xcframework # Build GhosttyKit from Zig source make build-app # Build the macOS app (Debug) -make run-app # Build, launch, and stream logs -make install-dev-build # Build Debug and install to /Applications +make run-app # Build, install, and launch Debug from /Applications/Prowl Debug.app +make install-debug # Build Debug and install to /Applications/Prowl Debug.app +make install-dev-build # Alias-compatible Debug install target make install-release # Build Release, sign locally, install to /Applications ``` diff --git a/docs/components/repositories-and-worktrees.md b/docs/components/repositories-and-worktrees.md index aff35d8c..a1d3b365 100644 --- a/docs/components/repositories-and-worktrees.md +++ b/docs/components/repositories-and-worktrees.md @@ -32,8 +32,8 @@ root; "not a git repository" → plain folder). ## Adding a repository - **Shortcut:** `⌘⇧O` (`open_repository`) -- **Toolbar:** the **Add Repository** button (folder-with-plus icon) at the top of - the sidebar, next to **New Workspace** (person-in-folder icon). +- **Toolbar:** the **Add...** button (folder-with-plus icon) at the top of the + sidebar. Choose **Add Local Repository/Folder**. - **Command Palette:** "Open Repository". Pick one or more directories. Prowl detects git vs plain, de-duplicates, and diff --git a/docs/components/workspaces.md b/docs/components/workspaces.md index 04c5a631..bed4f7ef 100644 --- a/docs/components/workspaces.md +++ b/docs/components/workspaces.md @@ -18,11 +18,11 @@ When you open a workspace in Prowl: ## Folder layout -Use **New Workspace** from the sidebar toolbar (the -person-in-folder button next to **Add Repository**), the Worktrees menu, or the -command palette to create a workspace. Prowl creates the shared folder, materializes the -selected repositories, writes `.prowl/workspace.json`, and opens the workspace -as a runnable folder. A workspace needs at least two repositories. +Use **Add...** from the sidebar toolbar and choose **Add Workspace**, or use the +Worktrees menu or command palette to create a workspace. Prowl creates the +shared folder, materializes the selected repositories, writes +`.prowl/workspace.json`, and opens the workspace as a runnable folder. A +workspace needs at least two repositories. While a workspace is being created the prompt shows a spinner. **Cancel** stops the creation and rolls back everything created so far: cloned folders, created @@ -44,12 +44,17 @@ Repository sources can be mixed in one workspace: - Local repository folders are selected from disk (`source_kind: local_repository`). - Remote repositories are added through a URL prompt that loads remote heads - before inserting the row. They are cloned into the workspace folder with - `source_kind: remote`. The inserted row defaults to **Use Existing** on the - detected default remote branch. -- Bare repositories are materialized with `git worktree add` and recorded with - `source_kind: bare_repository`. If both branch and base ref are supplied, - Prowl creates the worktree branch from that base ref. + before inserting the row. Loading can be canceled while the prompt is open. + They are cloned into the workspace folder with `source_kind: remote`. The + inserted row defaults to **Use Existing** on the detected default remote + branch. +- Bare repositories are supported by the metadata and materialization layer as + `source_kind: bare_repository`, but the first workspace UI keeps that + advanced source hidden. + +Opened and local repository rows show their source as read-only provenance +rather than a mode selector because both follow the same materialization rules +after they have been added. For already opened and local repositories, the branch action decides how the folder is materialized: @@ -64,8 +69,8 @@ folder is materialized: remote-tracking ref creates a local tracking branch instead of a detached worktree. Git rejects a branch that is already checked out elsewhere. -The creation prompt detects base-ref candidates for already opened, local, and -bare repositories by reading local git refs, preferring the detected default +The creation prompt detects base-ref candidates for already opened and local +repositories by reading local git refs, preferring the detected default branch such as `main` or `master`. Refs are grouped as local branches, remote tracking branches, or fetched remote branches, and the picker supports simple text search. `/HEAD` symbolic pointers are omitted from the picker @@ -73,6 +78,13 @@ because they only alias a branch that is already listed. Base refs are selected from detected refs so workspace creation does not try to checkout an arbitrary, nonexistent branch. +When creation validation fails inside a repository row, the repository list +scrolls to that row and highlights the invalid field with a red border. + +The **Folder** path follows the workspace **Title** while it is still generated +by Prowl. Once you edit or choose the folder directly, Prowl treats it as a +manual path and stops changing it when the title changes. + Branch behavior is explicit: - **Create Branch** uses `branch_name` plus the selected base ref to create a @@ -93,6 +105,12 @@ Branch behavior is explicit: branch). This prevents an unnoticed reset of a local branch that is ahead of the remote. +Workspace rows expand to show child repository rows. Each child row displays its +current branch, uncommitted line counts, and pull request badge when available, +including immediately after a newly created workspace is opened. Click a child +row to select it and focus its terminal tab rooted at that repository folder +inside the workspace, creating that tab the first time it is selected. + ## Removing a workspace **Remove Repository** on a workspace opens a dedicated confirmation. By default @@ -121,6 +139,7 @@ Example `.prowl/workspace.json`: ```json { + "schema_version": "prowl.workspace.v1", "title": "Checkout Flow", "description": "Update app UI, API contract, and shared package together.", "task_links": [ @@ -157,6 +176,8 @@ Example `.prowl/workspace.json`: Top-level fields: +- `schema_version` — metadata format version. Defaults to + `prowl.workspace.v1` when omitted. - `id` — optional stable identifier. Defaults to the workspace root path. - `title` — display title. Defaults to the folder name. - `description` — optional task summary shown in the detail view. diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 0894218f..7def5e4a 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -33,6 +33,7 @@ struct TerminalClient { customCommandIcon: String? = nil ) case createTabInDirectory(Worktree, directory: URL) + case focusOrCreateTabInDirectory(Worktree, directory: URL, title: String?) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) case runScript(Worktree, script: String) case insertText(Worktree, text: String) diff --git a/supacode/Domain/ProjectWorkspace.swift b/supacode/Domain/ProjectWorkspace.swift index 38f6be34..c33b3bd1 100644 --- a/supacode/Domain/ProjectWorkspace.swift +++ b/supacode/Domain/ProjectWorkspace.swift @@ -1,6 +1,8 @@ import Foundation -nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatable, Hashable, Sendable { +nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatable, Hashable, + Sendable +{ case remote case localRepository = "local_repository" case bareRepository = "bare_repository" @@ -40,7 +42,9 @@ nonisolated enum ProjectWorkspaceRepositorySourceKind: String, Codable, Equatabl } } -nonisolated enum ProjectWorkspaceRepositoryCheckoutMode: String, Codable, Equatable, Hashable, Sendable { +nonisolated enum ProjectWorkspaceRepositoryCheckoutMode: String, Codable, Equatable, Hashable, + Sendable +{ case link case createBranch = "create_branch" case useExistingRef = "use_existing_ref" @@ -84,7 +88,8 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send self.path = path sourceKind = .existingPath sourceLocation = normalizedURL.path(percentEncoded: false) - self.checkoutMode = checkoutMode ?? ProjectWorkspaceRepositorySourceKind.existingPath.defaultCheckoutMode + self.checkoutMode = + checkoutMode ?? ProjectWorkspaceRepositorySourceKind.existingPath.defaultCheckoutMode self.branchName = branchName self.baseRef = baseRef self.baseRefOptions = Self.normalizedBaseRefOptions(baseRefOptions) @@ -128,8 +133,7 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send else { return nil } - let localName = selectedRef.split(separator: "/").dropFirst().joined(separator: "/") - guard !localName.isEmpty, + guard let localName = Self.localBranchName(forRemoteRef: selectedRef), baseRefOptions.contains(where: { $0.kind == .local && $0.ref == localName }) else { return nil @@ -137,6 +141,19 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send return localName } + /// Converts refs loaded as `/` into the local branch name Git + /// would create or reset for a tracking checkout. Branch names can contain + /// slashes, so only the first path component is the remote name. + nonisolated static func localBranchName(forRemoteRef ref: String) -> String? { + let parts = ref.trimmingCharacters(in: .whitespacesAndNewlines).split( + separator: "/", maxSplits: 1) + guard parts.count == 2 else { + return nil + } + let branchName = String(parts[1]) + return branchName.isEmpty ? nil : branchName + } + nonisolated static func baseRefOptions( automaticBaseRef: String?, options: [GitBranchRefOption] @@ -151,7 +168,9 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send return normalizedBaseRefOptions(values) } - nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [GitBranchRefOption]) -> String? { + nonisolated static func preferredBaseRef(automaticBaseRef: String?, options: [GitBranchRefOption]) + -> String? + { let refs = Set(options.map(\.ref)) if let trimmed = automaticBaseRef?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty, @@ -163,7 +182,9 @@ nonisolated struct ProjectWorkspaceCreationRepository: Equatable, Hashable, Send ?? options.first?.ref } - nonisolated static func normalizedBaseRefOptions(_ values: [GitBranchRefOption]) -> [GitBranchRefOption] { + nonisolated static func normalizedBaseRefOptions(_ values: [GitBranchRefOption]) + -> [GitBranchRefOption] + { var seen = Set() var result: [GitBranchRefOption] = [] for value in values { @@ -274,7 +295,9 @@ nonisolated enum ProjectWorkspaceCreationError: LocalizedError, Equatable, Senda } } -nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable, Sendable, Identifiable { +nonisolated struct ProjectWorkspaceRepositoryEntry: Codable, Equatable, Hashable, Sendable, + Identifiable +{ var id: String var name: String var role: String? @@ -347,8 +370,10 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { nonisolated static let metadataDirectoryName = ".prowl" nonisolated static let metadataFileName = "workspace.json" + nonisolated static let currentSchemaVersion = "prowl.workspace.v1" nonisolated private static let log = SupaLogger("workspace") + var schemaVersion: String var id: String var title: String var description: String @@ -358,6 +383,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { var updatedAt: Date? enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" case id case title case description @@ -368,6 +394,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } init( + schemaVersion: String = currentSchemaVersion, id: String = "", title: String = "", description: String = "", @@ -376,6 +403,7 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { createdAt: Date? = nil, updatedAt: Date? = nil ) { + self.schemaVersion = schemaVersion self.id = id self.title = title self.description = description @@ -387,11 +415,15 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = + try container.decodeIfPresent(String.self, forKey: .schemaVersion) + ?? Self.currentSchemaVersion id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" taskLinks = try container.decodeIfPresent([String].self, forKey: .taskLinks) ?? [] - repositories = try container.decodeIfPresent([RepositoryEntry].self, forKey: .repositories) ?? [] + repositories = + try container.decodeIfPresent([RepositoryEntry].self, forKey: .repositories) ?? [] createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) } @@ -423,13 +455,18 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { workspace.id = normalizedRoot } if workspace.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - workspace.title = rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent + workspace.title = + rootURL.lastPathComponent.isEmpty ? normalizedRoot : rootURL.lastPathComponent } return workspace.normalized(relativeTo: rootURL) } func normalized(relativeTo rootURL: URL) -> ProjectWorkspace { var copy = self + copy.schemaVersion = copy.schemaVersion.trimmingCharacters(in: .whitespacesAndNewlines) + if copy.schemaVersion.isEmpty { + copy.schemaVersion = Self.currentSchemaVersion + } let normalizedRoot = rootURL.standardizedFileURL.path(percentEncoded: false) if copy.id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { copy.id = normalizedRoot @@ -451,11 +488,14 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } if entry.name.isEmpty { let resolvedURL = entry.resolvedURL(relativeTo: rootURL) - entry.name = resolvedURL.lastPathComponent.isEmpty ? entry.id : resolvedURL.lastPathComponent + entry.name = + resolvedURL.lastPathComponent.isEmpty ? entry.id : resolvedURL.lastPathComponent } entry.role = entry.role?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - entry.sourceLocation = entry.sourceLocation?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - entry.branchName = entry.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + entry.sourceLocation = + entry.sourceLocation?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + entry.branchName = + entry.branchName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty entry.baseRef = entry.baseRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty return entry } @@ -492,7 +532,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { ledger.createdRoot = true } - let metadataDirectoryURL = rootURL.appending(path: metadataDirectoryName, directoryHint: .isDirectory) + let metadataDirectoryURL = rootURL.appending( + path: metadataDirectoryName, directoryHint: .isDirectory) let metadataPath = metadataDirectoryURL.path(percentEncoded: false) let metadataURL = metadataURL(for: rootURL) if fileManager.fileExists(atPath: metadataURL.path(percentEncoded: false)) { @@ -570,7 +611,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { : []) for url in removableURLs { let path = url.path(percentEncoded: false) - guard fileManager.fileExists(atPath: path) || (try? url.checkResourceIsReachable()) == true else { + guard fileManager.fileExists(atPath: path) || (try? url.checkResourceIsReachable()) == true + else { continue } do { @@ -609,7 +651,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } let isSymbolicLink = (try? entryURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true - guard !isSymbolicLink, entry.sourceKind != .remote, let sourceLocation = entry.sourceLocation else { + guard !isSymbolicLink, entry.sourceKind != .remote, let sourceLocation = entry.sourceLocation + else { continue } do { @@ -661,13 +704,15 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { guard !sourceLocation.isEmpty else { throw ProjectWorkspaceCreationError.missingRepositorySource(name) } - let checkout = try validatedCheckout(repository.checkout, sourceKind: repository.sourceKind, name: name) + let checkout = try validatedCheckout( + repository.checkout, sourceKind: repository.sourceKind, name: name) let workspacePath = uniqueRepositoryPath( for: repository, displayName: name, occupiedNames: &ledger.occupiedNames ) - let destinationURL = workspaceRootURL.appending(path: workspacePath, directoryHint: .isDirectory) + let destinationURL = workspaceRootURL.appending( + path: workspacePath, directoryHint: .isDirectory) let destinationPath = normalizedPath(destinationURL, resolvingSymlinks: false) guard !fileManager.fileExists(atPath: destinationPath) else { throw ProjectWorkspaceCreationError.linkAlreadyExists(destinationPath) @@ -676,7 +721,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { let normalizedSourceLocation: String switch repository.sourceKind { case .existingPath, .localRepository: - let sourcePath = try localRepositoryPath(sourceLocation: sourceLocation, fileManager: fileManager) + let sourcePath = try localRepositoryPath( + sourceLocation: sourceLocation, fileManager: fileManager) switch checkout { case .link: let sourceURL = URL(fileURLWithPath: sourcePath, isDirectory: true).standardizedFileURL @@ -707,7 +753,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { normalizedSourceLocation = sourceLocation case .bareRepository: - let sourcePath = try localRepositoryPath(sourceLocation: sourceLocation, fileManager: fileManager) + let sourcePath = try localRepositoryPath( + sourceLocation: sourceLocation, fileManager: fileManager) try await gitRunner.run( worktreeAddCommand(checkout, sourcePath: sourcePath, destinationPath: destinationPath) ) @@ -758,7 +805,9 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } return .link case .createBranch(let branchName, let baseRef): - guard let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { + guard + let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + else { throw ProjectWorkspaceCreationError.missingBranchName(name) } return .createBranch( @@ -771,7 +820,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } return .useExistingRef(trimmedRef) case .trackRemoteRef(let remoteRef, let branchName): - guard let trimmedRemoteRef = remoteRef.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + guard + let trimmedRemoteRef = remoteRef.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, let trimmedBranch = branchName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { throw ProjectWorkspaceCreationError.missingExistingRef(name) @@ -829,7 +879,10 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { return ProjectWorkspaceGitCommand(arguments: arguments, currentDirectoryURL: nil) case .useExistingRef(let ref): return ProjectWorkspaceGitCommand( - arguments: ["-C", destinationPath, "checkout", "--end-of-options", remoteCloneExistingCheckoutRef(ref)], + arguments: [ + "-C", destinationPath, "checkout", "--end-of-options", + remoteCloneExistingCheckoutRef(ref), + ], currentDirectoryURL: nil ) case .trackRemoteRef(_, let branchName): @@ -859,7 +912,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { sourceLocation: String, fileManager: FileManager ) throws -> String { - let repositoryPath = normalizedPath(URL(fileURLWithPath: sourceLocation), resolvingSymlinks: true) + let repositoryPath = normalizedPath( + URL(fileURLWithPath: sourceLocation), resolvingSymlinks: true) var repositoryIsDirectory = ObjCBool(false) guard fileManager.fileExists(atPath: repositoryPath, isDirectory: &repositoryIsDirectory), repositoryIsDirectory.boolValue @@ -933,7 +987,8 @@ nonisolated struct ProjectWorkspace: Codable, Equatable, Hashable, Sendable { } private static func normalizedPath(_ url: URL, resolvingSymlinks: Bool) -> String { - var path = PathPolicy.normalizeURL(url, resolvingSymlinks: resolvingSymlinks).path(percentEncoded: false) + var path = PathPolicy.normalizeURL(url, resolvingSymlinks: resolvingSymlinks).path( + percentEncoded: false) while path.count > 1, path.hasSuffix("/") { path.removeLast() } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 4ba089f4..d119f623 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -10,7 +10,8 @@ extension RepositoriesFeature { action: Action ) -> Effect { switch action { - case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, .repositoryManagement, + case .worktreeCreation, .worktreeLifecycle, .worktreeOrdering, .githubIntegration, + .repositoryManagement, .workspaceCreation: return .none @@ -346,6 +347,7 @@ extension RepositoriesFeature { state.isShelfActive = false recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .archivedWorktrees + state.selectedWorkspaceChildID = nil state.sidebarSelectedWorktreeIDs = [] return .send(.delegate(.selectedWorktreeChanged(nil))) @@ -356,6 +358,7 @@ extension RepositoriesFeature { state.preCanvasTerminalTargetID = canvasSeedWorktree?.id state.isShelfActive = false state.selection = .canvas + state.selectedWorkspaceChildID = nil state.sidebarSelectedWorktreeIDs = [] // Canvas only renders cards for worktrees that already have a live // terminal surface. Normal/Shelf get the previously-focused worktree @@ -525,7 +528,9 @@ extension RepositoriesFeature { case .setSidebarSelectedWorktreeIDs(let worktreeIDs): let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) - if let selectedWorktreeID = state.selectedWorktreeID, validWorktreeIDs.contains(selectedWorktreeID) { + if let selectedWorktreeID = state.selectedWorktreeID, + validWorktreeIDs.contains(selectedWorktreeID) + { nextWorktreeIDs.insert(selectedWorktreeID) } state.sidebarSelectedWorktreeIDs = nextWorktreeIDs @@ -539,6 +544,7 @@ extension RepositoriesFeature { guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) state.selection = .repository(repositoryID) + state.selectedWorkspaceChildID = nil state.sidebarSelectedWorktreeIDs = [] if state.repositories[id: repositoryID]?.kind == .plain { // Plain folder selection opens the folder as a Shelf book. @@ -546,6 +552,37 @@ extension RepositoriesFeature { } return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) + case .openWorkspaceChild(let childID): + guard let child = state.allResolvedWorkspaceChildren().first(where: { $0.id == childID }), + let workspaceRepository = state.repositories[id: child.workspaceID] + else { + return .none + } + recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: nil, state: &state) + state.selection = .repository(child.workspaceID) + state.selectedWorkspaceChildID = child.id + state.sidebarSelectedWorktreeIDs = [] + state.openedWorktreeIDs.insert(child.workspaceID) + state.pendingTerminalFocusWorktreeIDs.insert(child.workspaceID) + let workspaceWorktree = Worktree( + id: workspaceRepository.id, + name: workspaceRepository.name, + detail: workspaceRepository.rootURL.path(percentEncoded: false), + workingDirectory: workspaceRepository.rootURL, + repositoryRootURL: workspaceRepository.rootURL + ) + return .merge( + .send(.delegate(.selectedWorktreeChanged(workspaceWorktree))), + .run { _ in + await terminalClient.send( + .focusOrCreateTabInDirectory( + workspaceWorktree, + directory: child.workingDirectory, + title: child.repositoryName + )) + } + ) + case .selectWorktree(let worktreeID, let focusTerminal, let recordHistory): let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") defer { repositoriesLogger.endInterval(selectWtToken) } @@ -567,7 +604,8 @@ extension RepositoriesFeature { } requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + await terminalClient.send( + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) } case .focusCanvasWorktree(let worktreeID): @@ -578,7 +616,8 @@ extension RepositoriesFeature { } requestCanvasFocus(.worktree(worktree.id), openedWorktreeID: worktree.id, state: &state) return .run { _ in - await terminalClient.send(.ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) + await terminalClient.send( + .ensureInitialTab(worktree, runSetupScriptIfNew: false, focusing: false)) } case .selectNextWorktree: @@ -740,7 +779,8 @@ extension RepositoriesFeature { case .alert(.presented(.confirmArchiveWorktrees(let targets))): return .merge( targets.map { target in - .send(.worktreeLifecycle(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID))) + .send( + .worktreeLifecycle(.archiveWorktreeConfirmed(target.worktreeID, target.repositoryID))) } ) @@ -761,21 +801,28 @@ extension RepositoriesFeature { repository.worktrees.contains(where: { $0.id == id }) } ?? false return .send( - .repositoryManagement(.repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) + .repositoryManagement( + .repositoryRemoved(repository.id, selectionWasRemoved: selectionWasRemoved))) - case .alert(.presented(.confirmWorkspaceRootDeletion(let repositoryID, let rootPath, let selectionWasRemoved))): + case .alert( + .presented( + .confirmWorkspaceRootDeletion(let repositoryID, let rootPath, let selectionWasRemoved))): state.alert = nil let rootURL = URL(fileURLWithPath: rootPath) return .run { send in ProjectWorkspace.removeWorkspaceFolder(at: rootURL) await send( - .repositoryManagement(.repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) + .repositoryManagement( + .repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) } - case .alert(.presented(.keepWorkspaceFolderAfterCleanupFailure(let repositoryID, let selectionWasRemoved))): + case .alert( + .presented(.keepWorkspaceFolderAfterCleanupFailure(let repositoryID, let selectionWasRemoved)) + ): state.alert = nil return .send( - .repositoryManagement(.repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) + .repositoryManagement( + .repositoryRemoved(repositoryID, selectionWasRemoved: selectionWasRemoved))) case .presentAlert(let title, let message): state.alert = messageAlert(title: title, message: message) @@ -824,7 +871,8 @@ extension RepositoriesFeature { let previousLineChanges = normalizedLineChanges(state.worktreeInfoByID[worktreeID]) return .run { send in if let changes = await gitClient.lineChanges(worktreeURL) { - let nextLineChanges = normalizedLineChanges(added: changes.added, removed: changes.removed) + let nextLineChanges = normalizedLineChanges( + added: changes.added, removed: changes.removed) guard !lineChangesEqual(nextLineChanges, previousLineChanges) else { return } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index 42018c1a..26c78f03 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -310,6 +310,7 @@ extension RepositoriesFeature { !isSidebarSelectionValid(state.selection, state: state) { state.selection = nil + state.selectedWorkspaceChildID = nil } if state.shouldRestoreLastFocusedWorktree { state.shouldRestoreLastFocusedWorktree = false @@ -317,11 +318,13 @@ extension RepositoriesFeature { isSelectionValid(state.lastFocusedWorktreeID, state: state) { state.selection = state.lastFocusedWorktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil } } if state.selection == nil, state.shouldSelectFirstAfterReload { state.selection = firstAvailableWorktreeID(from: repositories, state: state) .map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil state.shouldSelectFirstAfterReload = false } pruneWorkspaceChildInfo(state: &state) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift index c128b742..14c6ae34 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryManagement.swift @@ -160,6 +160,7 @@ extension RepositoriesFeature { if let effect = detectCodeHostsEffect(for: state.repositories) { allEffects.append(effect) } + allEffects.append(refreshWorkspaceChildrenEffect(state: state)) return .merge(allEffects) case .requestRemoveRepository(let repositoryID): @@ -333,6 +334,7 @@ extension RepositoriesFeature { state.removingRepositoryIDs.remove(repositoryID) if selectionWasRemoved { state.selection = nil + state.selectedWorkspaceChildID = nil state.shouldSelectFirstAfterReload = true } let selectedWorktree = state.worktree(for: state.selectedWorktreeID) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift index 47bf243e..bc6387b9 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Selection.swift @@ -141,6 +141,7 @@ func setSingleWorktreeSelection( recordWorktreeHistoryTransition(from: state.selectedWorktreeID, to: worktreeID, state: &state) } state.selection = worktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil if let worktreeID { state.sidebarSelectedWorktreeIDs = [worktreeID] } else { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift index abda4dfb..c9202e2b 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -128,4 +128,9 @@ func pruneWorkspaceChildInfo(state: inout RepositoriesFeature.State) { let validIDs = Set(state.allResolvedWorkspaceChildren().map(\.id)) state.workspaceChildInfoByID = state.workspaceChildInfoByID.filter { validIDs.contains($0.key) } state.workspaceChildBranchByID = state.workspaceChildBranchByID.filter { validIDs.contains($0.key) } + if let selectedWorkspaceChildID = state.selectedWorkspaceChildID, + !validIDs.contains(selectedWorkspaceChildID) + { + state.selectedWorkspaceChildID = nil + } } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift index f12fc7b2..5ad3d2a8 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeLifecycle.swift @@ -219,6 +219,7 @@ extension RepositoriesFeature { if selectionWasRemoved { let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) state.selection = nextWorktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil } } let archivedWorktrees = state.archivedWorktrees @@ -457,6 +458,7 @@ extension RepositoriesFeature { if selectionNeedsUpdate { let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) state.selection = nextWorktreeID.map(SidebarSelection.worktree) + state.selectedWorkspaceChildID = nil } } let roots = state.repositories.map(\.rootURL) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 6e2f4de4..e7893826 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -292,6 +292,7 @@ struct RepositoriesFeature { // folder. Refreshed by `refreshWorkspaceChildrenEffect` on each repo reload. var workspaceChildInfoByID: [String: WorktreeInfoEntry] = [:] var workspaceChildBranchByID: [String: String] = [:] + var selectedWorkspaceChildID: String? var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] var isOpenPanelPresented = false var isInitialLoadComplete = false @@ -419,6 +420,7 @@ struct RepositoriesFeature { case markWorktreeClosed(Worktree.ID) case setSidebarSelectedWorktreeIDs(Set) case selectRepository(Repository.ID?) + case openWorkspaceChild(String) case selectWorktree(Worktree.ID?, focusTerminal: Bool = false, recordHistory: Bool = true) case focusCanvasRepository(Repository.ID) case focusCanvasWorktree(Worktree.ID) @@ -486,8 +488,10 @@ struct RepositoriesFeature { case confirmArchiveWorktrees([ArchiveWorktreeTarget]) case confirmForceDeleteBranch(ForceDeleteBranchRequest) case confirmRemoveRepository(Repository.ID) - case confirmWorkspaceRootDeletion(repositoryID: Repository.ID, rootPath: String, selectionWasRemoved: Bool) - case keepWorkspaceFolderAfterCleanupFailure(repositoryID: Repository.ID, selectionWasRemoved: Bool) + case confirmWorkspaceRootDeletion( + repositoryID: Repository.ID, rootPath: String, selectionWasRemoved: Bool) + case keepWorkspaceFolderAfterCleanupFailure( + repositoryID: Repository.ID, selectionWasRemoved: Bool) } enum PullRequestAction: Equatable { diff --git a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift index 888f378d..782d31d2 100644 --- a/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift +++ b/supacode/Features/Repositories/Reducer/WorkspaceCreationPromptFeature.swift @@ -4,13 +4,20 @@ import IdentifiedCollections @Reducer struct WorkspaceCreationPromptFeature { + private enum CancelID { + static let remoteRepositoryPromptLoad = "workspaceCreationPrompt.remoteRepositoryPromptLoad" + } + @ObservableState struct State: Equatable { var repositories: IdentifiedArrayOf var openedRepositoryCandidates: IdentifiedArrayOf var title: String var rootPath: String + var isRootPathDirty = false var validationMessage: String? + var validationTarget: ValidationTarget? + var validationRequestID = 0 var isCreating = false var remoteRepositoryPrompt: RemoteRepositoryPromptState? @@ -36,6 +43,30 @@ struct WorkspaceCreationPromptFeature { self.title = title self.rootPath = rootPath } + + mutating func clearValidation() { + validationMessage = nil + validationTarget = nil + } + + mutating func setValidation(_ message: String, target: ValidationTarget?) { + validationMessage = message + validationTarget = target + validationRequestID += 1 + } + } + + enum ValidationTarget: Equatable, Sendable { + case title + case rootPath + case repository(Repository.ID, RepositoryField) + } + + enum RepositoryField: Equatable, Sendable { + case name + case source + case branchName + case baseRef } @ObservableState @@ -55,6 +86,9 @@ struct WorkspaceCreationPromptFeature { enum Action: BindableAction, Equatable { case binding(BindingAction) + case titleChanged(String) + case rootPathChanged(String) + case automaticRootPathResolved(path: String, requestedRootPath: String) case addOpenedRepository(Repository.ID) case addRepositoryFromURL(ProjectWorkspaceRepositorySourceKind, String) case addRemoteButtonTapped @@ -96,7 +130,35 @@ struct WorkspaceCreationPromptFeature { Reduce { state, action in switch action { case .binding: - state.validationMessage = nil + state.clearValidation() + return .none + + case .titleChanged(let title): + state.title = title + state.clearValidation() + guard !state.isRootPathDirty else { + return .none + } + let folderName = ProjectWorkspace.defaultWorkspaceFolderName(for: title) + let requestedRootPath = Self.workspaceRootPath(folderName: folderName, suffix: nil) + state.rootPath = requestedRootPath + return .run { send in + let resolved = Self.uniqueWorkspaceRootPath(folderName: folderName) + await send( + .automaticRootPathResolved(path: resolved, requestedRootPath: requestedRootPath)) + } + + case .rootPathChanged(let rootPath): + state.rootPath = rootPath + state.isRootPathDirty = true + state.clearValidation() + return .none + + case .automaticRootPathResolved(let path, let requestedRootPath): + guard !state.isRootPathDirty, state.rootPath == requestedRootPath else { + return .none + } + state.rootPath = path return .none case .addOpenedRepository(let repositoryID): @@ -106,12 +168,12 @@ struct WorkspaceCreationPromptFeature { return .none } state.repositories.append(repository) - state.validationMessage = nil + state.clearValidation() return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .addRemoteButtonTapped: state.remoteRepositoryPrompt = RemoteRepositoryPromptState() - state.validationMessage = nil + state.clearValidation() return .none case .remoteRepositoryPromptURLChanged(let url): @@ -119,7 +181,9 @@ struct WorkspaceCreationPromptFeature { state.remoteRepositoryPrompt?.validationMessage = nil state.remoteRepositoryPrompt?.branchOptions = [] state.remoteRepositoryPrompt?.defaultBaseRef = nil - if state.remoteRepositoryPrompt?.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true { + if state.remoteRepositoryPrompt?.name.trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty == true + { state.remoteRepositoryPrompt?.name = GitRemoteNaming.repositoryName(fromRemoteURL: url) } return .none @@ -149,12 +213,13 @@ struct WorkspaceCreationPromptFeature { let gitClient = gitClient return .run { send in do { - let refs = try await gitClient.remoteBranchRefs(url) + let refs = try await Self.loadRemoteBranchRefs(url, gitClient: gitClient) await send(.remoteRepositoryPromptLoaded(url, refs)) } catch { await send(.remoteRepositoryPromptFailed(error.localizedDescription)) } } + .cancellable(id: CancelID.remoteRepositoryPromptLoad, cancelInFlight: true) case .remoteRepositoryPromptLoaded(let url, let refs): guard var prompt = state.remoteRepositoryPrompt, @@ -204,17 +269,19 @@ struct WorkspaceCreationPromptFeature { ) ) state.remoteRepositoryPrompt = nil - state.validationMessage = nil + state.clearValidation() return .none case .remoteRepositoryPromptDismissed: state.remoteRepositoryPrompt = nil - return .none + return .cancel(id: CancelID.remoteRepositoryPromptLoad) case .addRepositoryFromURL(let sourceKind, let path): guard let rootPath = PathPolicy.normalizePath(path) else { - state.validationMessage = - ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription, + target: nil + ) return .none } let url = URL(fileURLWithPath: rootPath).standardizedFileURL @@ -227,12 +294,12 @@ struct WorkspaceCreationPromptFeature { sourceLocation: rootPath ) ) - state.validationMessage = nil + state.clearValidation() return .send(.delegate(.baseRefSourceChanged(id))) case .removeRepository(let repositoryID): state.repositories.remove(id: repositoryID) - state.validationMessage = nil + state.clearValidation() return .none case .repositoryCheckoutModeChanged(let repositoryID, let checkoutMode): @@ -244,7 +311,7 @@ struct WorkspaceCreationPromptFeature { } repository.checkoutMode = checkoutMode state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() return .none case .repositorySourceKindChanged(let repositoryID, let sourceKind): @@ -261,7 +328,7 @@ struct WorkspaceCreationPromptFeature { repository.sourceLocation = "" } state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() guard sourceKind != .remote, repository.localSourceURL != nil else { return .none } @@ -269,18 +336,20 @@ struct WorkspaceCreationPromptFeature { case .repositoryNameChanged(let repositoryID, let name): state.repositories[id: repositoryID]?.name = name - state.validationMessage = nil + state.clearValidation() return .none case .repositoryPathChanged(let repositoryID, let path): state.repositories[id: repositoryID]?.path = path - state.validationMessage = nil + state.clearValidation() return .none case .repositorySourceChosen(let repositoryID, let sourceLocation): guard let rootPath = PathPolicy.normalizePath(sourceLocation) else { - state.validationMessage = - ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.missingRepositorySource("repository").localizedDescription, + target: .repository(repositoryID, .source) + ) return .none } guard var repository = state.repositories[id: repositoryID] else { @@ -293,7 +362,7 @@ struct WorkspaceCreationPromptFeature { repository.baseRef = nil repository.baseRefOptions = [] state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .repositorySourceLocationChanged(let repositoryID, let sourceLocation): @@ -307,15 +376,17 @@ struct WorkspaceCreationPromptFeature { repository.baseRefOptions = [] } state.repositories[id: repositoryID] = repository - state.validationMessage = nil - guard sourceLocationChanged, repository.sourceKind != .remote, repository.localSourceURL != nil else { + state.clearValidation() + guard sourceLocationChanged, repository.sourceKind != .remote, + repository.localSourceURL != nil + else { return .none } return .send(.delegate(.baseRefSourceChanged(repositoryID))) case .repositoryBranchNameChanged(let repositoryID, let branchName): state.repositories[id: repositoryID]?.branchName = branchName - state.validationMessage = nil + state.clearValidation() return .none case .repositoryBaseRefChanged(let repositoryID, let baseRef): @@ -323,24 +394,26 @@ struct WorkspaceCreationPromptFeature { return .none } let trimmed = baseRef.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.isEmpty || repository.baseRefOptions.contains(where: { $0.ref == trimmed }) else { + guard trimmed.isEmpty || repository.baseRefOptions.contains(where: { $0.ref == trimmed }) + else { return .none } repository.baseRef = trimmed.isEmpty ? nil : trimmed // A new ref selection invalidates any previous keep/reset choice. repository.resetLocalBranchToRemote = false state.repositories[id: repositoryID] = repository - state.validationMessage = nil + state.clearValidation() return .none case .repositoryResetLocalBranchChanged(let repositoryID, let resetToRemote): state.repositories[id: repositoryID]?.resetLocalBranchToRemote = resetToRemote - state.validationMessage = nil + state.clearValidation() return .none case .rootPathChosen(let path): state.rootPath = path - state.validationMessage = nil + state.isRootPathDirty = true + state.clearValidation() return .none case .cancelButtonTapped: @@ -349,15 +422,25 @@ struct WorkspaceCreationPromptFeature { case .createButtonTapped: let title = state.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { - state.validationMessage = ProjectWorkspaceCreationError.missingTitle.localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.missingTitle.localizedDescription, + target: .title + ) return .none } - guard let rootPath = PathPolicy.normalizePath(state.rootPath, resolvingSymlinks: false) else { - state.validationMessage = ProjectWorkspaceCreationError.missingPath.localizedDescription + guard let rootPath = PathPolicy.normalizePath(state.rootPath, resolvingSymlinks: false) + else { + state.setValidation( + ProjectWorkspaceCreationError.missingPath.localizedDescription, + target: .rootPath + ) return .none } guard state.repositories.count >= 2 else { - state.validationMessage = ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription + state.setValidation( + ProjectWorkspaceCreationError.notEnoughRepositories.localizedDescription, + target: nil + ) return .none } var plans: [ProjectWorkspaceRepositoryPlan] = [] @@ -366,11 +449,14 @@ struct WorkspaceCreationPromptFeature { case .success(let plan): plans.append(plan) case .failure(let error): - state.validationMessage = error.localizedDescription + state.setValidation( + error.localizedDescription, + target: Self.validationTarget(for: error, repositoryID: repository.id) + ) return .none } } - state.validationMessage = nil + state.clearValidation() return .send( .delegate( .submit( @@ -397,6 +483,68 @@ struct WorkspaceCreationPromptFeature { return String(name.dropLast(4)) } + nonisolated private static func workspaceRootPath(folderName: String, suffix: Int?) -> String { + let component = suffix.map { "\(folderName)-\($0)" } ?? folderName + return SupacodePaths.workspacesDirectory + .appending(path: component, directoryHint: .isDirectory) + .standardizedFileURL + .path(percentEncoded: false) + } + + nonisolated static func uniqueWorkspaceRootPath(folderName: String) -> String { + var suffix: Int? + while true { + let candidate = workspaceRootPath(folderName: folderName, suffix: suffix) + if !FileManager.default.fileExists(atPath: candidate) { + return candidate + } + suffix = (suffix ?? 1) + 1 + } + } + + nonisolated private static func loadRemoteBranchRefs( + _ url: String, + gitClient: GitClientDependency + ) async throws -> GitRemoteBranchRefs { + try await withThrowingTaskGroup(of: GitRemoteBranchRefs.self) { group in + group.addTask { + try await gitClient.remoteBranchRefs(url) + } + group.addTask { + try await Task.sleep(for: .seconds(30)) + throw RemoteBranchLoadTimeoutError() + } + guard let refs = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + return refs + } + } + + nonisolated static func validationTarget( + for error: ProjectWorkspaceCreationError, + repositoryID: Repository.ID + ) -> ValidationTarget? { + switch error { + case .missingRepositoryName: + return .repository(repositoryID, .name) + case .missingRepositorySource: + return .repository(repositoryID, .source) + case .missingBranchName: + return .repository(repositoryID, .branchName) + case .missingExistingRef: + return .repository(repositoryID, .baseRef) + case .missingTitle: + return .title + case .missingPath: + return .rootPath + case .notEnoughRepositories, .linkCheckoutUnsupported, .destinationIsFile, + .workspaceAlreadyExists, .repositoryDoesNotExist, .linkAlreadyExists, .gitCommandFailed: + return nil + } + } + static func plan( for repository: ProjectWorkspaceCreationRepository ) -> Result { @@ -434,13 +582,14 @@ struct WorkspaceCreationPromptFeature { if kind == .local { checkout = .useExistingRef(baseRef) } else { - // Remote refs are named /; materialize them as a local - // tracking branch instead of a detached worktree. - let branchName = baseRef.split(separator: "/").dropFirst().joined(separator: "/") - guard !branchName.isEmpty else { + guard + let branchName = ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: baseRef) + else { return .failure(.missingExistingRef(displayName)) } - if let localBranchName = repository.resettableLocalBranchName, !repository.resetLocalBranchToRemote { + if let localBranchName = repository.resettableLocalBranchName, + !repository.resetLocalBranchToRemote + { // A same-named local branch already exists and the user chose to keep // it: check out the local branch directly rather than resetting it to // the remote ref with `-B`, which would discard local-only commits. @@ -462,3 +611,9 @@ struct WorkspaceCreationPromptFeature { ) } } + +private struct RemoteBranchLoadTimeoutError: LocalizedError { + var errorDescription: String? { + "Remote branch loading timed out." + } +} diff --git a/supacode/Features/Repositories/Views/EmptyStateView.swift b/supacode/Features/Repositories/Views/EmptyStateView.swift index 7569678b..1208e8cd 100644 --- a/supacode/Features/Repositories/Views/EmptyStateView.swift +++ b/supacode/Features/Repositories/Views/EmptyStateView.swift @@ -4,16 +4,18 @@ import SwiftUI struct EmptyStateView: View { let store: StoreOf @Environment(\.resolvedKeybindings) private var resolvedKeybindings + @State private var isAddChoicePresented = false var body: some View { - let shortcutDisplay = AppShortcuts.display(for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) + let shortcutDisplay = AppShortcuts.display( + for: AppShortcuts.CommandID.openRepository, in: resolvedKeybindings) ContentUnavailableView { Label("Open a repository or folder", systemImage: "folder.badge.plus") } description: { Text(promptText(shortcutDisplay: shortcutDisplay)) } actions: { - Button("Add Repository...") { - store.send(.setOpenPanelPresented(true)) + Button("Add...") { + isAddChoicePresented = true } .modifier( KeyboardShortcutModifier( @@ -27,14 +29,33 @@ struct EmptyStateView: View { in: resolvedKeybindings )) } + .confirmationDialog( + "Add to Prowl", + isPresented: $isAddChoicePresented, + titleVisibility: .visible + ) { + Button("Add Local Repository/Folder") { + store.send(.setOpenPanelPresented(true)) + } + Button("Add Workspace") { + store.send(.workspaceCreation(.promptRequested)) + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "A local repository or folder opens one project root. " + + "A workspace creates one shared task folder " + + "containing multiple repositories for one agent to work across." + ) + } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor)) } private func promptText(shortcutDisplay: String?) -> String { if let shortcutDisplay { - return "Press \(shortcutDisplay) or click Add Repository to add one." + return "Press \(shortcutDisplay) or click Add to add one." } - return "Click Add Repository to add one." + return "Click Add to add one." } } diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 935b36b9..ab806783 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -28,7 +28,7 @@ struct RepositorySectionView: View { // `supportsWorktrees` so a workspace never offers "New Worktree". let isExpandable = repository.capabilities.supportsWorktrees || repository.isWorkspace let isRemovingRepository = state.isRemovingRepository(repository) - let isSelected = state.selection == .repository(repository.id) + let isSelected = state.selection == .repository(repository.id) && state.selectedWorkspaceChildID == nil let openRepoSettings = { _ = store.send(.repositoryManagement(.openRepositorySettings(repository.id))) } @@ -188,7 +188,9 @@ struct RepositorySectionView: View { .accessibilityLabel(Text("Repo color: \(color.displayName)")) } } - .frame(maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center) + .frame( + maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center + ) .padding(.horizontal, 12) .padding(.top, hasTopSpacing ? 4 : 0) .padding(.bottom, hasTopSpacing && !isExpandable ? 4 : 0) @@ -242,7 +244,14 @@ struct RepositorySectionView: View { .tag(SidebarSelection.repository(repository.id)) if isExpanded { if repository.isWorkspace { - WorkspaceChildRowsView(rows: state.workspaceChildRows(in: repository)) + WorkspaceChildRowsView( + rows: state.workspaceChildRows(in: repository), + selectedID: state.selection == .repository(repository.id) + ? state.selectedWorkspaceChildID : nil, + onSelect: { childID in + store.send(.openWorkspaceChild(childID)) + } + ) } else { WorktreeRowsView( repository: repository, diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 3980d7ca..dd9f05c2 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -42,6 +42,7 @@ struct SidebarListView: View { @State private var sidebarHeight = 0.0 @State private var sidebarFooterHeight = 0.0 @State private var resizingPanelHeight: Double? + @State private var isAddChoicePresented = false @Namespace private var topSegmentNamespace @Environment(\.resolvedKeybindings) private var resolvedKeybindings @Environment(CommandKeyObserver.self) private var commandKeyObserver @@ -86,7 +87,8 @@ struct SidebarListView: View { let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 let activeAgentsPanelTopGap = 4.0 - let listBottomPadding = state.activeAgents.isPanelHidden ? 0 : panelHeight + activeAgentsPanelTopGap + let listBottomPadding = + state.activeAgents.isPanelHidden ? 0 : panelHeight + activeAgentsPanelTopGap ScrollViewReader { scrollProxy in ScrollView { @@ -95,7 +97,7 @@ struct SidebarListView: View { VStack(spacing: 0) { // When there are no repositories the sidebar stays empty — the // detail pane's `EmptyStateView` ("Open a repository or folder") - // carries the prompt and the Add Repository button instead. + // carries the prompt and the Add button instead. if !repositoryItems.isEmpty { repositoryListHeader( action: repositoryListHeaderAction, @@ -194,21 +196,34 @@ struct SidebarListView: View { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } .toolbar { - ToolbarItemGroup(placement: .automatic) { + ToolbarItem(placement: .automatic) { Button { - store.send(.workspaceCreation(.promptRequested)) + isAddChoicePresented = true } label: { - Label("New Workspace", systemImage: "folder.badge.person.crop") + Label("Add...", systemImage: "folder.badge.plus") } - .help("New Workspace") - Button { - store.send(.setOpenPanelPresented(true)) - } label: { - Label("Add Repository", systemImage: "folder.badge.plus") - } - .help("Add Repository") + .help("Add Repository or Workspace") } } + .confirmationDialog( + "Add to Prowl", + isPresented: $isAddChoicePresented, + titleVisibility: .visible + ) { + Button("Add Local Repository/Folder") { + store.send(.setOpenPanelPresented(true)) + } + Button("Add Workspace") { + store.send(.workspaceCreation(.promptRequested)) + } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "A local repository or folder opens one project root. " + + "A workspace creates one shared task folder " + + "containing multiple repositories for one agent to work across." + ) + } } // ScrollViewReader } @@ -477,7 +492,8 @@ struct SidebarListView: View { await Task.yield() await Task.yield() withAnimation(.easeOut(duration: 0.2)) { - scrollProxy.scrollTo(SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) + scrollProxy.scrollTo( + SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) } store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) } @@ -510,7 +526,8 @@ struct SidebarListView: View { } static func repositoryHeaderOpensCanvasTarget(_ repository: Repository) -> Bool { - repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees + repository.capabilities.supportsRunnableFolderActions + && !repository.capabilities.supportsWorktrees } static func activeAgentWorktreeMetadata( @@ -525,7 +542,9 @@ struct SidebarListView: View { for repository in repositories { let repositoryName = customTitles[repository.id] ?? repository.name let repositoryColor = repositoryAppearances[repository.id]?.color - if repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees { + if repository.capabilities.supportsRunnableFolderActions + && !repository.capabilities.supportsWorktrees + { repositoryNamesByWorktreeID[repository.id] = repositoryName branchNamesByWorktreeID[repository.id] = repository.name if let repositoryColor { @@ -612,7 +631,9 @@ struct SidebarListView: View { best = (id, depth) } for repository in repositories { - if repository.capabilities.supportsRunnableFolderActions, !repository.capabilities.supportsWorktrees { + if repository.capabilities.supportsRunnableFolderActions, + !repository.capabilities.supportsWorktrees + { consider(id: repository.id, directory: repository.rootURL) } for worktree in repository.worktrees { diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift index 53a46758..7e738a68 100644 --- a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -1,40 +1,71 @@ import SwiftUI -/// Display-only rows for the child repositories of an expanded workspace. -/// Reuses `WorktreeRow` for visual parity with git worktree rows (branch icon, -/// `+N/-M` diff badge, PR tag), but deliberately omits selection, drag, context -/// menu, and tap handling — a workspace has a single root terminal and its -/// children are not independently runnable targets. +/// Rows for the child repositories of an expanded workspace. +/// Reuses `WorktreeRow` for visual parity with git worktree rows (`+N/-M` diff +/// badge, PR tag), while clicking a child focuses its bound terminal tab, +/// creating one rooted at that repository's folder when needed. struct WorkspaceChildRowsView: View { let rows: [WorkspaceChildRowModel] + let selectedID: String? + let onSelect: (String) -> Void var body: some View { ForEach(rows) { row in - WorktreeRow( - name: row.branchName ?? row.repositoryName, - worktreeName: row.branchName == nil ? "" : row.repositoryName, - info: row.info, - showsPullRequestInfo: true, - isHovered: false, - isPinned: false, - isMainWorktree: false, - isLoading: false, - taskStatus: nil, - isRunScriptRunning: false, - showsNotificationIndicator: false, - notifications: [], - onFocusNotification: { _ in }, - shortcutHint: nil, - showsShortcutHint: false, - pinAction: nil, - isSelected: false, - archiveAction: nil, - onDiffTap: nil, - onStopRunScript: nil, - ) - .padding(.leading, 14) - .padding(.trailing, 8) - .id(row.id) + let isSelected = row.id == selectedID + WorkspaceChildRowButton(row: row, isSelected: isSelected, onSelect: onSelect) + .padding(.leading, 14) + .padding(.trailing, 8) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.18)) + .padding(.horizontal, 6) + } + } + .padding(.vertical, 2) + .id(row.id) } } } + +private struct WorkspaceChildRowButton: View { + let row: WorkspaceChildRowModel + let isSelected: Bool + let onSelect: (String) -> Void + + var body: some View { + Button { + onSelect(row.id) + } label: { + content + } + .buttonStyle(.plain) + .help("Focus Terminal in \(row.repositoryName)") + } + + private var content: some View { + WorktreeRow( + name: row.branchName ?? row.repositoryName, + worktreeName: row.branchName == nil ? "" : row.repositoryName, + info: row.info, + iconSystemName: "folder", + showsPullRequestInfo: true, + isHovered: false, + isPinned: false, + isMainWorktree: false, + isLoading: false, + taskStatus: nil, + isRunScriptRunning: false, + showsNotificationIndicator: false, + notifications: [], + onFocusNotification: { _ in }, + shortcutHint: nil, + showsShortcutHint: false, + pinAction: nil, + isSelected: isSelected, + archiveAction: nil, + onDiffTap: nil, + onStopRunScript: nil, + ) + } +} diff --git a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift index 5145608d..b9a989ec 100644 --- a/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceCreationPromptView.swift @@ -5,12 +5,6 @@ import SwiftUI struct WorkspaceCreationPromptView: View { @Bindable var store: StoreOf @FocusState private var isTitleFieldFocused: Bool - private let sourceKinds: [ProjectWorkspaceRepositorySourceKind] = [ - .existingPath, - .localRepository, - .remote, - .bareRepository, - ] var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -24,23 +18,43 @@ struct WorkspaceCreationPromptView: View { VStack(alignment: .leading, spacing: 8) { Text("Title") .foregroundStyle(.secondary) - TextField("Workspace title", text: $store.title) - .textFieldStyle(.roundedBorder) - .focused($isTitleFieldFocused) - .disabled(store.isCreating) - .onSubmit { - store.send(.createButtonTapped) - } + TextField( + "Workspace title", + text: Binding( + get: { store.title }, + set: { store.send(.titleChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .focused($isTitleFieldFocused) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(store.validationTarget == .title) + } + .onSubmit { + store.send(.createButtonTapped) + } + helpText( + "A short name for the shared task folder shown in the sidebar and workspace metadata.") } VStack(alignment: .leading, spacing: 8) { Text("Folder") .foregroundStyle(.secondary) HStack(spacing: 8) { - TextField("Workspace folder", text: $store.rootPath) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(store.isCreating) + TextField( + "Workspace folder", + text: Binding( + get: { store.rootPath }, + set: { store.send(.rootPathChanged($0)) } + ) + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(store.validationTarget == .rootPath) + } Button { chooseFolder() } label: { @@ -55,6 +69,9 @@ struct WorkspaceCreationPromptView: View { .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) + helpText( + "Where Prowl creates the workspace root. Until you edit it, this path follows the workspace title." + ) } VStack(alignment: .leading, spacing: 8) { @@ -90,24 +107,30 @@ struct WorkspaceCreationPromptView: View { } .help("Add Local Repository") .disabled(store.isCreating) - - Button { - chooseRepositorySource(kind: .bareRepository) - } label: { - Label("Add Bare", systemImage: "externaldrive") - } - .help("Add Bare Repository") - .disabled(store.isCreating) } - ScrollView { - VStack(spacing: 0) { - ForEach(store.repositories) { repository in - repositoryEditor(repository) - if repository.id != store.repositories.last?.id { - Divider() + helpText( + "Add at least two repositories. Opened and local repositories can be linked or materialized as worktrees." + ) + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + ForEach(store.repositories) { repository in + repositoryEditor(repository) + .id(repository.id) + if repository.id != store.repositories.last?.id { + Divider() + } } } } + .onChange(of: store.validationRequestID) { _, _ in + guard let repositoryID = validationRepositoryID else { + return + } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(repositoryID, anchor: .center) + } + } } .frame(maxHeight: 340) .clipShape(.rect(cornerRadius: 8)) @@ -182,21 +205,7 @@ struct WorkspaceCreationPromptView: View { .fontWeight(.medium) .lineLimit(1) - Picker( - "Source", - selection: Binding( - get: { repository.sourceKind }, - set: { store.send(.repositorySourceKindChanged(repository.id, $0)) } - ) - ) { - ForEach(sourceKinds(for: repository), id: \.self) { kind in - Text(sourceKindTitle(kind)).tag(kind) - } - } - .pickerStyle(.menu) - .labelsHidden() - .frame(width: 150) - .disabled(store.isCreating) + sourceKindBadge(repository.sourceKind) Spacer() @@ -212,104 +221,133 @@ struct WorkspaceCreationPromptView: View { } } - private func repositoryNameAndPathFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { + private func repositoryNameAndPathFields(_ repository: ProjectWorkspaceCreationRepository) + -> some View + { HStack(spacing: 8) { - TextField( - "Name", - text: Binding( - get: { repository.name }, - set: { store.send(.repositoryNameChanged(repository.id, $0)) } + VStack(alignment: .leading, spacing: 4) { + Text("Name") + .foregroundStyle(.secondary) + TextField( + "Repository name", + text: Binding( + get: { repository.name }, + set: { store.send(.repositoryNameChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(repositoryFieldIsInvalid(repository, .name)) + } + helpText("Display name for this repository in the workspace metadata.") + } - TextField( - "Workspace path", - text: Binding( - get: { repository.path ?? "" }, - set: { store.send(.repositoryPathChanged(repository.id, $0)) } + VStack(alignment: .leading, spacing: 4) { + Text("Folder inside workspace") + .foregroundStyle(.secondary) + TextField( + "Folder name", + text: Binding( + get: { repository.path ?? "" }, + set: { store.send(.repositoryPathChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + helpText( + "Destination folder under the workspace root. It does not change the original source path." + ) + } } } private func repositorySourceField(_ repository: ProjectWorkspaceCreationRepository) -> some View { - HStack(spacing: 8) { - TextField( - sourceLocationPlaceholder(repository.sourceKind), - text: Binding( - get: { repository.sourceLocation }, - set: { store.send(.repositorySourceLocationChanged(repository.id, $0)) } + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + TextField( + sourceLocationPlaceholder(repository.sourceKind), + text: Binding( + get: { repository.sourceLocation }, + set: { store.send(.repositorySourceLocationChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .font(.body.monospaced()) - .disabled(store.isCreating) + .textFieldStyle(.roundedBorder) + .font(.body.monospaced()) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(repositoryFieldIsInvalid(repository, .source)) + } - if repository.sourceKind != .remote { - Button { - chooseSource(for: repository) - } label: { - Image(systemName: "folder") - .accessibilityLabel("Choose Repository Source") + if repository.sourceKind != .remote { + Button { + chooseSource(for: repository) + } label: { + Image(systemName: "folder") + .accessibilityLabel("Choose Repository Source") + } + .help("Choose Repository Source") + .disabled(store.isCreating) } - .help("Choose Repository Source") - .disabled(store.isCreating) } + helpText(sourceLocationHelpText(repository.sourceKind)) } } private func repositoryBranchFields(_ repository: ProjectWorkspaceCreationRepository) -> some View { - HStack(spacing: 8) { - Picker( - "Branch action", - selection: Binding( - get: { repository.checkoutMode }, - set: { store.send(.repositoryCheckoutModeChanged(repository.id, $0)) } - ) - ) { - if repository.sourceKind.supportsLinkCheckout { - Text("Link").tag(ProjectWorkspaceRepositoryCheckoutMode.link) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Picker( + "Branch action", + selection: Binding( + get: { repository.checkoutMode }, + set: { store.send(.repositoryCheckoutModeChanged(repository.id, $0)) } + ) + ) { + if repository.sourceKind.supportsLinkCheckout { + Text("Link").tag(ProjectWorkspaceRepositoryCheckoutMode.link) + } + Text("Create Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) + Text("Use Existing").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) } - Text("Create Branch").tag(ProjectWorkspaceRepositoryCheckoutMode.createBranch) - Text("Use Existing").tag(ProjectWorkspaceRepositoryCheckoutMode.useExistingRef) - } - .pickerStyle(.menu) - .labelsHidden() - .frame(width: 150) - .help("Choose Branch Action") - .disabled(store.isCreating) + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 150) + .help("Choose Branch Action") + .disabled(store.isCreating) - if repository.checkoutMode == .createBranch { - TextField( - "Branch", - text: Binding( - get: { repository.branchName ?? "" }, - set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } + if repository.checkoutMode == .createBranch { + TextField( + "Branch", + text: Binding( + get: { repository.branchName ?? "" }, + set: { store.send(.repositoryBranchNameChanged(repository.id, $0)) } + ) ) - ) - .textFieldStyle(.roundedBorder) - .disabled(store.isCreating) - } + .textFieldStyle(.roundedBorder) + .disabled(store.isCreating) + .overlay { + invalidFieldBorder(repositoryFieldIsInvalid(repository, .branchName)) + } + } - if repository.checkoutMode == .link { - Text("Symlink to the repository as it is on disk") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - WorkspaceBranchRefPickerView( - title: repository.checkoutMode == .createBranch ? "Base ref" : "Existing branch", - selection: repository.baseRef, - options: repository.baseRefOptions, - isDisabled: store.isCreating || repository.baseRefOptions.isEmpty - ) { ref in - store.send(.repositoryBaseRefChanged(repository.id, ref)) + if repository.checkoutMode != .link { + WorkspaceBranchRefPickerView( + title: repository.checkoutMode == .createBranch ? "Base ref" : "Existing branch", + selection: repository.baseRef, + options: repository.baseRefOptions, + isDisabled: store.isCreating || repository.baseRefOptions.isEmpty, + isInvalid: repositoryFieldIsInvalid(repository, .baseRef) + ) { ref in + store.send(.repositoryBaseRefChanged(repository.id, ref)) + } + .disabled(store.isCreating || repository.baseRefOptions.isEmpty) } - .disabled(store.isCreating || repository.baseRefOptions.isEmpty) + } + helpText(branchActionHelpText(repository)) + + if repository.checkoutMode != .link { if let localBranchName = repository.resettableLocalBranchName { VStack(alignment: .leading, spacing: 4) { Text("Local branch “\(localBranchName)” already exists and would be reset to this ref.") @@ -335,14 +373,6 @@ struct WorkspaceCreationPromptView: View { } } - private func sourceKinds( - for repository: ProjectWorkspaceCreationRepository - ) -> [ProjectWorkspaceRepositorySourceKind] { - repository.sourceKind == .remote - ? sourceKinds - : sourceKinds.filter { $0 != .remote } - } - @ViewBuilder private func remoteRepositoryPromptView() -> some View { if let prompt = store.remoteRepositoryPrompt { @@ -363,6 +393,7 @@ struct WorkspaceCreationPromptView: View { .textFieldStyle(.roundedBorder) .font(.body.monospaced()) .disabled(prompt.isLoading) + helpText("Remote git URL to clone into the workspace, such as SSH or HTTPS.") } VStack(alignment: .leading, spacing: 8) { @@ -377,12 +408,15 @@ struct WorkspaceCreationPromptView: View { ) .textFieldStyle(.roundedBorder) .disabled(prompt.isLoading) + helpText("Display name and default folder name for this remote repository.") } if !prompt.branchOptions.isEmpty { Text("\(prompt.branchOptions.count) remote branches loaded") .font(.footnote) .foregroundStyle(.secondary) + } else { + helpText("Load branches before adding so Prowl can choose an existing branch safely.") } if let message = prompt.validationMessage, !message.isEmpty { @@ -402,13 +436,13 @@ struct WorkspaceCreationPromptView: View { } .keyboardShortcut(.cancelAction) .help("Cancel (Esc)") - .disabled(prompt.isLoading) Button("Load") { store.send(.remoteRepositoryPromptLoadButtonTapped) } .help("Load Remote Branches") - .disabled(prompt.isLoading || prompt.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled( + prompt.isLoading || prompt.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) Button("Add") { store.send(.remoteRepositoryPromptAddButtonTapped) @@ -450,7 +484,8 @@ struct WorkspaceCreationPromptView: View { } private func chooseSource(for repository: ProjectWorkspaceCreationRepository) { - let panel = repositorySourcePanel(kind: repository.sourceKind, currentPath: repository.sourceLocation) + let panel = repositorySourcePanel( + kind: repository.sourceKind, currentPath: repository.sourceLocation) panel.begin { response in guard response == .OK, let url = panel.url else { return @@ -472,16 +507,17 @@ struct WorkspaceCreationPromptView: View { if let currentPath, !currentPath.isEmpty { panel.directoryURL = URL(filePath: currentPath).deletingLastPathComponent() } - panel.message = kind == .bareRepository ? "Choose a bare repository folder" : "Choose a repository folder" + panel.message = + kind == .bareRepository ? "Choose a bare repository folder" : "Choose a repository folder" return panel } private func sourceKindTitle(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { switch kind { case .existingPath: - return "Opened Path" + return "Opened in Prowl" case .localRepository: - return "Local Repo" + return "Picked from Disk" case .remote: return "Remote Clone" case .bareRepository: @@ -489,6 +525,41 @@ struct WorkspaceCreationPromptView: View { } } + private func sourceKindIcon(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return "folder.badge.plus" + case .localRepository: + return "folder" + case .remote: + return "network" + case .bareRepository: + return "externaldrive" + } + } + + private func sourceKindBadgeHelp(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return "Added from repositories already opened in Prowl." + case .localRepository: + return "Added by choosing a repository folder from disk." + case .remote: + return "Added from a remote URL and cloned into the workspace." + case .bareRepository: + return "Added from a local bare repository." + } + } + + private func sourceKindBadge(_ kind: ProjectWorkspaceRepositorySourceKind) -> some View { + Label(sourceKindTitle(kind), systemImage: sourceKindIcon(kind)) + .font(.caption) + .foregroundStyle(.secondary) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .help(sourceKindBadgeHelp(kind)) + } + private func sourceLocationPlaceholder(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { switch kind { case .existingPath, .localRepository: @@ -499,6 +570,68 @@ struct WorkspaceCreationPromptView: View { return "Bare repository folder" } } + + private func sourceLocationHelpText(_ kind: ProjectWorkspaceRepositorySourceKind) -> String { + switch kind { + case .existingPath: + return + "Existing opened repository path. Link keeps using this checkout; " + + "branch actions create workspace worktrees from it." + case .localRepository: + return + "Local repository folder on disk. It can be linked as-is or used as the source for a workspace worktree." + case .remote: + return "Remote URL cloned into the workspace folder after branches are loaded." + case .bareRepository: + return "Advanced source: a local bare repository used only for git worktree materialization." + } + } + + private func branchActionHelpText(_ repository: ProjectWorkspaceCreationRepository) -> String { + switch repository.checkoutMode { + case .link: + return + "Link adds a symlink to the source checkout, so workspace edits affect the original folder directly." + case .createBranch: + return + "Create Branch materializes an isolated checkout on a new branch from the selected base ref." + case .useExistingRef: + if repository.resettableLocalBranchName != nil { + return + "Use Existing checks out the selected branch. " + + "If a matching local branch already exists, choose whether to keep or reset it." + } + return + "Use Existing checks out the selected local branch or creates a local tracking branch from a remote ref." + } + } + + private func helpText(_ text: String) -> some View { + Text(text) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + private var validationRepositoryID: Repository.ID? { + guard case .repository(let repositoryID, _) = store.validationTarget else { + return nil + } + return repositoryID + } + + private func repositoryFieldIsInvalid( + _ repository: ProjectWorkspaceCreationRepository, + _ field: WorkspaceCreationPromptFeature.RepositoryField + ) -> Bool { + store.validationTarget == .repository(repository.id, field) + } + + private func invalidFieldBorder(_ isInvalid: Bool) -> some View { + RoundedRectangle(cornerRadius: 5) + .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) + .allowsHitTesting(false) + } } private struct WorkspaceBranchRefPickerView: View { @@ -506,6 +639,7 @@ private struct WorkspaceBranchRefPickerView: View { let selection: String? let options: [GitBranchRefOption] let isDisabled: Bool + let isInvalid: Bool let onSelect: (String) -> Void @State private var isPresented = false @@ -527,6 +661,9 @@ private struct WorkspaceBranchRefPickerView: View { .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.bordered) + .overlay { + invalidFieldBorder + } .help("Choose \(title)") .disabled(isDisabled) .popover(isPresented: $isPresented, arrowEdge: .bottom) { @@ -587,6 +724,12 @@ private struct WorkspaceBranchRefPickerView: View { return selection } + private var invalidFieldBorder: some View { + RoundedRectangle(cornerRadius: 5) + .stroke(isInvalid ? Color.red : Color.clear, lineWidth: isInvalid ? 1.5 : 0) + .allowsHitTesting(false) + } + private var groupedOptions: [(kind: GitBranchRefKind, options: [GitBranchRefOption])] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) let filtered = diff --git a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift index a827c189..9bab292d 100644 --- a/supacode/Features/Repositories/Views/WorkspaceDetailView.swift +++ b/supacode/Features/Repositories/Views/WorkspaceDetailView.swift @@ -38,13 +38,18 @@ struct WorkspaceDetailView: View { .font(.subheadline.monospaced()) .foregroundStyle(.secondary) .textSelection(.enabled) - Text("\(workspace.repositories.count) repositories") + Text(repositoryCountText) .font(.subheadline) .foregroundStyle(.tertiary) } } } + private var repositoryCountText: String { + workspace.repositories.count == 1 + ? "1 repository" : "\(workspace.repositories.count) repositories" + } + private var taskLinks: some View { VStack(alignment: .leading, spacing: 6) { Text("Task Links") diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index df0dcb7d..f3f3be28 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -5,6 +5,7 @@ struct WorktreeRow: View { let name: String let worktreeName: String let info: WorktreeInfoEntry? + let iconSystemName: String? let showsPullRequestInfo: Bool let isHovered: Bool let isPinned: Bool @@ -25,9 +26,57 @@ struct WorktreeRow: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.resolvedKeybindings) private var resolvedKeybindings + init( + name: String, + worktreeName: String, + info: WorktreeInfoEntry?, + iconSystemName: String? = nil, + showsPullRequestInfo: Bool, + isHovered: Bool, + isPinned: Bool, + isMainWorktree: Bool, + isLoading: Bool, + taskStatus: WorktreeTaskStatus?, + isRunScriptRunning: Bool, + showsNotificationIndicator: Bool, + notifications: [WorktreeTerminalNotification], + onFocusNotification: @escaping (WorktreeTerminalNotification) -> Void, + shortcutHint: String?, + showsShortcutHint: Bool, + pinAction: (() -> Void)?, + isSelected: Bool, + archiveAction: (() -> Void)?, + onDiffTap: (() -> Void)?, + onStopRunScript: (() -> Void)? + ) { + self.name = name + self.worktreeName = worktreeName + self.info = info + self.iconSystemName = iconSystemName + self.showsPullRequestInfo = showsPullRequestInfo + self.isHovered = isHovered + self.isPinned = isPinned + self.isMainWorktree = isMainWorktree + self.isLoading = isLoading + self.taskStatus = taskStatus + self.isRunScriptRunning = isRunScriptRunning + self.showsNotificationIndicator = showsNotificationIndicator + self.notifications = notifications + self.onFocusNotification = onFocusNotification + self.shortcutHint = shortcutHint + self.showsShortcutHint = showsShortcutHint + self.pinAction = pinAction + self.isSelected = isSelected + self.archiveAction = archiveAction + self.onDiffTap = onDiffTap + self.onStopRunScript = onStopRunScript + } + var body: some View { let showsSpinner = isLoading || taskStatus == .running - let branchIconName = isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch") + let branchIconName = + iconSystemName + ?? (isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch")) let display = WorktreePullRequestDisplay( worktreeName: name, pullRequest: showsPullRequestInfo ? info?.pullRequest : nil @@ -35,7 +84,8 @@ struct WorktreeRow: View { let displayAddedLines = info?.addedLines let displayRemovedLines = info?.removedLines let mergeReadiness = pullRequestMergeReadiness(for: display.pullRequest) - let isQueued = display.pullRequest.flatMap(PullRequestMergeQueueStatus.init(pullRequest:)) != nil + let isQueued = + display.pullRequest.flatMap(PullRequestMergeQueueStatus.init(pullRequest:)) != nil let hasChangeCounts = displayAddedLines != nil && displayRemovedLines != nil let showsPullRequestTag = display.pullRequest != nil && display.pullRequestBadgeStyle != nil let nameColor = colorScheme == .dark ? Color.white : Color.primary @@ -265,11 +315,16 @@ private struct WorktreeRowPreview: View { addedLines: 632, removedLines: 344 ) - row(id: "pinned", name: "feature/pinned-branch", worktreeName: "pinned-branch", isPinned: true) + row( + id: "pinned", name: "feature/pinned-branch", worktreeName: "pinned-branch", isPinned: true) row(id: "running", name: "feature/auth-flow", worktreeName: "auth-flow", taskStatus: .running) row(id: "loading", name: "creating-worktree...", worktreeName: "Setting up", isLoading: true) - row(id: "notif", name: "feature/notifications", worktreeName: "notifications", showsNotificationIndicator: true) - row(id: "script", name: "feature/run-script", worktreeName: "run-script", isRunScriptRunning: true) + row( + id: "notif", name: "feature/notifications", worktreeName: "notifications", + showsNotificationIndicator: true) + row( + id: "script", name: "feature/run-script", worktreeName: "run-script", + isRunScriptRunning: true) row(id: "hint", name: "feature/shortcuts", worktreeName: "shortcuts", shortcutHint: "⌘1") row(id: "selected", name: "feature/selected", worktreeName: "selected", isSelected: true) } @@ -356,7 +411,9 @@ private struct WorktreeRowChangeCountView: View { .fixedSize(horizontal: true, vertical: false) .overlay { Capsule() - .stroke(isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), lineWidth: 1) + .stroke( + isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), + lineWidth: 1) } .monospacedDigit() } diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index d780b6d0..adad553a 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -140,8 +140,10 @@ struct RepositorySettingsView: View { isBranchPickerPresented = true } label: { HStack { - Text(store.settings.worktreeBaseRef ?? "Automatic (\(store.defaultWorktreeBaseRef))") - .foregroundStyle(.primary) + Text( + store.settings.worktreeBaseRef ?? "Automatic (\(store.defaultWorktreeBaseRef))" + ) + .foregroundStyle(.primary) Spacer() Image(systemName: "chevron.up.chevron.down") .foregroundStyle(.secondary) @@ -169,7 +171,7 @@ struct RepositorySettingsView: View { } } header: { VStack(alignment: .leading, spacing: 4) { - Text("Branch new workspaces from") + Text("Branch new worktrees from") Text("Each workspace is an isolated copy of your codebase.") .foregroundStyle(.secondary) } @@ -183,8 +185,10 @@ struct RepositorySettingsView: View { ) .textFieldStyle(.roundedBorder) - Text("Set a repository-specific worktree base directory. Leave empty to inherit the global setting.") - .foregroundStyle(.secondary) + Text( + "Set a repository-specific worktree base directory. Leave empty to inherit the global setting." + ) + .foregroundStyle(.secondary) Text("Example new worktree path: \(exampleWorktreePath)") .foregroundStyle(.secondary) .monospaced() @@ -370,8 +374,10 @@ struct RepositorySettingsView: View { } header: { VStack(alignment: .leading, spacing: 4) { Text("Custom Commands") - Text("Repository-local terminal actions. Custom command shortcuts take precedence in this repository.") - .foregroundStyle(.secondary) + Text( + "Repository-local terminal actions. Custom command shortcuts take precedence in this repository." + ) + .foregroundStyle(.secondary) } } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 4dd83d17..1f9b55cb 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -87,6 +87,8 @@ final class WorktreeTerminalManager { Task { createTabAsync(in: worktree, runSetupScriptIfNew: false, workingDirectory: directory) } + case .focusOrCreateTabInDirectory(let worktree, let directory, let title): + state(for: worktree).focusOrCreateTab(boundToDirectory: directory, title: title) case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): let state = state(for: worktree) { runSetupScriptIfNew } state.ensureInitialTab(focusing: focusing) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index e0ea530b..3da162b5 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -320,6 +320,7 @@ extension WorktreeTerminalState { focusedSurfaceIdByTab.removeAll() cleanupAllAgentDetectionState() tabIsRunningById.removeAll() + boundDirectoryTabIDs.removeAll() autoCloseSurfaceIds.removeAll() pendingCustomCommands.removeAll() setRunScriptTabId(nil) @@ -805,6 +806,7 @@ extension WorktreeTerminalState { if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) + removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) if tabId == runScriptTabId { setRunScriptTabId(nil) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 8faf1861..dbd7bebc 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -76,6 +76,7 @@ final class WorktreeTerminalState { var lastWorkingAtBySurface: [UUID: Date] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] var tabIsRunningById: [TerminalTabID: Bool] = [:] + var boundDirectoryTabIDs: [String: TerminalTabID] = [:] var surfaceRunningStartedAtById: [UUID: Date] = [:] var runScriptTabId: TerminalTabID? var pendingSetupScript: Bool @@ -291,6 +292,7 @@ final class WorktreeTerminalState { @discardableResult func createTab( focusing: Bool = true, + title: String? = nil, setupScript: String? = nil, initialInput: String? = nil, inheritingFromSurfaceId: UUID? = nil, @@ -298,7 +300,7 @@ final class WorktreeTerminalState { ) -> TerminalTabID? { let context = GHOSTTY_SURFACE_CONTEXT_TAB let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() - let title = "\(worktree.name) \(nextTabIndex())" + let title = title ?? "\(worktree.name) \(nextTabIndex())" let setupInput = setupScriptInput(setupScript: setupScript) let commandInput = initialInput.flatMap { runScriptInput($0) } let resolvedInput: String? @@ -334,6 +336,36 @@ final class WorktreeTerminalState { return tabId } + @discardableResult + func focusOrCreateTab( + boundToDirectory directory: URL, + title: String? + ) -> TerminalTabID? { + let directoryKey = boundDirectoryKey(for: directory) + if let tabId = boundDirectoryTabIDs[directoryKey], + tabManager.tabs.contains(where: { $0.id == tabId }) + { + selectTab(tabId) + return tabId + } + boundDirectoryTabIDs.removeValue(forKey: directoryKey) + + if let tabId = tabID(withWorkingDirectoryKey: directoryKey) { + boundDirectoryTabIDs[directoryKey] = tabId + selectTab(tabId) + return tabId + } + + let tabId = createTab( + title: title, + workingDirectoryOverride: directory + ) + if let tabId { + boundDirectoryTabIDs[directoryKey] = tabId + } + return tabId + } + @discardableResult func runScript(_ script: String) -> TerminalTabID? { guard let input = runScriptInput(script) else { return nil } @@ -564,6 +596,7 @@ final class WorktreeTerminalState { guard confirmCloseIfNeeded(tabIds: [tabId], mode: confirmation) else { return false } let wasRunScriptTab = tabId == runScriptTabId removeTree(for: tabId) + removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) if let selected = tabManager.selectedTabId { focusSurface(in: selected) @@ -621,6 +654,35 @@ final class WorktreeTerminalState { return formatCommandInput(script) } + func removeBoundDirectoryTab(_ tabId: TerminalTabID) { + boundDirectoryTabIDs = boundDirectoryTabIDs.filter { $0.value != tabId } + } + + private func tabID(withWorkingDirectoryKey directoryKey: String) -> TerminalTabID? { + for tab in tabManager.tabs { + let paneIDs = trees[tab.id]?.leaves().map(\.id) ?? [] + let hasMatchingPane = paneIDs.contains { paneID in + guard + let workingDirectory = inheritedSurfaceConfig( + fromSurfaceId: paneID, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ).workingDirectory + else { + return false + } + return boundDirectoryKey(for: workingDirectory) == directoryKey + } + if hasMatchingPane { + return tab.id + } + } + return nil + } + + private func boundDirectoryKey(for url: URL) -> String { + url.standardizedFileURL.path(percentEncoded: false) + } + // Env vars are injected into the surface's shell process via // `GhosttySurfaceView(environment:)`, so scripts no longer need a shell // export prefix. diff --git a/supacodeTests/ProjectWorkspaceTests.swift b/supacodeTests/ProjectWorkspaceTests.swift index 67876e07..d1ac4988 100644 --- a/supacodeTests/ProjectWorkspaceTests.swift +++ b/supacodeTests/ProjectWorkspaceTests.swift @@ -36,9 +36,13 @@ struct ProjectWorkspaceTests { } @Test func remoteNamingStripsOnlyTrailingGitSuffix() { - #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "git@github.com:onevcat/x.github.io.git") == "x.github.io") - #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app.git") == "app") - #expect(GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app") == "app") + #expect( + GitRemoteNaming.repositoryName(fromRemoteURL: "git@github.com:onevcat/x.github.io.git") + == "x.github.io") + #expect( + GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app.git") == "app") + #expect( + GitRemoteNaming.repositoryName(fromRemoteURL: "https://github.com/onevcat/app") == "app") } @Test func loadsWorkspaceMetadataWithDefaultsAndSnakeCaseSources() throws { @@ -78,6 +82,7 @@ struct ProjectWorkspaceTests { let rootPath = rootURL.standardizedFileURL.path(percentEncoded: false) #expect(workspace.id == rootPath) + #expect(workspace.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(workspace.title == "Multi Repo Task") #expect(workspace.description == "") #expect(workspace.taskLinks == []) @@ -208,13 +213,16 @@ struct ProjectWorkspaceTests { createdAt: createdAt ), gitRunner: ProjectWorkspaceGitRunner { command in - throw ProjectWorkspaceCreationError.gitCommandFailed(command: command.displayCommand, message: "unexpected") + throw ProjectWorkspaceCreationError.gitCommandFailed( + command: command.displayCommand, message: "unexpected") } ) #expect(workspace.title == "Checkout Flow") + #expect(workspace.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(workspace.createdAt == createdAt) let loaded = try #require(ProjectWorkspace.load(from: rootURL)) + #expect(loaded.schemaVersion == ProjectWorkspace.currentSchemaVersion) #expect(loaded.repositories.map(\.path) == ["App-Repo", "App-Repo-2"]) #expect(loaded.repositories.map(\.sourceKind) == [.existingPath, .existingPath]) let appPath = normalizedTestPath(appURL) @@ -228,8 +236,12 @@ struct ProjectWorkspaceTests { let appLinkPath = rootURL.appending(path: "App-Repo").path(percentEncoded: false) let apiLinkPath = rootURL.appending(path: "App-Repo-2").path(percentEncoded: false) - #expect(URL(fileURLWithPath: appLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == appPath) - #expect(URL(fileURLWithPath: apiLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) == apiPath) + #expect( + URL(fileURLWithPath: appLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) + == appPath) + #expect( + URL(fileURLWithPath: apiLinkPath).resolvingSymlinksInPath().path(percentEncoded: false) + == apiPath) } @Test func createWorkspaceMaterializesRemoteCloneAndBareWorktree() async throws { @@ -280,12 +292,18 @@ struct ProjectWorkspaceTests { #expect( commands.value.map(\.arguments) == [ ["clone", "--end-of-options", "git@github.com:onevcat/app.git", "\(rootPath)/app"], - ["-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "--end-of-options", "origin/main"], - ["-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", "--end-of-options", "main"], + [ + "-C", "\(rootPath)/app", "checkout", "-B", "codex/app", "--end-of-options", "origin/main", + ], + [ + "-C", barePath, "worktree", "add", "-b", "codex/api", "\(rootPath)/api", + "--end-of-options", "main", + ], ]) let loaded = try #require(ProjectWorkspace.load(from: rootURL)) - #expect(loaded.repositories.map(\.sourceLocation) == ["git@github.com:onevcat/app.git", barePath]) + #expect( + loaded.repositories.map(\.sourceLocation) == ["git@github.com:onevcat/app.git", barePath]) #expect(loaded.repositories.map(\.branchName) == ["codex/app", "codex/api"]) #expect(loaded.repositories.map(\.baseRef) == ["origin/main", "main"]) } @@ -367,7 +385,8 @@ struct ProjectWorkspaceTests { path: "app", sourceKind: .remote, sourceLocation: "git@github.com:onevcat/app.git", - checkout: .trackRemoteRef(remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") + checkout: .trackRemoteRef( + remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") ), ProjectWorkspaceRepositoryPlan( id: "maker", @@ -375,7 +394,8 @@ struct ProjectWorkspaceTests { path: nil, sourceKind: .bareRepository, sourceLocation: bareURL.path(percentEncoded: false), - checkout: .trackRemoteRef(remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") + checkout: .trackRemoteRef( + remoteRef: "origin/chore/disable-spine", branchName: "chore/disable-spine") ), ] ), @@ -398,7 +418,8 @@ struct ProjectWorkspaceTests { ], ]) #expect(workspace.repositories.map(\.path) == ["app", "maker"]) - #expect(workspace.repositories.map(\.branchName) == ["chore/disable-spine", "chore/disable-spine"]) + #expect( + workspace.repositories.map(\.branchName) == ["chore/disable-spine", "chore/disable-spine"]) #expect( workspace.repositories.map(\.baseRef) == ["origin/chore/disable-spine", "origin/chore/disable-spine"] @@ -445,6 +466,15 @@ struct ProjectWorkspaceTests { ) } + @Test func localBranchNameForRemoteRefKeepsSlashedBranchName() { + #expect( + ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "origin/chore/x") + == "chore/x") + #expect( + ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "upstream/main") == "main") + #expect(ProjectWorkspaceCreationRepository.localBranchName(forRemoteRef: "main") == nil) + } + @Test func planResetsLocalBranchToRemoteWhenChosen() { var repository = ProjectWorkspaceCreationRepository( id: "maker", @@ -468,7 +498,8 @@ struct ProjectWorkspaceTests { @Test func defaultRepositoryNameStripsGitSuffix() { #expect( - WorkspaceCreationPromptFeature.defaultRepositoryName(for: URL(fileURLWithPath: "/tmp/maker.git")) + WorkspaceCreationPromptFeature.defaultRepositoryName( + for: URL(fileURLWithPath: "/tmp/maker.git")) == "maker" ) #expect( @@ -525,7 +556,10 @@ struct ProjectWorkspaceTests { let apiPath = normalizedTestPath(apiURL) #expect( commands.value.map(\.arguments) == [ - ["-C", appPath, "worktree", "add", "-b", "codex/app", "\(rootPath)/app", "--end-of-options", "main"], + [ + "-C", appPath, "worktree", "add", "-b", "codex/app", "\(rootPath)/app", + "--end-of-options", "main", + ], ["-C", apiPath, "worktree", "add", "\(rootPath)/api", "--end-of-options", "origin/main"], ]) #expect(workspace.repositories.map(\.branchName) == ["codex/app", nil]) @@ -571,7 +605,8 @@ struct ProjectWorkspaceTests { gitRunner: ProjectWorkspaceGitRunner { command in commands.withValue { $0.append(command) } if command.arguments.first == "clone" { - try FileManager.default.createDirectory(at: cloneDestination, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: cloneDestination, withIntermediateDirectories: true) } if command.arguments.contains("checkout") { throw ProjectWorkspaceCreationError.gitCommandFailed( @@ -851,7 +886,8 @@ struct ProjectWorkspaceTests { workspace, rootURL: rootURL, gitRunner: ProjectWorkspaceGitRunner { _ in - throw ProjectWorkspaceCreationError.gitCommandFailed(command: "git worktree remove", message: "broken") + throw ProjectWorkspaceCreationError.gitCommandFailed( + command: "git worktree remove", message: "broken") } ) @@ -904,7 +940,8 @@ struct ProjectWorkspaceTests { private func writeWorkspaceJSON(_ json: String, to rootURL: URL) throws { let metadataDirectoryURL = rootURL.appending(path: ProjectWorkspace.metadataDirectoryName) - try FileManager.default.createDirectory(at: metadataDirectoryURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: metadataDirectoryURL, withIntermediateDirectories: true) try Data(json.utf8).write(to: ProjectWorkspace.metadataURL(for: rootURL)) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 6ebb1a9b..d3e446de 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -333,7 +333,8 @@ struct RepositoriesFeatureTests { $0.gitClient.worktrees = { _ in [existingWorktree, discoveredWorktree] } } - await store.send(.worktreeInfoEvent(.repositoryWorktreesChanged(repositoryRootURL: repository.rootURL))) + await store.send( + .worktreeInfoEvent(.repositoryWorktreesChanged(repositoryRootURL: repository.rootURL))) await store.receive(\.reloadRepositories) await store.receive(\.repositoriesLoaded) { $0.repositories[id: repository.id] = makeRepository( @@ -545,7 +546,8 @@ struct RepositoriesFeatureTests { workspace: workspace ) - let childID = repository.rootURL.appending(path: "app").standardizedFileURL.path(percentEncoded: false) + let childID = repository.rootURL.appending(path: "app").standardizedFileURL.path( + percentEncoded: false) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { @@ -554,7 +556,8 @@ struct RepositoriesFeatureTests { } $0.repositoryPersistence.saveRepositorySnapshot = { _ in } $0.gitClient.repoRoot = { url in - Issue.record("workspace should load as plain without git probing: \(url.path(percentEncoded: false))") + Issue.record( + "workspace should load as plain without git probing: \(url.path(percentEncoded: false))") return url } $0.gitClient.worktrees = { url in @@ -905,7 +908,8 @@ struct RepositoriesFeatureTests { } await store.receive( .remoteRepositoryPromptLoaded( - "git@github.com:onevcat/app.git", GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main")) + "git@github.com:onevcat/app.git", + GitRemoteBranchRefs(options: options, defaultBaseRef: "origin/main")) ) { $0.remoteRepositoryPrompt?.isLoading = false $0.remoteRepositoryPrompt?.branchOptions = options @@ -927,6 +931,43 @@ struct RepositoriesFeatureTests { } } + @Test func workspaceCreationPromptFolderFollowsTitleUntilEdited() async { + let store = TestStore( + initialState: WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Workspace", + rootPath: "/tmp/workspace" + ) + ) { + WorkspaceCreationPromptFeature() + } + + let requestedRootPath = defaultWorkspaceBaseRootPath(for: "Client App") + let resolvedRootPath = expectedDefaultWorkspaceRootPath(for: "Client App") + await store.send(.titleChanged("Client App")) { + $0.title = "Client App" + $0.rootPath = requestedRootPath + } + if resolvedRootPath == requestedRootPath { + await store.receive( + .automaticRootPathResolved(path: resolvedRootPath, requestedRootPath: requestedRootPath) + ) + } else { + await store.receive( + .automaticRootPathResolved(path: resolvedRootPath, requestedRootPath: requestedRootPath) + ) { + $0.rootPath = resolvedRootPath + } + } + await store.send(.rootPathChanged("/tmp/manual-workspace")) { + $0.rootPath = "/tmp/manual-workspace" + $0.isRootPathDirty = true + } + await store.send(.titleChanged("Other Title")) { + $0.title = "Other Title" + } + } + @Test func workspaceCreationPromptRemoteLoadUsesDetectedDefaultFromOptions() async { let store = TestStore( initialState: WorkspaceCreationPromptFeature.State( @@ -1038,7 +1079,9 @@ struct RepositoriesFeatureTests { WorkspaceCreationPromptFeature() } - await store.send(.repositorySourceLocationChanged(repositoryID, "git@github.com:onevcat/other.git")) { + await store.send( + .repositorySourceLocationChanged(repositoryID, "git@github.com:onevcat/other.git") + ) { $0.repositories[id: repositoryID]?.sourceLocation = "git@github.com:onevcat/other.git" $0.repositories[id: repositoryID]?.baseRef = nil $0.repositories[id: repositoryID]?.baseRefOptions = [] @@ -1105,10 +1148,13 @@ struct RepositoriesFeatureTests { await store.send(.createButtonTapped) { $0.validationMessage = "Choose an existing branch for Repo A." + $0.validationTarget = .repository(repoRootA, .baseRef) + $0.validationRequestID = 1 } await store.send(.repositoryBaseRefChanged(repoRootA, "main")) { $0.repositories[id: repoRootA]?.baseRef = "main" $0.validationMessage = nil + $0.validationTarget = nil } } @@ -1141,10 +1187,13 @@ struct RepositoriesFeatureTests { await store.send(.createButtonTapped) { $0.validationMessage = "Branch name required for Remote." + $0.validationTarget = .repository("remote", .branchName) + $0.validationRequestID = 1 } await store.send(.repositoryBranchNameChanged("remote", "codex/app")) { $0.repositories[id: "remote"]?.branchName = "codex/app" $0.validationMessage = nil + $0.validationTarget = nil } } @@ -1327,7 +1376,8 @@ struct RepositoriesFeatureTests { $0.shellClient.run = { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } $0.gitClient.deleteLocalBranch = { name, url, force in deleteCalls.withValue { - $0.append(DeleteBranchCall(name: name, root: url.path(percentEncoded: false), force: force)) + $0.append( + DeleteBranchCall(name: name, root: url.path(percentEncoded: false), force: force)) } return .deleted } @@ -1488,7 +1538,8 @@ struct RepositoriesFeatureTests { } store.exhaustivity = .off - await store.send(.repositoryManagement(.repositoryRemoved("/tmp/ws/", selectionWasRemoved: false))) + await store.send( + .repositoryManagement(.repositoryRemoved("/tmp/ws/", selectionWasRemoved: false))) await store.finish() #expect(savedEntries.value.last == []) @@ -1513,6 +1564,66 @@ struct RepositoriesFeatureTests { await store.receive(\.showToast, .warning("Workspace creation canceled")) } + @Test func workspaceCreationSuccessOpensWorkspaceAndShowsToast() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appending(path: "prowl-feature-success-\(UUID().uuidString)", directoryHint: .isDirectory) + .standardizedFileURL + defer { try? FileManager.default.removeItem(at: rootURL) } + var initialState = RepositoriesFeature.State() + initialState.workspaceCreationPrompt = WorkspaceCreationPromptFeature.State( + repositories: [], + title: "Feature", + rootPath: rootURL.path(percentEncoded: false) + ) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_700_000_000) + $0.shellClient.run = { _, _, _ in ShellOutput(stdout: "", stderr: "", exitCode: 0) } + $0.repositoryPersistence.loadRepositoryEntries = { [] } + $0.repositoryPersistence.saveRepositoryEntries = { _ in } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + let draft = ProjectWorkspaceCreationDraft( + title: "Feature", + rootURL: rootURL, + repositories: [ + ProjectWorkspaceRepositoryPlan( + id: "app", + name: "App", + path: "app", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/app.git", + checkout: .useExistingRef("origin/main") + ), + ProjectWorkspaceRepositoryPlan( + id: "api", + name: "API", + path: "api", + sourceKind: .remote, + sourceLocation: "git@github.com:onevcat/api.git", + checkout: .useExistingRef("origin/main") + ), + ] + ) + + await store.send(.workspaceCreation(.createWorkspace(draft))) { + $0.workspaceCreationPrompt?.isCreating = true + } + await store.receive(\.workspaceCreation.workspaceCreated) { + $0.workspaceCreationPrompt = nil + } + await store.receive(\.showToast, .success("Workspace created")) { + $0.statusToast = .success("Workspace created") + } + await store.receive(\.repositoryManagement.openRepositories) + await store.finish() + + #expect(ProjectWorkspace.load(from: rootURL)?.title == "Feature") + } + @Test func workspaceBaseRefsLoadErrorSurfacesValidationMessage() async { let repositoryID = "/tmp/repo" var initialState = RepositoriesFeature.State() @@ -1664,7 +1775,8 @@ struct RepositoriesFeatureTests { return URL(fileURLWithPath: ancestorRoot) } $0.gitClient.worktrees = { url in - Issue.record("downgraded git entry should not load worktrees: \(url.path(percentEncoded: false))") + Issue.record( + "downgraded git entry should not load worktrees: \(url.path(percentEncoded: false))") return [] } } @@ -2962,7 +3074,8 @@ struct RepositoriesFeatureTests { $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) - continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) + continuation.yield( + .outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) continuation.yield(.finished(createdWorktree)) continuation.finish() } @@ -3099,7 +3212,8 @@ struct RepositoriesFeatureTests { $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } $0.gitClient.remoteNames = { _ in ["origin"] } $0.gitClient.fetchRemote = { _, _ in - throw NSError(domain: "git", code: 128, userInfo: [NSLocalizedDescriptionKey: "network unreachable"]) + throw NSError( + domain: "git", code: 128, userInfo: [NSLocalizedDescriptionKey: "network unreachable"]) } $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } @@ -3223,7 +3337,8 @@ struct RepositoriesFeatureTests { #expect(observedBaseDirectory.value == expectedBaseDirectory) } - @Test(.dependencies) func createRandomWorktreeUsesGlobalWorktreeBaseDirectoryWhenRepositoryOverrideMissing() async { + @Test(.dependencies) + func createRandomWorktreeUsesGlobalWorktreeBaseDirectoryWhenRepositoryOverrideMissing() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) @@ -3275,7 +3390,9 @@ struct RepositoriesFeatureTests { #expect(observedBaseDirectory.value == expectedBaseDirectory) } - @Test(.dependencies) func createRandomWorktreeUsesGlobalCopyFlagsWhenRepositoryOverridesMissing() async { + @Test(.dependencies) func createRandomWorktreeUsesGlobalCopyFlagsWhenRepositoryOverridesMissing() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) @@ -3306,7 +3423,9 @@ struct RepositoriesFeatureTests { $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in - observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } + observedCopyFlags.withValue { + $0 = (request.copyFiles.ignored, request.copyFiles.untracked) + } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -3355,7 +3474,9 @@ struct RepositoriesFeatureTests { $0.gitClient.ignoredFileCount = { _ in 0 } $0.gitClient.untrackedFileCount = { _ in 0 } $0.gitClient.createWorktreeStream = { request in - observedCopyFlags.withValue { $0 = (request.copyFiles.ignored, request.copyFiles.untracked) } + observedCopyFlags.withValue { + $0 = (request.copyFiles.ignored, request.copyFiles.untracked) + } return AsyncThrowingStream { continuation in continuation.yield(.finished(createdWorktree)) continuation.finish() @@ -3373,7 +3494,9 @@ struct RepositoriesFeatureTests { #expect(observedCopyFlags.value?.1 == false) } - @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { + @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) @@ -3394,7 +3517,8 @@ struct RepositoriesFeatureTests { $0.gitClient.createWorktreeStream = { _ in AsyncThrowingStream { continuation in continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) - continuation.finish(throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) + continuation.finish( + throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) } } } @@ -3536,7 +3660,8 @@ struct RepositoriesFeatureTests { @Test func pendingProgressUpdateIsIgnoredAfterCreateFailureRemovesPendingWorktree() async { let repoRoot = "/tmp/repo" - let repository = makeRepository(id: repoRoot, worktrees: [makeWorktree(id: repoRoot, name: "main")]) + let repository = makeRepository( + id: repoRoot, worktrees: [makeWorktree(id: repoRoot, name: "main")]) let pendingID = "pending:test" var state = makeState(repositories: [repository]) state.selection = .worktree(pendingID) @@ -3603,7 +3728,10 @@ struct RepositoriesFeatureTests { id: 0, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", - targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + targets: [ + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree.id, repositoryID: repository.id) + ], deleteBranch: false ) $0.nextDeleteWorktreeConfirmationID = 1 @@ -3630,7 +3758,10 @@ struct RepositoriesFeatureTests { id: 0, title: "Delete worktree?", message: "Delete \(worktree.name)? The worktree directory will be removed.", - targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + targets: [ + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree.id, repositoryID: repository.id) + ], deleteBranch: true ) $0.nextDeleteWorktreeConfirmationID = 1 @@ -3647,7 +3778,10 @@ struct RepositoriesFeatureTests { id: 0, title: "Delete worktree?", message: "Delete feature? The worktree directory will be removed.", - targets: [RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree.id, repositoryID: repository.id)], + targets: [ + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree.id, repositoryID: repository.id) + ], deleteBranch: true ) let forceDeleteAttempts = LockIsolated<[Bool]>([]) @@ -3663,7 +3797,8 @@ struct RepositoriesFeatureTests { if force { return .deleted } - throw GitClientError.commandFailed(command: "git branch -d feature", message: "not fully merged") + throw GitClientError.commandFailed( + command: "git branch -d feature", message: "not fully merged") } $0.gitClient.worktrees = { _ in [mainWorktree] } } @@ -3776,8 +3911,10 @@ struct RepositoriesFeatureTests { let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) let targets = [ - RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree1.id, repositoryID: repository.id), - RepositoriesFeature.DeleteWorktreeTarget(worktreeID: worktree2.id, repositoryID: repository.id), + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree1.id, repositoryID: repository.id), + RepositoriesFeature.DeleteWorktreeTarget( + worktreeID: worktree2.id, repositoryID: repository.id), ] let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() @@ -3813,7 +3950,9 @@ struct RepositoriesFeatureTests { TextState("Cancel") } } message: { - TextState("Find \(worktree.name) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay)).") + TextState( + "Find \(worktree.name) later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay))." + ) } await store.send(.worktreeLifecycle(.requestArchiveWorktree(worktree.id, repository.id))) { @@ -3826,8 +3965,10 @@ struct RepositoriesFeatureTests { let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "hawk", repoRoot: "/tmp/repo") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) let targets = [ - RepositoriesFeature.ArchiveWorktreeTarget(worktreeID: worktree1.id, repositoryID: repository.id), - RepositoriesFeature.ArchiveWorktreeTarget(worktreeID: worktree2.id, repositoryID: repository.id), + RepositoriesFeature.ArchiveWorktreeTarget( + worktreeID: worktree1.id, repositoryID: repository.id), + RepositoriesFeature.ArchiveWorktreeTarget( + worktreeID: worktree2.id, repositoryID: repository.id), ] let store = TestStore(initialState: makeState(repositories: [repository])) { RepositoriesFeature() @@ -3844,7 +3985,8 @@ struct RepositoriesFeatureTests { TextState("Cancel") } } message: { - TextState("Find them later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay)).") + TextState( + "Find them later in Menu Bar > Worktrees > Archived Worktrees (\(archivedDisplay)).") } await store.send(.worktreeLifecycle(.requestArchiveWorktrees(targets))) { @@ -3915,13 +4057,16 @@ struct RepositoriesFeatureTests { AsyncThrowingStream { continuation in continuation.yield(.line(ShellStreamLine(source: .stdout, text: "syncing"))) continuation.yield(.line(ShellStreamLine(source: .stdout, text: "done"))) - continuation.yield(.finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) + continuation.yield( + .finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) continuation.finish() } } } - await store.send(.worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id))) { + await store.send( + .worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) + ) { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", @@ -3995,7 +4140,9 @@ struct RepositoriesFeatureTests { TextState("Command failed: bash -lc exit 7\nstderr:\nfail") } - await store.send(.worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id))) { + await store.send( + .worktreeLifecycle(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) + ) { $0.archivingWorktreeIDs = [featureWorktree.id] $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( titleText: "Running archive script", @@ -4025,7 +4172,8 @@ struct RepositoriesFeatureTests { } await store.send( - .worktreeLifecycle(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) + .worktreeLifecycle( + .archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) ) #expect(store.state.archivedWorktrees.isEmpty) } @@ -4043,7 +4191,9 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - await store.send(.worktreeLifecycle(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure"))) + await store.send( + .worktreeLifecycle( + .archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure"))) #expect(store.state.alert == nil) #expect(store.state.archivedWorktrees.isEmpty) } @@ -4081,7 +4231,8 @@ struct RepositoriesFeatureTests { #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) await store.send( - .worktreeLifecycle(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) + .worktreeLifecycle( + .archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) ) #expect(store.state.archivingWorktreeIDs.isEmpty) #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) @@ -4119,7 +4270,9 @@ struct RepositoriesFeatureTests { #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) - await store.send(.worktreeLifecycle(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed"))) + await store.send( + .worktreeLifecycle( + .archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed"))) #expect(store.state.archivingWorktreeIDs.isEmpty) #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) #expect(store.state.alert != nil) @@ -4247,7 +4400,8 @@ struct RepositoriesFeatureTests { let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) let featureC = makeWorktree(id: "/tmp/repo/c", name: "c", repoRoot: repoRoot) - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) + let repository = makeRepository( + id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) var state = makeState(repositories: [repository]) state.isSidebarDragActive = true state.pendingSidebarNotifyReorderIDs = [featureA.id, featureC.id, featureB.id] @@ -4362,8 +4516,11 @@ struct RepositoriesFeatureTests { ) $0.repositories[id: repository.id] = repository } - #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.name == "falcon") - #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt == createdAt) + #expect( + store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.name == "falcon") + #expect( + store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt + == createdAt) } @Test func orderedWorktreeRowsAreGlobal() { @@ -4493,7 +4650,9 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - await store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3))) { + await store.send( + .worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repoRoot, IndexSet(integer: 0), 3)) + ) { $0.worktreeOrderByRepository[repoRoot] = [worktree2.id, worktree3.id, worktree1.id] } } @@ -4512,7 +4671,9 @@ struct RepositoriesFeatureTests { RepositoriesFeature() } - await store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0))) { + await store.send( + .worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repoA, IndexSet(integer: 1), 0)) + ) { $0.pinnedWorktreeIDs = [worktreeA2.id, worktreeB1.id, worktreeA1.id] } } @@ -4597,14 +4758,16 @@ struct RepositoriesFeatureTests { } await store.receive(\.delegate.repositoriesChanged) - #expect(store.state.archivedWorktrees == [ArchivedWorktree(id: worktree.id, archivedAt: fixedDate)]) + #expect( + store.state.archivedWorktrees == [ArchivedWorktree(id: worktree.id, archivedAt: fixedDate)]) } @Test func repositoriesLoadedSkipsSelectionChangeWhenOnlyDisplayDataChanges() async { let repoRoot = "/tmp/repo" let worktree = makeWorktree(id: "/tmp/repo/main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [worktree]) - let updatedWorktree = makeWorktree(id: "/tmp/repo/main", name: "main-updated", repoRoot: repoRoot) + let updatedWorktree = makeWorktree( + id: "/tmp/repo/main", name: "main-updated", repoRoot: repoRoot) let updatedRepository = makeRepository(id: repoRoot, worktrees: [updatedWorktree]) var initialState = makeState(repositories: [repository]) initialState.selection = .worktree(worktree.id) @@ -4754,7 +4917,8 @@ struct RepositoriesFeatureTests { let existingWorktree = makeWorktree(id: "/tmp/repo/wt-main", name: "main", repoRoot: repoRoot) let repository = makeRepository(id: repoRoot, worktrees: [existingWorktree]) let newWorktree = makeWorktree(id: "/tmp/repo/wt-new", name: "new", repoRoot: repoRoot) - let updatedRepository = makeRepository(id: repoRoot, worktrees: [newWorktree, existingWorktree]) + let updatedRepository = makeRepository( + id: repoRoot, worktrees: [newWorktree, existingWorktree]) let pendingID = "pending:\(UUID().uuidString)" var initialState = makeState(repositories: [repository]) initialState.pendingWorktrees = [ @@ -4898,7 +5062,9 @@ struct RepositoriesFeatureTests { } } - @Test(.dependencies) func repositoryPullRequestsLoadedAutoDeleteOnlyDeletesProwlCreatedBranches() async { + @Test(.dependencies) func repositoryPullRequestsLoadedAutoDeleteOnlyDeletesProwlCreatedBranches() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let externalWorktree = makeWorktree( @@ -4968,7 +5134,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.mergedWorktreeAction = .archive @@ -4977,7 +5144,8 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") let mergedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() @@ -5013,7 +5181,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5021,7 +5190,8 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") let mergedStrategies = LockIsolated<[PullRequestMergeStrategy]>([]) @Shared(.settingsFile) var settingsFile $settingsFile.withLock { @@ -5063,8 +5233,10 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5114,7 +5286,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5169,7 +5342,8 @@ struct RepositoriesFeatureTests { repoRoot: repoRoot ) let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) - let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + let openPullRequest = makePullRequest( + state: "OPEN", headRefName: featureWorktree.name, number: 12) var state = makeState(repositories: [repository]) state.githubIntegrationAvailability = .disabled state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( @@ -5177,7 +5351,8 @@ struct RepositoriesFeatureTests { removedLines: nil, pullRequest: openPullRequest ) - let upstreamRemoteInfo = GithubRemoteInfo(host: "github.com", owner: "supabitapp", repo: "supacode") + let upstreamRemoteInfo = GithubRemoteInfo( + host: "github.com", owner: "supabitapp", repo: "supacode") let closedNumbers = LockIsolated<[Int]>([]) let store = TestStore(initialState: state) { RepositoriesFeature() @@ -5409,10 +5584,11 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { - $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) } await store.receive(\.githubIntegration.refreshGithubIntegrationAvailability) { $0.githubIntegrationAvailability = .checking @@ -5455,10 +5631,11 @@ struct RepositoriesFeatureTests { ) ) await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { - $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) } await store.finish() } @@ -5474,10 +5651,11 @@ struct RepositoriesFeatureTests { let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .unavailable - initialState.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + initialState.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { @@ -5514,20 +5692,22 @@ struct RepositoriesFeatureTests { var initialState = makeState(repositories: [repository]) initialState.githubIntegrationAvailability = .available initialState.inFlightPullRequestRefreshRepositoryIDs = [repository.id] - initialState.queuedPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + initialState.queuedPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) let store = TestStore(initialState: initialState) { RepositoriesFeature() } await store.send(.githubIntegration(.githubIntegrationAvailabilityUpdated(false))) { $0.githubIntegrationAvailability = .unavailable - $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] - ) + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) $0.queuedPullRequestRefreshByRepositoryID = [:] $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -5543,10 +5723,11 @@ struct RepositoriesFeatureTests { @Test func githubIntegrationAvailabilityUpdatedWhileDisabledIsIgnored() async { var state = makeState(repositories: []) state.githubIntegrationAvailability = .disabled - state.pendingPullRequestRefreshByRepositoryID["repo"] = RepositoriesFeature.PendingPullRequestRefresh( - repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), - worktreeIDs: [] - ) + state.pendingPullRequestRefreshByRepositoryID["repo"] = + RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + worktreeIDs: [] + ) let expectedState = state let store = TestStore(initialState: state) { RepositoriesFeature() @@ -5727,7 +5908,9 @@ struct RepositoriesFeatureTests { await store.receive(\.worktreeLifecycle.worktreeDeleted) } - @Test(.dependencies) func autoDeleteExpiredArchivedWorktreesOnlyDeletesProwlCreatedBranches() async { + @Test(.dependencies) func autoDeleteExpiredArchivedWorktreesOnlyDeletesProwlCreatedBranches() + async + { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) let expiredWorktree = makeWorktree(id: "/tmp/repo/expired", name: "expired", repoRoot: repoRoot) @@ -6318,8 +6501,10 @@ struct RepositoriesFeatureTests { let pullRequest = makePullRequest(state: "OPEN", headRefName: "feature") applyWorkspaceChildrenInfo( [ - WorkspaceChildInfoUpdate(id: "/ws/app", branch: "feature", added: 7, removed: 2, pullRequest: pullRequest), - WorkspaceChildInfoUpdate(id: "/ws/api", branch: " ", added: 0, removed: 0, pullRequest: nil), + WorkspaceChildInfoUpdate( + id: "/ws/app", branch: "feature", added: 7, removed: 2, pullRequest: pullRequest), + WorkspaceChildInfoUpdate( + id: "/ws/api", branch: " ", added: 0, removed: 0, pullRequest: nil), ], state: &state ) @@ -6345,7 +6530,8 @@ struct RepositoriesFeatureTests { var state = makeState(repositories: [repository]) let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) state.workspaceChildBranchByID[childID] = "live-branch" - state.workspaceChildInfoByID[childID] = WorktreeInfoEntry(addedLines: 3, removedLines: 1, pullRequest: nil) + state.workspaceChildInfoByID[childID] = WorktreeInfoEntry( + addedLines: 3, removedLines: 1, pullRequest: nil) let rows = state.workspaceChildRows(in: repository) @@ -6372,6 +6558,50 @@ struct RepositoriesFeatureTests { #expect(rows.first?.info == nil) } + @Test func openWorkspaceChildFocusesOrCreatesBoundTerminalTabInChildDirectory() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-open-child", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: makeState(repositories: [repository])) { + RepositoriesFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sentCommands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.openWorkspaceChild(childID)) { + $0.selection = .repository(repository.id) + $0.selectedWorkspaceChildID = childID + $0.openedWorktreeIDs = [repository.id] + $0.pendingTerminalFocusWorktreeIDs = [repository.id] + } + await store.finish() + + let workspaceWorktree = Worktree( + id: repository.id, + name: repository.name, + detail: repository.rootURL.path(percentEncoded: false), + workingDirectory: repository.rootURL, + repositoryRootURL: repository.rootURL + ) + #expect( + sentCommands.value == [ + .focusOrCreateTabInDirectory( + workspaceWorktree, + directory: URL(fileURLWithPath: childID), + title: "App" + ) + ]) + } + @Test func repositoriesLoadedRefreshesAndPrunesWorkspaceChildren() async { let entry = ProjectWorkspace.RepositoryEntry( id: "app", @@ -6410,6 +6640,103 @@ struct RepositoriesFeatureTests { #expect(store.state.workspaceChildInfoByID[childID]?.removedLines == 2) } + @Test func openRepositoriesFinishedRefreshesWorkspaceChildren() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-open-refresh", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + let store = TestStore(initialState: RepositoriesFeature.State()) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.branchName = { _ in "feature/live" } + $0.gitClient.lineChanges = { _ in nil } + $0.gitClient.remoteInfo = { _ in nil } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + await store.send( + .repositoryManagement( + .openRepositoriesFinished( + [repository], + failures: [], + invalidRoots: [], + openFailures: [], + roots: [repository.rootURL] + )) + ) { + $0.repositories = [repository] + $0.repositoryRoots = [repository.rootURL] + $0.isInitialLoadComplete = true + $0.snapshotPersistencePhase = .active + } + await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.workspaceChildrenInfoLoaded) { + $0.workspaceChildBranchByID = [childID: "feature/live"] + } + await store.finish() + } + + @Test func workspaceChildRefreshLoadsPullRequestWhenGithubIntegrationAvailable() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-pr", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + let pullRequest = makePullRequest(state: "OPEN", headRefName: "feature/live", number: 42) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + struct PullRequestBatchCall: Equatable, Sendable { + let host: String + let owner: String + let repo: String + let branches: [String] + } + let calls = LockIsolated<[PullRequestBatchCall]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.branchName = { _ in "feature/live" } + $0.gitClient.lineChanges = { _ in nil } + $0.gitClient.remoteInfo = { url in + #expect(url.path(percentEncoded: false) == childID) + return GithubRemoteInfo(host: "github.com", owner: "onevcat", repo: "app") + } + $0.githubCLI.batchPullRequests = { host, owner, repo, branches, accountOverride in + #expect(accountOverride == nil) + calls.withValue { + $0.append( + PullRequestBatchCall(host: host, owner: owner, repo: repo, branches: branches)) + } + return ["feature/live": pullRequest] + } + $0.repositoryPersistence.saveRepositorySnapshot = { _ in } + } + store.exhaustivity = .off + + await store.send( + .repositoriesLoaded([repository], failures: [], roots: [repository.rootURL], animated: false) + ) + await store.receive(\.workspaceChildrenInfoLoaded) + await store.finish() + + #expect(calls.value.count == 1) + #expect(calls.value.first?.host == "github.com") + #expect(calls.value.first?.owner == "onevcat") + #expect(calls.value.first?.repo == "app") + #expect(calls.value.first?.branches == ["feature/live"]) + #expect(store.state.workspaceChildInfoByID[childID]?.pullRequest == pullRequest) + } + private func makeWorktree( id: String, name: String, -- 2.51.2