native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #232 from onevcat/feat/shelf-close-worktree

feat(shelf): replace Remove Book with Close Worktree/Folder

authored by

Wei Wang and committed by
GitHub
82e054a4 ce37503d

+114 -18
+11 -7
supacode/Features/Shelf/Views/ShelfSpineView.swift
··· 24 24 let onNewTab: (() -> Void)? 25 25 let onSplitVertical: (() -> Void)? 26 26 let onSplitHorizontal: (() -> Void)? 27 - /// "Remove this book" — drives the book-level context menu entry on 28 - /// the spine header / empty body. Nil disables the menu. 29 - let onRemoveBook: (() -> Void)? 27 + /// "Close this book" — drives the book-level context menu entry on 28 + /// the spine header / empty body. Nil disables the menu. The label 29 + /// text is supplied by the parent so it can vary per book kind 30 + /// ("Close Worktree" vs "Close Folder") without leaking the `Kind` 31 + /// enum into this view. 32 + let closeMenuTitle: String 33 + let onCloseBook: (() -> Void)? 30 34 31 35 @State private var isHovering = false 32 36 ··· 118 122 119 123 @ViewBuilder 120 124 private var bookContextMenu: some View { 121 - if let onRemoveBook { 122 - Button(role: .destructive) { 123 - onRemoveBook() 125 + if let onCloseBook { 126 + Button { 127 + onCloseBook() 124 128 } label: { 125 - Text("Remove Book") 129 + Text(closeMenuTitle) 126 130 } 127 131 } 128 132 }
+22 -11
supacode/Features/Shelf/Views/ShelfView.swift
··· 88 88 }, 89 89 onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil, 90 90 onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil, 91 - onRemoveBook: { removeBook(book) } 91 + closeMenuTitle: closeMenuTitle(for: book), 92 + onCloseBook: { closeBook(book) } 92 93 ) 93 94 .matchedGeometryEffect(id: book.id, in: spineNamespace) 94 95 } ··· 114 115 _ = state.performBindingActionOnFocusedSurface(direction) 115 116 } 116 117 117 - /// "Remove Book" context action. Worktree books funnel through the 118 - /// existing archive flow (which shows confirmation + progress); plain 119 - /// folder books go through repository removal. Both pathways 120 - /// eventually drop the book off the Shelf via the same prune logic 121 - /// that drives the left navigation. 122 - private func removeBook(_ book: ShelfBook) { 118 + /// "Close Worktree / Close Folder" context action. Equivalent to 119 + /// closing the last tab on this book: tears down all of its terminal 120 + /// tabs, which lets the existing `tabClosed(remainingTabs: 0)` → 121 + /// `markWorktreeClosed` pipeline retire the book from the Shelf and 122 + /// auto-advance selection. Intentionally does *not* archive the 123 + /// worktree or remove the repository — Shelf removal is a view-state 124 + /// concern, not a destructive resource operation. 125 + private func closeBook(_ book: ShelfBook) { 126 + if let state = terminalManager.stateIfExists(for: book.id), !state.tabManager.tabs.isEmpty { 127 + state.closeAllTabs() 128 + } else { 129 + // No live tabs to fall through the closeAllTabs → tabClosed 130 + // pipeline — drive the Shelf removal directly. 131 + store.send(.markWorktreeClosed(book.id)) 132 + } 133 + } 134 + 135 + private func closeMenuTitle(for book: ShelfBook) -> String { 123 136 switch book.kind { 124 - case .worktree: 125 - store.send(.worktreeLifecycle(.requestArchiveWorktree(book.id, book.repositoryID))) 126 - case .plainFolder: 127 - store.send(.repositoryManagement(.requestRemoveRepository(book.repositoryID))) 137 + case .worktree: "Close Worktree" 138 + case .plainFolder: "Close Folder" 128 139 } 129 140 } 130 141
+81
supacodeTests/ShelfFeatureTests.swift
··· 569 569 await store.finish() 570 570 } 571 571 572 + @Test(.dependencies) func markWorktreeClosedRemovesPlainFolderBookFromOpenedSet() async { 573 + // Plain-folder books live in `openedWorktreeIDs` under their 574 + // `Repository.ID`. The Shelf "Close Folder" menu dispatches 575 + // `.markWorktreeClosed(book.id)` for this path, so the reducer must 576 + // handle a plain-folder ID the same way it handles a worktree ID. 577 + let rootURL = URL(fileURLWithPath: "/tmp/folder") 578 + let repo = Repository( 579 + id: rootURL.path(percentEncoded: false), 580 + rootURL: rootURL, 581 + name: "folder", 582 + kind: .plain, 583 + worktrees: [] 584 + ) 585 + var state = RepositoriesFeature.State(repositories: [repo]) 586 + state.repositoryRoots = [rootURL] 587 + state.repositoryOrderIDs = [repo.id] 588 + state.selection = .repository(repo.id) 589 + state.isShelfActive = true 590 + state.openedWorktreeIDs = [repo.id] 591 + let store = TestStore(initialState: state) { 592 + RepositoriesFeature() 593 + } 594 + 595 + // Only book on the Shelf — closing it leaves the opened set empty 596 + // and produces no replacement selection, so the Shelf falls back to 597 + // the empty state. 598 + await store.send(.markWorktreeClosed(repo.id)) { 599 + $0.openedWorktreeIDs = [] 600 + } 601 + await store.finish() 602 + } 603 + 604 + @Test(.dependencies) func markWorktreeClosedAdvancesToPlainFolderNeighbor() async { 605 + // Closing a worktree book whose neighbor is a plain folder must 606 + // route through the `.selectRepository` branch of 607 + // `shelfBookSelectionEffect`, not `.selectWorktree`. Covers the 608 + // plain-folder path of the replacement dispatch that the new 609 + // Shelf "Close Worktree/Folder" menu relies on. 610 + let gitRootURL = URL(fileURLWithPath: "/tmp/git") 611 + let worktree = Worktree( 612 + id: "/tmp/git", 613 + name: "main", 614 + detail: "", 615 + workingDirectory: gitRootURL, 616 + repositoryRootURL: gitRootURL 617 + ) 618 + let gitRepo = Repository( 619 + id: gitRootURL.path(percentEncoded: false), 620 + rootURL: gitRootURL, 621 + name: "git", 622 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 623 + ) 624 + let plainRootURL = URL(fileURLWithPath: "/tmp/plain") 625 + let plainRepo = Repository( 626 + id: plainRootURL.path(percentEncoded: false), 627 + rootURL: plainRootURL, 628 + name: "plain", 629 + kind: .plain, 630 + worktrees: [] 631 + ) 632 + var state = RepositoriesFeature.State(repositories: [gitRepo, plainRepo]) 633 + state.repositoryRoots = [gitRootURL, plainRootURL] 634 + state.repositoryOrderIDs = [gitRepo.id, plainRepo.id] 635 + state.selection = .worktree(worktree.id) 636 + state.isShelfActive = true 637 + state.openedWorktreeIDs = [worktree.id, plainRepo.id] 638 + let store = TestStore(initialState: state) { 639 + RepositoriesFeature() 640 + } 641 + 642 + await store.send(.markWorktreeClosed(worktree.id)) { 643 + $0.openedWorktreeIDs = [plainRepo.id] 644 + } 645 + await store.receive(\.selectRepository) { 646 + $0.selection = .repository(plainRepo.id) 647 + $0.sidebarSelectedWorktreeIDs = [] 648 + } 649 + await store.receive(\.delegate.selectedWorktreeChanged) 650 + await store.finish() 651 + } 652 + 572 653 private struct ThreeWorktreeFixture { 573 654 let repo: Repository 574 655 let worktrees: [Worktree]