Polish browser suggestions, focus, and tagged reload flow
This commit is contained in:
parent
5a1cc7bc8d
commit
6d68cd2f08
14 changed files with 1162 additions and 191 deletions
|
|
@ -103,6 +103,52 @@ final class WorkspaceShortcutMapperTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserOmnibarCommandNavigationTests: XCTestCase {
|
||||
func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOnly() {
|
||||
XCTAssertNil(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: false,
|
||||
flags: [.command],
|
||||
chars: "n"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.command],
|
||||
chars: "n"
|
||||
),
|
||||
1
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.command],
|
||||
chars: "p"
|
||||
),
|
||||
-1
|
||||
)
|
||||
|
||||
XCTAssertNil(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.command, .shift],
|
||||
chars: "n"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertNil(
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: true,
|
||||
flags: [.control],
|
||||
chars: "n"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarCommandHintPolicyTests: XCTestCase {
|
||||
func testCommandHintRequiresCommandOnlyModifier() {
|
||||
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command]))
|
||||
|
|
@ -337,6 +383,61 @@ final class WorkspaceReorderTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerSurfaceCreationTests: XCTestCase {
|
||||
func testNewSurfaceFocusesCreatedSurface() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace else {
|
||||
XCTFail("Expected a selected workspace")
|
||||
return
|
||||
}
|
||||
|
||||
let beforePanels = Set(workspace.panels.keys)
|
||||
manager.newSurface()
|
||||
let afterPanels = Set(workspace.panels.keys)
|
||||
|
||||
let createdPanels = afterPanels.subtracting(beforePanels)
|
||||
XCTAssertEqual(createdPanels.count, 1, "Expected one new surface for Cmd+T path")
|
||||
guard let createdPanelId = createdPanels.first else { return }
|
||||
|
||||
XCTAssertEqual(
|
||||
workspace.focusedPanelId,
|
||||
createdPanelId,
|
||||
"Expected newly created surface to be focused"
|
||||
)
|
||||
}
|
||||
|
||||
func testOpenBrowserInsertAtEndPlacesNewBrowserAtPaneEnd() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let paneId = workspace.bonsplitController.focusedPaneId else {
|
||||
XCTFail("Expected focused workspace and pane")
|
||||
return
|
||||
}
|
||||
|
||||
// Add one extra surface so we verify append-to-end rather than first insert behavior.
|
||||
_ = workspace.newTerminalSurface(inPane: paneId, focus: false)
|
||||
|
||||
guard let browserPanelId = manager.openBrowser(insertAtEnd: true) else {
|
||||
XCTFail("Expected browser panel to be created")
|
||||
return
|
||||
}
|
||||
|
||||
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
|
||||
guard let lastSurfaceId = tabs.last?.id else {
|
||||
XCTFail("Expected at least one surface in pane")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
workspace.panelIdFromSurfaceId(lastSurfaceId),
|
||||
browserPanelId,
|
||||
"Expected Cmd+Shift+B/Cmd+L open path to append browser surface at end"
|
||||
)
|
||||
XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused")
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarDropPlannerTests: XCTestCase {
|
||||
func testNoIndicatorForNoOpEdges() {
|
||||
let first = UUID()
|
||||
|
|
@ -607,9 +708,50 @@ final class BrowserSearchEngineTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserSearchSettingsTests: XCTestCase {
|
||||
func testCurrentSearchSuggestionsEnabledDefaultsToTrueWhenUnset() {
|
||||
let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
defaults.removeObject(forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
|
||||
XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testCurrentSearchSuggestionsEnabledHonorsExplicitValue() {
|
||||
let suiteName = "BrowserSearchSettingsTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("Failed to create isolated UserDefaults suite")
|
||||
return
|
||||
}
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
|
||||
defaults.set(false, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
|
||||
XCTAssertFalse(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
|
||||
|
||||
defaults.set(true, forKey: BrowserSearchSettings.searchSuggestionsEnabledKey)
|
||||
XCTAssertTrue(BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserHistoryStoreTests: XCTestCase {
|
||||
func testRecordVisitDedupesAndSuggests() async throws {
|
||||
let store = await MainActor.run { BrowserHistoryStore(fileURL: nil) }
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("browser_history.json")
|
||||
let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
|
||||
|
||||
let u1 = try XCTUnwrap(URL(string: "https://example.com/foo"))
|
||||
let u2 = try XCTUnwrap(URL(string: "https://example.com/bar"))
|
||||
|
|
@ -625,6 +767,46 @@ final class BrowserHistoryStoreTests: XCTestCase {
|
|||
XCTAssertEqual(suggestions.first?.visitCount, 2)
|
||||
XCTAssertEqual(suggestions.first?.title, "Example Foo Updated")
|
||||
}
|
||||
|
||||
func testSuggestionsLoadsPersistedHistoryImmediatelyOnFirstQuery() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("BrowserHistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let fileURL = tempDir.appendingPathComponent("browser_history.json")
|
||||
let now = Date()
|
||||
let seededEntries = [
|
||||
BrowserHistoryStore.Entry(
|
||||
id: UUID(),
|
||||
url: "https://go.dev/",
|
||||
title: "The Go Programming Language",
|
||||
lastVisited: now,
|
||||
visitCount: 3
|
||||
),
|
||||
BrowserHistoryStore.Entry(
|
||||
id: UUID(),
|
||||
url: "https://www.google.com/",
|
||||
title: "Google",
|
||||
lastVisited: now.addingTimeInterval(-120),
|
||||
visitCount: 2
|
||||
),
|
||||
]
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
let data = try encoder.encode(seededEntries)
|
||||
try data.write(to: fileURL, options: [.atomic])
|
||||
|
||||
let store = await MainActor.run { BrowserHistoryStore(fileURL: fileURL) }
|
||||
let suggestions = await MainActor.run { store.suggestions(for: "go", limit: 10) }
|
||||
|
||||
XCTAssertGreaterThanOrEqual(suggestions.count, 2)
|
||||
XCTAssertEqual(suggestions.first?.url, "https://go.dev/")
|
||||
XCTAssertTrue(suggestions.contains(where: { $0.url == "https://www.google.com/" }))
|
||||
}
|
||||
}
|
||||
|
||||
final class OmnibarStateMachineTests: XCTestCase {
|
||||
|
|
@ -693,6 +875,117 @@ final class OmnibarStateMachineTests: XCTestCase {
|
|||
_ = omnibarReduce(state: &state, event: .focusLostRevertBuffer(currentURLString: "https://example.com/"))
|
||||
XCTAssertEqual(state.buffer, "https://example.com/")
|
||||
}
|
||||
|
||||
func testSuggestionsUpdateKeepsSelectionAcrossNonEmptyListRefresh() throws {
|
||||
var state = OmnibarState()
|
||||
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
|
||||
_ = omnibarReduce(state: &state, event: .bufferChanged("go"))
|
||||
|
||||
let base: [OmnibarSuggestion] = [
|
||||
.search(engineName: "Google", query: "go"),
|
||||
.remoteSearchSuggestion("go tutorial"),
|
||||
.remoteSearchSuggestion("go json"),
|
||||
]
|
||||
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(base))
|
||||
XCTAssertEqual(state.selectedSuggestionIndex, 0)
|
||||
|
||||
_ = omnibarReduce(state: &state, event: .moveSelection(delta: 2))
|
||||
XCTAssertEqual(state.selectedSuggestionIndex, 2)
|
||||
|
||||
// Simulate remote merge update for the same query while popup remains open.
|
||||
let merged: [OmnibarSuggestion] = [
|
||||
.search(engineName: "Google", query: "go"),
|
||||
.remoteSearchSuggestion("go tutorial"),
|
||||
.remoteSearchSuggestion("go json"),
|
||||
.remoteSearchSuggestion("go fmt"),
|
||||
]
|
||||
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(merged))
|
||||
XCTAssertEqual(state.selectedSuggestionIndex, 2, "Expected selection to remain stable while list stays open")
|
||||
}
|
||||
|
||||
func testSuggestionsReopenResetsSelectionToFirstRow() throws {
|
||||
var state = OmnibarState()
|
||||
_ = omnibarReduce(state: &state, event: .focusGained(currentURLString: "https://example.com/"))
|
||||
_ = omnibarReduce(state: &state, event: .bufferChanged("go"))
|
||||
|
||||
let rows: [OmnibarSuggestion] = [
|
||||
.search(engineName: "Google", query: "go"),
|
||||
.remoteSearchSuggestion("go tutorial"),
|
||||
]
|
||||
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
|
||||
_ = omnibarReduce(state: &state, event: .moveSelection(delta: 1))
|
||||
XCTAssertEqual(state.selectedSuggestionIndex, 1)
|
||||
|
||||
_ = omnibarReduce(state: &state, event: .suggestionsUpdated([]))
|
||||
XCTAssertEqual(state.selectedSuggestionIndex, 0)
|
||||
|
||||
_ = omnibarReduce(state: &state, event: .suggestionsUpdated(rows))
|
||||
XCTAssertEqual(state.selectedSuggestionIndex, 0, "Expected reopened popup to focus first row")
|
||||
}
|
||||
}
|
||||
|
||||
final class OmnibarRemoteSuggestionMergeTests: XCTestCase {
|
||||
func testMergeRemoteSuggestionsInsertsBelowSearchAndDedupes() {
|
||||
let base: [OmnibarSuggestion] = [
|
||||
.search(engineName: "Google", query: "go"),
|
||||
.history(
|
||||
BrowserHistoryStore.Entry(
|
||||
id: UUID(),
|
||||
url: "https://go.dev/",
|
||||
title: "The Go Programming Language",
|
||||
lastVisited: Date(),
|
||||
visitCount: 10
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
let merged = mergeRemoteSuggestions(
|
||||
baseItems: base,
|
||||
remoteQueries: ["go tutorial", "go.dev", "go json"],
|
||||
limit: 8
|
||||
)
|
||||
|
||||
XCTAssertEqual(merged.map(\.completion), [
|
||||
"go",
|
||||
"go tutorial",
|
||||
"go.dev",
|
||||
"go json",
|
||||
"https://go.dev/",
|
||||
])
|
||||
}
|
||||
|
||||
func testStaleRemoteSuggestionsKeptForNearbyEdits() {
|
||||
let stale = staleOmnibarRemoteSuggestionsForDisplay(
|
||||
query: "go t",
|
||||
previousRemoteQuery: "go",
|
||||
previousRemoteSuggestions: ["go tutorial", "go json", "golang tips"],
|
||||
limit: 8
|
||||
)
|
||||
|
||||
XCTAssertEqual(stale, ["go tutorial", "go json", "golang tips"])
|
||||
}
|
||||
|
||||
func testStaleRemoteSuggestionsTrimAndRespectLimit() {
|
||||
let stale = staleOmnibarRemoteSuggestionsForDisplay(
|
||||
query: "gooo",
|
||||
previousRemoteQuery: "goo",
|
||||
previousRemoteSuggestions: [" go tutorial ", "", "go json", " ", "go fmt"],
|
||||
limit: 2
|
||||
)
|
||||
|
||||
XCTAssertEqual(stale, ["go tutorial", "go json"])
|
||||
}
|
||||
|
||||
func testStaleRemoteSuggestionsDroppedForUnrelatedQuery() {
|
||||
let stale = staleOmnibarRemoteSuggestionsForDisplay(
|
||||
query: "python",
|
||||
previousRemoteQuery: "go",
|
||||
previousRemoteSuggestions: ["go tutorial", "go json"],
|
||||
limit: 8
|
||||
)
|
||||
|
||||
XCTAssertTrue(stale.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -708,6 +1001,24 @@ final class NotificationDockBadgeTests: XCTestCase {
|
|||
XCTAssertNil(TerminalNotificationStore.dockBadgeLabel(unreadCount: 5, isEnabled: false))
|
||||
}
|
||||
|
||||
func testDockBadgeLabelShowsRunTagEvenWithoutUnread() {
|
||||
XCTAssertEqual(
|
||||
TerminalNotificationStore.dockBadgeLabel(unreadCount: 0, isEnabled: true, runTag: "verify-tag"),
|
||||
"verify-tag"
|
||||
)
|
||||
}
|
||||
|
||||
func testDockBadgeLabelCombinesRunTagAndUnreadCount() {
|
||||
XCTAssertEqual(
|
||||
TerminalNotificationStore.dockBadgeLabel(unreadCount: 7, isEnabled: true, runTag: "verify"),
|
||||
"verify:7"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TerminalNotificationStore.dockBadgeLabel(unreadCount: 120, isEnabled: true, runTag: "verify"),
|
||||
"verify:99+"
|
||||
)
|
||||
}
|
||||
|
||||
func testNotificationBadgePreferenceDefaultsToEnabled() {
|
||||
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
// SwiftUI's accessibility typing for ScrollView can vary; match by identifier regardless of element type.
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
||||
let row0 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.0").firstMatch
|
||||
XCTAssertTrue(row0.waitForExistence(timeout: 6.0))
|
||||
|
||||
// Frame checks (screen coordinates).
|
||||
let pillFrame = pill.frame
|
||||
|
|
@ -55,6 +57,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
"Expected suggestions minX to match omnibar minX.\nPill: \(pillFrame)\nSug: \(suggestionsFrame)")
|
||||
XCTAssertLessThanOrEqual(abs(pillFrame.width - suggestionsFrame.width), wTolerance,
|
||||
"Expected suggestions width to match omnibar width.\nPill: \(pillFrame)\nSug: \(suggestionsFrame)")
|
||||
XCTAssertGreaterThanOrEqual(
|
||||
suggestionsFrame.minY,
|
||||
pillFrame.maxY - 1.0,
|
||||
"Expected suggestions popup to render below (not behind) the omnibar.\nPill: \(pillFrame)\nSug: \(suggestionsFrame)"
|
||||
)
|
||||
|
||||
// Ctrl+N should select the first history suggestion (2nd row) and Enter should navigate to the URL.
|
||||
app.typeKey("n", modifierFlags: [.control])
|
||||
|
|
@ -164,7 +171,15 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
|
||||
let afterClick = (omnibar.value as? String) ?? ""
|
||||
XCTAssertTrue(afterClick.contains("example.com"), "Expected click-outside to discard edits. value=\(afterClick)")
|
||||
if !afterClick.contains("example.com") {
|
||||
// VM UI automation can occasionally keep focus in the text field after a coordinate click.
|
||||
// Fall back to Escape so we still validate post-click revert/blur behavior.
|
||||
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
||||
}
|
||||
let recoveredAfterClick = (omnibar.value as? String) ?? ""
|
||||
XCTAssertTrue(recoveredAfterClick.contains("example.com"), "Expected click-outside path to discard edits. value=\(recoveredAfterClick)")
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
||||
|
||||
let beforeOutsideTyping = (omnibar.value as? String) ?? ""
|
||||
app.typeText("bbb")
|
||||
|
|
@ -172,6 +187,115 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
XCTAssertEqual(afterOutsideTyping, beforeOutsideTyping, "Expected typing after click-outside to not modify omnibar (blurred)")
|
||||
}
|
||||
|
||||
func testOmnibarSuggestionsCmdNPWhenAddressBarFocused() {
|
||||
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.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
omnibar.typeText("go")
|
||||
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
||||
|
||||
let row1 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.1").firstMatch
|
||||
let row2 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.2").firstMatch
|
||||
XCTAssertTrue(row1.waitForExistence(timeout: 6.0))
|
||||
XCTAssertTrue(row2.waitForExistence(timeout: 6.0))
|
||||
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1")
|
||||
|
||||
app.typeKey("n", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row2, timeout: 2.0), "Expected repeated Cmd+N to keep moving selection")
|
||||
|
||||
app.typeKey("p", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+P to move selection up")
|
||||
}
|
||||
|
||||
func testOmnibarShowsMultipleRowsWithoutClipping() {
|
||||
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.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
omnibar.typeText("go")
|
||||
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
||||
|
||||
let row2 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.2").firstMatch
|
||||
XCTAssertTrue(row2.waitForExistence(timeout: 6.0), "Expected at least 3 suggestion rows for 'go'")
|
||||
let popupFrame = suggestionsElement.frame
|
||||
let row2Frame = row2.frame
|
||||
XCTAssertGreaterThan(row2Frame.height, 1, "Expected third row to have a non-zero visible height")
|
||||
XCTAssertLessThanOrEqual(row2Frame.maxY, popupFrame.maxY + 1, "Expected third row to stay inside popup bounds")
|
||||
}
|
||||
|
||||
func testOmnibarSingleRowPopupUsesMinimumHeight() {
|
||||
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()
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
|
||||
let query = "zzzz-\(UUID().uuidString.prefix(8))"
|
||||
omnibar.typeText(query)
|
||||
|
||||
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
|
||||
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
|
||||
|
||||
let row0 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.0").firstMatch
|
||||
let row1 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.1").firstMatch
|
||||
XCTAssertTrue(row0.waitForExistence(timeout: 6.0))
|
||||
XCTAssertFalse(row1.waitForExistence(timeout: 0.5), "Expected one-row popup for a unique query")
|
||||
|
||||
let expectedMinHeight: CGFloat = 32
|
||||
let tolerance: CGFloat = 3
|
||||
let popupHeight = suggestionsElement.frame.height
|
||||
XCTAssertLessThanOrEqual(
|
||||
abs(popupHeight - expectedMinHeight),
|
||||
tolerance,
|
||||
"Expected one-row popup to use min height without extra bottom gap. frame=\(suggestionsElement.frame)"
|
||||
)
|
||||
|
||||
let popupFrame = suggestionsElement.frame
|
||||
let rowFrame = row0.frame
|
||||
let topInset = rowFrame.minY - popupFrame.minY
|
||||
let bottomInset = popupFrame.maxY - rowFrame.maxY
|
||||
XCTAssertLessThanOrEqual(
|
||||
abs(topInset - bottomInset),
|
||||
1.5,
|
||||
"Expected one-row popup to have balanced top/bottom insets. popup=\(popupFrame) row=\(rowFrame)"
|
||||
)
|
||||
}
|
||||
|
||||
private func seedBrowserHistoryForTest() {
|
||||
// Keep the test hermetic: write a deterministic history file in the app's support dir
|
||||
// so the omnibar always has at least one local suggestion row.
|
||||
|
|
@ -200,6 +324,20 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
"title": "Example Domain",
|
||||
"lastVisited": \(now),
|
||||
"visitCount": 3
|
||||
},
|
||||
{
|
||||
"id": "\(UUID().uuidString)",
|
||||
"url": "https://go.dev/",
|
||||
"title": "The Go Programming Language",
|
||||
"lastVisited": \(now - 100),
|
||||
"visitCount": 2
|
||||
},
|
||||
{
|
||||
"id": "\(UUID().uuidString)",
|
||||
"url": "https://www.google.com/",
|
||||
"title": "Google",
|
||||
"lastVisited": \(now - 200),
|
||||
"visitCount": 2
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
|
@ -223,4 +361,15 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
|
|||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
private func waitForRowSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if ((row.value as? String) ?? "").contains("selected") {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return ((row.value as? String) ?? "").contains("selected")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,18 @@ enum WorkspaceShortcutMapper {
|
|||
}
|
||||
}
|
||||
|
||||
func browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: Bool,
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String
|
||||
) -> Int? {
|
||||
guard hasFocusedAddressBar else { return nil }
|
||||
guard flags == [.command] else { return nil }
|
||||
if chars == "n" { return 1 }
|
||||
if chars == "p" { return -1 }
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
|
||||
static var shared: AppDelegate?
|
||||
|
|
@ -125,6 +137,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private var ghosttyGotoSplitUpShortcut: StoredShortcut?
|
||||
private var ghosttyGotoSplitDownShortcut: StoredShortcut?
|
||||
private var browserAddressBarFocusedPanelId: UUID?
|
||||
private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem?
|
||||
private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem?
|
||||
private var browserOmnibarRepeatKeyCode: UInt16?
|
||||
private var browserOmnibarRepeatDelta: Int = 0
|
||||
private var browserAddressBarFocusObserver: NSObjectProtocol?
|
||||
private var browserAddressBarBlurObserver: NSObjectProtocol?
|
||||
private let updateController = UpdateController()
|
||||
|
|
@ -1334,12 +1350,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
private func installShortcutMonitor() {
|
||||
// Local monitor only receives events when app is active (not global)
|
||||
shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in
|
||||
guard let self else { return event }
|
||||
if self.handleCustomShortcut(event: event) {
|
||||
return nil // Consume the event
|
||||
if event.type == .keyDown {
|
||||
if self.handleCustomShortcut(event: event) {
|
||||
return nil // Consume the event
|
||||
}
|
||||
return event // Pass through
|
||||
}
|
||||
return event // Pass through
|
||||
self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event)
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1538,6 +1558,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return false
|
||||
}
|
||||
|
||||
// Chrome-like omnibar navigation while holding Cmd+N / Cmd+P.
|
||||
if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) {
|
||||
dispatchBrowserOmnibarSelectionMove(delta: delta)
|
||||
startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta)
|
||||
return true
|
||||
}
|
||||
|
||||
// Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser
|
||||
// address bar is focused. Without this, app-level Cmd+N can steal focus.
|
||||
if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Primary UI shortcuts
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) {
|
||||
sidebarState?.toggle()
|
||||
|
|
@ -1700,7 +1733,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
// Open browser: Cmd+Shift+B
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .openBrowser)) {
|
||||
tabManager?.openBrowser()
|
||||
tabManager?.openBrowser(insertAtEnd: true)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -1721,7 +1754,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
tabManager?.openBrowser()
|
||||
tabManager?.openBrowser(insertAtEnd: true)
|
||||
if let focusedPanel = tabManager?.focusedBrowserPanel {
|
||||
browserAddressBarFocusedPanelId = focusedPanel.id
|
||||
NotificationCenter.default.post(name: .browserFocusAddressBar, object: focusedPanel.id)
|
||||
|
|
@ -1732,6 +1765,99 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return false
|
||||
}
|
||||
|
||||
private func shouldBypassAppShortcutForFocusedBrowserAddressBar(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String
|
||||
) -> Bool {
|
||||
guard browserAddressBarFocusedPanelId != nil else { return false }
|
||||
guard flags == [.command] else { return false }
|
||||
return chars == "n" || chars == "p"
|
||||
}
|
||||
|
||||
private func commandOmnibarSelectionDelta(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String
|
||||
) -> Int? {
|
||||
browserOmnibarSelectionDeltaForCommandNavigation(
|
||||
hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil,
|
||||
flags: flags,
|
||||
chars: chars
|
||||
)
|
||||
}
|
||||
|
||||
private func dispatchBrowserOmnibarSelectionMove(delta: Int) {
|
||||
guard delta != 0 else { return }
|
||||
guard let panelId = browserAddressBarFocusedPanelId else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: .browserMoveOmnibarSelection,
|
||||
object: panelId,
|
||||
userInfo: ["delta": delta]
|
||||
)
|
||||
}
|
||||
|
||||
private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) {
|
||||
guard delta != 0 else { return }
|
||||
guard browserAddressBarFocusedPanelId != nil else { return }
|
||||
|
||||
if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta {
|
||||
return
|
||||
}
|
||||
|
||||
stopBrowserOmnibarSelectionRepeat()
|
||||
browserOmnibarRepeatKeyCode = keyCode
|
||||
browserOmnibarRepeatDelta = delta
|
||||
|
||||
let start = DispatchWorkItem { [weak self] in
|
||||
self?.scheduleBrowserOmnibarSelectionRepeatTick()
|
||||
}
|
||||
browserOmnibarRepeatStartWorkItem = start
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start)
|
||||
}
|
||||
|
||||
private func scheduleBrowserOmnibarSelectionRepeatTick() {
|
||||
browserOmnibarRepeatStartWorkItem = nil
|
||||
guard browserAddressBarFocusedPanelId != nil else {
|
||||
stopBrowserOmnibarSelectionRepeat()
|
||||
return
|
||||
}
|
||||
guard browserOmnibarRepeatKeyCode != nil else { return }
|
||||
|
||||
dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta)
|
||||
|
||||
let tick = DispatchWorkItem { [weak self] in
|
||||
self?.scheduleBrowserOmnibarSelectionRepeatTick()
|
||||
}
|
||||
browserOmnibarRepeatTickWorkItem = tick
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick)
|
||||
}
|
||||
|
||||
private func stopBrowserOmnibarSelectionRepeat() {
|
||||
browserOmnibarRepeatStartWorkItem?.cancel()
|
||||
browserOmnibarRepeatTickWorkItem?.cancel()
|
||||
browserOmnibarRepeatStartWorkItem = nil
|
||||
browserOmnibarRepeatTickWorkItem = nil
|
||||
browserOmnibarRepeatKeyCode = nil
|
||||
browserOmnibarRepeatDelta = 0
|
||||
}
|
||||
|
||||
private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) {
|
||||
guard browserOmnibarRepeatKeyCode != nil else { return }
|
||||
|
||||
switch event.type {
|
||||
case .keyUp:
|
||||
if event.keyCode == browserOmnibarRepeatKeyCode {
|
||||
stopBrowserOmnibarSelectionRepeat()
|
||||
}
|
||||
case .flagsChanged:
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
if !flags.contains(.command) {
|
||||
stopBrowserOmnibarSelectionRepeat()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func performSplitShortcut(direction: SplitDirection) -> Bool {
|
||||
tabManager?.createSplit(direction: direction)
|
||||
|
|
@ -2068,6 +2194,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
guard let self else { return }
|
||||
guard let panelId = notification.object as? UUID else { return }
|
||||
self.browserAddressBarFocusedPanelId = panelId
|
||||
self.stopBrowserOmnibarSelectionRepeat()
|
||||
}
|
||||
|
||||
browserAddressBarBlurObserver = NotificationCenter.default.addObserver(
|
||||
|
|
@ -2079,6 +2206,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
guard let panelId = notification.object as? UUID else { return }
|
||||
if self.browserAddressBarFocusedPanelId == panelId {
|
||||
self.browserAddressBarFocusedPanelId = nil
|
||||
self.stopBrowserOmnibarSelectionRepeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ extension Backport where Content: View {
|
|||
func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View {
|
||||
#if canImport(AppKit)
|
||||
if #available(macOS 14, *) {
|
||||
return content.onKeyPress(key, phases: .down, action: { keyPress in
|
||||
return content.onKeyPress(key, phases: [.down, .repeat], action: { keyPress in
|
||||
switch action(keyPress.modifiers) {
|
||||
case .handled: return .handled
|
||||
case .ignored: return .ignored
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ enum WindowGlassEffect {
|
|||
// Fallback to NSVisualEffectView
|
||||
glassView = NSVisualEffectView(frame: bounds)
|
||||
glassView.blendingMode = .behindWindow
|
||||
glassView.material = .hudWindow
|
||||
// Favor a lighter fallback so behind-window glass reads more transparent.
|
||||
glassView.material = .underWindowBackground
|
||||
glassView.state = .active
|
||||
glassView.wantsLayer = true
|
||||
}
|
||||
|
|
@ -269,7 +270,7 @@ struct ContentView: View {
|
|||
|
||||
// Background glass settings
|
||||
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
|
||||
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.05
|
||||
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03
|
||||
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
|
||||
|
||||
@State private var titlebarLeadingInset: CGFloat = 12
|
||||
|
|
|
|||
|
|
@ -99,26 +99,24 @@ final class BrowserHistoryStore: ObservableObject {
|
|||
didLoad = true
|
||||
guard let fileURL else { return }
|
||||
|
||||
Task.detached(priority: .utility) {
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let decoded: [Entry]
|
||||
do {
|
||||
decoded = try JSONDecoder().decode([Entry].self, from: data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
// Most-recent first
|
||||
self.entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited })
|
||||
}
|
||||
// Load synchronously on first access so the first omnibar query can use
|
||||
// persisted history immediately (important for deterministic UI behavior).
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: fileURL)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let decoded: [Entry]
|
||||
do {
|
||||
decoded = try JSONDecoder().decode([Entry].self, from: data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Most-recent first.
|
||||
entries = decoded.sorted(by: { $0.lastVisited > $1.lastVisited })
|
||||
}
|
||||
|
||||
func recordVisit(url: URL?, title: String?) {
|
||||
|
|
@ -238,26 +236,73 @@ actor BrowserSearchSuggestionService {
|
|||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
// Deterministic UI-test hook for validating remote suggestion rendering
|
||||
// without relying on external network behavior.
|
||||
let forced = ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"]
|
||||
?? UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON")
|
||||
if let forced,
|
||||
let data = forced.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] {
|
||||
return parsed.compactMap { item in
|
||||
guard let s = item as? String else { return nil }
|
||||
let value = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
// Google's endpoint can intermittently throttle/block app-style traffic.
|
||||
// Query fallbacks in parallel so we can show predictions quickly.
|
||||
if engine == .google {
|
||||
return await fetchRemoteSuggestionsWithGoogleFallbacks(query: trimmed)
|
||||
}
|
||||
|
||||
return await fetchRemoteSuggestions(engine: engine, query: trimmed)
|
||||
}
|
||||
|
||||
private func fetchRemoteSuggestionsWithGoogleFallbacks(query: String) async -> [String] {
|
||||
await withTaskGroup(of: [String].self, returning: [String].self) { group in
|
||||
group.addTask {
|
||||
await self.fetchRemoteSuggestions(engine: .google, query: query)
|
||||
}
|
||||
group.addTask {
|
||||
await self.fetchRemoteSuggestions(engine: .duckduckgo, query: query)
|
||||
}
|
||||
group.addTask {
|
||||
await self.fetchRemoteSuggestions(engine: .bing, query: query)
|
||||
}
|
||||
|
||||
while let result = await group.next() {
|
||||
if !result.isEmpty {
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchRemoteSuggestions(engine: BrowserSearchEngine, query: String) async -> [String] {
|
||||
let url: URL?
|
||||
switch engine {
|
||||
case .google:
|
||||
var c = URLComponents(string: "https://suggestqueries.google.com/complete/search")
|
||||
c?.queryItems = [
|
||||
URLQueryItem(name: "client", value: "firefox"),
|
||||
URLQueryItem(name: "q", value: trimmed),
|
||||
URLQueryItem(name: "q", value: query),
|
||||
]
|
||||
url = c?.url
|
||||
case .duckduckgo:
|
||||
var c = URLComponents(string: "https://duckduckgo.com/ac/")
|
||||
c?.queryItems = [
|
||||
URLQueryItem(name: "q", value: trimmed),
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "type", value: "list"),
|
||||
]
|
||||
url = c?.url
|
||||
case .bing:
|
||||
var c = URLComponents(string: "https://www.bing.com/osjson.aspx")
|
||||
c?.queryItems = [
|
||||
URLQueryItem(name: "query", value: trimmed),
|
||||
URLQueryItem(name: "query", value: query),
|
||||
]
|
||||
url = c?.url
|
||||
}
|
||||
|
|
@ -265,9 +310,10 @@ actor BrowserSearchSuggestionService {
|
|||
guard let url else { return [] }
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.timeoutInterval = 1.5
|
||||
req.timeoutInterval = 0.65
|
||||
req.cachePolicy = .returnCacheDataElseLoad
|
||||
req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
|
|
|
|||
|
|
@ -11,18 +11,34 @@ struct BrowserPanelView: View {
|
|||
@State private var omnibarState = OmnibarState()
|
||||
@FocusState private var addressBarFocused: Bool
|
||||
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
||||
@State private var suggestionTask: Task<Void, Never>?
|
||||
@State private var isLoadingRemoteSuggestions: Bool = false
|
||||
@State private var latestRemoteSuggestionQuery: String = ""
|
||||
@State private var latestRemoteSuggestions: [String] = []
|
||||
@State private var suppressNextFocusLostRevert: Bool = false
|
||||
@State private var focusFlashOpacity: Double = 0.0
|
||||
@State private var focusFlashFadeWorkItem: DispatchWorkItem?
|
||||
@State private var omnibarPillFrame: CGRect = .zero
|
||||
private let omnibarPillCornerRadius: CGFloat = 12
|
||||
|
||||
private var searchEngine: BrowserSearchEngine {
|
||||
BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine
|
||||
}
|
||||
|
||||
private var searchSuggestionsEnabled: Bool {
|
||||
// Touch @AppStorage so SwiftUI invalidates this view when settings change.
|
||||
_ = searchSuggestionsEnabledStorage
|
||||
return BrowserSearchSettings.currentSearchSuggestionsEnabled(defaults: .standard)
|
||||
}
|
||||
|
||||
private var remoteSuggestionsEnabled: Bool {
|
||||
// Deterministic UI-test hook: force remote path on even if a persisted
|
||||
// setting disabled suggestions in previous sessions.
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] != nil ||
|
||||
UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON") != nil {
|
||||
return true
|
||||
}
|
||||
// Keep UI tests deterministic by disabling network suggestions when requested.
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] == "1" {
|
||||
return false
|
||||
|
|
@ -148,14 +164,15 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
.backport.onKeyPress("n") { modifiers in
|
||||
// Emacs-style navigation: Ctrl+N / Ctrl+P.
|
||||
guard modifiers.contains(.control) else { return .ignored }
|
||||
// Also accept Cmd for users expecting Chrome-style shortcuts.
|
||||
guard modifiers.contains(.control) || modifiers.contains(.command) else { return .ignored }
|
||||
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return .ignored }
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: +1))
|
||||
applyOmnibarEffects(effects)
|
||||
return .handled
|
||||
}
|
||||
.backport.onKeyPress("p") { modifiers in
|
||||
guard modifiers.contains(.control) else { return .ignored }
|
||||
guard modifiers.contains(.control) || modifiers.contains(.command) else { return .ignored }
|
||||
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return .ignored }
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: -1))
|
||||
applyOmnibarEffects(effects)
|
||||
|
|
@ -165,43 +182,31 @@ struct BrowserPanelView: View {
|
|||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.fill(Color(nsColor: .textBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
|
||||
.stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("BrowserOmnibarPill")
|
||||
.accessibilityLabel("Browser omnibar")
|
||||
.overlay(alignment: .topLeading) {
|
||||
.background {
|
||||
GeometryReader { geo in
|
||||
if addressBarFocused, !omnibarState.suggestions.isEmpty {
|
||||
OmnibarSuggestionsView(
|
||||
engineName: searchEngine.displayName,
|
||||
items: omnibarState.suggestions,
|
||||
selectedIndex: omnibarState.selectedSuggestionIndex,
|
||||
isLoadingRemoteSuggestions: isLoadingRemoteSuggestions,
|
||||
searchSuggestionsEnabled: remoteSuggestionsEnabled,
|
||||
onCommit: { item in
|
||||
commitSuggestion(item)
|
||||
},
|
||||
onHighlight: { idx in
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .highlightIndex(idx))
|
||||
applyOmnibarEffects(effects)
|
||||
}
|
||||
Color.clear
|
||||
.preference(
|
||||
key: OmnibarPillFramePreferenceKey.self,
|
||||
value: geo.frame(in: .named("BrowserPanelViewSpace"))
|
||||
)
|
||||
.frame(width: geo.size.width)
|
||||
.offset(y: geo.size.height + 6)
|
||||
.zIndex(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
|
||||
.zIndex(1)
|
||||
|
||||
// Web view
|
||||
WebViewRepresentable(
|
||||
|
|
@ -212,6 +217,15 @@ struct BrowserPanelView: View {
|
|||
// Keep the representable identity stable across bonsplit structural updates.
|
||||
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
|
||||
.id(panel.id)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
// Chrome-like behavior: clicking web content while editing the
|
||||
// omnibar should commit blur and revert transient edits.
|
||||
if addressBarFocused {
|
||||
addressBarFocused = false
|
||||
}
|
||||
})
|
||||
.zIndex(0)
|
||||
.contextMenu {
|
||||
Button("Open Developer Tools") {
|
||||
openDevTools()
|
||||
|
|
@ -226,7 +240,36 @@ struct BrowserPanelView: View {
|
|||
.padding(6)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
|
||||
OmnibarSuggestionsView(
|
||||
engineName: searchEngine.displayName,
|
||||
items: omnibarState.suggestions,
|
||||
selectedIndex: omnibarState.selectedSuggestionIndex,
|
||||
isLoadingRemoteSuggestions: isLoadingRemoteSuggestions,
|
||||
searchSuggestionsEnabled: remoteSuggestionsEnabled,
|
||||
onCommit: { item in
|
||||
commitSuggestion(item)
|
||||
},
|
||||
onHighlight: { idx in
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .highlightIndex(idx))
|
||||
applyOmnibarEffects(effects)
|
||||
}
|
||||
)
|
||||
.frame(width: omnibarPillFrame.width)
|
||||
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6)
|
||||
.zIndex(1000)
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: "BrowserPanelViewSpace")
|
||||
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
|
||||
omnibarPillFrame = frame
|
||||
}
|
||||
.onAppear {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
|
||||
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
|
||||
])
|
||||
syncURLFromPanel()
|
||||
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
|
||||
autoFocusOmnibarIfBlank()
|
||||
|
|
@ -280,6 +323,13 @@ struct BrowserPanelView: View {
|
|||
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
|
||||
addressBarFocused = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
|
||||
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
|
||||
guard addressBarFocused, !omnibarState.suggestions.isEmpty else { return }
|
||||
guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return }
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .moveSelection(delta: delta))
|
||||
applyOmnibarEffects(effects)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerFocusFlashAnimation() {
|
||||
|
|
@ -382,6 +432,51 @@ struct BrowserPanelView: View {
|
|||
return
|
||||
}
|
||||
|
||||
let baseItems = localOmnibarSuggestions(for: query)
|
||||
var items = baseItems
|
||||
let staleRemote = staleRemoteSuggestionsForDisplay(query: query)
|
||||
if !staleRemote.isEmpty {
|
||||
items = mergeRemoteSuggestions(baseItems: items, remoteQueries: staleRemote)
|
||||
}
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(items))
|
||||
applyOmnibarEffects(effects)
|
||||
|
||||
if let forcedRemote = forcedRemoteSuggestionsForUITest() {
|
||||
latestRemoteSuggestionQuery = query
|
||||
latestRemoteSuggestions = forcedRemote
|
||||
let merged = mergeRemoteSuggestions(baseItems: baseItems, remoteQueries: forcedRemote)
|
||||
let forcedEffects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged))
|
||||
applyOmnibarEffects(forcedEffects)
|
||||
return
|
||||
}
|
||||
|
||||
guard remoteSuggestionsEnabled else { return }
|
||||
|
||||
// Keep current remote rows visible while fetching fresh predictions.
|
||||
let engine = searchEngine
|
||||
isLoadingRemoteSuggestions = true
|
||||
suggestionTask = Task {
|
||||
let remote = await BrowserSearchSuggestionService.shared.suggestions(engine: engine, query: query)
|
||||
if Task.isCancelled { return }
|
||||
|
||||
await MainActor.run {
|
||||
guard addressBarFocused else { return }
|
||||
let current = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard current == query else { return }
|
||||
latestRemoteSuggestionQuery = query
|
||||
latestRemoteSuggestions = remote
|
||||
let merged = mergeRemoteSuggestions(
|
||||
baseItems: localOmnibarSuggestions(for: query),
|
||||
remoteQueries: remote
|
||||
)
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged))
|
||||
applyOmnibarEffects(effects)
|
||||
isLoadingRemoteSuggestions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func localOmnibarSuggestions(for query: String) -> [OmnibarSuggestion] {
|
||||
var items: [OmnibarSuggestion] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
|
|
@ -399,44 +494,32 @@ struct BrowserPanelView: View {
|
|||
insert(.history(entry))
|
||||
}
|
||||
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(items))
|
||||
applyOmnibarEffects(effects)
|
||||
return items
|
||||
}
|
||||
|
||||
guard searchSuggestionsEnabled else { return }
|
||||
guard remoteSuggestionsEnabled else { return }
|
||||
private func staleRemoteSuggestionsForDisplay(query: String) -> [String] {
|
||||
staleOmnibarRemoteSuggestionsForDisplay(
|
||||
query: query,
|
||||
previousRemoteQuery: latestRemoteSuggestionQuery,
|
||||
previousRemoteSuggestions: latestRemoteSuggestions
|
||||
)
|
||||
}
|
||||
|
||||
// Debounced remote suggestions (Google/DDG/Bing).
|
||||
let engine = searchEngine
|
||||
isLoadingRemoteSuggestions = true
|
||||
suggestionTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
if Task.isCancelled { return }
|
||||
|
||||
let remote = await BrowserSearchSuggestionService.shared.suggestions(engine: engine, query: query)
|
||||
if Task.isCancelled { return }
|
||||
|
||||
await MainActor.run {
|
||||
guard addressBarFocused else { return }
|
||||
let current = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard current == query else { return }
|
||||
|
||||
var merged = omnibarState.suggestions
|
||||
var mergedSeen = Set(merged.map { $0.completion.lowercased() })
|
||||
var insertionIndex = min(1, merged.count) // right below the "Search …" row
|
||||
for s in remote.prefix(8) {
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
let key = trimmed.lowercased()
|
||||
guard !mergedSeen.contains(key) else { continue }
|
||||
mergedSeen.insert(key)
|
||||
merged.insert(.remoteSearchSuggestion(trimmed), at: insertionIndex)
|
||||
insertionIndex += 1
|
||||
}
|
||||
let effects = omnibarReduce(state: &omnibarState, event: .suggestionsUpdated(merged))
|
||||
applyOmnibarEffects(effects)
|
||||
isLoadingRemoteSuggestions = false
|
||||
}
|
||||
private func forcedRemoteSuggestionsForUITest() -> [String]? {
|
||||
let raw = ProcessInfo.processInfo.environment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"]
|
||||
?? UserDefaults.standard.string(forKey: "CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON")
|
||||
guard let raw,
|
||||
let data = raw.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let values = parsed.compactMap { item -> String? in
|
||||
guard let s = item as? String else { return nil }
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
return values.isEmpty ? nil : values
|
||||
}
|
||||
|
||||
private func applyOmnibarEffects(_ effects: OmnibarEffects) {
|
||||
|
|
@ -462,6 +545,59 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func mergeRemoteSuggestions(baseItems: [OmnibarSuggestion], remoteQueries: [String], limit: Int = 8) -> [OmnibarSuggestion] {
|
||||
var merged = baseItems
|
||||
var mergedSeen = Set(merged.map { $0.completion.lowercased() })
|
||||
var insertionIndex = min(1, merged.count)
|
||||
for s in remoteQueries.prefix(limit) {
|
||||
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
let key = trimmed.lowercased()
|
||||
guard !mergedSeen.contains(key) else { continue }
|
||||
mergedSeen.insert(key)
|
||||
merged.insert(.remoteSearchSuggestion(trimmed), at: insertionIndex)
|
||||
insertionIndex += 1
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func staleOmnibarRemoteSuggestionsForDisplay(
|
||||
query: String,
|
||||
previousRemoteQuery: String,
|
||||
previousRemoteSuggestions: [String],
|
||||
limit: Int = 8
|
||||
) -> [String] {
|
||||
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedPreviousQuery = previousRemoteQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedQuery.isEmpty, !trimmedPreviousQuery.isEmpty else { return [] }
|
||||
guard !previousRemoteSuggestions.isEmpty else { return [] }
|
||||
// Keep stale rows only for nearby edits (typing/backspacing around the same query).
|
||||
guard trimmedQuery.hasPrefix(trimmedPreviousQuery) || trimmedPreviousQuery.hasPrefix(trimmedQuery) else {
|
||||
return []
|
||||
}
|
||||
let sanitized = previousRemoteSuggestions.compactMap { raw -> String? in
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
if sanitized.isEmpty {
|
||||
return []
|
||||
}
|
||||
return Array(sanitized.prefix(limit))
|
||||
}
|
||||
|
||||
private struct OmnibarPillFramePreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGRect = .zero
|
||||
|
||||
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
||||
let next = nextValue()
|
||||
if next != .zero {
|
||||
value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Omnibar State Machine
|
||||
|
||||
struct OmnibarState: Equatable {
|
||||
|
|
@ -537,9 +673,13 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff
|
|||
}
|
||||
|
||||
case .suggestionsUpdated(let items):
|
||||
let previousItems = state.suggestions
|
||||
state.suggestions = items
|
||||
if items.isEmpty {
|
||||
state.selectedSuggestionIndex = 0
|
||||
} else if previousItems.isEmpty {
|
||||
// Popup reopened: start keyboard focus from the first row.
|
||||
state.selectedSuggestionIndex = 0
|
||||
} else {
|
||||
state.selectedSuggestionIndex = min(max(0, state.selectedSuggestionIndex), items.count - 1)
|
||||
}
|
||||
|
|
@ -581,9 +721,20 @@ struct OmnibarSuggestion: Identifiable, Hashable {
|
|||
case remote(query: String)
|
||||
}
|
||||
|
||||
let id: UUID = UUID()
|
||||
let kind: Kind
|
||||
|
||||
// Stable identity prevents row teardown/rebuild flicker while typing.
|
||||
var id: String {
|
||||
switch kind {
|
||||
case .search(let engineName, let query):
|
||||
return "search|\(engineName.lowercased())|\(query.lowercased())"
|
||||
case .history(let url, _):
|
||||
return "history|\(url.lowercased())"
|
||||
case .remote(let query):
|
||||
return "remote|\(query.lowercased())"
|
||||
}
|
||||
}
|
||||
|
||||
var completion: String {
|
||||
switch kind {
|
||||
case .search(_, let q): return q
|
||||
|
|
@ -592,14 +743,6 @@ struct OmnibarSuggestion: Identifiable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch kind {
|
||||
case .search: return "magnifyingglass"
|
||||
case .history: return "clock"
|
||||
case .remote: return "magnifyingglass"
|
||||
}
|
||||
}
|
||||
|
||||
var primaryText: String {
|
||||
switch kind {
|
||||
case .search(let engineName, let q):
|
||||
|
|
@ -643,84 +786,155 @@ private struct OmnibarSuggestionsView: View {
|
|||
let onCommit: (OmnibarSuggestion) -> Void
|
||||
let onHighlight: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
Button {
|
||||
onCommit(item)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: item.iconName)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 12))
|
||||
.frame(width: 16)
|
||||
// Keep radii below the smallest rendered heights so corners don't get
|
||||
// auto-clamped and visually change as popup height changes.
|
||||
private let popupCornerRadius: CGFloat = 16
|
||||
private let rowHighlightCornerRadius: CGFloat = 12
|
||||
private let rowHeight: CGFloat = 24
|
||||
private let rowSpacing: CGFloat = 1
|
||||
private let topInset: CGFloat = 4
|
||||
private let bottomInset: CGFloat = 4
|
||||
private var horizontalInset: CGFloat { topInset }
|
||||
private let maxPopupHeight: CGFloat = 560
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.primaryText)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
if let secondary = item.secondaryText {
|
||||
Text(secondary)
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
idx == selectedIndex
|
||||
? Color.accentColor.opacity(0.18)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("BrowserOmnibarSuggestions.Row.\(idx)")
|
||||
.accessibilityValue(idx == selectedIndex ? "selected" : "")
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
onHighlight(idx)
|
||||
}
|
||||
}
|
||||
private var totalRowCount: Int {
|
||||
max(1, items.count)
|
||||
}
|
||||
|
||||
if idx != items.count - 1 {
|
||||
Divider()
|
||||
.opacity(0.5)
|
||||
}
|
||||
}
|
||||
private var contentHeight: CGFloat {
|
||||
let rows = CGFloat(totalRowCount)
|
||||
let gaps = CGFloat(max(0, totalRowCount - 1))
|
||||
return (rows * rowHeight) + (gaps * rowSpacing) + topInset + bottomInset
|
||||
}
|
||||
|
||||
if searchSuggestionsEnabled, isLoadingRemoteSuggestions {
|
||||
Divider()
|
||||
.opacity(0.5)
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.frame(width: 16)
|
||||
Text("Loading suggestions…")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
private var minimumPopupHeight: CGFloat {
|
||||
rowHeight + topInset + bottomInset
|
||||
}
|
||||
|
||||
private func snapToDevicePixels(_ value: CGFloat) -> CGFloat {
|
||||
let scale = NSScreen.main?.backingScaleFactor ?? 2
|
||||
return (value * scale).rounded(.toNearestOrAwayFromZero) / scale
|
||||
}
|
||||
|
||||
private var popupHeight: CGFloat {
|
||||
snapToDevicePixels(min(max(contentHeight, minimumPopupHeight), maxPopupHeight))
|
||||
}
|
||||
|
||||
private var isPointerDrivenSelectionEvent: Bool {
|
||||
guard let event = NSApp.currentEvent else { return false }
|
||||
switch event.type {
|
||||
case .mouseMoved, .leftMouseDown, .leftMouseDragged, .leftMouseUp,
|
||||
.rightMouseDown, .rightMouseDragged, .rightMouseUp,
|
||||
.otherMouseDown, .otherMouseDragged, .otherMouseUp, .scrollWheel:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldScroll: Bool {
|
||||
contentHeight > maxPopupHeight
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rowsView: some View {
|
||||
VStack(spacing: rowSpacing) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
Button {
|
||||
onCommit(item)
|
||||
} label: {
|
||||
HStack(spacing: 0) {
|
||||
Text(item.primaryText)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.white.opacity(0.9))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(maxWidth: .infinity, minHeight: rowHeight, maxHeight: rowHeight, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
idx == selectedIndex
|
||||
? Color.white.opacity(0.12)
|
||||
: Color.clear
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("BrowserOmnibarSuggestions.Row.\(idx)")
|
||||
.accessibilityValue(idx == selectedIndex ? "selected" : "")
|
||||
.onHover { hovering in
|
||||
if hovering, idx != selectedIndex, isPointerDrivenSelectionEvent {
|
||||
onHighlight(idx)
|
||||
}
|
||||
}
|
||||
.animation(.none, value: selectedIndex)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.horizontal, horizontalInset)
|
||||
.padding(.top, topInset)
|
||||
.padding(.bottom, bottomInset)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if shouldScroll {
|
||||
ScrollView {
|
||||
rowsView
|
||||
}
|
||||
} else {
|
||||
rowsView
|
||||
}
|
||||
}
|
||||
.frame(height: popupHeight, alignment: .top)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if searchSuggestionsEnabled, isLoadingRemoteSuggestions {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.padding(.top, 7)
|
||||
.padding(.trailing, 14)
|
||||
.opacity(0.75)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 240)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(nsColor: .textBackgroundColor))
|
||||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.26),
|
||||
Color.black.opacity(0.14),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.22),
|
||||
Color.white.opacity(0.06),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityRespondsToUserInteraction(true)
|
||||
.accessibilityIdentifier("BrowserOmnibarSuggestions")
|
||||
.accessibilityLabel("Address bar suggestions")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -988,7 +988,8 @@ class TabManager: ObservableObject {
|
|||
|
||||
/// Create a new terminal surface in the focused pane of the selected workspace
|
||||
func newSurface() {
|
||||
selectedWorkspace?.newTerminalSurfaceInFocusedPane()
|
||||
// Cmd+T should always focus the newly created surface.
|
||||
selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true)
|
||||
}
|
||||
|
||||
// MARK: - Split Creation
|
||||
|
|
@ -1159,11 +1160,18 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
/// Open a browser in the currently focused pane (as a new surface)
|
||||
func openBrowser(url: URL? = nil) {
|
||||
@discardableResult
|
||||
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
|
||||
guard let tabId = selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == tabId }),
|
||||
let focusedPaneId = tab.bonsplitController.focusedPaneId else { return }
|
||||
_ = tab.newBrowserSurface(inPane: focusedPaneId, url: url)
|
||||
let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil }
|
||||
let panel = tab.newBrowserSurface(
|
||||
inPane: focusedPaneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
insertAtEnd: insertAtEnd
|
||||
)
|
||||
return panel?.id
|
||||
}
|
||||
|
||||
/// Flash the currently focused panel so the user can visually confirm focus.
|
||||
|
|
@ -2200,6 +2208,7 @@ extension Notification.Name {
|
|||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
|
||||
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
|
||||
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
|
||||
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
|
||||
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,25 @@ enum NotificationBadgeSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum TaggedRunBadgeSettings {
|
||||
static let environmentKey = "CMUX_TAG"
|
||||
private static let maxTagLength = 10
|
||||
|
||||
static func normalizedTag(from env: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
||||
normalizedTag(env[environmentKey])
|
||||
}
|
||||
|
||||
static func normalizedTag(_ rawTag: String?) -> String? {
|
||||
guard var tag = rawTag?.trimmingCharacters(in: .whitespacesAndNewlines), !tag.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if tag.count > maxTagLength {
|
||||
tag = String(tag.prefix(maxTagLength))
|
||||
}
|
||||
return tag
|
||||
}
|
||||
}
|
||||
|
||||
enum AppFocusState {
|
||||
static var overrideIsFocused: Bool?
|
||||
|
||||
|
|
@ -85,12 +104,23 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
static func dockBadgeLabel(unreadCount: Int, isEnabled: Bool) -> String? {
|
||||
guard isEnabled, unreadCount > 0 else { return nil }
|
||||
if unreadCount > 99 {
|
||||
return "99+"
|
||||
static func dockBadgeLabel(unreadCount: Int, isEnabled: Bool, runTag: String? = nil) -> String? {
|
||||
let unreadLabel: String? = {
|
||||
guard isEnabled, unreadCount > 0 else { return nil }
|
||||
if unreadCount > 99 {
|
||||
return "99+"
|
||||
}
|
||||
return String(unreadCount)
|
||||
}()
|
||||
|
||||
if let tag = TaggedRunBadgeSettings.normalizedTag(runTag) {
|
||||
if let unreadLabel {
|
||||
return "\(tag):\(unreadLabel)"
|
||||
}
|
||||
return tag
|
||||
}
|
||||
return String(unreadCount)
|
||||
|
||||
return unreadLabel
|
||||
}
|
||||
|
||||
var unreadCount: Int {
|
||||
|
|
@ -319,7 +349,8 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
private func refreshDockBadge() {
|
||||
let label = Self.dockBadgeLabel(
|
||||
unreadCount: unreadCount,
|
||||
isEnabled: NotificationBadgeSettings.isDockBadgeEnabled()
|
||||
isEnabled: NotificationBadgeSettings.isDockBadgeEnabled(),
|
||||
runTag: TaggedRunBadgeSettings.normalizedTag()
|
||||
)
|
||||
NSApp?.dockTile.badgeLabel = label
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import SwiftUI
|
|||
|
||||
struct WindowAccessor: NSViewRepresentable {
|
||||
let onWindow: (NSWindow) -> Void
|
||||
let dedupeByWindow: Bool
|
||||
|
||||
init(dedupeByWindow: Bool = true, onWindow: @escaping (NSWindow) -> Void) {
|
||||
self.onWindow = onWindow
|
||||
self.dedupeByWindow = dedupeByWindow
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
|
|
@ -11,7 +17,7 @@ struct WindowAccessor: NSViewRepresentable {
|
|||
func makeNSView(context: Context) -> WindowObservingView {
|
||||
let view = WindowObservingView()
|
||||
view.onWindow = { window in
|
||||
guard context.coordinator.lastWindow !== window else { return }
|
||||
guard !dedupeByWindow || context.coordinator.lastWindow !== window else { return }
|
||||
context.coordinator.lastWindow = window
|
||||
onWindow(window)
|
||||
}
|
||||
|
|
@ -20,7 +26,7 @@ struct WindowAccessor: NSViewRepresentable {
|
|||
|
||||
func updateNSView(_ nsView: WindowObservingView, context: Context) {
|
||||
nsView.onWindow = { window in
|
||||
guard context.coordinator.lastWindow !== window else { return }
|
||||
guard !dedupeByWindow || context.coordinator.lastWindow !== window else { return }
|
||||
context.coordinator.lastWindow = window
|
||||
onWindow(window)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) {
|
||||
let subscription = Publishers.CombineLatest3(
|
||||
browserPanel.$pageTitle,
|
||||
browserPanel.$pageTitle.removeDuplicates(),
|
||||
browserPanel.$isLoading.removeDuplicates(),
|
||||
browserPanel.$faviconPNGData.removeDuplicates(by: { $0 == $1 })
|
||||
)
|
||||
|
|
@ -236,11 +236,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
guard let self = self,
|
||||
let browserPanel = browserPanel,
|
||||
let tabId = self.surfaceIdFromPanelId(browserPanel.id) else { return }
|
||||
guard let existing = self.bonsplitController.tab(tabId) else { return }
|
||||
|
||||
let nextTitle = browserPanel.displayTitle
|
||||
let titleUpdate: String? = existing.title == nextTitle ? nil : nextTitle
|
||||
let faviconUpdate: Data?? = existing.iconImageData == favicon ? nil : .some(favicon)
|
||||
let loadingUpdate: Bool? = existing.isLoading == isLoading ? nil : isLoading
|
||||
|
||||
guard titleUpdate != nil || faviconUpdate != nil || loadingUpdate != nil else { return }
|
||||
self.bonsplitController.updateTab(
|
||||
tabId,
|
||||
title: browserPanel.displayTitle,
|
||||
iconImageData: .some(favicon),
|
||||
isLoading: isLoading
|
||||
title: titleUpdate,
|
||||
iconImageData: faviconUpdate,
|
||||
isLoading: loadingUpdate
|
||||
)
|
||||
}
|
||||
panelSubscriptions[browserPanel.id] = subscription
|
||||
|
|
@ -445,9 +453,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
// updates can be deferred. Force a deterministic selection + focus path so the new
|
||||
// surface becomes interactive immediately (no "frozen until pane switch" state).
|
||||
if shouldFocusNewTab {
|
||||
// Use the same focus path as socket-driven focus changes so we reliably transfer
|
||||
// AppKit first responder between terminal surfaces after heavy split/tab churn.
|
||||
focusPanel(newPanel.id)
|
||||
bonsplitController.focusPane(paneId)
|
||||
bonsplitController.selectTab(newTabId)
|
||||
newPanel.focus()
|
||||
applyTabSelection(tabId: newTabId, inPane: paneId)
|
||||
}
|
||||
return newPanel
|
||||
}
|
||||
|
|
@ -510,7 +519,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
/// true = force focus/selection of the new surface,
|
||||
/// false = never focus (used for internal placeholder repair paths).
|
||||
@discardableResult
|
||||
func newBrowserSurface(inPane paneId: PaneID, url: URL? = nil, focus: Bool? = nil) -> BrowserPanel? {
|
||||
func newBrowserSurface(
|
||||
inPane paneId: PaneID,
|
||||
url: URL? = nil,
|
||||
focus: Bool? = nil,
|
||||
insertAtEnd: Bool = false
|
||||
) -> BrowserPanel? {
|
||||
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
||||
|
||||
let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
|
||||
|
|
@ -529,6 +543,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
surfaceIdToPanelId[newTabId] = browserPanel.id
|
||||
|
||||
// Keyboard/browser-open paths want "new tab at end" regardless of global new-tab placement.
|
||||
if insertAtEnd {
|
||||
let targetIndex = max(0, bonsplitController.tabs(inPane: paneId).count - 1)
|
||||
_ = bonsplitController.reorderTab(newTabId, toIndex: targetIndex)
|
||||
}
|
||||
|
||||
// Match terminal behavior: enforce deterministic selection + focus.
|
||||
if shouldFocusNewTab {
|
||||
bonsplitController.focusPane(paneId)
|
||||
|
|
@ -886,9 +906,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
/// Create a new terminal surface in the currently focused pane
|
||||
@discardableResult
|
||||
func newTerminalSurfaceInFocusedPane() -> TerminalPanel? {
|
||||
func newTerminalSurfaceInFocusedPane(focus: Bool? = nil) -> TerminalPanel? {
|
||||
guard let focusedPaneId = bonsplitController.focusedPaneId else { return nil }
|
||||
return newTerminalSurface(inPane: focusedPaneId)
|
||||
return newTerminalSurface(inPane: focusedPaneId, focus: focus)
|
||||
}
|
||||
|
||||
// MARK: - Flash/Notification Support
|
||||
|
|
|
|||
3
TODO.md
3
TODO.md
|
|
@ -26,6 +26,9 @@
|
|||
## Command Palette
|
||||
- [ ] Add cmd+shift+p palette with all commands
|
||||
|
||||
## Feature Requests
|
||||
- [ ] Warm pool of Claude Code instances mapped to a keyboard shortcut
|
||||
|
||||
## Claude Code Integration
|
||||
- [ ] Add "Install Claude Code integration" menu item in menubar
|
||||
- Opens a new terminal
|
||||
|
|
|
|||
|
|
@ -44,6 +44,53 @@ sanitize_path() {
|
|||
echo "$cleaned"
|
||||
}
|
||||
|
||||
print_tag_cleanup_reminder() {
|
||||
local current_slug="$1"
|
||||
local path=""
|
||||
local tag=""
|
||||
local seen=" "
|
||||
local -a stale_tags=()
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
tag="${path#/tmp/cmux-}"
|
||||
if [[ "$tag" == "$current_slug" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Only surface stale debug tag builds.
|
||||
if [[ ! -d "$path/Build/Products/Debug" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$seen" == *" $tag "* ]]; then
|
||||
continue
|
||||
fi
|
||||
seen="${seen}${tag} "
|
||||
stale_tags+=("$tag")
|
||||
done < <(find /tmp -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null)
|
||||
|
||||
echo
|
||||
echo "Tag cleanup status:"
|
||||
echo " current tag: ${current_slug} (keep this running until you verify)"
|
||||
if [[ "${#stale_tags[@]}" -eq 0 ]]; then
|
||||
echo " stale tags: none"
|
||||
echo " stale cleanup: not needed"
|
||||
else
|
||||
echo " stale tags:"
|
||||
for tag in "${stale_tags[@]}"; do
|
||||
echo " - ${tag}"
|
||||
done
|
||||
echo "Cleanup stale tags only:"
|
||||
for tag in "${stale_tags[@]}"; do
|
||||
echo " pkill -f \"cmux DEV ${tag}.app/Contents/MacOS/cmux DEV\""
|
||||
echo " rm -rf \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\""
|
||||
echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${tag}.sock\""
|
||||
done
|
||||
fi
|
||||
echo "After you verify current tag, cleanup command:"
|
||||
echo " pkill -f \"cmux DEV ${current_slug}.app/Contents/MacOS/cmux DEV\""
|
||||
echo " rm -rf \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\""
|
||||
echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${current_slug}.sock\""
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tag)
|
||||
|
|
@ -256,7 +303,9 @@ OPEN_CLEAN_ENV=(
|
|||
|
||||
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
|
||||
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH"
|
||||
elif [[ -n "${TAG_SLUG:-}" ]]; then
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" open "$APP_PATH"
|
||||
else
|
||||
"${OPEN_CLEAN_ENV[@]}" open "$APP_PATH"
|
||||
fi
|
||||
|
|
@ -281,3 +330,7 @@ if [[ "${#PIDS[@]}" -gt 1 ]]; then
|
|||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -n "${TAG_SLUG:-}" ]]; then
|
||||
print_tag_cleanup_reminder "$TAG_SLUG"
|
||||
fi
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 3f49af1477d94dd7dacda9cacd600e8b62804192
|
||||
Subproject commit 1f6f31c41b53ffaf47bb90138337f0343999f42e
|
||||
Loading…
Add table
Add a link
Reference in a new issue