Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,429 changes: 733 additions & 696 deletions GhosttyTabs.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,18 @@ final class CmuxMainThreadTurnProfiler {
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
nonisolated(unsafe) static var shared: AppDelegate?

/// Lazy shared FTS5 search index. See `Sources/Search/SearchIndex.swift`
/// and `docs/menubar-global-search.md`. Nil if the on-disk store
/// cannot be opened — callers must tolerate this.
lazy var searchIndex: SearchIndex? = {
let base = (try? FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true))
?? URL(fileURLWithPath: NSTemporaryDirectory())
let url = base.appending(path: "cmux/search.db")
return try? SearchIndex(url: url)
}()

private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment)

private var isRunningUnderXCTestCached: Bool {
Expand Down Expand Up @@ -1002,6 +1014,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
syncActivationPolicy()
}

// Register ⌥⌘F → focus inline titlebar search. Tracking #3865.
GlobalSearchHotkey.shared.install()

// Bridge: a picked search hit → focus its workspace+surface via
// the existing Feed focus pathway (so the user lands in the
// correct window/workspace/panel without inventing a new route).
NotificationCenter.default.addObserver(
forName: .cmuxJumpToSearchHit, object: nil, queue: .main
) { note in
guard let hit = note.object as? SearchIndex.Hit else { return }
FeedCoordinator.focus(
workspaceId: hit.workspaceID.uuidString,
surfaceId: hit.panelID.uuidString)
}
// Soft-focus while arrowing — same focus path, no reward/dismiss.
NotificationCenter.default.addObserver(
forName: .cmuxPreviewSearchHit, object: nil, queue: .main
) { note in
guard let hit = note.object as? SearchIndex.Hit else { return }
FeedCoordinator.focus(
workspaceId: hit.workspaceID.uuidString,
surfaceId: hit.panelID.uuidString)
}

claimAuthCallbackURLSchemes()

// Install the Feed (workstream) store. Separate from the transport
Expand Down
102 changes: 102 additions & 0 deletions Sources/Search/BrowserCaptureHook.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Foundation
import WebKit

/// Installs a debounced page-text extractor on a `WKWebView` so that
/// browser-panel contents flow into the global search index.
///
/// Designed as a tiny extension callable from `BrowserPanel` /
/// `CmuxWebView` once per webview, *after* the navigation delegate
/// is set. The hook captures `document.body.innerText` on
/// `webView:didFinish:` (with a 600 ms debounce against rapid SPA
/// navigation) and forwards the result to `SearchIndex.upsert(...)`
/// in a background task. No effect on rendering.
///
/// Wire-up (P3.5, one line in `BrowserPanel.makeWebView` or wherever
/// the webview is first handed back):
///
/// CmuxBrowserCaptureHook.install(
/// on: webView,
/// windowID: window.id,
/// workspaceID: workspace.id,
/// panelID: panel.id,
/// index: AppDelegate.shared?.searchIndex)
///
/// The hook holds itself alive via the webview's
/// `associatedObjects`; releasing the webview releases the hook.
@MainActor
public final class CmuxBrowserCaptureHook: NSObject, WKNavigationDelegate {
private static var keyHolder: UInt8 = 0

private weak var webView: WKWebView?
private weak var forwardDelegate: WKNavigationDelegate?
private let windowID: UUID
private let workspaceID: UUID
private let panelID: UUID
private let index: SearchIndex?
private var debounce: Task<Void, Never>?

public static func install(
on webView: WKWebView,
windowID: UUID, workspaceID: UUID, panelID: UUID,
index: SearchIndex?
) {
let hook = CmuxBrowserCaptureHook(
webView: webView,
windowID: windowID, workspaceID: workspaceID,
panelID: panelID, index: index)
// Retain via objc associated object; chain the previous delegate.
hook.forwardDelegate = webView.navigationDelegate
webView.navigationDelegate = hook
objc_setAssociatedObject(
webView, &keyHolder, hook, .OBJC_ASSOCIATION_RETAIN)
}

private init(webView: WKWebView,
windowID: UUID, workspaceID: UUID, panelID: UUID,
index: SearchIndex?) {
self.webView = webView
self.windowID = windowID
self.workspaceID = workspaceID
self.panelID = panelID
self.index = index
}

public func webView(_ wv: WKWebView, didFinish nav: WKNavigation!) {
forwardDelegate?.webView?(wv, didFinish: nav)
scheduleCapture()
}

public func webView(_ wv: WKWebView,
didFailProvisionalNavigation nav: WKNavigation!,
withError error: Error) {
forwardDelegate?.webView?(wv, didFailProvisionalNavigation: nav, withError: error)
}

private func scheduleCapture() {
debounce?.cancel()
debounce = Task { [weak self] in
try? await Task.sleep(nanoseconds: 600_000_000)
guard !Task.isCancelled, let self else { return }
await self.capture()
}
}

private func capture() async {
guard let webView, let index else { return }
let urlString = webView.url?.absoluteString ?? ""
let title = webView.title ?? ""
let text: String = await withCheckedContinuation { cont in
webView.evaluateJavaScript("document.body.innerText") { result, _ in
cont.resume(returning: (result as? String) ?? "")
}
}
let body = [title, urlString, text]
.filter { !$0.isEmpty }
.joined(separator: "\n")
guard !body.isEmpty else { return }
await index.upsert(
windowID: windowID, workspaceID: workspaceID,
panelID: panelID, kind: .browser,
anchor: urlString, text: String(body.prefix(100_000)))
}
}
47 changes: 47 additions & 0 deletions Sources/Search/GlobalSearchHotkey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import AppKit
import Carbon.HIToolbox

/// Registers a process-global hotkey that focuses the inline
/// `TitlebarSearchField` of the key window. Posts
/// `.cmuxFocusTitlebarSearch`; the field listens and grabs first
/// responder. Falls back to opening `MenubarSearchPopover` when no
/// window is key.
///
/// Default chord: ⌥⌘F (kVK_ANSI_F + cmd+option). User-rebindable in
/// Settings (TODO: surface in `BetaFeaturesSettingsView` until promoted).
@MainActor
public final class GlobalSearchHotkey {
public static let shared = GlobalSearchHotkey()

private var ref: EventHotKeyRef?
private var handler: EventHandlerRef?

public func install(keyCode: UInt32 = UInt32(kVK_ANSI_F),
modifiers: UInt32 = UInt32(cmdKey | optionKey)) {
uninstall()

var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed))
InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in
DispatchQueue.main.async {
if NSApp.keyWindow != nil {
NotificationCenter.default.post(
name: .cmuxFocusTitlebarSearch, object: nil)
} else {
MenubarSearchPopover.shared.toggle()
}
}
return noErr
}, 1, &spec, nil, &handler)

let hkID = EventHotKeyID(signature: OSType(0x434D5853 /* CMXS */),
id: 1)
RegisterEventHotKey(keyCode, modifiers, hkID,
GetApplicationEventTarget(), 0, &ref)
}

public func uninstall() {
if let ref { UnregisterEventHotKey(ref); self.ref = nil }
if let handler { RemoveEventHandler(handler); self.handler = nil }
}
}
32 changes: 32 additions & 0 deletions Sources/Search/MarkdownCaptureHook.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// Feeds markdown-panel contents into the global search index.
///
/// Tiny static helper — call once on load and again on every save:
///
/// CmuxMarkdownCaptureHook.feed(
/// text: editor.text,
/// windowID: window.id, workspaceID: workspace.id,
/// panelID: panel.id, anchor: file?.path ?? "",
/// index: AppDelegate.shared?.searchIndex)
///
/// Cheap enough to call inline on save; debounce at call-site if
/// needed.
public enum CmuxMarkdownCaptureHook {
@MainActor
public static func feed(
text: String,
windowID: UUID, workspaceID: UUID,
panelID: UUID, anchor: String,
index: SearchIndex?
) {
guard let index, !text.isEmpty else { return }
let payload = String(text.prefix(200_000))
Task.detached(priority: .utility) {
await index.upsert(
windowID: windowID, workspaceID: workspaceID,
panelID: panelID, kind: .markdown,
anchor: anchor, text: payload)
}
}
}
109 changes: 109 additions & 0 deletions Sources/Search/MenubarSearchPopover.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import AppKit
import SwiftUI

/// Hosts the global search palette in the menubar.
///
/// Anchored to the existing `NSStatusItem` in `AppDelegate`. Opens via
/// hotkey (default `⌥⌘F`, see `GlobalSearchHotkey`) or click.
///
/// Wiring step (P1): in `AppDelegate.applicationDidFinishLaunching`,
/// after `statusItem` is created, instantiate
/// `MenubarSearchPopover.shared.attach(to: statusItem.button)` and
/// register the hotkey.
@MainActor
public final class MenubarSearchPopover {
public static let shared = MenubarSearchPopover()

private let popover = NSPopover()
private weak var anchor: NSView?

private init() {
popover.behavior = .transient
popover.animates = false
popover.contentSize = NSSize(width: 640, height: 420)
}

public func attach(to view: NSView?, index: SearchIndex) {
anchor = view
popover.contentViewController = NSHostingController(
rootView: SearchPaletteView(index: index, onPick: { [weak self] hit in
self?.dismiss()
NotificationCenter.default.post(
name: .cmuxJumpToSearchHit, object: hit)
}))
}

public func toggle() {
if popover.isShown { dismiss() } else { show() }
}

public func show() {
guard let anchor, popover.contentViewController != nil else { return }
popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .minY)
}

public func dismiss() { popover.performClose(nil) }
}

public extension Notification.Name {
static let cmuxJumpToSearchHit = Notification.Name("cmux.jumpToSearchHit")
static let cmuxFocusTitlebarSearch = Notification.Name("cmux.focusTitlebarSearch")
/// Soft-focus while arrowing through results (no reward, no dismiss).
static let cmuxPreviewSearchHit = Notification.Name("cmux.previewSearchHit")
}

/// SwiftUI palette body.
struct SearchPaletteView: View {
let index: SearchIndex
let onPick: (SearchIndex.Hit) -> Void

@State private var query: String = ""
@State private var hits: [SearchIndex.Hit] = []
@State private var selection: Int = 0

var body: some View {
VStack(spacing: 0) {
TextField("Search all windows, panels, browser tabs…", text: $query)
.textFieldStyle(.plain)
.font(.system(size: 16))
.padding(12)
.onChange(of: query) { _, new in
Task { await refresh(new) }
}
Divider()
List(Array(hits.enumerated()), id: \.offset, selection: $selection) { idx, hit in
HitRow(hit: hit, hotkey: idx < 9 ? "⌘\(idx + 1)" : nil)
.onTapGesture { onPick(hit) }
}
.listStyle(.plain)
}
.frame(width: 640, height: 420)
}

private func refresh(_ q: String) async {
guard !q.isEmpty else { hits = []; return }
let result = await index.search(q)
await MainActor.run { hits = result; selection = 0 }
}
}

private struct HitRow: View {
let hit: SearchIndex.Hit
let hotkey: String?

var body: some View {
HStack(spacing: 8) {
Text(hit.kind.rawValue.uppercased())
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 6).padding(.vertical, 2)
.background(.secondary.opacity(0.15), in: Capsule())
Text(hit.snippet).lineLimit(1)
Spacer()
if let hotkey {
Text(hotkey).font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
Loading