···2424 let onNewTab: (() -> Void)?
2525 let onSplitVertical: (() -> Void)?
2626 let onSplitHorizontal: (() -> Void)?
2727- /// "Remove this book" — drives the book-level context menu entry on
2828- /// the spine header / empty body. Nil disables the menu.
2929- let onRemoveBook: (() -> Void)?
2727+ /// "Close this book" — drives the book-level context menu entry on
2828+ /// the spine header / empty body. Nil disables the menu. The label
2929+ /// text is supplied by the parent so it can vary per book kind
3030+ /// ("Close Worktree" vs "Close Folder") without leaking the `Kind`
3131+ /// enum into this view.
3232+ let closeMenuTitle: String
3333+ let onCloseBook: (() -> Void)?
30343135 @State private var isHovering = false
3236···118122119123 @ViewBuilder
120124 private var bookContextMenu: some View {
121121- if let onRemoveBook {
122122- Button(role: .destructive) {
123123- onRemoveBook()
125125+ if let onCloseBook {
126126+ Button {
127127+ onCloseBook()
124128 } label: {
125125- Text("Remove Book")
129129+ Text(closeMenuTitle)
126130 }
127131 }
128132 }
+22-11
supacode/Features/Shelf/Views/ShelfView.swift
···8888 },
8989 onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil,
9090 onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil,
9191- onRemoveBook: { removeBook(book) }
9191+ closeMenuTitle: closeMenuTitle(for: book),
9292+ onCloseBook: { closeBook(book) }
9293 )
9394 .matchedGeometryEffect(id: book.id, in: spineNamespace)
9495 }
···114115 _ = state.performBindingActionOnFocusedSurface(direction)
115116 }
116117117117- /// "Remove Book" context action. Worktree books funnel through the
118118- /// existing archive flow (which shows confirmation + progress); plain
119119- /// folder books go through repository removal. Both pathways
120120- /// eventually drop the book off the Shelf via the same prune logic
121121- /// that drives the left navigation.
122122- private func removeBook(_ book: ShelfBook) {
118118+ /// "Close Worktree / Close Folder" context action. Equivalent to
119119+ /// closing the last tab on this book: tears down all of its terminal
120120+ /// tabs, which lets the existing `tabClosed(remainingTabs: 0)` →
121121+ /// `markWorktreeClosed` pipeline retire the book from the Shelf and
122122+ /// auto-advance selection. Intentionally does *not* archive the
123123+ /// worktree or remove the repository — Shelf removal is a view-state
124124+ /// concern, not a destructive resource operation.
125125+ private func closeBook(_ book: ShelfBook) {
126126+ if let state = terminalManager.stateIfExists(for: book.id), !state.tabManager.tabs.isEmpty {
127127+ state.closeAllTabs()
128128+ } else {
129129+ // No live tabs to fall through the closeAllTabs → tabClosed
130130+ // pipeline — drive the Shelf removal directly.
131131+ store.send(.markWorktreeClosed(book.id))
132132+ }
133133+ }
134134+135135+ private func closeMenuTitle(for book: ShelfBook) -> String {
123136 switch book.kind {
124124- case .worktree:
125125- store.send(.worktreeLifecycle(.requestArchiveWorktree(book.id, book.repositoryID)))
126126- case .plainFolder:
127127- store.send(.repositoryManagement(.requestRemoveRepository(book.repositoryID)))
137137+ case .worktree: "Close Worktree"
138138+ case .plainFolder: "Close Folder"
128139 }
129140 }
130141
+81
supacodeTests/ShelfFeatureTests.swift
···569569 await store.finish()
570570 }
571571572572+ @Test(.dependencies) func markWorktreeClosedRemovesPlainFolderBookFromOpenedSet() async {
573573+ // Plain-folder books live in `openedWorktreeIDs` under their
574574+ // `Repository.ID`. The Shelf "Close Folder" menu dispatches
575575+ // `.markWorktreeClosed(book.id)` for this path, so the reducer must
576576+ // handle a plain-folder ID the same way it handles a worktree ID.
577577+ let rootURL = URL(fileURLWithPath: "/tmp/folder")
578578+ let repo = Repository(
579579+ id: rootURL.path(percentEncoded: false),
580580+ rootURL: rootURL,
581581+ name: "folder",
582582+ kind: .plain,
583583+ worktrees: []
584584+ )
585585+ var state = RepositoriesFeature.State(repositories: [repo])
586586+ state.repositoryRoots = [rootURL]
587587+ state.repositoryOrderIDs = [repo.id]
588588+ state.selection = .repository(repo.id)
589589+ state.isShelfActive = true
590590+ state.openedWorktreeIDs = [repo.id]
591591+ let store = TestStore(initialState: state) {
592592+ RepositoriesFeature()
593593+ }
594594+595595+ // Only book on the Shelf — closing it leaves the opened set empty
596596+ // and produces no replacement selection, so the Shelf falls back to
597597+ // the empty state.
598598+ await store.send(.markWorktreeClosed(repo.id)) {
599599+ $0.openedWorktreeIDs = []
600600+ }
601601+ await store.finish()
602602+ }
603603+604604+ @Test(.dependencies) func markWorktreeClosedAdvancesToPlainFolderNeighbor() async {
605605+ // Closing a worktree book whose neighbor is a plain folder must
606606+ // route through the `.selectRepository` branch of
607607+ // `shelfBookSelectionEffect`, not `.selectWorktree`. Covers the
608608+ // plain-folder path of the replacement dispatch that the new
609609+ // Shelf "Close Worktree/Folder" menu relies on.
610610+ let gitRootURL = URL(fileURLWithPath: "/tmp/git")
611611+ let worktree = Worktree(
612612+ id: "/tmp/git",
613613+ name: "main",
614614+ detail: "",
615615+ workingDirectory: gitRootURL,
616616+ repositoryRootURL: gitRootURL
617617+ )
618618+ let gitRepo = Repository(
619619+ id: gitRootURL.path(percentEncoded: false),
620620+ rootURL: gitRootURL,
621621+ name: "git",
622622+ worktrees: IdentifiedArray(uniqueElements: [worktree])
623623+ )
624624+ let plainRootURL = URL(fileURLWithPath: "/tmp/plain")
625625+ let plainRepo = Repository(
626626+ id: plainRootURL.path(percentEncoded: false),
627627+ rootURL: plainRootURL,
628628+ name: "plain",
629629+ kind: .plain,
630630+ worktrees: []
631631+ )
632632+ var state = RepositoriesFeature.State(repositories: [gitRepo, plainRepo])
633633+ state.repositoryRoots = [gitRootURL, plainRootURL]
634634+ state.repositoryOrderIDs = [gitRepo.id, plainRepo.id]
635635+ state.selection = .worktree(worktree.id)
636636+ state.isShelfActive = true
637637+ state.openedWorktreeIDs = [worktree.id, plainRepo.id]
638638+ let store = TestStore(initialState: state) {
639639+ RepositoriesFeature()
640640+ }
641641+642642+ await store.send(.markWorktreeClosed(worktree.id)) {
643643+ $0.openedWorktreeIDs = [plainRepo.id]
644644+ }
645645+ await store.receive(\.selectRepository) {
646646+ $0.selection = .repository(plainRepo.id)
647647+ $0.sidebarSelectedWorktreeIDs = []
648648+ }
649649+ await store.receive(\.delegate.selectedWorktreeChanged)
650650+ await store.finish()
651651+ }
652652+572653 private struct ThreeWorktreeFixture {
573654 let repo: Repository
574655 let worktrees: [Worktree]