diff --git a/supacode/Features/Repositories/Models/SidebarPresentation.swift b/supacode/Features/Repositories/Models/SidebarPresentation.swift new file mode 100644 index 00000000..8fbc6053 --- /dev/null +++ b/supacode/Features/Repositories/Models/SidebarPresentation.swift @@ -0,0 +1,196 @@ +import Foundation + +struct SidebarPresentation: Equatable { + var items: [SidebarItem] + + static func showsListHeader(repositoryCount: Int) -> Bool { + repositoryCount > 10 + } + + var repositoryOrderIDs: [Repository.ID] { + items.compactMap(\.repositoryOrderID) + } + + func repositoryOrderAfterMove( + fromOffsets source: IndexSet, + toOffset destination: Int + ) -> [Repository.ID] { + var orderedIDs = repositoryOrderIDs + orderedIDs.moveElements(fromOffsets: source, toOffset: destination) + return orderedIDs + } +} + +enum SidebarItem: Equatable, Identifiable { + case listHeader(SidebarListHeaderModel) + case repository(SidebarRepositoryContainerModel) + case failedRepository(FailedRepositoryModel) + case archivedWorktrees(ArchivedWorktreesRowModel) + + var id: SidebarPresentationItemID { + switch self { + case .listHeader: + return .listHeader + case .repository(let model): + return .repository(model.id) + case .failedRepository(let model): + return .failedRepository(model.id) + case .archivedWorktrees: + return .archivedWorktrees + } + } + + var repositoryOrderID: Repository.ID? { + switch self { + case .repository(let model): + return model.id + case .failedRepository(let model) where model.isReorderable: + return model.id + case .listHeader, .failedRepository, .archivedWorktrees: + return nil + } + } +} + +enum SidebarPresentationItemID: Equatable, Hashable { + case listHeader + case repository(Repository.ID) + case failedRepository(Repository.ID) + case archivedWorktrees +} + +struct SidebarListHeaderModel: Equatable, Identifiable { + let id = SidebarPresentationItemID.listHeader + var repositoryCount: Int +} + +struct SidebarRepositoryContainerModel: Equatable, Identifiable { + var id: Repository.ID { repositoryID } + + var repositoryID: Repository.ID + var title: String + var rootURL: URL + var kind: Repository.Kind + var isExpanded: Bool + var isRemoving: Bool + var worktreeSections: WorktreeRowSections +} + +struct FailedRepositoryModel: Equatable, Identifiable { + var id: Repository.ID + var name: String + var path: String + var failureMessage: String + var isReorderable: Bool +} + +struct ArchivedWorktreesRowModel: Equatable, Identifiable { + let id = SidebarPresentationItemID.archivedWorktrees + var count: Int +} + +enum SidebarWorktreeSection: Equatable { + case pinned + case unpinned +} + +struct SidebarWorktreeDropTarget: Equatable { + var repositoryID: Repository.ID + var section: SidebarWorktreeSection + var source: IndexSet + var destination: Int + + var action: RepositoriesFeature.WorktreeOrderingAction { + switch section { + case .pinned: + return .pinnedWorktreesMoved(repositoryID: repositoryID, source, destination) + case .unpinned: + return .unpinnedWorktreesMoved(repositoryID: repositoryID, source, destination) + } + } +} + +extension RepositoriesFeature.State { + func sidebarPresentation( + expandedRepositoryIDs: Set, + includesArchivedWorktreesRow: Bool = false + ) -> SidebarPresentation { + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) + let roots = sidebarPresentationRoots() + let repositoryCount = roots.count + var items: [SidebarItem] = [] + + if SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) { + items.append(.listHeader(SidebarListHeaderModel(repositoryCount: repositoryCount))) + } + + for rootURL in roots { + let standardizedRootURL = rootURL.standardizedFileURL + let repositoryID = standardizedRootURL.path(percentEncoded: false) + if let failureMessage = loadFailuresByID[repositoryID] { + let path = standardizedRootURL.path(percentEncoded: false) + items.append( + .failedRepository( + FailedRepositoryModel( + id: repositoryID, + name: Repository.name(for: standardizedRootURL), + path: path, + failureMessage: failureMessage, + isReorderable: true + ) + ) + ) + } else if let repository = repositoriesByID[repositoryID] { + let isExpanded = expandedRepositoryIDs.contains(repository.id) + items.append( + .repository( + SidebarRepositoryContainerModel( + repositoryID: repository.id, + title: repository.name, + rootURL: repository.rootURL, + kind: repository.kind, + isExpanded: isExpanded, + isRemoving: isRemovingRepository(repository), + worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty + ) + ) + ) + } + } + + if includesArchivedWorktreesRow, !archivedWorktrees.isEmpty { + items.append(.archivedWorktrees(ArchivedWorktreesRowModel(count: archivedWorktrees.count))) + } + + return SidebarPresentation(items: items) + } + + private func sidebarPresentationRoots() -> [URL] { + let orderedRoots = orderedRepositoryRoots() + if !orderedRoots.isEmpty { + return orderedRoots + } + return repositories.map(\.rootURL) + } +} + +extension WorktreeRowSections { + static let empty = WorktreeRowSections( + main: nil, + pinned: [], + pending: [], + unpinned: [] + ) +} + +extension Array { + fileprivate mutating func moveElements(fromOffsets source: IndexSet, toOffset destination: Int) { + let sourceIndexes = source.sorted() + let movedElements = sourceIndexes.map { self[$0] } + for index in sourceIndexes.reversed() { + remove(at: index) + } + let removedBeforeDestination = sourceIndexes.filter { $0 < destination }.count + insert(contentsOf: movedElements, at: destination - removedBeforeDestination) + } +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift index 587fcaf6..0bd227d0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift @@ -142,32 +142,46 @@ extension RepositoriesFeature { return .merge(effects) case .worktreeNotificationReceived(let worktreeID): - guard let repositoryID = state.repositoryID(containing: worktreeID), - let repository = state.repositories[id: repositoryID], - let worktree = repository.worktrees[id: worktreeID] - else { + guard notificationReorderTarget(for: worktreeID, state: state) != nil else { return .none } - if state.isWorktreeArchived(worktree.id) { + if state.isSidebarDragActive { + state.pendingSidebarNotifyReorderIDs.removeAll { $0 == worktreeID } + state.pendingSidebarNotifyReorderIDs.append(worktreeID) return .none } - if state.moveNotifiedWorktreeToTop, !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { - let reordered = reorderedUnpinnedWorktreeIDs( - for: worktreeID, - in: repository, - state: state - ) - if state.worktreeOrderByRepository[repositoryID] != reordered { - withAnimation(.snappy(duration: 0.2)) { - state.worktreeOrderByRepository[repositoryID] = reordered - } - let worktreeOrderByRepository = state.worktreeOrderByRepository - return .run { _ in - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) - } - } + guard applyNotificationReorder(for: worktreeID, state: &state, animated: true) else { + return .none + } + let worktreeOrderByRepository = state.worktreeOrderByRepository + return .run { _ in + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) + } + + case .setSidebarDragActive(let isActive): + guard state.isSidebarDragActive != isActive else { + return .none + } + state.isSidebarDragActive = isActive + guard !isActive else { + return .none + } + let pendingWorktreeIDs = state.pendingSidebarNotifyReorderIDs + state.pendingSidebarNotifyReorderIDs = [] + guard !pendingWorktreeIDs.isEmpty else { + return .none + } + var didReorder = false + for worktreeID in pendingWorktreeIDs { + didReorder = applyNotificationReorder(for: worktreeID, state: &state, animated: false) || didReorder + } + guard didReorder else { + return .none + } + let worktreeOrderByRepository = state.worktreeOrderByRepository + return .run { _ in + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) } - return .none case .setMoveNotifiedWorktreeToTop(let isEnabled): state.moveNotifiedWorktreeToTop = isEnabled @@ -184,3 +198,46 @@ extension RepositoriesFeature { } } } + +private func notificationReorderTarget( + for worktreeID: Worktree.ID, + state: RepositoriesFeature.State +) -> (repositoryID: Repository.ID, repository: Repository)? { + guard state.moveNotifiedWorktreeToTop, + let repositoryID = state.repositoryID(containing: worktreeID), + let repository = state.repositories[id: repositoryID], + let worktree = repository.worktrees[id: worktreeID], + !state.isWorktreeArchived(worktree.id), + !state.isMainWorktree(worktree), + !state.isWorktreePinned(worktree) + else { + return nil + } + return (repositoryID, repository) +} + +private func applyNotificationReorder( + for worktreeID: Worktree.ID, + state: inout RepositoriesFeature.State, + animated: Bool +) -> Bool { + guard let target = notificationReorderTarget(for: worktreeID, state: state) else { + return false + } + let reordered = reorderedUnpinnedWorktreeIDs( + for: worktreeID, + in: target.repository, + state: state + ) + guard state.worktreeOrderByRepository[target.repositoryID] != reordered else { + return false + } + if animated { + withAnimation(.snappy(duration: 0.2)) { + state.worktreeOrderByRepository[target.repositoryID] = reordered + } + } else { + state.worktreeOrderByRepository[target.repositoryID] = reordered + } + return true +} diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index a3d85ed7..18be5ba1 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -146,6 +146,7 @@ struct RepositoriesFeature { case pinWorktree(Worktree.ID) case unpinWorktree(Worktree.ID) case worktreeNotificationReceived(Worktree.ID) + case setSidebarDragActive(Bool) case setMoveNotifiedWorktreeToTop(Bool) } @@ -235,6 +236,8 @@ struct RepositoriesFeature { var sidebarSelectedWorktreeIDs: Set = [] var nextPendingSidebarRevealID = 0 var pendingSidebarReveal: PendingSidebarReveal? + var isSidebarDragActive = false + var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? @Presents var alert: AlertState? @@ -1977,7 +1980,7 @@ extension RepositoriesFeature.State { } } -struct WorktreeRowSections { +struct WorktreeRowSections: Equatable { let main: WorktreeRowModel? let pinned: [WorktreeRowModel] let pending: [WorktreeRowModel] diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index 5d6300da..4e6d8078 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -39,14 +39,19 @@ struct SidebarListView: View { var body: some View { let state = store.state let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) - let orderedRoots = state.orderedRepositoryRoots() + let presentation = state.sidebarPresentation(expandedRepositoryIDs: expandedRepoIDs) let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) let repositoryListHeaderAction = Self.repositoryListHeaderAction( expandedRepoIDs: expandedRepoIDs, expandableRepositoryIDs: expandableRepositoryIDs ) - let visibleRepositoryCount = orderedRoots.isEmpty ? state.repositories.count : orderedRoots.count - let showsRepositoryListHeader = Self.showsRepositoryListHeader(repositoryCount: visibleRepositoryCount) + let repositoryItems = presentation.items.filter(\.isRepositoryOrderItem) + let showsRepositoryListHeader = presentation.items.contains { item in + if case .listHeader = item { + return true + } + return false + } let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) let selection = Binding>( get: { @@ -131,7 +136,6 @@ struct SidebarListView: View { } } ) - let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) let pendingSidebarReveal = state.pendingSidebarReveal ScrollViewReader { scrollProxy in @@ -144,75 +148,17 @@ struct SidebarListView: View { .listRowInsets(EdgeInsets()) } - if orderedRoots.isEmpty { - let repositories = store.repositories - ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in - RepositorySectionView( - repository: repository, - hasTopSpacing: index > 0, - isDragActive: isDragActive, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs, - expandedRepoIDs: $expandedRepoIDs, - store: store, - terminalManager: terminalManager - ) - .listRowInsets(EdgeInsets()) - } - } else { - let orderedRows = Array(orderedRoots.enumerated()).map { index, rootURL in - ( - index: index, - rootURL: rootURL, - repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) - ) - } - ForEach(orderedRows, id: \.repositoryID) { row in - let index = row.index - let rootURL = row.rootURL - let repositoryID = row.repositoryID - if let failureMessage = state.loadFailuresByID[repositoryID] { - let name = Repository.name(for: rootURL.standardizedFileURL) - let path = rootURL.standardizedFileURL.path(percentEncoded: false) - FailedRepositoryRow( - name: name, - path: path, - showFailure: { - let message = "\(path)\n\n\(failureMessage)" - store.send(.presentAlert(title: "Unable to load \(name)", message: message)) - }, - removeRepository: { - store.send(.repositoryManagement(.removeFailedRepository(repositoryID))) - } - ) - .padding(.horizontal, 12) - .overlay(alignment: .top) { - if index > 0 { - Rectangle() - .fill(.secondary) - .frame(height: 1) - .frame(maxWidth: .infinity) - .accessibilityHidden(true) - } - } - .listRowInsets(EdgeInsets()) - } else if let repository = repositoriesByID[repositoryID] { - RepositorySectionView( - repository: repository, - hasTopSpacing: index > 0, - isDragActive: isDragActive, - hotkeyRows: hotkeyRows, - selectedWorktreeIDs: selectedWorktreeIDs, - expandedRepoIDs: $expandedRepoIDs, - store: store, - terminalManager: terminalManager - ) - .listRowInsets(EdgeInsets()) - } - } - .onMove { offsets, destination in - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) - } + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in + repositoryItemView( + item, + index: index, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs + ) + .listRowInsets(EdgeInsets()) + } + .onMove { offsets, destination in + store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) } } .listStyle(.sidebar) @@ -222,17 +168,20 @@ struct SidebarListView: View { if case .ended = session.phase { if isDragActive { isDragActive = false + store.send(.worktreeOrdering(.setSidebarDragActive(false))) } return } if case .dataTransferCompleted = session.phase { if isDragActive { isDragActive = false + store.send(.worktreeOrdering(.setSidebarDragActive(false))) } return } if !isDragActive { isDragActive = true + store.send(.worktreeOrdering(.setSidebarDragActive(true))) } } .safeAreaInset(edge: .top) { @@ -318,6 +267,56 @@ struct SidebarListView: View { .padding(.bottom, 4) } + @ViewBuilder + private func repositoryItemView( + _ item: SidebarItem, + index: Int, + hotkeyRows: [WorktreeRowModel], + selectedWorktreeIDs: Set + ) -> some View { + switch item { + case .repository(let model): + if let repository = store.state.repositories[id: model.repositoryID] { + RepositorySectionView( + repository: repository, + hasTopSpacing: index > 0, + isDragActive: isDragActive, + hotkeyRows: hotkeyRows, + selectedWorktreeIDs: selectedWorktreeIDs, + expandedRepoIDs: $expandedRepoIDs, + store: store, + terminalManager: terminalManager + ) + } + + case .failedRepository(let model): + FailedRepositoryRow( + name: model.name, + path: model.path, + showFailure: { + let message = "\(model.path)\n\n\(model.failureMessage)" + store.send(.presentAlert(title: "Unable to load \(model.name)", message: message)) + }, + removeRepository: { + store.send(.repositoryManagement(.removeFailedRepository(model.id))) + } + ) + .padding(.horizontal, 12) + .overlay(alignment: .top) { + if index > 0 { + Rectangle() + .fill(.secondary) + .frame(height: 1) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } + } + + case .listHeader, .archivedWorktrees: + EmptyView() + } + } + @MainActor private func revealPendingSidebarWorktree( _ pendingSidebarReveal: PendingSidebarReveal?, @@ -354,7 +353,13 @@ struct SidebarListView: View { } static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { - repositoryCount > 10 + SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) + } +} + +extension SidebarItem { + fileprivate var isRepositoryOrderItem: Bool { + repositoryOrderID != nil } } diff --git a/supacode/Features/Repositories/Views/WorktreeRowsView.swift b/supacode/Features/Repositories/Views/WorktreeRowsView.swift index c33b6628..cb39234b 100644 --- a/supacode/Features/Repositories/Views/WorktreeRowsView.swift +++ b/supacode/Features/Repositories/Views/WorktreeRowsView.swift @@ -25,6 +25,7 @@ struct WorktreeRowsView: View { let state = store.state let sections = state.worktreeRowSections(in: repository) let isRepositoryRemoving = state.isRemovingRepository(repository) + let isSidebarDragActive = state.isSidebarDragActive let showShortcutHints = commandKeyObserver.isPressed let allRows = showShortcutHints ? hotkeyRows : [] let shortcutIndexByID = Dictionary( @@ -37,7 +38,7 @@ struct WorktreeRowsView: View { showShortcutHints: showShortcutHints, shortcutIndexByID: shortcutIndexByID ) - .animation(.easeOut(duration: 0.2), value: rowIDs) + .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) } @ViewBuilder @@ -94,6 +95,7 @@ struct WorktreeRowsView: View { moveDisabled: Bool, shortcutHint: String? ) -> some View { + let isSidebarDragActive = store.state.isSidebarDragActive let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) let displayName = if row.isDeleting { @@ -103,7 +105,7 @@ struct WorktreeRowsView: View { } else { row.name } - let canShowRowActions = row.isRemovable && !isRepositoryRemoving + let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isSidebarDragActive let pinAction: (() -> Void)? = canShowRowActions && !row.isMainWorktree ? { togglePin(for: row.id, isPinned: row.isPinned) } @@ -134,9 +136,9 @@ struct WorktreeRowsView: View { let config = WorktreeRowViewConfig( displayName: displayName, worktreeName: worktreeName(for: row), - isHovered: hoveredWorktreeID == row.id, - showsNotificationIndicator: showsNotificationIndicator, - notifications: notifications, + isHovered: !isSidebarDragActive && hoveredWorktreeID == row.id, + showsNotificationIndicator: !isSidebarDragActive && showsNotificationIndicator, + notifications: isSidebarDragActive ? [] : notifications, onFocusNotification: onFocusNotification, shortcutHint: shortcutHint, pinAction: pinAction, @@ -205,11 +207,12 @@ struct WorktreeRowsView: View { let isSelected = selectedWorktreeIDs.contains(row.id) let taskStatus = terminalManager.taskStatus(for: row.id) let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) + let isSidebarDragActive = store.state.isSidebarDragActive return WorktreeRow( name: config.displayName, worktreeName: config.worktreeName, info: row.info, - showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), + showsPullRequestInfo: !isSidebarDragActive && !draggingWorktreeIDs.contains(row.id), isHovered: config.isHovered, isPinned: row.isPinned, isMainWorktree: row.isMainWorktree, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 3a26bb48..2545f92e 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2577,6 +2577,115 @@ struct RepositoriesFeatureTests { #expect(store.state.statusToast == nil) } + @Test func worktreeNotificationDuringSidebarDragDefersReorder() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) + var state = makeState(repositories: [repository]) + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.setSidebarDragActive(true))) { + $0.isSidebarDragActive = true + } + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureB.id] + } + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) + } + + @Test func endingSidebarDragAppliesPendingNotificationReordersInOrder() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + 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]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.pendingSidebarNotifyReorderIDs = [featureA.id, featureC.id, featureB.id] + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id, featureC.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { + $0.isSidebarDragActive = false + $0.pendingSidebarNotifyReorderIDs = [] + $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureC.id, featureA.id] + } + } + + @Test func repeatedNotificationDuringSidebarDragKeepsLatestPosition() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureA.id] + } + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureA.id, featureB.id] + } + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { + $0.pendingSidebarNotifyReorderIDs = [featureB.id, featureA.id] + } + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { + $0.isSidebarDragActive = false + $0.pendingSidebarNotifyReorderIDs = [] + $0.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] + } + } + + @Test func stalePendingNotificationReordersAreIgnoredWhenDragEnds() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.pendingSidebarNotifyReorderIDs = ["/tmp/repo/stale", featureA.id] + state.worktreeOrderByRepository[repoRoot] = [featureA.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { + $0.isSidebarDragActive = false + $0.pendingSidebarNotifyReorderIDs = [] + } + } + + @Test func notificationDuringSidebarDragDoesNotRecordWhenMoveToTopDisabled() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) + var state = makeState(repositories: [repository]) + state.isSidebarDragActive = true + state.moveNotifiedWorktreeToTop = false + state.worktreeOrderByRepository[repoRoot] = [featureA.id] + let store = TestStore(initialState: state) { + RepositoriesFeature() + } + + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) + #expect(store.state.pendingSidebarNotifyReorderIDs.isEmpty) + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id]) + } + @Test func setMoveNotifiedWorktreeToTopUpdatesState() async { var state = makeState(repositories: []) state.moveNotifiedWorktreeToTop = true diff --git a/supacodeTests/SidebarPresentationTests.swift b/supacodeTests/SidebarPresentationTests.swift new file mode 100644 index 00000000..bfc6df66 --- /dev/null +++ b/supacodeTests/SidebarPresentationTests.swift @@ -0,0 +1,197 @@ +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct SidebarPresentationTests { + @Test func expandedRepositoryIsOneOuterItemWithChildRows() { + let repoRoot = "/tmp/repo" + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) + let state = makeState(repositories: [repository]) + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) + + #expect(presentation.items.count == 1) + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(model.id == repository.id) + #expect(model.isExpanded) + #expect(model.worktreeSections.allRows.map(\.id) == [main.id, feature.id]) + } + + @Test func collapsedRepositoryKeepsContainerButHidesChildRows() { + let repoRoot = "/tmp/repo" + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) + let state = makeState(repositories: [repository]) + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: []) + + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(!model.isExpanded) + #expect(model.worktreeSections.allRows.isEmpty) + } + + @Test func failedRepositoriesParticipateInRootOrder() { + let repoA = makeRepository(id: "/tmp/a", worktrees: []) + var state = makeState(repositories: [repoA]) + state.repositoryRoots = [ + URL(fileURLWithPath: "/tmp/missing"), + repoA.rootURL, + ] + state.repositoryOrderIDs = ["/tmp/missing", repoA.id] + state.loadFailuresByID["/tmp/missing"] = "missing" + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id]) + + #expect(presentation.repositoryOrderIDs == ["/tmp/missing", repoA.id]) + #expect( + presentation.repositoryOrderAfterMove(fromOffsets: IndexSet(integer: 0), toOffset: 2) == [ + repoA.id, "/tmp/missing", + ]) + guard case .failedRepository(let failed) = presentation.items.first else { + Issue.record("Expected failed repository first") + return + } + #expect(failed.id == "/tmp/missing") + #expect(failed.isReorderable) + } + + @Test func plainFolderProducesContainerWithoutWorktreeChildren() { + let repository = makeRepository(id: "/tmp/plain", kind: .plain, worktrees: []) + let state = makeState(repositories: [repository]) + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) + + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(model.kind == .plain) + #expect(model.worktreeSections.allRows.isEmpty) + } + + @Test func worktreeSectionsPreservePinnedMainPendingAndUnpinnedRows() { + let repoRoot = "/tmp/repo" + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let pinned = makeWorktree(id: "/tmp/repo/pinned", name: "pinned", repoRoot: repoRoot) + let unpinned = makeWorktree(id: "/tmp/repo/unpinned", name: "unpinned", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [main, pinned, unpinned]) + var state = makeState(repositories: [repository]) + state.pinnedWorktreeIDs = [pinned.id] + state.pendingWorktrees = [ + PendingWorktree( + id: "/tmp/repo/pending", + repositoryID: repository.id, + progress: WorktreeCreationProgress(stage: .choosingWorktreeName, worktreeName: "pending") + ) + ] + state.worktreeOrderByRepository[repository.id] = [unpinned.id] + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) + + guard case .repository(let model) = presentation.items.first else { + Issue.record("Expected repository container") + return + } + #expect(model.worktreeSections.main?.id == main.id) + #expect(model.worktreeSections.pinned.map(\.id) == [pinned.id]) + #expect(model.worktreeSections.pending.map(\.id) == ["/tmp/repo/pending"]) + #expect(model.worktreeSections.unpinned.map(\.id) == [unpinned.id]) + } + + @Test func emptyOrderedRootsStillBuildsRepositoryPresentation() { + let repoA = makeRepository(id: "/tmp/a", worktrees: []) + let repoB = makeRepository(id: "/tmp/b", worktrees: []) + var state = RepositoriesFeature.State() + state.repositories = [repoA, repoB] + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) + + #expect(presentation.repositoryOrderIDs == [repoA.id, repoB.id]) + } + + @Test func customOrderedRootsUseSamePresentationRules() { + let repoA = makeRepository(id: "/tmp/a", worktrees: []) + let repoB = makeRepository(id: "/tmp/b", worktrees: []) + var state = makeState(repositories: [repoA, repoB]) + state.repositoryOrderIDs = [repoB.id, repoA.id] + + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) + + #expect(presentation.repositoryOrderIDs == [repoB.id, repoA.id]) + } + + @Test func worktreeDropDestinationsMapToExistingOrderingActions() { + let pinned = SidebarWorktreeDropTarget( + repositoryID: "/tmp/repo", + section: .pinned, + source: IndexSet(integer: 1), + destination: 0 + ) + let unpinned = SidebarWorktreeDropTarget( + repositoryID: "/tmp/repo", + section: .unpinned, + source: IndexSet(integer: 0), + destination: 2 + ) + + #expect( + pinned.action + == RepositoriesFeature.WorktreeOrderingAction.pinnedWorktreesMoved( + repositoryID: "/tmp/repo", + IndexSet(integer: 1), + 0 + ) + ) + #expect( + unpinned.action + == RepositoriesFeature.WorktreeOrderingAction.unpinnedWorktreesMoved( + repositoryID: "/tmp/repo", + IndexSet(integer: 0), + 2 + ) + ) + } + + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { + Worktree( + id: id, + name: name, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: repoRoot) + ) + } + + private func makeRepository( + id: String, + kind: Repository.Kind = .git, + worktrees: [Worktree] + ) -> Repository { + Repository( + id: id, + rootURL: URL(fileURLWithPath: id), + name: URL(fileURLWithPath: id).lastPathComponent, + kind: kind, + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } + + private func makeState(repositories: [Repository]) -> RepositoriesFeature.State { + var state = RepositoriesFeature.State() + state.repositories = IdentifiedArray(uniqueElements: repositories) + state.repositoryRoots = repositories.map(\.rootURL) + return state + } +}