From 9b6ca144f85ea93c72dd700640597ff6428bbb1a Mon Sep 17 00:00:00 2001 From: Jerry Date: Wed, 5 Aug 2026 19:37:25 +0800 Subject: [PATCH 1/2] Fix the worktree ref browse submenu closing on ancestor re-renders Wrap the native browse Menu in an Equatable value boundary so SwiftUI skips its body while the menu inputs stay equal, preserving an open submenu when agent-presence updates re-evaluate the sheet. --- .../Views/WorktreeCreationPromptView.swift | 94 ++++++++++++++----- 1 file changed, 70 insertions(+), 24 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index 7ad862b1c..af4f82510 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -125,6 +125,11 @@ private struct WorktreeBaseRefField: View { branchMenu: store.branchMenu, remoteNames: store.remoteNames, selectedRef: store.selectedBaseRef, + menuContentToken: .base( + automaticBaseRef: store.automaticBaseRef, + defaultBranch: store.defaultBranch, + selectedBaseRef: store.selectedBaseRef + ), onSelect: { store.send(.baseRefSelected($0)) }, topRows: { WorktreeBaseRefTopRows(store: store) } ) @@ -142,6 +147,7 @@ private struct WorktreeUpstreamField: View { branchMenu: store.upstreamBranchMenu, remoteNames: store.remoteNames, selectedRef: store.selectedUpstreamBranch, + menuContentToken: .upstream(store.selectedUpstream), onSelect: { store.send(.upstreamSelected(.branch($0))) }, topRows: { WorktreeUpstreamTopRows(store: store) } ) @@ -157,6 +163,7 @@ private struct WorktreeRefPickerField: View { let branchMenu: BaseRefBranchMenu? let remoteNames: [String] let selectedRef: String? + let menuContentToken: WorktreeRefMenuToken let onSelect: (String) -> Void @ViewBuilder let topRows: TopRows @@ -201,30 +208,15 @@ private struct WorktreeRefPickerField: View { .onKeyPress(.upArrow) { moveHighlight(by: -1) } .onKeyPress(.return) { commitHighlighted() } // Browse: the hierarchical menu, kept for when you don't know the branch name up front. - Menu { - topRows - - Divider() - - if let branchMenu { - if !branchMenu.localBranches.isEmpty { - Menu("Local") { - ForEach(branchMenu.localBranches) { node in - WorktreeBranchNodeMenu(node: node, selectedRef: selectedRef, onSelect: select) - } - } - } - ForEach(branchMenu.remotes) { remote in - WorktreeRemoteBranchMenu(remote: remote, selectedRef: selectedRef, onSelect: select) - } - } else { - Text("Loading branches…") - } - } label: { - Text(menuLabel) - .lineLimit(1) - .truncationMode(.middle) - } + WorktreeRefBrowseMenu( + menuLabel: menuLabel, + branchMenu: branchMenu, + selectedRef: selectedRef, + menuContentToken: menuContentToken, + topRows: topRows, + onSelect: select + ) + .equatable() // Cap and pin trailing so a long ref can't crowd the search field yet still grazes the right edge. .frame(maxWidth: 160, alignment: .trailing) .layoutPriority(1) @@ -282,6 +274,60 @@ private struct WorktreeRefPickerField: View { } } +/// The values that can change the top rows or their selection marks. The menu +/// action closures capture the stable prompt store, so this token is the only +/// state the equatable menu needs in addition to its branch tree. +private enum WorktreeRefMenuToken: Hashable { + case base(automaticBaseRef: String, defaultBranch: String?, selectedBaseRef: String?) + case upstream(WorktreeUpstreamPreference) +} + +/// A value-backed boundary around the native nested Menu. Agent presence +/// updates can re-evaluate the sheet's ancestors, but SwiftUI skips this view's +/// body while the menu inputs remain equal, preserving AppKit's open submenu. +private struct WorktreeRefBrowseMenu: View, Equatable { + let menuLabel: String + let branchMenu: BaseRefBranchMenu? + let selectedRef: String? + let menuContentToken: WorktreeRefMenuToken + let topRows: TopRows + let onSelect: (String) -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.menuLabel == rhs.menuLabel + && lhs.branchMenu == rhs.branchMenu + && lhs.selectedRef == rhs.selectedRef + && lhs.menuContentToken == rhs.menuContentToken + } + + var body: some View { + Menu { + topRows + + Divider() + + if let branchMenu { + if !branchMenu.localBranches.isEmpty { + Menu("Local") { + ForEach(branchMenu.localBranches) { node in + WorktreeBranchNodeMenu(node: node, selectedRef: selectedRef, onSelect: onSelect) + } + } + } + ForEach(branchMenu.remotes) { remote in + WorktreeRemoteBranchMenu(remote: remote, selectedRef: selectedRef, onSelect: onSelect) + } + } else { + Text("Loading branches…") + } + } label: { + Text(menuLabel) + .lineLimit(1) + .truncationMode(.middle) + } + } +} + /// Inline matches under the filter field (#387). A flat row list rather than a /// popover, so there's no keyboard-focus juggling; the browse Menu still covers /// "I don't know the name yet". From 07fff210c2887c362a1bd6eb6088f8c9fec65785 Mon Sep 17 00:00:00 2001 From: Jerry Date: Fri, 7 Aug 2026 10:16:49 +0800 Subject: [PATCH 2/2] fix(repositories): preserve worktree ref submenus (#771) --- .../Views/WorktreeCreationPromptView.swift | 391 ++++++++++-------- 1 file changed, 219 insertions(+), 172 deletions(-) diff --git a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift index af4f82510..6ccb172e0 100644 --- a/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift +++ b/supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import SupacodeSettingsFeature import SupacodeSettingsShared @@ -117,6 +118,28 @@ private struct WorktreeCreationFooter: View { private struct WorktreeBaseRefField: View { @Bindable var store: StoreOf + private var browseTopRows: [WorktreeRefBrowseTopRow] { + var rows = [ + WorktreeRefBrowseTopRow( + title: store.automaticBaseRef.isEmpty ? "Auto" : store.automaticBaseRef, + detail: store.automaticBaseRef.isEmpty ? nil : "Auto", + selection: nil, + isSelected: store.selectedBaseRef == nil + ) + ] + if let defaultBranch = store.defaultBranch { + rows.append( + WorktreeRefBrowseTopRow( + title: defaultBranch, + detail: "Local", + selection: defaultBranch, + isSelected: store.selectedBaseRef == defaultBranch + ) + ) + } + return rows + } + var body: some View { WorktreeRefPickerField( title: "Base ref", @@ -125,13 +148,9 @@ private struct WorktreeBaseRefField: View { branchMenu: store.branchMenu, remoteNames: store.remoteNames, selectedRef: store.selectedBaseRef, - menuContentToken: .base( - automaticBaseRef: store.automaticBaseRef, - defaultBranch: store.defaultBranch, - selectedBaseRef: store.selectedBaseRef - ), + browseTopRows: browseTopRows, onSelect: { store.send(.baseRefSelected($0)) }, - topRows: { WorktreeBaseRefTopRows(store: store) } + onSelectBrowseTopRow: { store.send(.baseRefSelected($0)) } ) } } @@ -139,6 +158,23 @@ private struct WorktreeBaseRefField: View { private struct WorktreeUpstreamField: View { @Bindable var store: StoreOf + private var browseTopRows: [WorktreeRefBrowseTopRow] { + [ + WorktreeRefBrowseTopRow( + title: "Auto", + detail: nil, + selection: .automatic, + isSelected: store.selectedUpstream == .automatic + ), + WorktreeRefBrowseTopRow( + title: "None", + detail: nil, + selection: .unset, + isSelected: store.selectedUpstream == .unset + ), + ] + } + var body: some View { WorktreeRefPickerField( title: "Upstream", @@ -147,25 +183,25 @@ private struct WorktreeUpstreamField: View { branchMenu: store.upstreamBranchMenu, remoteNames: store.remoteNames, selectedRef: store.selectedUpstreamBranch, - menuContentToken: .upstream(store.selectedUpstream), + browseTopRows: browseTopRows, onSelect: { store.send(.upstreamSelected(.branch($0))) }, - topRows: { WorktreeUpstreamTopRows(store: store) } + onSelectBrowseTopRow: { store.send(.upstreamSelected($0)) } ) } } -/// Shared search + browse picker over the branch inventory; the callers inject -/// the non-branch top rows (Auto / None / quick picks) and the selection action. -private struct WorktreeRefPickerField: View { +/// Shared search + browse picker over the branch inventory; the callers inject the +/// non-branch top rows (Auto / None / quick picks) and their selection action. +private struct WorktreeRefPickerField: View { let title: String let caption: String let menuLabel: String let branchMenu: BaseRefBranchMenu? let remoteNames: [String] let selectedRef: String? - let menuContentToken: WorktreeRefMenuToken + let browseTopRows: [WorktreeRefBrowseTopRow] let onSelect: (String) -> Void - @ViewBuilder let topRows: TopRows + let onSelectBrowseTopRow: (TopSelection) -> Void private var isLoading: Bool { branchMenu == nil @@ -212,11 +248,10 @@ private struct WorktreeRefPickerField: View { menuLabel: menuLabel, branchMenu: branchMenu, selectedRef: selectedRef, - menuContentToken: menuContentToken, - topRows: topRows, - onSelect: select + topRows: browseTopRows, + onSelectBranch: select, + onSelectTopRow: selectBrowseTopRow ) - .equatable() // Cap and pin trailing so a long ref can't crowd the search field yet still grazes the right edge. .frame(maxWidth: 160, alignment: .trailing) .layoutPriority(1) @@ -272,58 +307,189 @@ private struct WorktreeRefPickerField: View { onSelect(ref) query = "" } + + private func selectBrowseTopRow(_ selection: TopSelection) { + onSelectBrowseTopRow(selection) + query = "" + } } -/// The values that can change the top rows or their selection marks. The menu -/// action closures capture the stable prompt store, so this token is the only -/// state the equatable menu needs in addition to its branch tree. -private enum WorktreeRefMenuToken: Hashable { - case base(automaticBaseRef: String, defaultBranch: String?, selectedBaseRef: String?) - case upstream(WorktreeUpstreamPreference) +private struct WorktreeRefBrowseTopRow: Hashable { + let title: String + let detail: String? + let selection: Selection + let isSelected: Bool } -/// A value-backed boundary around the native nested Menu. Agent presence -/// updates can re-evaluate the sheet's ancestors, but SwiftUI skips this view's -/// body while the menu inputs remain equal, preserving AppKit's open submenu. -private struct WorktreeRefBrowseMenu: View, Equatable { +/// Owns the AppKit menu so unrelated SwiftUI updates cannot replace an open submenu. +private struct WorktreeRefBrowseMenu: NSViewRepresentable { let menuLabel: String let branchMenu: BaseRefBranchMenu? let selectedRef: String? - let menuContentToken: WorktreeRefMenuToken - let topRows: TopRows - let onSelect: (String) -> Void + let topRows: [WorktreeRefBrowseTopRow] + let onSelectBranch: (String) -> Void + let onSelectTopRow: (TopSelection) -> Void + + struct Snapshot: Equatable { + let menuLabel: String + let branchMenu: BaseRefBranchMenu? + let selectedRef: String? + let topRows: [WorktreeRefBrowseTopRow] + } - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.menuLabel == rhs.menuLabel - && lhs.branchMenu == rhs.branchMenu - && lhs.selectedRef == rhs.selectedRef - && lhs.menuContentToken == rhs.menuContentToken + func makeCoordinator() -> Coordinator { + Coordinator() } - var body: some View { - Menu { - topRows + func makeNSView(context: Context) -> NSPopUpButton { + let button = NSPopUpButton(frame: .zero, pullsDown: true) + button.setContentHuggingPriority(.required, for: .horizontal) + return button + } + + func updateNSView(_ button: NSPopUpButton, context: Context) { + context.coordinator.update( + button, + snapshot: Snapshot( + menuLabel: menuLabel, + branchMenu: branchMenu, + selectedRef: selectedRef, + topRows: topRows + ), + onSelectBranch: onSelectBranch, + onSelectTopRow: onSelectTopRow + ) + } - Divider() + final class Coordinator: NSObject { + private var snapshot: Snapshot? + private var topRows: [WorktreeRefBrowseTopRow] = [] + private var onSelectBranch: (String) -> Void = { _ in } + private var onSelectTopRow: (TopSelection) -> Void = { _ in } + + func update( + _ button: NSPopUpButton, + snapshot: Snapshot, + onSelectBranch: @escaping (String) -> Void, + onSelectTopRow: @escaping (TopSelection) -> Void + ) { + self.topRows = snapshot.topRows + self.onSelectBranch = onSelectBranch + self.onSelectTopRow = onSelectTopRow + button.toolTip = snapshot.menuLabel + button.setAccessibilityLabel(snapshot.menuLabel) + + guard snapshot != self.snapshot else { return } + button.menu = makeMenu(for: snapshot) + self.snapshot = snapshot + } - if let branchMenu { - if !branchMenu.localBranches.isEmpty { - Menu("Local") { - ForEach(branchMenu.localBranches) { node in - WorktreeBranchNodeMenu(node: node, selectedRef: selectedRef, onSelect: onSelect) - } + private func makeMenu(for snapshot: Snapshot) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + menu.addItem(NSMenuItem(title: snapshot.menuLabel, action: nil, keyEquivalent: "")) + + for (index, row) in snapshot.topRows.enumerated() { + let item = NSMenuItem( + title: row.title, + action: #selector(selectTopRow(_:)), + keyEquivalent: "" + ) + item.attributedTitle = attributedTitle(row.title, detail: row.detail) + item.state = row.isSelected ? .on : .off + item.target = self + item.tag = index + menu.addItem(item) + } + + menu.addItem(.separator()) + guard let branchMenu = snapshot.branchMenu else { + let loadingItem = NSMenuItem(title: "Loading branches…", action: nil, keyEquivalent: "") + loadingItem.isEnabled = false + menu.addItem(loadingItem) + return menu + } + + if !branchMenu.localBranches.isEmpty { + let localItem = NSMenuItem(title: "Local", action: nil, keyEquivalent: "") + let localMenu = NSMenu() + for node in branchMenu.localBranches { + if let item = branchItem(for: node, selectedRef: snapshot.selectedRef) { + localMenu.addItem(item) } } - ForEach(branchMenu.remotes) { remote in - WorktreeRemoteBranchMenu(remote: remote, selectedRef: selectedRef, onSelect: onSelect) + localItem.submenu = localMenu + menu.addItem(localItem) + } + + for remote in branchMenu.remotes { + let remoteItem = NSMenuItem(title: remote.name, action: nil, keyEquivalent: "") + remoteItem.attributedTitle = attributedTitle(remote.name, detail: "Remote") + let remoteMenu = NSMenu() + for node in remote.branches { + if let item = branchItem(for: node, selectedRef: snapshot.selectedRef) { + remoteMenu.addItem(item) + } } - } else { - Text("Loading branches…") + remoteItem.submenu = remoteMenu + menu.addItem(remoteItem) } - } label: { - Text(menuLabel) - .lineLimit(1) - .truncationMode(.middle) + return menu + } + + private func branchItem(for node: BranchMenuNode, selectedRef: String?) -> NSMenuItem? { + guard !node.children.isEmpty else { + guard let ref = node.ref else { return nil } + return branchLeaf(title: node.name, ref: ref, selectedRef: selectedRef) + } + + let item = NSMenuItem(title: node.name, action: nil, keyEquivalent: "") + let submenu = NSMenu() + if let ref = node.ref { + submenu.addItem(branchLeaf(title: node.name, ref: ref, selectedRef: selectedRef)) + } + for child in node.children { + if let childItem = branchItem(for: child, selectedRef: selectedRef) { + submenu.addItem(childItem) + } + } + item.submenu = submenu + return item + } + + private func branchLeaf(title: String, ref: String, selectedRef: String?) -> NSMenuItem { + let item = NSMenuItem( + title: title, + action: #selector(selectBranch(_:)), + keyEquivalent: "" + ) + item.state = selectedRef == ref ? .on : .off + item.target = self + item.representedObject = ref + return item + } + + private func attributedTitle(_ title: String, detail: String?) -> NSAttributedString { + let attributedTitle = NSMutableAttributedString(string: title) + if let detail { + attributedTitle.append( + NSAttributedString( + string: " \(detail)", + attributes: [.foregroundColor: NSColor.secondaryLabelColor] + ) + ) + } + return attributedTitle + } + + @objc private func selectBranch(_ sender: NSMenuItem) { + guard let ref = sender.representedObject as? String else { return } + onSelectBranch(ref) + } + + @objc private func selectTopRow(_ sender: NSMenuItem) { + guard topRows.indices.contains(sender.tag) else { return } + onSelectTopRow(topRows[sender.tag].selection) } } } @@ -409,122 +575,3 @@ private struct WorktreeRefResultRow: View { .help(ref) } } - -/// Non-branch rows atop the base-ref browse menu: the Auto ref and the -/// matching local default branch quick pick. -private struct WorktreeBaseRefTopRows: View { - @Bindable var store: StoreOf - - var body: some View { - WorktreeRefMenuItem( - isSelected: store.selectedBaseRef == nil, - label: store.automaticBaseRef.isEmpty - ? Text("Auto") - : Text("\(store.automaticBaseRef) \(Text("Auto").foregroundStyle(.secondary))") - ) { - store.send(.baseRefSelected(nil)) - } - if let defaultBranch = store.defaultBranch { - // Tagged "Local" to distinguish it from the remote-tracking Auto ref above. - WorktreeRefMenuItem( - isSelected: store.selectedBaseRef == defaultBranch, - label: Text("\(defaultBranch) \(Text("Local").foregroundStyle(.secondary))") - ) { - store.send(.baseRefSelected(defaultBranch)) - } - } - } -} - -/// Non-branch rows atop the upstream browse menu: Git's automatic tracking and -/// an explicit no-upstream choice. -private struct WorktreeUpstreamTopRows: View { - @Bindable var store: StoreOf - - var body: some View { - WorktreeRefMenuItem( - isSelected: store.selectedUpstream == .automatic, - label: Text("Auto") - ) { - store.send(.upstreamSelected(.automatic)) - } - WorktreeRefMenuItem( - isSelected: store.selectedUpstream == .unset, - label: Text("None") - ) { - store.send(.upstreamSelected(.unset)) - } - } -} - -private struct WorktreeRemoteBranchMenu: View { - let remote: BaseRefBranchMenu.Remote - let selectedRef: String? - let onSelect: (String) -> Void - - var body: some View { - Menu { - ForEach(remote.branches) { node in - WorktreeBranchNodeMenu(node: node, selectedRef: selectedRef, onSelect: onSelect) - } - } label: { - Text("\(remote.name) \(Text("Remote").foregroundStyle(.secondary))") - } - } -} - -private struct WorktreeBranchNodeMenu: View { - let node: BranchMenuNode - let selectedRef: String? - let onSelect: (String) -> Void - - var body: some View { - if node.children.isEmpty { - WorktreeBranchNodeMenuItem(node: node, selectedRef: selectedRef, onSelect: onSelect) - } else { - Menu(node.name) { - // A namespace segment that is also a branch (rare) stays selectable; - // the item renders nothing for a ref-less segment. - WorktreeBranchNodeMenuItem(node: node, selectedRef: selectedRef, onSelect: onSelect) - ForEach(node.children) { child in - WorktreeBranchNodeMenu(node: child, selectedRef: selectedRef, onSelect: onSelect) - } - } - } - } -} - -private struct WorktreeBranchNodeMenuItem: View { - let node: BranchMenuNode - let selectedRef: String? - let onSelect: (String) -> Void - - var body: some View { - if let ref = node.ref { - WorktreeRefMenuItem(isSelected: selectedRef == ref, label: Text(node.name)) { - onSelect(ref) - } - } - } -} - -private struct WorktreeRefMenuItem: View { - let isSelected: Bool - let label: Text - let action: () -> Void - - var body: some View { - Button(action: action) { - if isSelected { - Label { - label - } icon: { - Image(systemName: "checkmark") - .accessibilityHidden(true) - } - } else { - label - } - } - } -}