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:
parent
23db0a3fa2
commit
0cca513eac
4 changed files with 209 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
BIN
web/public/ghostty-vt.wasm
Executable file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue