Browser searchbar bugs (#51)

* Add "+" menu button to horizontal tab bar for new terminal/browser tabs

Adds a "+" button to the tab bar (next to split buttons) that shows a
dropdown menu with "New Terminal ⌘T" and "New Browser ⌘⇧L" options.

- Uses native NSButton + NSMenu so the icon matches the split buttons
- Menu appears below the button
- Routes tab creation through new didRequestNewTab delegate method

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* works

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Austin Wang 2026-02-17 16:26:42 -08:00 committed by GitHub
parent 23db0a3fa2
commit 0cca513eac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 209 additions and 7 deletions

View file

@ -1339,6 +1339,92 @@ final class OmnibarSuggestionRankingTests: XCTestCase {
XCTAssertEqual(row.listText, "Example Domain — example.com/path?q=1")
XCTAssertFalse(row.listText.contains("\n"))
}
func testPublishedBufferTextUsesTypedPrefixWhenInlineSuffixIsSelected() {
let inline = OmnibarInlineCompletion(
typedText: "l",
displayText: "localhost:3000",
acceptedText: "https://localhost:3000/"
)
let published = omnibarPublishedBufferTextForFieldChange(
fieldValue: inline.displayText,
inlineCompletion: inline,
selectionRange: inline.suffixRange,
hasMarkedText: false
)
XCTAssertEqual(published, "l")
}
func testPublishedBufferTextKeepsUserTypedValueWhenDisplayDiffersFromInlineText() {
let inline = OmnibarInlineCompletion(
typedText: "l",
displayText: "localhost:3000",
acceptedText: "https://localhost:3000/"
)
let published = omnibarPublishedBufferTextForFieldChange(
fieldValue: "la",
inlineCompletion: inline,
selectionRange: NSRange(location: 2, length: 0),
hasMarkedText: false
)
XCTAssertEqual(published, "la")
}
func testInlineCompletionRenderIgnoresStaleTypedPrefixMismatch() {
let staleInline = OmnibarInlineCompletion(
typedText: "g",
displayText: "github.com",
acceptedText: "https://github.com/"
)
let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: "l",
inlineCompletion: staleInline
)
XCTAssertNil(active)
}
func testInlineCompletionRenderKeepsMatchingTypedPrefix() {
let inline = OmnibarInlineCompletion(
typedText: "l",
displayText: "localhost:3000",
acceptedText: "https://localhost:3000/"
)
let active = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: "l",
inlineCompletion: inline
)
XCTAssertEqual(active, inline)
}
func testInlineCompletionSkipsTitleMatchWhoseURLDoesNotStartWithTypedText() {
// History entry: visited google.com/search?q=localhost:3000 with title
// "localhost:3000 - Google Search". Typing "l" should NOT inline-complete
// to "google.com/..." because that replaces the typed "l" with "g".
let suggestions: [OmnibarSuggestion] = [
.history(
url: "https://www.google.com/search?q=localhost:3000",
title: "localhost:3000 - Google Search"
),
]
let result = omnibarInlineCompletionForDisplay(
typedText: "l",
suggestions: suggestions,
isFocused: true,
selectionRange: NSRange(location: 1, length: 0),
hasMarkedText: false
)
XCTAssertNil(result, "Should not inline-complete when display text does not start with typed prefix")
}
}
@MainActor

View file

@ -307,6 +307,59 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey("x", modifierFlags: [])
}
func testCmdLImmediateTypingReplacesExistingURLBuffer() {
seedBrowserHistoryForTest()
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
// Navigate to a non-empty URL first so Cmd+L must replace existing text.
app.typeKey("l", modifierFlags: [.command])
omnibar.typeText("example.com")
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
let loadedDeadline = Date().addingTimeInterval(8.0)
var loaded = false
while Date() < loadedDeadline {
let value = ((omnibar.value as? String) ?? "").lowercased()
if value.contains("example.com") || value.contains("example.org") {
loaded = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.")
// Reproduce user flow: Cmd+L then immediate typing without waiting.
app.typeKey("l", modifierFlags: [.command])
app.typeText("lo")
let typedDeadline = Date().addingTimeInterval(4.0)
var observedValue = ""
var startsWithTypedPrefix = false
while Date() < typedDeadline {
observedValue = ((omnibar.value as? String) ?? "").lowercased()
if observedValue.hasPrefix("lo") {
startsWithTypedPrefix = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
XCTAssertTrue(
startsWithTypedPrefix,
"Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)"
)
}
func testOmnibarAutocompleteCandidateIsCommittedOnEnter() {
seedBrowserHistoryForTest(
seedEntries: [

View file

@ -170,7 +170,16 @@ struct BrowserPanelView: View {
.onReceive(NotificationCenter.default.publisher(for: .browserFocusAddressBar)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
panel.beginSuppressWebViewFocusForAddressBar()
addressBarFocused = true
if addressBarFocused {
// Cmd+L should always refresh omnibar state/select-all, even when the
// field already has focus.
let urlString = panel.preferredURLStringForOmnibar() ?? ""
let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString))
applyOmnibarEffects(effects)
refreshInlineCompletion()
} else {
addressBarFocused = true
}
}
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
@ -750,6 +759,11 @@ struct BrowserPanelView: View {
refreshSuggestions()
}
if effects.shouldSelectAll {
// Apply immediately for fast Cmd+L typing, then retry once in case
// first responder wasn't fully settled on the same runloop.
DispatchQueue.main.async {
NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil)
}
@ -1292,6 +1306,11 @@ func omnibarInlineCompletionForDisplay(
}
guard omnibarSuggestionSupportsAutocompletion(query: query, suggestion: candidate) else { return nil }
// The display text must start with the typed query so the inline completion
// visually extends what the user typed rather than replacing it (e.g. a
// history entry matched via title "localhost:3000" whose URL is google.com
// should not replace a typed "l" with "g").
guard displayText.lowercased().hasPrefix(loweredQuery) else { return nil }
guard displayText.utf16.count > queryCount else {
return nil
}
@ -1339,6 +1358,40 @@ func omnibarDesiredSelectionRangeForInlineCompletion(
return inlineCompletion.suffixRange
}
func omnibarPublishedBufferTextForFieldChange(
fieldValue: String,
inlineCompletion: OmnibarInlineCompletion?,
selectionRange: NSRange?,
hasMarkedText: Bool
) -> String {
guard !hasMarkedText else { return fieldValue }
guard let inlineCompletion else { return fieldValue }
guard fieldValue == inlineCompletion.displayText else { return fieldValue }
guard let selectionRange else { return inlineCompletion.typedText }
let typedCount = inlineCompletion.typedText.utf16.count
let displayCount = inlineCompletion.displayText.utf16.count
let typedPrefixSelection = NSRange(location: 0, length: typedCount)
let isCaretAtTypedBoundary = selectionRange.location == typedCount && selectionRange.length == 0
let isSuffixSelection = NSEqualRanges(selectionRange, inlineCompletion.suffixRange)
let isSelectAllSelection = selectionRange.location == 0 && selectionRange.length == displayCount
let isTypedPrefixSelection = NSEqualRanges(selectionRange, typedPrefixSelection)
if isCaretAtTypedBoundary || isSuffixSelection || isSelectAllSelection || isTypedPrefixSelection {
return inlineCompletion.typedText
}
return fieldValue
}
func omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: String,
inlineCompletion: OmnibarInlineCompletion?
) -> OmnibarInlineCompletion? {
guard let inlineCompletion else { return nil }
guard bufferText == inlineCompletion.typedText else { return nil }
return inlineCompletion
}
private func typedQueryHasExplicitPathOrQuery(_ typedQuery: String) -> Bool {
var normalized = typedQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.hasPrefix("https://") {
@ -1863,7 +1916,13 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
func controlTextDidChange(_ obj: Notification) {
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
parent.text = field.stringValue
let editor = field.currentEditor() as? NSTextView
parent.text = omnibarPublishedBufferTextForFieldChange(
fieldValue: field.stringValue,
inlineCompletion: parent.inlineCompletion,
selectionRange: editor?.selectedRange(),
hasMarkedText: editor?.hasMarkedText() ?? false
)
publishSelectionState()
}
@ -2051,7 +2110,11 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
context.coordinator.parentField = nsView
nsView.placeholderString = placeholder
let desiredDisplayText = inlineCompletion?.displayText ?? text
let activeInlineCompletion = omnibarInlineCompletionIfBufferMatchesTypedPrefix(
bufferText: text,
inlineCompletion: inlineCompletion
)
let desiredDisplayText = activeInlineCompletion?.displayText ?? text
if let editor = nsView.currentEditor() as? NSTextView {
if editor.string != desiredDisplayText {
context.coordinator.isProgrammaticMutation = true
@ -2076,13 +2139,13 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
if let editor = nsView.currentEditor() as? NSTextView {
if let inlineCompletion {
if let activeInlineCompletion {
let currentSelection = editor.selectedRange()
let desiredSelection = omnibarDesiredSelectionRangeForInlineCompletion(
currentSelection: currentSelection,
inlineCompletion: inlineCompletion
inlineCompletion: activeInlineCompletion
)
if context.coordinator.appliedInlineCompletion != inlineCompletion ||
if context.coordinator.appliedInlineCompletion != activeInlineCompletion ||
!NSEqualRanges(currentSelection, desiredSelection) {
context.coordinator.isProgrammaticMutation = true
editor.setSelectedRange(desiredSelection)
@ -2098,7 +2161,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
}
}
context.coordinator.appliedInlineCompletion = inlineCompletion
context.coordinator.appliedInlineCompletion = activeInlineCompletion
context.coordinator.attachSelectionObserverIfNeeded()
context.coordinator.publishSelectionState()
}

BIN
web/public/ghostty-vt.wasm Executable file

Binary file not shown.