Replace command palette SwiftUI TextField with native AppKit NSTextField

The SwiftUI TextField lost arrow-key and backspace handlers when the
query prefix (">") was deleted, because the scope transition tore down
the .onKeyPress modifiers. Using an NSViewRepresentable with an AppKit
field editor keeps navigation commands (up/down/enter/escape) on the
native delegate, making them immune to SwiftUI scope changes.

Fixes #1409

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
austinpower1258 2026-03-13 18:16:57 -07:00
parent f90bcbc862
commit 48ec60946f

View file

@ -3071,35 +3071,18 @@ struct ContentView: View {
let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight)
return VStack(spacing: 0) {
HStack(spacing: 8) {
TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery)
.textFieldStyle(.plain)
.font(.system(size: 13, weight: .regular))
.tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0)))
.focused($isCommandPaletteSearchFocused)
.accessibilityIdentifier("CommandPaletteSearchField")
.onSubmit {
runSelectedCommandPaletteResult()
}
.backport.onKeyPress(.downArrow) { _ in
moveCommandPaletteSelection(by: 1)
return .handled
}
.backport.onKeyPress(.upArrow) { _ in
moveCommandPaletteSelection(by: -1)
return .handled
}
.backport.onKeyPress("n") { modifiers in
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1)
}
.backport.onKeyPress("p") { modifiers in
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1)
}
.backport.onKeyPress("j") { modifiers in
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1)
}
.backport.onKeyPress("k") { modifiers in
handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1)
}
CommandPaletteSearchFieldRepresentable(
placeholder: commandPaletteSearchPlaceholder,
text: $commandPaletteQuery,
isFocused: Binding(
get: { isCommandPaletteSearchFocused },
set: { isCommandPaletteSearchFocused = $0 }
),
onSubmit: runSelectedCommandPaletteResult,
onEscape: { dismissCommandPalette() },
onMoveSelection: moveCommandPaletteSelection(by:)
)
.frame(maxWidth: .infinity)
}
.padding(.horizontal, 9)
.padding(.vertical, 7)
@ -3345,6 +3328,141 @@ struct ContentView: View {
}
}
private final class CommandPaletteNativeTextField: NSTextField {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
isBordered = false
isBezeled = false
drawsBackground = false
focusRingType = .none
usesSingleLineMode = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// Keep navigation on the AppKit field editor so deleting the ">" prefix
// cannot drop the palette's arrow-key handlers during the scope switch.
private struct CommandPaletteSearchFieldRepresentable: NSViewRepresentable {
let placeholder: String
@Binding var text: String
@Binding var isFocused: Bool
let onSubmit: () -> Void
let onEscape: () -> Void
let onMoveSelection: (Int) -> Void
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: CommandPaletteSearchFieldRepresentable
var isProgrammaticMutation = false
weak var parentField: CommandPaletteNativeTextField?
var pendingFocusRequest: Bool?
init(parent: CommandPaletteSearchFieldRepresentable) {
self.parent = parent
}
func controlTextDidChange(_ obj: Notification) {
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
parent.text = field.stringValue
}
func controlTextDidBeginEditing(_ obj: Notification) {
if !parent.isFocused {
DispatchQueue.main.async {
self.parent.isFocused = true
}
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
case #selector(NSResponder.moveDown(_:)):
parent.onMoveSelection(1)
return true
case #selector(NSResponder.moveUp(_:)):
parent.onMoveSelection(-1)
return true
case #selector(NSResponder.insertNewline(_:)):
guard !textView.hasMarkedText() else { return false }
parent.onSubmit()
return true
case #selector(NSResponder.cancelOperation(_:)):
guard !textView.hasMarkedText() else { return false }
parent.onEscape()
return true
default:
return false
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> CommandPaletteNativeTextField {
let field = CommandPaletteNativeTextField(frame: .zero)
field.font = .systemFont(ofSize: 13)
field.placeholderString = placeholder
field.setAccessibilityIdentifier("CommandPaletteSearchField")
field.delegate = context.coordinator
field.stringValue = text
field.isEditable = true
field.isSelectable = true
field.isEnabled = true
context.coordinator.parentField = field
return field
}
func updateNSView(_ nsView: CommandPaletteNativeTextField, context: Context) {
context.coordinator.parent = self
context.coordinator.parentField = nsView
nsView.placeholderString = placeholder
if let editor = nsView.currentEditor() as? NSTextView {
if editor.string != text, !editor.hasMarkedText() {
context.coordinator.isProgrammaticMutation = true
editor.string = text
nsView.stringValue = text
context.coordinator.isProgrammaticMutation = false
}
} else if nsView.stringValue != text {
nsView.stringValue = text
}
guard let window = nsView.window else { return }
let firstResponder = window.firstResponder
let isFirstResponder =
firstResponder === nsView ||
nsView.currentEditor() != nil ||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let coordinator, coordinator.parent.isFocused else { return }
guard let nsView, let window = nsView.window else { return }
let firstResponder = window.firstResponder
let alreadyFocused =
firstResponder === nsView ||
nsView.currentEditor() != nil ||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
guard !alreadyFocused else { return }
window.makeFirstResponder(nsView)
}
}
}
static func dismantleNSView(_ nsView: CommandPaletteNativeTextField, coordinator: Coordinator) {
nsView.delegate = nil
coordinator.parentField = nil
}
}
private func renameInputHintText(target: CommandPaletteRenameTarget) -> String {
switch target.kind {
case .workspace:
@ -5670,20 +5788,6 @@ struct ContentView: View {
syncCommandPaletteDebugStateForObservedWindow()
}
private func handleCommandPaletteControlNavigationKey(
modifiers: EventModifiers,
delta: Int
) -> BackportKeyPressResult {
guard modifiers.contains(.control),
!modifiers.contains(.command),
!modifiers.contains(.shift),
!modifiers.contains(.option) else {
return .ignored
}
moveCommandPaletteSelection(by: delta)
return .handled
}
static func commandPaletteShouldPopRenameInputOnDelete(
renameDraft: String,
modifiers: EventModifiers