diff --git a/supacode/Features/Repositories/Models/SidebarPresentation.swift b/supacode/Features/Repositories/Models/SidebarPresentation.swift index 44617867..e1e5031c 100644 --- a/supacode/Features/Repositories/Models/SidebarPresentation.swift +++ b/supacode/Features/Repositories/Models/SidebarPresentation.swift @@ -79,7 +79,19 @@ struct SidebarRepositoryContainerModel: Equatable, Identifiable { var kind: Repository.Kind var isExpanded: Bool var isRemoving: Bool + var isWorkspace: Bool var worktreeSections: WorktreeRowSections + var workspaceChildRows: [WorkspaceChildRowModel] +} + +/// A display-only sidebar row for one workspace child repository. `branchName` +/// is the live current branch (falling back to metadata); `info` carries the +/// uncommitted diff counts and PR, mirroring a worktree row's badges. +struct WorkspaceChildRowModel: Equatable, Identifiable { + let id: String + let repositoryName: String + let branchName: String? + let info: WorktreeInfoEntry? } struct FailedRepositoryModel: Equatable, Identifiable { @@ -157,7 +169,11 @@ extension RepositoriesFeature.State { kind: repository.kind, isExpanded: isExpanded, isRemoving: isRemovingRepository(repository), - worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty + isWorkspace: repository.isWorkspace, + worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty, + workspaceChildRows: isExpanded && repository.isWorkspace + ? workspaceChildRows(in: repository) + : [] ) ) ) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index f26798d5..17ae0feb 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -284,6 +284,7 @@ extension RepositoriesFeature { { allEffects.append(effect) } + allEffects.append(refreshWorkspaceChildrenEffect(state: state)) return .merge(allEffects) case .refreshAllCustomTitles: @@ -848,6 +849,10 @@ extension RepositoriesFeature { ) return .none + case .workspaceChildrenInfoLoaded(let updates): + applyWorkspaceChildrenInfo(updates, state: &state) + return .none + case .alert(.dismiss): dismissCurrentForceDeleteBranchRequest(state: &state) return .none diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index 4d8c7d1e..42018c1a 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -324,6 +324,7 @@ extension RepositoriesFeature { .map(SidebarSelection.worktree) state.shouldSelectFirstAfterReload = false } + pruneWorkspaceChildInfo(state: &state) return ApplyRepositoriesResult( didPrunePinned: didPrunePinned, didPruneRepositoryOrder: didPruneRepositoryOrder, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift index 21be338f..d741a893 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+StateQueries.swift @@ -148,6 +148,43 @@ extension RepositoriesFeature.State { return worktrees.filter { !archivedSet.contains($0.id) } } + /// Child repositories materialized inside every workspace, resolved to their + /// on-disk working directory. Used both to refresh their live status and to + /// render their sidebar rows. Child id is the working-directory path string. + func resolvedWorkspaceChildren(in repository: Repository) -> [ResolvedWorkspaceChild] { + guard let workspace = repository.workspace else { + return [] + } + return workspace.repositories.map { entry in + let url = entry.resolvedURL(relativeTo: repository.rootURL) + return ResolvedWorkspaceChild( + id: url.path(percentEncoded: false), + workspaceID: repository.id, + repositoryName: entry.name, + metadataBranch: entry.branchName, + workingDirectory: url + ) + } + } + + func allResolvedWorkspaceChildren() -> [ResolvedWorkspaceChild] { + repositories.filter(\.isWorkspace).flatMap { resolvedWorkspaceChildren(in: $0) } + } + + /// Sidebar display rows for one workspace's children, merging the resolved + /// metadata with live branch (`workspaceChildBranchByID`) and diff/PR info + /// (`workspaceChildInfoByID`). Live branch falls back to the metadata branch. + func workspaceChildRows(in repository: Repository) -> [WorkspaceChildRowModel] { + resolvedWorkspaceChildren(in: repository).map { child in + WorkspaceChildRowModel( + id: child.id, + repositoryName: child.repositoryName, + branchName: workspaceChildBranchByID[child.id] ?? child.metadataBranch, + info: workspaceChildInfoByID[child.id] + ) + } + } + struct ArchivedWorktreeGroup: Equatable { var repository: Repository var worktrees: [Worktree] diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift new file mode 100644 index 00000000..419b687f --- /dev/null +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorkspaceChildren.swift @@ -0,0 +1,130 @@ +import ComposableArchitecture +import Foundation + +/// A workspace child repository resolved to its on-disk working directory. +/// `id` is the working-directory path string (the key for all child maps). +struct ResolvedWorkspaceChild: Equatable, Sendable, Identifiable { + let id: String + let workspaceID: Repository.ID + let repositoryName: String + let metadataBranch: String? + let workingDirectory: URL +} + +extension RepositoriesFeature { + /// Refresh live status (current branch + uncommitted diff) for every + /// workspace child. Driven from `repositoriesLoaded`, which fires on initial + /// load, explicit reloads, and the periodic scene-active refresh — so child + /// rows update on the same cadence without watching their files directly. + /// + /// Workspace children are deliberately NOT fed through the worktree info + /// watcher: that pipeline bails on any worktree not tracked in + /// `repository.worktrees`, and children are metadata entries, not tracked + /// worktrees. + func refreshWorkspaceChildrenEffect(state: State) -> Effect { + let children = state.allResolvedWorkspaceChildren() + guard !children.isEmpty else { + return .none + } + let gitClient = self.gitClient + let githubCLI = self.githubCLI + // PR fetch only when GitHub integration is globally available; each child is + // its own repo, so its remote is resolved from the child's own git root. + let fetchesPullRequests = state.githubIntegrationAvailability == .available + return .run { send in + let updates = await withTaskGroup(of: WorkspaceChildInfoUpdate.self) { group in + for child in children { + group.addTask { + async let branchTask = gitClient.branchName(child.workingDirectory) + async let changesTask = gitClient.lineChanges(child.workingDirectory) + let changes = await changesTask + let branch = await branchTask + let pullRequest = await Self.fetchWorkspaceChildPullRequest( + workingDirectory: child.workingDirectory, + branch: branch, + enabled: fetchesPullRequests, + gitClient: gitClient, + githubCLI: githubCLI + ) + return WorkspaceChildInfoUpdate( + id: child.id, + branch: branch, + added: changes?.added, + removed: changes?.removed, + pullRequest: pullRequest + ) + } + } + var results: [WorkspaceChildInfoUpdate] = [] + for await update in group { + results.append(update) + } + return results + } + await send(.workspaceChildrenInfoLoaded(updates)) + } + .cancellable(id: CancelID.workspaceChildrenRefresh, cancelInFlight: true) + } + + /// Resolve the child repo's GitHub remote (local-only, cheap) and fetch the + /// PR for the current branch. Returns nil on any failure — children are + /// best-effort status, never blocking. + nonisolated private static func fetchWorkspaceChildPullRequest( + workingDirectory: URL, + branch: String?, + enabled: Bool, + gitClient: GitClientDependency, + githubCLI: GithubCLIClient + ) async -> GithubPullRequest? { + guard enabled, + let branch = branch?.trimmingCharacters(in: .whitespacesAndNewlines), !branch.isEmpty, + let remote = await gitClient.remoteInfo(workingDirectory) + else { + return nil + } + let pullRequestsByBranch = try? await githubCLI.batchPullRequests( + remote.host, + remote.owner, + remote.repo, + [branch] + ) + return pullRequestsByBranch?[branch] + } +} + +/// Merge a batch of child refresh results into the child maps. +func applyWorkspaceChildrenInfo( + _ updates: [WorkspaceChildInfoUpdate], + state: inout RepositoriesFeature.State +) { + for update in updates { + if let branch = update.branch?.trimmingCharacters(in: .whitespacesAndNewlines), !branch.isEmpty { + state.workspaceChildBranchByID[update.id] = branch + } else { + state.workspaceChildBranchByID.removeValue(forKey: update.id) + } + + var entry = state.workspaceChildInfoByID[update.id] ?? WorktreeInfoEntry() + if let added = update.added, let removed = update.removed, !(added == 0 && removed == 0) { + entry.addedLines = added + entry.removedLines = removed + } else { + entry.addedLines = nil + entry.removedLines = nil + } + entry.pullRequest = update.pullRequest + if entry.isEmpty { + state.workspaceChildInfoByID.removeValue(forKey: update.id) + } else { + state.workspaceChildInfoByID[update.id] = entry + } + } +} + +/// Drop child map entries that no longer belong to any current workspace. +/// Called from `applyRepositories` on every reload. +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) } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 00f8f0f6..0aee3c16 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -69,6 +69,17 @@ struct ForceDeleteBranchRequest: Equatable { let errorMessage: String } +// Result of refreshing one workspace child repository's live status: current +// branch, uncommitted diff counts, and (when GitHub integration is available) +// the PR for that branch. +struct WorkspaceChildInfoUpdate: Equatable, Sendable { + let id: String + let branch: String? + let added: Int? + let removed: Int? + let pullRequest: GithubPullRequest? +} + struct RemoveWorkspaceConfirmation: Equatable { struct BranchOption: Equatable, Identifiable { let id: String @@ -94,6 +105,7 @@ struct RepositoriesFeature { static let worktreePromptLoad = "repositories.worktreePromptLoad" static let worktreePromptValidation = "repositories.worktreePromptValidation" static let workspaceCreation = "repositories.workspaceCreation" + static let workspaceChildrenRefresh = "repositories.workspaceChildrenRefresh" static func archiveScript(_ worktreeID: Worktree.ID) -> String { "repositories.archiveScript.\(worktreeID)" } @@ -266,6 +278,13 @@ struct RepositoriesFeature { var repositoryCustomTitles: [Repository.ID: String] = [:] var selection: SidebarSelection? var worktreeInfoByID: [Worktree.ID: WorktreeInfoEntry] = [:] + // Live status for workspace child repositories, keyed by the child's + // working-directory path. Kept separate from `worktreeInfoByID` (which is + // pruned to tracked worktrees) because workspace children are not tracked + // worktrees — they are metadata entries materialized inside the workspace + // folder. Refreshed by `refreshWorkspaceChildrenEffect` on each repo reload. + var workspaceChildInfoByID: [String: WorktreeInfoEntry] = [:] + var workspaceChildBranchByID: [String: String] = [:] var worktreeOrderByRepository: [Repository.ID: [Worktree.ID]] = [:] var isOpenPanelPresented = false var isInitialLoadComplete = false @@ -412,6 +431,7 @@ struct RepositoriesFeature { case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) case worktreeBranchNameLoaded(worktreeID: Worktree.ID, name: String) case worktreeLineChangesLoaded(worktreeID: Worktree.ID, added: Int, removed: Int) + case workspaceChildrenInfoLoaded([WorkspaceChildInfoUpdate]) case showToast(StatusToast) case dismissToast case worktreeCreationPrompt(PresentationAction) diff --git a/supacode/Features/Repositories/Views/RepositorySectionView.swift b/supacode/Features/Repositories/Views/RepositorySectionView.swift index 45103c7d..935b36b9 100644 --- a/supacode/Features/Repositories/Views/RepositorySectionView.swift +++ b/supacode/Features/Repositories/Views/RepositorySectionView.swift @@ -22,6 +22,11 @@ struct RepositorySectionView: View { var body: some View { let state = store.state let isExpanded = expandedRepoIDs.contains(repository.id) + // Workspaces are `.plain` (no git worktrees) but still expand to reveal + // their child repository rows, so they get the chevron even though + // `supportsWorktrees` is false. Worktree-creation affordances stay gated on + // `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 openRepoSettings = { @@ -147,7 +152,7 @@ struct RepositorySectionView: View { ) .disabled(isRemovingRepository) } - if repository.capabilities.supportsWorktrees { + if isExpandable { Button { toggleExpanded() } label: { @@ -186,7 +191,7 @@ struct RepositorySectionView: View { .frame(maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center) .padding(.horizontal, 12) .padding(.top, hasTopSpacing ? 4 : 0) - .padding(.bottom, hasTopSpacing && !repository.capabilities.supportsWorktrees ? 4 : 0) + .padding(.bottom, hasTopSpacing && !isExpandable ? 4 : 0) .contentShape(.interaction, .rect) .background { if isSelected { @@ -236,14 +241,18 @@ struct RepositorySectionView: View { header .tag(SidebarSelection.repository(repository.id)) if isExpanded { - WorktreeRowsView( - repository: repository, - isExpanded: isExpanded, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs, - store: store, - terminalManager: terminalManager - ) + if repository.isWorkspace { + WorkspaceChildRowsView(rows: state.workspaceChildRows(in: repository)) + } else { + WorktreeRowsView( + repository: repository, + isExpanded: isExpanded, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs, + store: store, + terminalManager: terminalManager + ) + } } } .id(SidebarScrollID.repository(repository.id)) diff --git a/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift new file mode 100644 index 00000000..53a46758 --- /dev/null +++ b/supacode/Features/Repositories/Views/WorkspaceChildRowsView.swift @@ -0,0 +1,40 @@ +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. +struct WorkspaceChildRowsView: View { + let rows: [WorkspaceChildRowModel] + + 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) + } + } +} diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 3d010394..89581455 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -545,6 +545,7 @@ struct RepositoriesFeatureTests { workspace: workspace ) + let childID = repository.rootURL.appending(path: "app").standardizedFileURL.path(percentEncoded: false) let store = TestStore(initialState: RepositoriesFeature.State()) { RepositoriesFeature() } withDependencies: { @@ -560,6 +561,11 @@ struct RepositoriesFeatureTests { Issue.record("workspace should not load git worktrees: \(url.path(percentEncoded: false))") return [] } + // The workspace's child repository is refreshed via the child pipeline + // (live branch + diff), distinct from the worktree probing above. + $0.gitClient.branchName = { _ in "main" } + $0.gitClient.lineChanges = { _ in nil } + $0.gitClient.remoteInfo = { _ in nil } } await store.send(.loadPersistedRepositories) @@ -570,6 +576,9 @@ struct RepositoriesFeatureTests { $0.snapshotPersistencePhase = .active } await store.receive(\.delegate.repositoriesChanged) + await store.receive(\.workspaceChildrenInfoLoaded) { + $0.workspaceChildBranchByID = [childID: "main"] + } await store.finish() } @@ -6056,6 +6065,118 @@ struct RepositoriesFeatureTests { #expect(store.state.canNavigateWorktreeHistoryBackward) } + // MARK: - Workspace child rows + + private func makeWorkspaceRepository( + id: String, + children: [ProjectWorkspace.RepositoryEntry] + ) -> Repository { + makeRepository( + id: id, + name: "Workspace", + kind: .plain, + worktrees: [], + workspace: ProjectWorkspace(title: "Workspace", repositories: children) + ) + } + + @Test func applyWorkspaceChildrenInfoWritesBranchDiffAndPR() { + var state = RepositoriesFeature.State() + 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), + ], + state: &state + ) + + #expect(state.workspaceChildBranchByID["/ws/app"] == "feature") + #expect(state.workspaceChildInfoByID["/ws/app"]?.addedLines == 7) + #expect(state.workspaceChildInfoByID["/ws/app"]?.removedLines == 2) + #expect(state.workspaceChildInfoByID["/ws/app"]?.pullRequest == pullRequest) + // Blank branch + empty diff + no PR → no entries. + #expect(state.workspaceChildBranchByID["/ws/api"] == nil) + #expect(state.workspaceChildInfoByID["/ws/api"] == nil) + } + + @Test func workspaceChildRowsMergesLiveBranchAndInfo() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata-branch" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws", children: [entry]) + 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) + + let rows = state.workspaceChildRows(in: repository) + + #expect(rows.count == 1) + #expect(rows.first?.repositoryName == "App") + // Live branch wins over the metadata branch. + #expect(rows.first?.branchName == "live-branch") + #expect(rows.first?.info?.addedLines == 3) + } + + @Test func workspaceChildRowsFallsBackToMetadataBranch() { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .bareRepository, + branchName: "metadata-branch" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws2", children: [entry]) + let state = makeState(repositories: [repository]) + + let rows = state.workspaceChildRows(in: repository) + #expect(rows.first?.branchName == "metadata-branch") + #expect(rows.first?.info == nil) + } + + @Test func repositoriesLoadedRefreshesAndPrunesWorkspaceChildren() async { + let entry = ProjectWorkspace.RepositoryEntry( + id: "app", + name: "App", + path: "app", + sourceKind: .existingPath, + branchName: "metadata" + ) + let repository = makeWorkspaceRepository(id: "/tmp/ws-refresh", children: [entry]) + let childID = entry.resolvedURL(relativeTo: repository.rootURL).path(percentEncoded: false) + var initialState = makeState(repositories: [repository]) + // A stale child entry from a workspace that no longer exists must be pruned. + initialState.workspaceChildInfoByID["/tmp/gone/app"] = WorktreeInfoEntry( + addedLines: 1, + removedLines: 1, + pullRequest: nil + ) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.branchName = { _ in "feature/live" } + $0.gitClient.lineChanges = { _ in (7, 2) } + $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(store.state.workspaceChildInfoByID["/tmp/gone/app"] == nil) + #expect(store.state.workspaceChildBranchByID[childID] == "feature/live") + #expect(store.state.workspaceChildInfoByID[childID]?.addedLines == 7) + #expect(store.state.workspaceChildInfoByID[childID]?.removedLines == 2) + } + private func makeWorktree( id: String, name: String,