diff --git a/Sources/App/App.swift b/Sources/App/App.swift index 082fb567..dde58606 100644 --- a/Sources/App/App.swift +++ b/Sources/App/App.swift @@ -28,6 +28,27 @@ enum ApplicationShortcutMenuModel { .toggleSidebar, ] + /// Find targets the Application Log sheet's own terminal, so that sheet + /// must not take the Find bindings away from the menu. + static let logViewerActions: Set = [ + .find, + .findNext, + .findPrevious, + .hideFindBar, + ] + + static func sheetSuppressesBinding( + for action: ApplicationShortcutAction, + settingsPresented: Bool, + commandPalettePresented: Bool, + logViewerPresented: Bool + ) -> Bool { + if settingsPresented || commandPalettePresented { + return true + } + return logViewerPresented && !logViewerActions.contains(action) + } + static func items( _ actions: [ApplicationShortcutAction], shortcuts: ResolvedApplicationShortcuts @@ -214,6 +235,7 @@ struct GhosthubApp: App { AppMenuCommands(updateController: updateController) CommandGroup(replacing: .toolbar) {} editMenuCommands + FindMenuCommands() FileMenuCommands(applicationDelegate: appDelegate) SessionMenuCommands() ViewMenuCommands() diff --git a/Sources/App/BorrowedHerdrSessionView.swift b/Sources/App/BorrowedHerdrSessionView.swift index 6969162e..cc78b37d 100644 --- a/Sources/App/BorrowedHerdrSessionView.swift +++ b/Sources/App/BorrowedHerdrSessionView.swift @@ -186,8 +186,8 @@ private struct NativeHerdrTerminalView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(TerminalSurfaceBackdrop.color(for: backgroundAppearance)) .overlay(alignment: .top) { - if let message = surfaceView.paneSplitErrorMessage { - NativePaneSplitErrorOverlay(message: message) + if let message = surfaceView.terminalOperationErrorMessage { + NativeTerminalOperationErrorOverlay(message: message) } } .onAppear { @@ -207,5 +207,6 @@ private struct NativeHerdrTerminalView: View { \.terminalHasEffectiveKeyboardFocus, surfaceView.hasEffectiveKeyboardFocus ) + .focusedSceneObject(surfaceView.terminalFindController) } } diff --git a/Sources/App/BorrowedTmuxSessionView.swift b/Sources/App/BorrowedTmuxSessionView.swift index 333bcad2..3a9e15c7 100644 --- a/Sources/App/BorrowedTmuxSessionView.swift +++ b/Sources/App/BorrowedTmuxSessionView.swift @@ -1,5 +1,6 @@ import GhosthubTransport import GhosthubTerminal +import GhosthubTerminalSupport import GhosthubTmux import GhosthubUI import SwiftUI @@ -253,10 +254,18 @@ private struct NativeTmuxTerminalView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(TerminalSurfaceBackdrop.color(for: backgroundAppearance)) .overlay(alignment: .top) { - if let message = surfaceView.paneSplitErrorMessage { - NativePaneSplitErrorOverlay(message: message) + if let message = surfaceView.terminalOperationErrorMessage { + NativeTerminalOperationErrorOverlay(message: message) } } + .overlay(alignment: .topTrailing) { + TerminalFindOverlay( + controller: surfaceView.terminalFindController, + restoreTerminalFocus: { [weak surfaceView] in + surfaceView?.requestKeyboardFocus() + } + ) + } .onAppear { surfaceView.registerPaneCloseRequestObserver( id: observerID, @@ -275,5 +284,6 @@ private struct NativeTmuxTerminalView: View { \.terminalHasEffectiveKeyboardFocus, surfaceView.hasEffectiveKeyboardFocus ) + .focusedSceneObject(surfaceView.terminalFindController) } } diff --git a/Sources/App/BorrowedZellijSessionView.swift b/Sources/App/BorrowedZellijSessionView.swift index 6391b936..9f2ac6e8 100644 --- a/Sources/App/BorrowedZellijSessionView.swift +++ b/Sources/App/BorrowedZellijSessionView.swift @@ -197,5 +197,6 @@ private struct NativeZellijTerminalView: View { \.terminalHasEffectiveKeyboardFocus, surfaceView.hasEffectiveKeyboardFocus ) + .focusedSceneObject(surfaceView.terminalFindController) } } diff --git a/Sources/App/MenuCommands.swift b/Sources/App/MenuCommands.swift index ae266499..82f9c916 100644 --- a/Sources/App/MenuCommands.swift +++ b/Sources/App/MenuCommands.swift @@ -28,7 +28,7 @@ struct MenuActionContext { for: action, sceneIsFocused: sceneModel?.acceptsApplicationShortcutKeyEvents == true, - hasAttachedSheet: sceneHasAttachedSheet, + hasAttachedSheet: sheetSuppressesBinding(for: action), actionIsAvailable: actionIsAvailable )?.swiftUI } @@ -46,16 +46,21 @@ struct MenuActionContext { for: action, sceneIsFocused: sceneModel?.acceptsApplicationShortcutKeyEvents == true, - hasAttachedSheet: sceneHasAttachedSheet, + hasAttachedSheet: sheetSuppressesBinding(for: action), actionIsAvailable: sceneModel?.canSplitActivePane == true )?.swiftUI } - var sceneHasAttachedSheet: Bool { + private func sheetSuppressesBinding( + for action: ApplicationShortcutAction + ) -> Bool { guard let sceneModel else { return false } - return sceneModel.isSettingsPresented - || sceneModel.isCommandPalettePresented - || sceneModel.isLogViewerPresented + return ApplicationShortcutMenuModel.sheetSuppressesBinding( + for: action, + settingsPresented: sceneModel.isSettingsPresented, + commandPalettePresented: sceneModel.isCommandPalettePresented, + logViewerPresented: sceneModel.isLogViewerPresented + ) } func invoke(_ action: ApplicationShortcutAction) { @@ -108,6 +113,63 @@ struct AppMenuCommands: Commands { } } +struct FindMenuCommands: Commands { + @FocusedValue(\.sceneModel) private var focusedSceneModel + @FocusedObject private var findController: TerminalFindController? + @ObservedObject private var settingsStore = SettingsStore.shared + + private var context: MenuActionContext { + MenuActionContext( + sceneModel: focusedSceneModel, + terminalHasEffectiveKeyboardFocus: nil, + settingsStore: settingsStore + ) + } + + var body: some Commands { + CommandGroup(after: .pasteboard) { + if let findController { + Divider() + Button("Find…") { + context.invoke(.find) + } + .keyboardShortcut(context.shortcut( + .find, + actionIsAvailable: findController.isAvailable + )) + .disabled(!findController.isAvailable) + + Button("Find Next") { + context.invoke(.findNext) + } + .keyboardShortcut(context.shortcut( + .findNext, + actionIsAvailable: findController.canNavigate + )) + .disabled(!findController.canNavigate) + + Button("Find Previous") { + context.invoke(.findPrevious) + } + .keyboardShortcut(context.shortcut( + .findPrevious, + actionIsAvailable: findController.canNavigate + )) + .disabled(!findController.canNavigate) + + Button("Hide Find Bar") { + context.invoke(.hideFindBar) + } + .keyboardShortcut(context.shortcut( + .hideFindBar, + actionIsAvailable: findController.isOpen + )) + .disabled(!findController.isOpen) + } + } + } +} + struct FileMenuCommands: Commands { let applicationDelegate: ApplicationDelegate @FocusedValue(\.sceneModel) private var focusedSceneModel diff --git a/Sources/App/NativeHerdrSessionCoordinator.swift b/Sources/App/NativeHerdrSessionCoordinator.swift index 51798f28..54ebde8b 100644 --- a/Sources/App/NativeHerdrSessionCoordinator.swift +++ b/Sources/App/NativeHerdrSessionCoordinator.swift @@ -425,6 +425,7 @@ final class NativeHerdrSessionCoordinator { ) return nil } + surface.terminalFindController = .unavailable if let error = surface.launchError { failSurfaceLaunch( handle, @@ -653,7 +654,7 @@ final class NativeHerdrSessionCoordinator { guard attachments[handle.id]?.id == request.attachmentID, launchedHandles.contains(handle.id) else { continue } - request.surface.paneSplitErrorMessage = nil + request.surface.terminalOperationErrorMessage = nil let failure = await paneSplitter.split( request.shortcut, target: request.target @@ -662,7 +663,7 @@ final class NativeHerdrSessionCoordinator { paneSplitWorkers[handle.id]?.id == workerID, attachments[handle.id]?.id == request.attachmentID else { return } - request.surface.paneSplitErrorMessage = failure?.localizedDescription + request.surface.terminalOperationErrorMessage = failure?.localizedDescription if let failure { invalidateUnusableConnection( status: failure.status, diff --git a/Sources/App/NativeSessionAttachmentSupport.swift b/Sources/App/NativeSessionAttachmentSupport.swift index fdc03ed1..4b7aa31b 100644 --- a/Sources/App/NativeSessionAttachmentSupport.swift +++ b/Sources/App/NativeSessionAttachmentSupport.swift @@ -9,13 +9,15 @@ protocol NativeSessionPaneSurfacing: AnyObject { var paneSplitShortcutHandler: ((TerminalPaneSplitShortcut) -> Void)? { get set } - var paneSplitErrorMessage: String? { get set } + var terminalOperationErrorMessage: String? { get set } + var terminalFindController: TerminalFindController { get set } var hasEffectiveKeyboardFocus: Bool { get } var launchError: Error? { get } /// True when `launchError` describes a transient condition that a later /// attach can recover from, rather than a rejected launch. var launchFailureIsRetryable: Bool { get } var childExitCode: UInt32? { get } + func requestKeyboardFocus() @discardableResult func sizeForPreviewGrid(columns: Int, rows: Int) -> Bool func clearPreviewGridSize() @@ -31,15 +33,22 @@ extension NativeSessionPaneSurfacing { set {} } - var paneSplitErrorMessage: String? { + var terminalOperationErrorMessage: String? { get { nil } set {} } + var terminalFindController: TerminalFindController { + get { .unavailable } + set {} + } + var hasEffectiveKeyboardFocus: Bool { false } var launchFailureIsRetryable: Bool { false } + func requestKeyboardFocus() {} + @discardableResult func sizeForPreviewGrid(columns _: Int, rows _: Int) -> Bool { false diff --git a/Sources/App/NativePaneSplitErrorOverlay.swift b/Sources/App/NativeTerminalOperationErrorOverlay.swift similarity index 88% rename from Sources/App/NativePaneSplitErrorOverlay.swift rename to Sources/App/NativeTerminalOperationErrorOverlay.swift index 62ec37fd..08bb90f9 100644 --- a/Sources/App/NativePaneSplitErrorOverlay.swift +++ b/Sources/App/NativeTerminalOperationErrorOverlay.swift @@ -1,6 +1,6 @@ import SwiftUI -struct NativePaneSplitErrorOverlay: View { +struct NativeTerminalOperationErrorOverlay: View { let message: String var body: some View { diff --git a/Sources/App/NativeTmuxSessionCoordinator.swift b/Sources/App/NativeTmuxSessionCoordinator.swift index 27472192..ec79dd18 100644 --- a/Sources/App/NativeTmuxSessionCoordinator.swift +++ b/Sources/App/NativeTmuxSessionCoordinator.swift @@ -81,6 +81,7 @@ private struct NativeTmuxAttachment { var id: UUID var host: CommandHost var tmuxPath: String + var tmuxVersion: TmuxVersion var kwtPath: String? var remoteKwtCommandPrelude: String? var windowsKwtRelativePath: String? @@ -161,7 +162,7 @@ final class NativeTmuxSessionCoordinator { var task: Task } - private struct PaneSplitErrorDismissal { + private struct TerminalOperationErrorDismissal { var id: UUID var task: Task } @@ -190,7 +191,8 @@ final class NativeTmuxSessionCoordinator { private let appliesPresentationStyleToExistingSessionsProvider: () -> Bool private let paneSplitter: TmuxPaneSplitter - private let paneSplitErrorDuration: Duration + private let paneFinder: TmuxPaneFinder + private let terminalOperationErrorDuration: Duration private let clientIdentityRetryDelays: [Duration] private let sleep: @Sendable (Duration) async throws -> Void private let remoteExitStatusStore: RemoteExitStatusStore @@ -210,8 +212,8 @@ final class NativeTmuxSessionCoordinator { private var paneSplitRequests: [UUID: [PaneSplitRequest]] = [:] private var paneSplitWorkers: [UUID: PaneSplitWorker] = [:] private var paneSplitClientBindings: [UUID: PaneSplitClientBinding] = [:] - private var paneSplitClients: [UUID: TmuxPaneSplitClientIdentity] = [:] - private var paneSplitErrorDismissals: [UUID: PaneSplitErrorDismissal] = [:] + private var paneSplitClients: [UUID: TmuxAttachedClientIdentity] = [:] + private var terminalOperationErrorDismissals: [UUID: TerminalOperationErrorDismissal] = [:] private var previewIdentityRetryHandles: Set = [] private var unavailablePreviewIdentityHandles: Set = [] private var deferredPresentationStyleHandles: Set = [] @@ -266,9 +268,10 @@ final class NativeTmuxSessionCoordinator { throw KwtSSHLeaseError.helperUnavailable }, paneSplitter: TmuxPaneSplitter = TmuxPaneSplitter(), - paneSplitErrorDuration: Duration = .seconds(4), + paneFinder: TmuxPaneFinder = TmuxPaneFinder(), + terminalOperationErrorDuration: Duration = .seconds(4), clientIdentityRetryDelays: [Duration] = [ - .milliseconds(250), .seconds(1), + .milliseconds(250), .seconds(1), .seconds(2), ], sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) @@ -292,7 +295,8 @@ final class NativeTmuxSessionCoordinator { self.remoteTmuxPathProvider = remoteTmuxPathProvider self.remoteConnectionProvider = remoteConnectionProvider self.paneSplitter = paneSplitter - self.paneSplitErrorDuration = paneSplitErrorDuration + self.paneFinder = paneFinder + self.terminalOperationErrorDuration = terminalOperationErrorDuration self.clientIdentityRetryDelays = clientIdentityRetryDelays self.sleep = sleep remoteExitStatusStore = RemoteExitStatusStore( @@ -495,6 +499,7 @@ final class NativeTmuxSessionCoordinator { id: attachmentID, host: host, tmuxPath: resolved.path, + tmuxVersion: TmuxVersion(output: resolved.version)!, kwtPath: host.isRemote ? nil : localKwtPathProvider(), remoteKwtCommandPrelude: host.isRemote ? remoteKwtCommandPreludeProvider() @@ -1028,6 +1033,7 @@ final class NativeTmuxSessionCoordinator { ) return nil } + (surface as? TerminalSurfaceView)?.useExternalFindBackend() if let error = surface.launchError { failSurfaceLaunch( handle, @@ -1039,6 +1045,9 @@ final class NativeTmuxSessionCoordinator { } let didCreateSurface = previousSurfaceIdentity != ObjectIdentifier(surface) + if didCreateSurface { + surface.terminalFindController = .unavailable + } if isFirstLaunch, appliesPresentationStyle, presentationStyle == nil { deferredPresentationStyleHandles.insert(handle.id) @@ -1063,6 +1072,15 @@ final class NativeTmuxSessionCoordinator { clientTTYDirectory: attachment.clientTTYDirectory, expectedClient: paneSplitClients[handle.id] ) + if let client = paneSplitClients[handle.id], + !surface.terminalFindController.isAvailable { + installFindController( + on: surface, + handle: handle, + attachment: attachment, + client: client + ) + } if attachment.supportsPaneSplitting { surface.paneSplitShortcutHandler = { [weak self, weak surface] shortcut in @@ -1111,7 +1129,7 @@ final class NativeTmuxSessionCoordinator { attachmentID: UUID ) { guard let client = paneSplitClients[handle.id] else { - presentPaneSplitError(TmuxPaneSplitFailure( + presentTerminalOperationError(TmuxPaneSplitFailure( host: target.host.displayName, sessionName: target.sessionName, status: 75, @@ -1188,7 +1206,7 @@ final class NativeTmuxSessionCoordinator { launchedAttachmentIDs[handle.id] == request.attachmentID else { continue } - clearPaneSplitError( + clearTerminalOperationError( on: request.surface, handleID: handle.id ) @@ -1208,7 +1226,7 @@ final class NativeTmuxSessionCoordinator { status: 75, diagnostic: "The attached tmux session changed." ) - presentPaneSplitError( + presentTerminalOperationError( failure, on: request.surface, handle: handle, @@ -1224,7 +1242,7 @@ final class NativeTmuxSessionCoordinator { paneSplitWorkers[handle.id]?.id == workerID, attachments[handle.id]?.id == request.attachmentID else { return } - presentPaneSplitError( + presentTerminalOperationError( failure, on: request.surface, handle: handle, @@ -1272,7 +1290,7 @@ final class NativeTmuxSessionCoordinator { } } if let failure { - presentPaneSplitError( + presentTerminalOperationError( failure, on: request.surface, handle: handle, @@ -1282,7 +1300,7 @@ final class NativeTmuxSessionCoordinator { "tmux pane split: \(failure.localizedDescription)" ) } else { - clearPaneSplitError( + clearTerminalOperationError( on: request.surface, handleID: handle.id ) @@ -1331,7 +1349,15 @@ final class NativeTmuxSessionCoordinator { if let surface = terminalCoordinator.paneSurfaceIfPresent( for: surfaceKey(handle) ) { - clearPaneSplitError(on: surface, handleID: handle.id) + clearTerminalOperationError(on: surface, handleID: handle.id) + if let attachment = attachments[handle.id] { + installFindController( + on: surface, + handle: handle, + attachment: attachment, + client: client + ) + } } onSurfaceReady?(handle) return @@ -1342,9 +1368,7 @@ final class NativeTmuxSessionCoordinator { handleID: handle.id ) } - guard previewIdentityRetryHandles.contains(handle.id), - retryIndex < clientIdentityRetryDelays.count - else { + guard retryIndex < clientIdentityRetryDelays.count else { paneSplitClientBindings.removeValue(forKey: handle.id) if previewIdentityRetryHandles.remove(handle.id) != nil { unavailablePreviewIdentityHandles.insert(handle.id) @@ -1367,10 +1391,193 @@ final class NativeTmuxSessionCoordinator { ) } + private func installFindController( + on surface: any NativeSessionPaneSurfacing, + handle: BorrowedTmuxSessionHandle, + attachment: NativeTmuxAttachment, + client: TmuxAttachedClientIdentity + ) { + surface.terminalFindController.close() + let isPOSIX = switch attachment.host { + case .local: true + case let .ssh(info): info.platform == .posix + } + guard attachment.tmuxVersion >= .minimumFind, isPOSIX else { + surface.terminalFindController = .unavailable + return + } + paneSplitClients[handle.id] = client + let concreteSurface = surface as? TerminalSurfaceView + surface.terminalFindController = TerminalFindController( + isAvailable: true, + failureHandler: { [weak self, weak concreteSurface] message in + guard let self, let concreteSurface else { return } + presentTerminalOperationError( + message, + on: concreteSurface, + handle: handle, + attachmentID: attachment.id + ) + }, + sessionProvider: { [weak self] in + guard let self else { return nil } + return TerminalFindSession( + search: { [weak self] query, _ in + guard let self else { + return .failure(Self.findTargetChangedFailure()) + } + return await performFind( + .search(query), + handle: handle, + attachmentID: attachment.id + ) + }, + navigate: { [weak self] direction, _ in + guard let self else { + return .failure(Self.findTargetChangedFailure()) + } + let mutation: TmuxFindMutation = direction == .next + ? .next : .previous + return await performFind( + mutation, + handle: handle, + attachmentID: attachment.id + ) + }, + close: { [weak self] in + guard let self else { + return Self.findTargetChangedFailure() + } + switch await performFind( + .cancel, + handle: handle, + attachmentID: attachment.id + ) { + case .success: return nil + case let .failure(failure): return failure + } + } + ) + } + ) + } + + private func performFind( + _ mutation: TmuxFindMutation, + handle: BorrowedTmuxSessionHandle, + attachmentID: UUID + ) async -> Result { + switch await currentFindTarget( + handle: handle, + attachmentID: attachmentID + ) { + case let .success(target): + let result = await paneFinder.perform(mutation, target: target) + if case let .failure(failure) = result, + failure.kind == .transport, + mutation != .cancel { + await invalidateFindConnection( + handleID: handle.id, + attachmentID: attachmentID + ) + } + return Self.findResponse(result) + case let .failure(failure): + return .failure(failure) + } + } + + private func currentFindTarget( + handle: BorrowedTmuxSessionHandle, + attachmentID: UUID + ) async -> Result { + guard let attachment = attachments[handle.id], + attachment.id == attachmentID, + launchedAttachmentIDs[handle.id] == attachmentID, + let expectedClient = paneSplitClients[handle.id] + else { return .failure(Self.findTargetChangedFailure()) } + + var identityTarget = paneSplitTarget( + handle: handle, + attachment: attachment, + expectedIdentity: attachment.sessionIdentity + ) + identityTarget.expectedClient = expectedClient + let identity = await paneSplitter.clientIdentity(target: identityTarget) + guard let currentAttachment = attachments[handle.id], + currentAttachment.id == attachmentID, + launchedAttachmentIDs[handle.id] == attachmentID + else { return .failure(Self.findTargetChangedFailure()) } + + switch identity { + case let .success(client): + guard client.matchesClient(expectedClient) else { + return .failure(Self.findTargetChangedFailure()) + } + paneSplitClients[handle.id] = client + return .success(TmuxFindTarget( + host: currentAttachment.host, + tmuxPath: currentAttachment.tmuxPath, + tmuxVersion: currentAttachment.tmuxVersion, + sessionName: handle.name, + socketName: currentAttachment.socketName, + sshConnectionArguments: currentAttachment + .sshConnectionSnapshot.arguments, + expectedClient: client + )) + case let .failure(failure): + await invalidateUnusableFindConnection( + status: failure.status, + output: failure.diagnostic, + handleID: handle.id + ) + return .failure(Self.findFailure( + status: failure.status, + host: currentAttachment.host + )) + } + } + + private nonisolated static func findTargetChangedFailure() + -> TerminalFindFailure { + TerminalFindFailure(message: "The attached tmux session changed.") + } + + private nonisolated static func findFailure( + status: Int32, + host: CommandHost + ) -> TerminalFindFailure { + if status == 75 { + return findTargetChangedFailure() + } + if status == AccountCommandRunner.timedOutStatus + || (status == 255 && host.isRemote) { + return TerminalFindFailure( + message: "Find lost its connection to tmux." + ) + } + return TerminalFindFailure(message: "tmux could not search this pane.") + } + + private nonisolated static func findResponse( + _ result: Result + ) -> Result { + switch result { + case let .success(.match(total)): + .success(.result(.match(total: total, selected: nil))) + case .success(.noMatch): + .success(.result(.noMatch)) + case .success(nil): + .success(.result(.idle)) + case let .failure(failure): + .failure(.init(message: failure.message)) + } + } + private func cancelPaneSplits(handleID: UUID) { paneSplitClientBindings.removeValue(forKey: handleID)?.task.cancel() paneSplitWorkers.removeValue(forKey: handleID)?.task.cancel() - paneSplitErrorDismissals.removeValue(forKey: handleID)?.task.cancel() + terminalOperationErrorDismissals.removeValue(forKey: handleID)?.task.cancel() paneSplitRequests.removeValue(forKey: handleID) paneSplitClients.removeValue(forKey: handleID) previewIdentityRetryHandles.remove(handleID) @@ -1389,17 +1596,57 @@ final class NativeTmuxSessionCoordinator { Task { await connection.invalidate() } } - private func presentPaneSplitError( + private func invalidateUnusableFindConnection( + status: Int32, + output: String, + handleID: UUID + ) async { + guard SSHConnectionFailure.indicatesUnusableConnection( + status: status, + output: output + ), let connection = attachments[handleID]?.sshConnection + else { return } + await connection.invalidate() + } + + private func invalidateFindConnection( + handleID: UUID, + attachmentID: UUID + ) async { + guard let attachment = attachments[handleID], + attachment.id == attachmentID, + let connection = attachment.sshConnection + else { + return + } + await connection.invalidate() + } + + private func presentTerminalOperationError( _ failure: TmuxPaneSplitFailure, on surface: any NativeSessionPaneSurfacing, handle: BorrowedTmuxSessionHandle, attachmentID: UUID ) { invalidateUnusableConnection(failure, handleID: handle.id) - paneSplitErrorDismissals.removeValue(forKey: handle.id)?.task.cancel() - surface.paneSplitErrorMessage = failure.localizedDescription + presentTerminalOperationError( + failure.localizedDescription, + on: surface, + handle: handle, + attachmentID: attachmentID + ) + } + + private func presentTerminalOperationError( + _ message: String, + on surface: any NativeSessionPaneSurfacing, + handle: BorrowedTmuxSessionHandle, + attachmentID: UUID + ) { + terminalOperationErrorDismissals.removeValue(forKey: handle.id)?.task.cancel() + surface.terminalOperationErrorMessage = message let dismissalID = UUID() - let duration = paneSplitErrorDuration + let duration = terminalOperationErrorDuration let task = Task { [weak self] in do { try await Task.sleep(for: duration) @@ -1407,24 +1654,24 @@ final class NativeTmuxSessionCoordinator { return } guard let self, - paneSplitErrorDismissals[handle.id]?.id == dismissalID, + terminalOperationErrorDismissals[handle.id]?.id == dismissalID, attachments[handle.id]?.id == attachmentID else { return } - paneSplitErrorDismissals.removeValue(forKey: handle.id) - surface.paneSplitErrorMessage = nil + terminalOperationErrorDismissals.removeValue(forKey: handle.id) + surface.terminalOperationErrorMessage = nil } - paneSplitErrorDismissals[handle.id] = PaneSplitErrorDismissal( + terminalOperationErrorDismissals[handle.id] = TerminalOperationErrorDismissal( id: dismissalID, task: task ) } - private func clearPaneSplitError( + private func clearTerminalOperationError( on surface: any NativeSessionPaneSurfacing, handleID: UUID ) { - paneSplitErrorDismissals.removeValue(forKey: handleID)?.task.cancel() - surface.paneSplitErrorMessage = nil + terminalOperationErrorDismissals.removeValue(forKey: handleID)?.task.cancel() + surface.terminalOperationErrorMessage = nil } private func failSurfaceLaunch( @@ -1465,6 +1712,23 @@ final class NativeTmuxSessionCoordinator { terminalCoordinator.surfaceIdentity(for: surfaceKey(handle)) } + func findController( + _ handle: BorrowedTmuxSessionHandle + ) -> TerminalFindController? { + findSurface(handle)?.terminalFindController + } + + func findSurface( + _ handle: BorrowedTmuxSessionHandle + ) -> (any NativeSessionPaneSurfacing)? { + guard let attachment = attachments[handle.id], + launchedAttachmentIDs[handle.id] == attachment.id + else { return nil } + return terminalCoordinator.paneSurfaceIfPresent( + for: surfaceKey(handle) + ) + } + func supportsPaneSplitting(_ handle: BorrowedTmuxSessionHandle) -> Bool { attachments[handle.id]?.supportsPaneSplitting == true } @@ -1644,11 +1908,11 @@ final class NativeTmuxSessionCoordinator { provisioningTasks.values.forEach { $0.cancel() } paneSplitClientBindings.values.forEach { $0.task.cancel() } paneSplitWorkers.values.forEach { $0.task.cancel() } - paneSplitErrorDismissals.values.forEach { $0.task.cancel() } + terminalOperationErrorDismissals.values.forEach { $0.task.cancel() } provisioningTasks.removeAll() paneSplitClientBindings.removeAll() paneSplitWorkers.removeAll() - paneSplitErrorDismissals.removeAll() + terminalOperationErrorDismissals.removeAll() paneSplitRequests.removeAll() paneSplitClients.removeAll() previewIdentityRetryHandles.removeAll() diff --git a/Sources/App/NativeZellijSessionCoordinator.swift b/Sources/App/NativeZellijSessionCoordinator.swift index ab1a607b..84b7a63a 100644 --- a/Sources/App/NativeZellijSessionCoordinator.swift +++ b/Sources/App/NativeZellijSessionCoordinator.swift @@ -350,6 +350,7 @@ final class NativeZellijSessionCoordinator { ) return nil } + surface.terminalFindController = .unavailable if let error = surface.launchError { failSurfaceLaunch( handle, diff --git a/Sources/App/TerminalFindBar.swift b/Sources/App/TerminalFindBar.swift new file mode 100644 index 00000000..09285936 --- /dev/null +++ b/Sources/App/TerminalFindBar.swift @@ -0,0 +1,186 @@ +import AppKit +import GhosthubTerminalSupport +import SwiftUI + +struct TerminalFindOverlay: View { + @ObservedObject var controller: TerminalFindController + var restoreTerminalFocus: @MainActor @Sendable () -> Void + + var body: some View { + if controller.isOpen { + TerminalFindBar( + controller: controller, + restoreTerminalFocus: restoreTerminalFocus + ) + } + } +} + +struct TerminalFindBar: View { + enum FieldCommand: Equatable { + case next + case previous + case close + } + + @ObservedObject var controller: TerminalFindController + var restoreTerminalFocus: @MainActor @Sendable () -> Void + + var body: some View { + HStack(spacing: 8) { + TerminalFindSearchField( + controller: controller, + close: close + ) + .frame(width: 220) + .accessibilityIdentifier("terminal-find-field") + if let status = controller.failureMessage + ?? Self.statusText(for: controller.result) { + Text(status) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("terminal-find-status") + } + Button { + controller.findNext() + } label: { + Image(systemName: "chevron.up") + } + .help("Find Next") + .disabled(!controller.canNavigate) + .accessibilityIdentifier("terminal-find-next") + Button { + controller.findPrevious() + } label: { + Image(systemName: "chevron.down") + } + .help("Find Previous") + .disabled(!controller.canNavigate) + .accessibilityIdentifier("terminal-find-previous") + Button { + close() + } label: { + Image(systemName: "xmark") + } + .help("Hide Find Bar") + .accessibilityIdentifier("terminal-find-close") + } + .buttonStyle(.plain) + .padding(8) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding() + } + + static func statusText(for result: TerminalFindResult) -> String? { + switch result { + case .idle: + nil + case .noMatch: + "No matches" + case let .match(total: .some(total), selected: .some(selected)): + "\(selected) of \(total)" + case let .match(total: .some(total), selected: nil): + "\(total) \(total == 1 ? "match" : "matches")" + case .match(total: nil, selected: _): + nil + } + } + + static func fieldCommand( + selector: Selector, + shift: Bool + ) -> FieldCommand? { + switch selector { + case #selector(NSResponder.insertNewline(_:)): + shift ? .previous : .next + case #selector(NSResponder.cancelOperation(_:)): + .close + default: + nil + } + } + + private func close() { + controller.close() + DispatchQueue.main.async(execute: restoreTerminalFocus) + } +} + +private struct TerminalFindSearchField: NSViewRepresentable { + @ObservedObject var controller: TerminalFindController + var close: @MainActor @Sendable () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(controller: controller, close: close) + } + + func makeNSView(context: Context) -> NSSearchField { + let field = NSSearchField() + field.placeholderString = "Find" + field.delegate = context.coordinator + field.focusRingType = .none + DispatchQueue.main.async { + field.window?.makeFirstResponder(field) + field.selectText(nil) + } + return field + } + + func updateNSView(_ field: NSSearchField, context: Context) { + if field.stringValue != controller.query { + field.stringValue = controller.query + } + context.coordinator.controller = controller + context.coordinator.close = close + guard context.coordinator.selectionRevision + != controller.fieldSelectionRevision + else { return } + context.coordinator.selectionRevision = controller.fieldSelectionRevision + DispatchQueue.main.async { + field.window?.makeFirstResponder(field) + field.selectText(nil) + } + } + + final class Coordinator: NSObject, NSSearchFieldDelegate { + var controller: TerminalFindController + var close: @MainActor @Sendable () -> Void + var selectionRevision: UInt64 = 0 + + init( + controller: TerminalFindController, + close: @escaping @MainActor @Sendable () -> Void + ) { + self.controller = controller + self.close = close + } + + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSSearchField else { return } + controller.updateQuery(field.stringValue) + } + + func control( + _: NSControl, + textView _: NSTextView, + doCommandBy selector: Selector + ) -> Bool { + switch TerminalFindBar.fieldCommand( + selector: selector, + shift: NSApp.currentEvent?.modifierFlags.contains(.shift) == true + ) { + case .next: + controller.findNext() + return true + case .previous: + controller.findPrevious() + return true + case .close: + close() + return true + case nil: + return false + } + } + } +} diff --git a/Sources/App/TmuxAttachedClientGuard.swift b/Sources/App/TmuxAttachedClientGuard.swift new file mode 100644 index 00000000..69df3090 --- /dev/null +++ b/Sources/App/TmuxAttachedClientGuard.swift @@ -0,0 +1,131 @@ +import GhosthubTmux +import GhosthubTransport + +struct TmuxAttachedClientIdentity: Hashable, Sendable { + var serverPID: String + var clientPID: String + var clientCreatedAt: String + var clientTTY: String + var sessionID: String + var sessionCreatedAt: String + var paneID: String + + var sessionIdentity: TmuxSessionIdentity { + TmuxSessionIdentity( + serverPID: serverPID, + sessionID: sessionID, + createdAt: sessionCreatedAt + ) + } + + func matchesClient(_ other: Self) -> Bool { + serverPID == other.serverPID + && clientPID == other.clientPID + && clientCreatedAt == other.clientCreatedAt + && clientTTY == other.clientTTY + && sessionID == other.sessionID + && sessionCreatedAt == other.sessionCreatedAt + } +} + +enum TmuxAttachedClientGuard { + enum Scope { + case client + case pane + } + + static func command( + tmuxPath: String, + socketName: String?, + expectedClient: TmuxAttachedClientIdentity, + marker: String, + hookIndex: Int, + action: String, + scope: Scope = .pane + ) -> String { + let tmux = tmuxCommand(tmuxPath: tmuxPath, socketName: socketName) + let hookName = "after-refresh-client[\(hookIndex)]" + let clientIdentity = "#{&&:" + + "#{==:#{client_pid},\(expectedClient.clientPID)}," + + "#{&&:" + + "#{==:#{client_created},\(expectedClient.clientCreatedAt)}," + + "#{==:#{client_tty},\(expectedClient.clientTTY)}}}" + let exactClient = "#{==:#{L:#{?\(clientIdentity),1,}},1}" + let targetCondition: String + switch scope { + case .client: + targetCondition = exactClient + case .pane: + targetCondition = "#{&&:\(exactClient)," + + "#{==:#{pane_id},\(expectedClient.paneID)}}" + } + let condition = "#{&&:" + + expectedClient.sessionIdentity.formatCondition + + ",\(targetCondition)}" + let guardedAction = [ + "if-shell", "-F", condition, + action, + "display-message -p " + shellQuotedCommandArgument(marker), + ].map(shellQuotedCommandArgument).joined(separator: " ") + let hookBody = guardedHookBody( + hookName: hookName, + marker: marker, + action: guardedAction + ) + let queue = tmux + " " + [ + "set-hook", "-g", hookName, hookBody, ";", + "refresh-client", "-t", expectedClient.clientTTY, + marker, ";", + "set-hook", "-gu", hookName, + ].map(shellQuotedCommandArgument).joined(separator: " ") + let cleanup = cleanupCommand( + tmuxPath: tmuxPath, + socketName: socketName, + hookIndex: hookIndex + ) + return queue + "; ghosthub_status=$?; " + + cleanup + " >/dev/null 2>&1; exit \"$ghosthub_status\"" + } + + static func cleanupCommand( + tmuxPath: String, + socketName: String?, + hookIndex: Int + ) -> String { + var arguments = [tmuxPath] + if let socketName, !socketName.isEmpty { + arguments += ["-L", socketName] + } + arguments += [ + "set-hook", "-gu", "after-refresh-client[\(hookIndex)]", + ] + return arguments.map(shellQuotedCommandArgument).joined(separator: " ") + } + + private static func tmuxCommand( + tmuxPath: String, + socketName: String? + ) -> String { + var arguments = [tmuxPath] + if let socketName, !socketName.isEmpty { + arguments += ["-L", socketName] + } + return arguments.map(shellQuotedCommandArgument).joined(separator: " ") + } + + private static func guardedHookBody( + hookName: String, + marker: String, + action: String + ) -> String { + let markerCondition = "#{==:#{hook_argument_0},\(marker)}" + let removeHook = [ + "set-hook", "-gu", hookName, + ].map(shellQuotedCommandArgument).joined(separator: " ") + return [ + "if-shell", "-F", markerCondition, + removeHook + " ; " + action, + "", + ].map(shellQuotedCommandArgument).joined(separator: " ") + } +} diff --git a/Sources/App/TmuxBinaryResolver.swift b/Sources/App/TmuxBinaryResolver.swift index c3acb24c..091cbe26 100644 --- a/Sources/App/TmuxBinaryResolver.swift +++ b/Sources/App/TmuxBinaryResolver.swift @@ -620,15 +620,8 @@ struct TmuxBinaryResolver: Sendable { _ output: String, platform: SSHHostInfo.Platform ) -> Bool { - let fields = output.split(whereSeparator: \.isWhitespace) - guard fields.count >= 2, fields[0] == "tmux" else { return false } - let components = fields[1].split(separator: ".", maxSplits: 1) - guard components.count == 2, - let major = Int(components[0]) else { return false } - let minorDigits = components[1].prefix(while: \.isNumber) - guard let minor = Int(minorDigits) else { return false } - let minimumMinor = 2 - return major > 3 || (major == 3 && minor >= minimumMinor) + guard let version = TmuxVersion(output: output) else { return false } + return version >= .minimumSupported } private static func minimumVersion( diff --git a/Sources/App/TmuxPaneFinder.swift b/Sources/App/TmuxPaneFinder.swift new file mode 100644 index 00000000..b4a4d0aa --- /dev/null +++ b/Sources/App/TmuxPaneFinder.swift @@ -0,0 +1,284 @@ +import Foundation +import GhosthubTransport + +struct TmuxFindTarget: Sendable { + let host: CommandHost + let tmuxPath: String + let tmuxVersion: TmuxVersion + let sessionName: String + let socketName: String? + let sshConnectionArguments: [String] + let expectedClient: TmuxAttachedClientIdentity +} + +enum TmuxFindMutation: Equatable, Sendable { + case search(String) + case next + case previous + case cancel +} + +enum TmuxFindState: Equatable, Sendable { + case match(total: UInt?) + case noMatch +} + +struct TmuxFindFailure: Error, Equatable, Sendable { + enum Kind: Equatable, Sendable { + case targetChanged + case transport + case command + case malformedState + } + + let kind: Kind + let status: Int32 + let message: String + let diagnostic: String +} + +struct TmuxPaneFinder: Sendable { + typealias Runner = @Sendable ( + CommandHost, + [String], + String + ) -> (status: Int32, diagnostic: String) + + private let runner: Runner + + init(runner: Runner? = nil) { + self.runner = runner ?? Self.run + } + + func perform( + _ mutation: TmuxFindMutation, + target: TmuxFindTarget + ) async -> Result { + let guardMarker = "GHOSTHUB_TMUX_FIND_IDENTITY_MISMATCH_" + + UUID().uuidString + let stateMarker = "GHOSTHUB_TMUX_FIND_STATE_" + UUID().uuidString + let hookIndex = Int.random(in: 1_000_000_000 ... 2_000_000_000) + let rendered = Self.command( + mutation, + target: target, + guardMarker: guardMarker, + stateMarker: stateMarker, + hookIndex: hookIndex + ) + let runner = runner + let task = Task.detached(priority: .userInitiated) { + guard !Task.isCancelled else { + return (status: Int32(0), diagnostic: "") + } + return runner( + target.host, + target.sshConnectionArguments, + rendered + ) + } + let result = await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + if Task.isCancelled { + let cleanup = TmuxAttachedClientGuard.cleanupCommand( + tmuxPath: target.tmuxPath, + socketName: target.socketName, + hookIndex: hookIndex + ) + _ = await Task.detached(priority: .userInitiated) { + runner(target.host, target.sshConnectionArguments, cleanup) + }.value + return .success(nil) + } + + if result.diagnostic.split(whereSeparator: \.isNewline) + .contains(Substring(guardMarker)) + || result.diagnostic.lowercased().contains("can't find client:") { + return .failure(.init( + kind: .targetChanged, + status: 75, + message: "The attached tmux session changed.", + diagnostic: result.diagnostic + )) + } + guard result.status == 0 else { + if Self.isTransportFailure( + status: result.status, + host: target.host + ) { + return .failure(.init( + kind: .transport, + status: result.status, + message: "Find lost its connection to tmux.", + diagnostic: result.diagnostic + )) + } + return .failure(.init( + kind: .command, + status: result.status, + message: "tmux could not search this pane.", + diagnostic: result.diagnostic + )) + } + guard mutation != .cancel else { return .success(nil) } + guard let state = Self.parseState( + result.diagnostic, + marker: stateMarker, + includesCount: target.tmuxVersion >= .searchCount + ) else { + return .failure(.init( + kind: .malformedState, + status: result.status, + message: "tmux returned an invalid Find result.", + diagnostic: result.diagnostic + )) + } + return .success(state) + } + + static func command( + _ mutation: TmuxFindMutation, + target: TmuxFindTarget, + guardMarker: String, + stateMarker: String, + hookIndex: Int + ) -> String { + TmuxAttachedClientGuard.command( + tmuxPath: target.tmuxPath, + socketName: target.socketName, + expectedClient: target.expectedClient, + marker: guardMarker, + hookIndex: hookIndex, + action: action( + mutation, + target: target, + stateMarker: stateMarker + ) + ) + } + + static func action( + _ mutation: TmuxFindMutation, + target: TmuxFindTarget, + stateMarker: String + ) -> String { + let pane = target.expectedClient.paneID + var commands: [[String]] + switch mutation { + case let .search(query): + var search = [ + "send-keys", "-t", pane, "-X", "search-backward-text", + ] + if target.tmuxVersion >= .copyModeOptionParsing { + search.append("--") + } + search.append(query) + commands = [ + ["copy-mode", "-t", pane], + ["send-keys", "-t", pane, "-X", "history-bottom"], + search, + ] + case .next: + commands = [["send-keys", "-t", pane, "-X", "search-again"]] + case .previous: + commands = [["send-keys", "-t", pane, "-X", "search-reverse"]] + case .cancel: + commands = [["send-keys", "-t", pane, "-X", "cancel"]] + } + if mutation != .cancel { + var format = stateMarker + "\t#{search_present}" + if target.tmuxVersion >= .searchCount { + format += "\t#{search_count}\t#{search_count_partial}" + } + commands.append(["display-message", "-p", "-t", pane, format]) + } + return commands.map { + $0.map(shellQuotedCommandArgument).joined(separator: " ") + }.joined(separator: " ; ") + } + + static func parseState( + _ output: String, + marker: String, + includesCount: Bool + ) -> TmuxFindState? { + guard let line = output.split(whereSeparator: \.isNewline) + .map(String.init) + .last(where: { $0.hasPrefix(marker + "\t") }) + else { return nil } + let fields = line.split( + separator: "\t", + omittingEmptySubsequences: false + ) + guard fields.count == (includesCount ? 4 : 2), + fields[0] == Substring(marker) + else { return nil } + switch fields[1] { + case "0": + return .noMatch + case "1": + guard includesCount else { return .match(total: nil) } + guard fields[3] == "0" else { return .match(total: nil) } + return .match(total: UInt(fields[2])) + default: + return nil + } + } + + private static func isTransportFailure( + status: Int32, + host: CommandHost + ) -> Bool { + status == AccountCommandRunner.timedOutStatus + || { + if case .ssh = host { + return status == 255 + } + return false + }() + } + + private static func run( + host: CommandHost, + sshConnectionArguments: [String], + command: String + ) -> (status: Int32, diagnostic: String) { + switch host { + case .local: + let result = AccountCommandRunner.runLoginShell( + shell: AccountCommandRunner.loginShell(), + command: command, + timeout: 15, + captureStandardError: true + ) + return (result.status, result.stdout) + case let .ssh(info): + var arguments = [ + "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", + ] + arguments += sshConnectionArguments + if let port = info.port { + arguments += ["-p", String(port)] + } + let destination = info.user.map { "\($0)@\(info.hostname)" } + ?? info.hostname + arguments += [ + "--", + destination, + AccountCommandRunner.remoteLoginCommand( + host: info, + command: command + ), + ] + let result = AccountCommandRunner.runProcessInLoginShell( + executable: "/usr/bin/ssh", + arguments: arguments, + timeout: 15, + captureStandardError: true + ) + return (result.status, result.stdout) + } + } +} diff --git a/Sources/App/TmuxPaneSplitter.swift b/Sources/App/TmuxPaneSplitter.swift index b1669c0d..4ef3ea0f 100644 --- a/Sources/App/TmuxPaneSplitter.swift +++ b/Sources/App/TmuxPaneSplitter.swift @@ -12,35 +12,7 @@ struct TmuxPaneSplitTarget: Sendable { var expectedIdentity: TmuxSessionIdentity? var clientToken: String? var clientTTYDirectory: String? - var expectedClient: TmuxPaneSplitClientIdentity? -} - -struct TmuxPaneSplitClientIdentity: Hashable, Sendable { - var serverPID: String - var clientPID: String - var clientCreatedAt: String - var clientTTY: String - var sessionID: String - var sessionCreatedAt: String - var paneID: String - - var sessionIdentity: TmuxSessionIdentity { - TmuxSessionIdentity( - serverPID: serverPID, - sessionID: sessionID, - createdAt: sessionCreatedAt - ) - } - - func matchesClient(_ other: Self) -> Bool { - serverPID == other.serverPID - && clientPID == other.clientPID - && clientCreatedAt == other.clientCreatedAt - && clientTTY == other.clientTTY - && sessionID == other.sessionID - && sessionCreatedAt == other.sessionCreatedAt - } - + var expectedClient: TmuxAttachedClientIdentity? } struct TmuxPaneSplitFailure: Error, Equatable, LocalizedError, Sendable { @@ -104,14 +76,8 @@ struct TmuxPaneSplitter: Sendable { host: CommandHost ) -> Bool { guard platform(for: host) == .posix else { return false } - let fields = version.split(whereSeparator: \.isWhitespace) - guard fields.count == 2, fields[0] == "tmux" else { return false } - let components = fields[1].split(separator: ".", maxSplits: 1) - guard components.count == 2, - let major = Int(components[0]), - let minor = Int(components[1].prefix(while: \.isNumber)) - else { return false } - return major > 3 || (major == 3 && minor >= 4) + guard let version = TmuxVersion(output: version) else { return false } + return version >= .minimumFind } func split( @@ -156,7 +122,7 @@ struct TmuxPaneSplitter: Sendable { task.cancel() } if Task.isCancelled { - let cleanupCommand = Self.cleanupCommand( + let cleanupCommand = TmuxAttachedClientGuard.cleanupCommand( tmuxPath: target.tmuxPath, socketName: target.socketName, hookIndex: hookIndex @@ -239,7 +205,7 @@ struct TmuxPaneSplitter: Sendable { ) let result = await run(command: command, target: target) if Task.isCancelled { - let cleanup = Self.cleanupCommand( + let cleanup = TmuxAttachedClientGuard.cleanupCommand( tmuxPath: target.tmuxPath, socketName: target.socketName, hookIndex: hookIndex @@ -278,79 +244,29 @@ struct TmuxPaneSplitter: Sendable { tmuxPath: String, socketName: String?, shortcut: TerminalPaneSplitShortcut, - expectedClient: TmuxPaneSplitClientIdentity, + expectedClient: TmuxAttachedClientIdentity, mismatchMarker: String, hookIndex: Int ) -> String { - var arguments = [tmuxPath] - if let socketName, !socketName.isEmpty { - arguments += ["-L", socketName] - } - let tmux = arguments.map(shellQuotedCommandArgument) - .joined(separator: " ") - let hookName = "after-refresh-client[\(hookIndex)]" let mutation = [ "split-window", shortcut == .right ? "-h" : "-v", "-t", expectedClient.paneID, ].map(shellQuotedCommandArgument).joined(separator: " ") - let clientIdentity = "#{&&:" - + "#{==:#{client_pid},\(expectedClient.clientPID)}," - + "#{&&:" - + "#{==:#{client_created},\(expectedClient.clientCreatedAt)}," - + "#{==:#{client_tty},\(expectedClient.clientTTY)}}}" - let exactClient = "#{==:#{L:#{?\(clientIdentity),1,}},1}" - let condition = "#{&&:" - + expectedClient.sessionIdentity.formatCondition - + ",#{&&:\(exactClient)," - + "#{&&:#{==:#{pane_id},\(expectedClient.paneID)}," - + "#{==:#{hook_argument_0},\(mismatchMarker)}}}}" - let split = [ - "if-shell", "-F", condition, - mutation, - "display-message -p " - + shellQuotedCommandArgument(mismatchMarker), - ].map(shellQuotedCommandArgument).joined(separator: " ") - let guardedMutation = guardedHookBody( - hookName: hookName, - marker: mismatchMarker, - action: split - ) - let queue = tmux + " " + [ - "set-hook", "-g", hookName, guardedMutation, ";", - "refresh-client", "-t", expectedClient.clientTTY, - mismatchMarker, ";", - "set-hook", "-gu", hookName, - ].map(shellQuotedCommandArgument).joined(separator: " ") - let cleanup = cleanupCommand( + return TmuxAttachedClientGuard.command( tmuxPath: tmuxPath, socketName: socketName, - hookIndex: hookIndex + expectedClient: expectedClient, + marker: mismatchMarker, + hookIndex: hookIndex, + action: mutation ) - return queue + "; ghosthub_status=$?; " - + cleanup + " >/dev/null 2>&1; exit \"$ghosthub_status\"" - } - - static func cleanupCommand( - tmuxPath: String, - socketName: String?, - hookIndex: Int - ) -> String { - var arguments = [tmuxPath] - if let socketName, !socketName.isEmpty { - arguments += ["-L", socketName] - } - arguments += [ - "set-hook", "-gu", "after-refresh-client[\(hookIndex)]", - ] - return arguments.map(shellQuotedCommandArgument) - .joined(separator: " ") } static func enableSizingCommand( tmuxPath: String, socketName: String?, - expectedClient: TmuxPaneSplitClientIdentity, + expectedClient: TmuxAttachedClientIdentity, mismatchMarker: String, hookIndex: Int ) -> String { @@ -367,76 +283,30 @@ struct TmuxPaneSplitter: Sendable { static func sizingCommand( tmuxPath: String, socketName: String?, - expectedClient: TmuxPaneSplitClientIdentity, + expectedClient: TmuxAttachedClientIdentity, ignoresClientSize: Bool, mismatchMarker: String, hookIndex: Int ) -> String { - var arguments = [tmuxPath] - if let socketName, !socketName.isEmpty { - arguments += ["-L", socketName] - } - let tmux = arguments.map(shellQuotedCommandArgument) - .joined(separator: " ") - let hookName = "after-refresh-client[\(hookIndex)]" - let clientIdentity = "#{&&:" - + "#{==:#{client_pid},\(expectedClient.clientPID)}," - + "#{&&:" - + "#{==:#{client_created},\(expectedClient.clientCreatedAt)}," - + "#{==:#{client_tty},\(expectedClient.clientTTY)}}}" - let exactClient = "#{==:#{L:#{?\(clientIdentity),1,}},1}" - let condition = "#{&&:" - + expectedClient.sessionIdentity.formatCondition - + ",\(exactClient)}" let updateSizing = [ - "if-shell", "-F", condition, - [ - "refresh-client", "-t", expectedClient.clientTTY, - "-f", ignoresClientSize ? "ignore-size" : "!ignore-size", - ].map(shellQuotedCommandArgument).joined(separator: " "), - "display-message -p " - + shellQuotedCommandArgument(mismatchMarker), - ].map(shellQuotedCommandArgument).joined(separator: " ") - let guardedMutation = guardedHookBody( - hookName: hookName, - marker: mismatchMarker, - action: updateSizing - ) - let queue = tmux + " " + [ - "set-hook", "-g", hookName, guardedMutation, ";", "refresh-client", "-t", expectedClient.clientTTY, - mismatchMarker, ";", - "set-hook", "-gu", hookName, + "-f", ignoresClientSize ? "ignore-size" : "!ignore-size", ].map(shellQuotedCommandArgument).joined(separator: " ") - let cleanup = cleanupCommand( + return TmuxAttachedClientGuard.command( tmuxPath: tmuxPath, socketName: socketName, - hookIndex: hookIndex + expectedClient: expectedClient, + marker: mismatchMarker, + hookIndex: hookIndex, + action: updateSizing, + scope: .client ) - return queue + "; ghosthub_status=$?; " - + cleanup + " >/dev/null 2>&1; exit \"$ghosthub_status\"" - } - - static func guardedHookBody( - hookName: String, - marker: String, - action: String - ) -> String { - let markerCondition = "#{==:#{hook_argument_0},\(marker)}" - let removeHook = [ - "set-hook", "-gu", hookName, - ].map(shellQuotedCommandArgument).joined(separator: " ") - return [ - "if-shell", "-F", markerCondition, - removeHook + " ; " + action, - "", - ].map(shellQuotedCommandArgument).joined(separator: " ") } func clientIdentity( target: TmuxPaneSplitTarget, priority: TaskPriority = .userInitiated - ) async -> Result { + ) async -> Result { guard let clientToken = target.clientToken else { return .failure(clientIdentityUnavailable(target: target)) } @@ -566,7 +436,7 @@ struct TmuxPaneSplitter: Sendable { private static func parseClientIdentity( _ output: String - ) -> TmuxPaneSplitClientIdentity? { + ) -> TmuxAttachedClientIdentity? { guard let line = output.split(whereSeparator: \.isNewline) .map(String.init) .last(where: { $0.hasPrefix(clientIdentityMarker) }) @@ -586,7 +456,7 @@ struct TmuxPaneSplitter: Sendable { fields[6].first == "%", isNumeric(fields[6].dropFirst()) else { return nil } - return TmuxPaneSplitClientIdentity( + return TmuxAttachedClientIdentity( serverPID: String(fields[0]), clientPID: String(fields[1]), clientCreatedAt: String(fields[2]), diff --git a/Sources/App/TmuxVersion.swift b/Sources/App/TmuxVersion.swift new file mode 100644 index 00000000..8691bebe --- /dev/null +++ b/Sources/App/TmuxVersion.swift @@ -0,0 +1,31 @@ +import Foundation + +struct TmuxVersion: Comparable, Equatable, Sendable { + static let minimumSupported = Self(major: 3, minor: 2) + static let minimumFind = Self(major: 3, minor: 4) + static let searchCount = Self(major: 3, minor: 5) + static let copyModeOptionParsing = Self(major: 3, minor: 6) + + let major: Int + let minor: Int + + init(major: Int, minor: Int) { + self.major = major + self.minor = minor + } + + init?(output: String) { + let fields = output.split(whereSeparator: \.isWhitespace) + guard fields.count >= 2, fields[0] == "tmux" else { return nil } + let components = fields[1].split(separator: ".", maxSplits: 1) + guard components.count == 2, + let major = Int(components[0]), + let minor = Int(components[1].prefix(while: \.isNumber)) + else { return nil } + self.init(major: major, minor: minor) + } + + static func < (lhs: Self, rhs: Self) -> Bool { + (lhs.major, lhs.minor) < (rhs.major, rhs.minor) + } +} diff --git a/Sources/App/WorkspaceSceneModel+Selection.swift b/Sources/App/WorkspaceSceneModel+Selection.swift index 6550abd5..14ac05b5 100644 --- a/Sources/App/WorkspaceSceneModel+Selection.swift +++ b/Sources/App/WorkspaceSceneModel+Selection.swift @@ -94,6 +94,30 @@ extension WorkspaceSceneModel { guard snapshot.host(id: selection.selectedHostID)? .herdrAvailable == true else { return false } return postShortcutRequest(action) + case .find: + guard let controller = activeTerminalFindController, + controller.isAvailable else { return false } + controller.open() + return true + case .findNext: + guard let controller = activeTerminalFindController, + controller.canNavigate else { return false } + controller.findNext() + return true + case .findPrevious: + guard let controller = activeTerminalFindController, + controller.canNavigate else { return false } + controller.findPrevious() + return true + case .hideFindBar: + guard let surface = activeTerminalFindSurface, + surface.terminalFindController.isOpen else { return false } + let controller = surface.terminalFindController + controller.close() + DispatchQueue.main.async { [weak surface] in + surface?.requestKeyboardFocus() + } + return true case .splitRight: guard canSplitActivePane, invocation == .menu || hasFocusedTerminalSurface @@ -226,6 +250,16 @@ extension WorkspaceSceneModel { if canSplitActivePane { actions.formUnion([.splitRight, .splitDown]) } + if let controller = activeTerminalFindController, + controller.isAvailable { + actions.insert(.find) + if controller.canNavigate { + actions.formUnion([.findNext, .findPrevious]) + } + if controller.isOpen { + actions.insert(.hideFindBar) + } + } return actions } diff --git a/Sources/App/WorkspaceSceneModel.swift b/Sources/App/WorkspaceSceneModel.swift index bf7d203c..aa23e060 100644 --- a/Sources/App/WorkspaceSceneModel.swift +++ b/Sources/App/WorkspaceSceneModel.swift @@ -366,6 +366,45 @@ final class WorkspaceSceneModel: ObservableObject { @Published private(set) var activeBorrowedZellijSelection: WorkspaceZellijSessionSelection? private var activeBorrowedZellijHandle: BorrowedZellijSessionHandle? + var activeTerminalFindSurface: + (any NativeSessionPaneSurfacing)? { + let entries = terminalCoordinator.surfaceEntries() + if isLogViewerPresented, + let logSurface = entries.first(where: { + $0.key.target == .logViewer + })?.view { + return logSurface + } + if activeBorrowedTmuxSelection != nil { + guard let activeBorrowedTmuxHandle else { return nil } + return nativeTmuxSessionCoordinator.findSurface( + activeBorrowedTmuxHandle + ) + } + if activeBorrowedHerdrSelection != nil { + guard let activeBorrowedHerdrHandle else { return nil } + return nativeHerdrSessionCoordinator.surface( + handle: activeBorrowedHerdrHandle + ) + } + if activeBorrowedZellijSelection != nil { + guard let activeBorrowedZellijHandle else { return nil } + return nativeZellijSessionCoordinator.surface( + handle: activeBorrowedZellijHandle + ) + } + if let openController = entries.first(where: { + $0.view.terminalFindController.isOpen + })?.view { + return openController + } + return entries.first { + $0.view.hasEffectiveKeyboardFocus + }?.view + } + var activeTerminalFindController: TerminalFindController? { + activeTerminalFindSurface?.terminalFindController + } @Published private(set) var activeBorrowedZellijRecoveryState: NativeSessionRecoveryState? private var zellijPresentationTask: Task? @@ -5818,6 +5857,23 @@ final class WorkspaceSceneModel: ObservableObject { } return AnyView( TerminalSurfaceSwiftUIView(surfaceView: surface) + .overlay(alignment: .top) { + if let message = surface.terminalOperationErrorMessage { + NativeTerminalOperationErrorOverlay(message: message) + } + } + .overlay(alignment: .topTrailing) { + TerminalFindOverlay( + controller: surface.terminalFindController, + restoreTerminalFocus: { [weak surface] in + surface?.requestKeyboardFocus() + } + ) + } + .focusedSceneObject(surface.terminalFindController) + .onDisappear { + surface.terminalFindController.close() + } ) } @@ -10023,6 +10079,7 @@ final class WorkspaceSceneModel: ObservableObject { guard let handle = activeBorrowedTmuxHandle, let presentation = retainedTmuxPresentation(for: handle) else { return } + nativeTmuxSessionCoordinator.findController(handle)?.close() let key = TmuxPresentationKey(presentation.selection) tmuxSessionPreviewCoordinator.captureBeforeDeactivation( key.previewKey, diff --git a/Sources/Terminal/LibghosttyFind.swift b/Sources/Terminal/LibghosttyFind.swift new file mode 100644 index 00000000..fcc173da --- /dev/null +++ b/Sources/Terminal/LibghosttyFind.swift @@ -0,0 +1,283 @@ +import Foundation +import GhosthubTerminalSupport + +final class LibghosttyFindOperationRegistry: @unchecked Sendable { + static let shared = LibghosttyFindOperationRegistry() + + enum Backend: Equatable { + case libghostty + case external + } + + enum Callback: Equatable { + case total(Int) + case selected(Int) + } + + private struct PendingOperation { + let token: TerminalFindOperationToken + } + + private struct OperationState { + var token: TerminalFindOperationToken? + var pending: [PendingOperation] + var receivedResetTotal = false + } + + private let lock = NSLock() + private var operations: [UInt: OperationState] = [:] + private var backends: [UInt: Backend] = [:] + + func setBackend(_ backend: Backend, for surfaceIdentity: UInt) { + lock.lock() + backends[surfaceIdentity] = backend + if backend == .external { + operations.removeValue(forKey: surfaceIdentity) + } + lock.unlock() + } + + func prepareSearch( + _ operation: TerminalFindOperationToken, + for surfaceIdentity: UInt + ) { + lock.lock() + defer { lock.unlock() } + var state = operations[surfaceIdentity] ?? OperationState( + token: nil, + pending: [] + ) + state.pending.append(PendingOperation(token: operation)) + operations[surfaceIdentity] = state + } + + func prepareNavigation( + _ operation: TerminalFindOperationToken, + for surfaceIdentity: UInt + ) { + lock.lock() + defer { lock.unlock() } + if var state = operations[surfaceIdentity] { + state.token = operation + state.pending.removeAll() + state.receivedResetTotal = false + operations[surfaceIdentity] = state + } else { + operations[surfaceIdentity] = OperationState( + token: operation, + pending: [] + ) + } + } + + func beginExternalOperation( + for surfaceIdentity: UInt + ) -> TerminalFindOperationToken? { + let operation = TerminalFindOperationToken() + lock.lock() + guard backends[surfaceIdentity] != .external else { + lock.unlock() + return nil + } + var state = operations[surfaceIdentity] ?? OperationState( + token: nil, + pending: [] + ) + // Keep the current generation for its in-flight reset pair, but let + // the upstream search replace every operation still waiting behind it. + state.token = state.token ?? state.pending.first?.token + state.pending = [PendingOperation(token: operation)] + operations[surfaceIdentity] = state + lock.unlock() + return operation + } + + func operation(for surfaceIdentity: UInt) -> TerminalFindOperationToken? { + lock.lock() + defer { lock.unlock() } + guard let state = operations[surfaceIdentity] else { return nil } + return state.pending.last?.token ?? state.token + } + + func operation( + for surfaceIdentity: UInt, + callback: Callback + ) -> TerminalFindOperationToken? { + lock.lock() + defer { lock.unlock() } + guard var state = operations[surfaceIdentity] else { return nil } + let operation = state.token ?? state.pending.first?.token + + // The search thread emits this pair before results for every new + // needle. Advance one queued operation only after the full pair, so + // callbacks already queued for the prior needle keep its token. + switch callback { + case .total(0): + state.receivedResetTotal = true + case .selected(-1) where state.receivedResetTotal: + if !state.pending.isEmpty { + let pending = state.pending.removeFirst() + state.token = pending.token + } + state.receivedResetTotal = false + default: + break + } + operations[surfaceIdentity] = state + return operation + } + + func removeOperation(for surfaceIdentity: UInt) { + lock.lock() + operations.removeValue(forKey: surfaceIdentity) + lock.unlock() + } + + func removeSurface(for surfaceIdentity: UInt) { + lock.lock() + operations.removeValue(forKey: surfaceIdentity) + backends.removeValue(forKey: surfaceIdentity) + lock.unlock() + } +} + +extension TerminalSurfaceView { + public func installLibghosttyFindController() { + if let surfaceIdentity { + LibghosttyFindOperationRegistry.shared.setBackend( + .libghostty, + for: surfaceIdentity + ) + } + terminalFindController = TerminalFindController( + isAvailable: true, + failureHandler: { [weak self] message in + self?.terminalOperationErrorMessage = message + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(4)) + if self?.terminalOperationErrorMessage == message { + self?.terminalOperationErrorMessage = nil + } + } + }, + sessionProvider: { [weak self] in + guard let self else { return nil } + return TerminalFindSession( + search: { [weak self] query, operation in + await MainActor.run { + guard let self else { + return .failure(.init( + message: "The terminal could not start Find." + )) + } + self.prepareLibghosttyFindSearch( + operation + ) + guard self.performBindingAction("search:\(query)") + else { + self.removeLibghosttyFindOperation() + return .failure(.init( + message: "The terminal could not start Find." + )) + } + return .success(.awaitingCallback) + } + }, + navigate: { [weak self] direction, operation in + await MainActor.run { + let action = direction == .next + ? "navigate_search:next" + : "navigate_search:previous" + guard let self else { + return .failure(.init( + message: "The terminal could not navigate Find." + )) + } + self.prepareLibghosttyFindNavigation(operation) + guard self.performBindingAction(action) + else { + self.removeLibghosttyFindOperation() + return .failure(.init( + message: "The terminal could not navigate Find." + )) + } + return .success(.awaitingCallback) + } + }, + close: { [weak self] in + await MainActor.run { + guard let self else { return nil } + _ = self.performBindingAction("end_search") + return nil + } + } + ) + } + ) + } + + public func useExternalFindBackend() { + guard let surfaceIdentity else { return } + LibghosttyFindOperationRegistry.shared.setBackend( + .external, + for: surfaceIdentity + ) + } + + func publishLibghosttyFindTotal( + _ total: Int, + operation: TerminalFindOperationToken + ) { + terminalFindController.publishBackendTotal( + total, + operation: operation + ) + } + + func beginLibghosttyFind( + _ query: String, + operation: TerminalFindOperationToken + ) { + terminalFindController.backendDidOpen( + query: query, + operation: operation + ) + } + + func publishLibghosttyFindSelected( + _ selected: Int, + operation: TerminalFindOperationToken + ) { + terminalFindController.publishBackendSelected( + selected, + operation: operation + ) + } + + private func prepareLibghosttyFindSearch( + _ operation: TerminalFindOperationToken + ) { + guard let surfaceIdentity else { return } + LibghosttyFindOperationRegistry.shared.prepareSearch( + operation, + for: surfaceIdentity + ) + } + + private func prepareLibghosttyFindNavigation( + _ operation: TerminalFindOperationToken + ) { + guard let surfaceIdentity else { return } + LibghosttyFindOperationRegistry.shared.prepareNavigation( + operation, + for: surfaceIdentity + ) + } + + private func removeLibghosttyFindOperation() { + guard let surfaceIdentity else { return } + LibghosttyFindOperationRegistry.shared.removeOperation( + for: surfaceIdentity + ) + } +} diff --git a/Sources/Terminal/LibghosttyRuntime.swift b/Sources/Terminal/LibghosttyRuntime.swift index e950e7cc..b13e0ac5 100644 --- a/Sources/Terminal/LibghosttyRuntime.swift +++ b/Sources/Terminal/LibghosttyRuntime.swift @@ -1003,6 +1003,76 @@ public final class LibghosttyRuntime: ObservableObject, } return true + case GHOSTTY_ACTION_START_SEARCH: + let query = action.action.start_search.needle.map { + String(cString: $0) + } ?? "" + guard let sourceSurfaceIdentity else { return true } + // `search:` updates libghostty's search thread directly; + // it does not emit START_SEARCH. This action therefore represents + // a new upstream-owned search, such as start_search or + // search_selection, and supersedes the registered operation. + guard let operation = LibghosttyFindOperationRegistry.shared + .beginExternalOperation(for: sourceSurfaceIdentity) + else { return true } + DispatchQueue.main.async { + surfaceView(fromSurfaceIdentity: sourceSurfaceIdentity)? + .beginLibghosttyFind(query, operation: operation) + } + return true + + case GHOSTTY_ACTION_END_SEARCH: + guard let sourceSurfaceIdentity, + let operation = LibghosttyFindOperationRegistry.shared + .operation(for: sourceSurfaceIdentity) + else { return true } + LibghosttyFindOperationRegistry.shared.removeOperation( + for: sourceSurfaceIdentity + ) + DispatchQueue.main.async { + surfaceView(fromSurfaceIdentity: sourceSurfaceIdentity)? + .terminalFindController.backendDidEnd( + operation: operation + ) + } + return true + + case GHOSTTY_ACTION_SEARCH_TOTAL: + let total = Int(action.action.search_total.total) + guard let sourceSurfaceIdentity, + let operation = LibghosttyFindOperationRegistry.shared + .operation( + for: sourceSurfaceIdentity, + callback: .total(total) + ) + else { return true } + DispatchQueue.main.async { + surfaceView(fromSurfaceIdentity: sourceSurfaceIdentity)? + .publishLibghosttyFindTotal( + total, + operation: operation + ) + } + return true + + case GHOSTTY_ACTION_SEARCH_SELECTED: + let selected = Int(action.action.search_selected.selected) + guard let sourceSurfaceIdentity, + let operation = LibghosttyFindOperationRegistry.shared + .operation( + for: sourceSurfaceIdentity, + callback: .selected(selected) + ) + else { return true } + DispatchQueue.main.async { + surfaceView(fromSurfaceIdentity: sourceSurfaceIdentity)? + .publishLibghosttyFindSelected( + selected, + operation: operation + ) + } + return true + default: DispatchQueue.main.async { let state = runtime(from: userdataValue) diff --git a/Sources/Terminal/TerminalSurfaceCoordinator.swift b/Sources/Terminal/TerminalSurfaceCoordinator.swift index 21cec000..34d3ed2f 100644 --- a/Sources/Terminal/TerminalSurfaceCoordinator.swift +++ b/Sources/Terminal/TerminalSurfaceCoordinator.swift @@ -44,6 +44,7 @@ public final class TerminalSurfaceCoordinator: ObservableObject { app: appHandle, configuration: configuration ) + view.installLibghosttyFindController() view.applicationShortcutsProvider = applicationShortcutsProvider view.onSurfaceDestroyed = { [weak runtime] identity in runtime?.unregisterSurfaceForResolvedColors(identity) @@ -99,6 +100,7 @@ public final class TerminalSurfaceCoordinator: ObservableObject { public func removeSurface(for key: SurfaceKey) { if let view = surfaces[key] { + view.terminalFindController.close() removeMappings(for: view) } surfaces.removeValue(forKey: key) @@ -110,6 +112,7 @@ public final class TerminalSurfaceCoordinator: ObservableObject { } for key in keysToRemove { if let view = surfaces[key] { + view.terminalFindController.close() removeMappings(for: view) } surfaces.removeValue(forKey: key) @@ -122,6 +125,7 @@ public final class TerminalSurfaceCoordinator: ObservableObject { } for key in keysToRemove { if let view = surfaces[key] { + view.terminalFindController.close() removeMappings(for: view) } surfaces.removeValue(forKey: key) diff --git a/Sources/Terminal/TerminalSurfaceView.swift b/Sources/Terminal/TerminalSurfaceView.swift index 7f3c1032..8f09585b 100644 --- a/Sources/Terminal/TerminalSurfaceView.swift +++ b/Sources/Terminal/TerminalSurfaceView.swift @@ -174,7 +174,8 @@ public final class TerminalSurfaceView: NSView, ObservableObject { public var onPrimaryInteraction: (() -> Void)? public var onCloseRequest: (() -> Void)? public var shouldConfirmClose: (() -> Bool)? - @Published public var paneSplitErrorMessage: String? + @Published public var terminalOperationErrorMessage: String? + @Published public var terminalFindController = TerminalFindController.unavailable /// Installed only for native session surfaces whose backend supports /// semantic pane splitting. public var paneSplitShortcutHandler: ((TerminalPaneSplitShortcut) -> Void)? @@ -377,6 +378,7 @@ public final class TerminalSurfaceView: NSView, ObservableObject { /// caller with a shorter process lifetime, such as an isolated XCTest /// worker, can use this method to finish libghostty cleanup before exit. package func shutdown() async { + terminalFindController.close() guard let surfaceHandle = surface else { return } surface = nil await Self.freeSurface( @@ -403,6 +405,9 @@ public final class TerminalSurfaceView: NSView, ObservableObject { } } viewsBySurfaceIdentity.removeValue(forKey: identity) + LibghosttyFindOperationRegistry.shared.removeSurface( + for: identity + ) onSurfaceDestroyed?(identity) callbackToken.view = nil ghostty_surface_free(surfaceHandle) @@ -611,6 +616,7 @@ public final class TerminalSurfaceView: NSView, ObservableObject { public func setParkedForPreview(_ parked: Bool) { guard isParkedForPreview != parked else { return } if parked { + terminalFindController.close() suppressAutoFocus = true mouseEventHandler.resetPointerStateForParking() isParkedForPreview = true diff --git a/Sources/TerminalSupport/ApplicationShortcut.swift b/Sources/TerminalSupport/ApplicationShortcut.swift index 3e3b17a4..8975cd99 100644 --- a/Sources/TerminalSupport/ApplicationShortcut.swift +++ b/Sources/TerminalSupport/ApplicationShortcut.swift @@ -18,6 +18,10 @@ public enum ApplicationShortcutAction: String, CaseIterable, Sendable { case importPullRequest = "import-pull-request" case newTmuxSession = "new-tmux-session" case newHerdrSession = "new-herdr-session" + case find + case findNext = "find-next" + case findPrevious = "find-previous" + case hideFindBar = "hide-find-bar" case splitRight = "split-right" case splitDown = "split-down" case reloadConfiguration = "reload-configuration" @@ -407,6 +411,16 @@ public enum ApplicationShortcutCatalog { definition(.importPullRequest, "Import Pull Request", .application, "cmd+shift+i"), definition(.newTmuxSession, "New tmux Session", .application, nil), definition(.newHerdrSession, "New Herdr Session", .application, nil), + definition(.find, "Find…", .application, "cmd+f"), + definition(.findNext, "Find Next", .application, "cmd+g", repeats: true), + definition( + .findPrevious, + "Find Previous", + .application, + "cmd+shift+g", + repeats: true + ), + definition(.hideFindBar, "Hide Find Bar", .application, "cmd+shift+f"), definition(.splitRight, "Split Right", .multiplexer, "cmd+d"), definition(.splitDown, "Split Down", .multiplexer, "cmd+shift+d"), definition(.reloadConfiguration, "Reload Configuration", .application, "cmd+shift+,"), diff --git a/Sources/TerminalSupport/TerminalFindController.swift b/Sources/TerminalSupport/TerminalFindController.swift new file mode 100644 index 00000000..ec436645 --- /dev/null +++ b/Sources/TerminalSupport/TerminalFindController.swift @@ -0,0 +1,557 @@ +import Combine +import Foundation + +public enum TerminalFindDirection: Equatable, Sendable { + case next + case previous +} + +public struct TerminalFindOperationToken: Hashable, Sendable { + private let value = UUID() + + public init() {} +} + +public enum TerminalFindResult: Equatable, Sendable { + case idle + case match(total: UInt?, selected: UInt?) + case noMatch +} + +public enum TerminalFindBackendResponse: Equatable, Sendable { + case result(TerminalFindResult) + case awaitingCallback +} + +public struct TerminalFindFailure: Error, Equatable, Sendable { + public let message: String + + public init(message: String) { + self.message = message + } +} + +public struct TerminalFindSession: Sendable { + public let search: @Sendable (String, TerminalFindOperationToken) async + -> Result + public let navigate: @Sendable ( + TerminalFindDirection, TerminalFindOperationToken + ) async + -> Result + public let close: @Sendable () async -> TerminalFindFailure? + + public init( + search: @escaping @Sendable ( + String, TerminalFindOperationToken + ) async + -> Result, + navigate: @escaping @Sendable ( + TerminalFindDirection, TerminalFindOperationToken + ) async + -> Result, + close: @escaping @Sendable () async -> TerminalFindFailure? + ) { + self.search = search + self.navigate = navigate + self.close = close + } +} + +@MainActor +public final class TerminalFindController: ObservableObject { + @Published public private(set) var isOpen = false + @Published public private(set) var query = "" + @Published public private(set) var result: TerminalFindResult = .idle + @Published public private(set) var isWorking = false + @Published public private(set) var failureMessage: String? + @Published public private(set) var fieldSelectionRevision: UInt64 = 0 + + public let isAvailable: Bool + + private let debounce: Duration + private let sessionProvider: @MainActor @Sendable () -> TerminalFindSession? + private let failureHandler: @MainActor @Sendable (String) -> Void + private var findSessionID = UUID() + private var queryGeneration: UInt64 = 0 + private var callbackRevision: UInt64 = 0 + private var backendTotal = -1 + private var backendSelected = -1 + private var activeSession: TerminalFindSession? + private var pendingSearch: Operation? + private var pendingNavigations: [Operation] = [] + private var pendingEnds: [Operation] = [] + private var debounceTask: Task? + private var workerTask: Task? + private var activeBackendTask: BackendTask? + private var callbackWaiter: CallbackWaiter? + private var activeCallbackOperation: CallbackOperation? + + public init( + isAvailable: Bool, + debounce: Duration = .milliseconds(150), + failureHandler: @escaping @MainActor @Sendable (String) -> Void = { _ in }, + sessionProvider: @escaping @MainActor @Sendable () + -> TerminalFindSession? + ) { + self.isAvailable = isAvailable + self.debounce = debounce + self.failureHandler = failureHandler + self.sessionProvider = sessionProvider + } + + public static var unavailable: TerminalFindController { + TerminalFindController(isAvailable: false, sessionProvider: { nil }) + } + + public var canNavigate: Bool { + guard isAvailable, isOpen, !query.isEmpty else { return false } + if case .match = result { + return true + } + return false + } + + public func open() { + guard isAvailable else { return } + if isOpen { + fieldSelectionRevision &+= 1 + return + } + + findSessionID = UUID() + activeSession = nil + activeCallbackOperation = nil + isOpen = true + failureMessage = nil + resetBackendResult() + result = .idle + fieldSelectionRevision &+= 1 + if !query.isEmpty { + scheduleSearch(query) + } + } + + public func updateQuery(_ value: String) { + guard isAvailable, isOpen else { return } + query = value + queryGeneration &+= 1 + result = .idle + failureMessage = nil + resetBackendResult() + pendingSearch = nil + pendingNavigations.removeAll() + debounceTask?.cancel() + cancelActiveBackendTask() + activeCallbackOperation = nil + resumeCallbackWaiter() + isWorking = false + + if value.isEmpty { + endActiveBackendSession() + } else { + scheduleSearch(value) + } + } + + public func findNext() { + enqueueNavigation(.next) + } + + public func findPrevious() { + enqueueNavigation(.previous) + } + + public func close() { + close(notifyBackend: true, clearFailure: true) + } + + public func backendDidOpen( + query value: String, + operation: TerminalFindOperationToken + ) { + guard isAvailable else { return } + let opensBar = !isOpen + let replacesActiveOperation = activeCallbackOperation.map { + $0.token != operation + } ?? false + if opensBar { + findSessionID = UUID() + activeSession = nil + query = value + queryGeneration &+= 1 + } else if replacesActiveOperation { + query = value + queryGeneration &+= 1 + pendingSearch = nil + pendingNavigations.removeAll() + debounceTask?.cancel() + debounceTask = nil + cancelActiveBackendTask() + activeCallbackOperation = nil + resumeCallbackWaiter() + isWorking = false + } else if query != value { + query = value + queryGeneration &+= 1 + result = .idle + failureMessage = nil + } + let callbackOperation = CallbackOperation( + token: operation, + sessionID: findSessionID, + generation: queryGeneration, + expectedCallback: .total + ) + activeCallbackOperation = callbackOperation + callbackRevision &+= 1 + resumeCallbackWaiter(matching: operation) + activeSession = activeSession ?? sessionProvider() + isOpen = true + resetBackendResult() + result = .idle + failureMessage = nil + isWorking = false + if opensBar { + fieldSelectionRevision &+= 1 + } + } + + public func backendDidEnd(operation: TerminalFindOperationToken) { + guard activeCallbackOperation?.token == operation else { return } + callbackRevision &+= 1 + resumeCallbackWaiter(matching: operation) + close(notifyBackend: false, clearFailure: true) + } + + public func publishBackendTotal( + _ total: Int, + operation: TerminalFindOperationToken + ) { + publishBackendCallback( + .total, + value: total, + operation: operation + ) + } + + public func publishBackendSelected( + _ selected: Int, + operation: TerminalFindOperationToken + ) { + publishBackendCallback( + .selected, + value: selected, + operation: operation + ) + } + + private func publishBackendCallback( + _ callback: CallbackKind, + value: Int, + operation: TerminalFindOperationToken + ) { + guard isOpen, + let callbackOperation = activeCallbackOperation, + callbackOperation.token == operation, + isCurrent( + sessionID: callbackOperation.sessionID, + generation: callbackOperation.generation + ) + else { return } + + switch callback { + case .total: + backendTotal = value + case .selected: + backendSelected = value + } + result = Self.result(total: backendTotal, selected: backendSelected) + + if callbackOperation.expectedCallback == callback { + callbackRevision &+= 1 + resumeCallbackWaiter(matching: operation) + isWorking = false + } + } + + private func scheduleSearch(_ value: String) { + let sessionID = findSessionID + let generation = queryGeneration + debounceTask?.cancel() + debounceTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await Task.sleep(for: debounce) + } catch { + return + } + guard isOpen, + query == value, + findSessionID == sessionID, + queryGeneration == generation + else { return } + pendingSearch = .search( + value, + sessionID: sessionID, + generation: generation + ) + startWorkerIfNeeded() + } + } + + private func enqueueNavigation(_ direction: TerminalFindDirection) { + guard canNavigate else { return } + pendingNavigations.append(.navigate( + direction, + sessionID: findSessionID, + generation: queryGeneration + )) + startWorkerIfNeeded() + } + + private func endActiveBackendSession() { + let session = activeSession + activeSession = nil + guard let session else { + isWorking = false + return + } + pendingEnds.append(.end(session)) + startWorkerIfNeeded() + } + + private func close(notifyBackend: Bool, clearFailure: Bool) { + guard isOpen else { return } + let session = activeSession + debounceTask?.cancel() + debounceTask = nil + pendingSearch = nil + pendingNavigations.removeAll() + queryGeneration &+= 1 + cancelActiveBackendTask() + resumeCallbackWaiter() + isOpen = false + result = .idle + resetBackendResult() + isWorking = false + activeCallbackOperation = nil + if clearFailure { + failureMessage = nil + } + activeSession = nil + if notifyBackend, let session { + pendingEnds.append(.end(session)) + startWorkerIfNeeded() + } + } + + private func startWorkerIfNeeded() { + guard workerTask == nil else { return } + workerTask = Task { @MainActor [weak self] in + await self?.runWorker() + } + } + + private func runWorker() async { + while let operation = nextOperation() { + switch operation { + case let .search(value, sessionID, generation): + guard isCurrent(sessionID: sessionID, generation: generation), + let session = activeSession ?? sessionProvider() + else { continue } + activeSession = session + isWorking = true + let callbackOperation = CallbackOperation( + token: TerminalFindOperationToken(), + sessionID: sessionID, + generation: generation, + expectedCallback: .total + ) + resetBackendResult() + activeCallbackOperation = callbackOperation + let revision = callbackRevision + let response = await performBackendOperation { + await session.search( + value, + callbackOperation.token + ) + } + await handle( + response, + callbackOperation: callbackOperation, + callbackRevision: revision + ) + + case let .navigate(direction, sessionID, generation): + guard isCurrent(sessionID: sessionID, generation: generation), + let session = activeSession + else { continue } + isWorking = true + let callbackOperation = CallbackOperation( + token: TerminalFindOperationToken(), + sessionID: sessionID, + generation: generation, + expectedCallback: .selected + ) + activeCallbackOperation = callbackOperation + let revision = callbackRevision + let response = await performBackendOperation { + await session.navigate( + direction, + callbackOperation.token + ) + } + await handle( + response, + callbackOperation: callbackOperation, + callbackRevision: revision + ) + + case let .end(session): + _ = await session.close() + } + } + workerTask = nil + } + + private func performBackendOperation( + _ operation: @escaping @Sendable () async + -> Result + ) async -> Result { + let id = UUID() + let task = Task { await operation() } + activeBackendTask = BackendTask(id: id, task: task) + let response = await task.value + if activeBackendTask?.id == id { + activeBackendTask = nil + } + return response + } + + private func cancelActiveBackendTask() { + activeBackendTask?.task.cancel() + } + + private func resetBackendResult() { + backendTotal = -1 + backendSelected = -1 + } + + private func nextOperation() -> Operation? { + if !pendingEnds.isEmpty { + return pendingEnds.removeFirst() + } + if let search = pendingSearch { + pendingSearch = nil + return search + } + if !pendingNavigations.isEmpty { + return pendingNavigations.removeFirst() + } + return nil + } + + private func handle( + _ response: Result, + callbackOperation: CallbackOperation, + callbackRevision initialRevision: UInt64 + ) async { + guard isCurrent( + sessionID: callbackOperation.sessionID, + generation: callbackOperation.generation + ) else { + return + } + switch response { + case let .success(.result(value)): + result = value + isWorking = false + if activeCallbackOperation == callbackOperation { + activeCallbackOperation = nil + } + case .success(.awaitingCallback): + guard callbackRevision == initialRevision else { + isWorking = false + return + } + await waitForBackendCallback(callbackOperation) + case let .failure(failure): + failureMessage = failure.message + failureHandler(failure.message) + close(notifyBackend: true, clearFailure: false) + } + } + + private func waitForBackendCallback( + _ operation: CallbackOperation + ) async { + await withCheckedContinuation { continuation in + callbackWaiter = CallbackWaiter( + operation: operation, + continuation: continuation + ) + } + } + + private func resumeCallbackWaiter( + matching token: TerminalFindOperationToken + ) { + guard callbackWaiter?.operation.token == token else { return } + resumeCallbackWaiter() + } + + private func resumeCallbackWaiter() { + let continuation = callbackWaiter?.continuation + callbackWaiter = nil + continuation?.resume() + } + + private func isCurrent(sessionID: UUID, generation: UInt64) -> Bool { + isOpen && findSessionID == sessionID && queryGeneration == generation + } + + private static func result(total: Int, selected: Int?) -> TerminalFindResult { + guard total >= 0 else { return .idle } + guard total > 0 else { return .noMatch } + let selectedIndex = selected.flatMap { value -> UInt? in + guard value >= 0 else { return nil } + return UInt(value) + 1 + } + return .match(total: UInt(total), selected: selectedIndex) + } + + private enum Operation { + case search(String, sessionID: UUID, generation: UInt64) + case navigate( + TerminalFindDirection, + sessionID: UUID, + generation: UInt64 + ) + case end(TerminalFindSession) + } + + private struct CallbackWaiter { + let operation: CallbackOperation + let continuation: CheckedContinuation + } + + private struct BackendTask { + let id: UUID + let task: Task< + Result, + Never + > + } + + private struct CallbackOperation: Equatable { + let token: TerminalFindOperationToken + let sessionID: UUID + let generation: UInt64 + let expectedCallback: CallbackKind + } + + private enum CallbackKind: Equatable { + case total + case selected + } +} diff --git a/Sources/TerminalUnavailable/LibghosttyRuntime.swift b/Sources/TerminalUnavailable/LibghosttyRuntime.swift index cb157073..30acf40a 100644 --- a/Sources/TerminalUnavailable/LibghosttyRuntime.swift +++ b/Sources/TerminalUnavailable/LibghosttyRuntime.swift @@ -1,4 +1,5 @@ import Combine +import AppKit import Foundation import GhosthubTerminalSupport import GhosthubWorkspace @@ -27,6 +28,8 @@ public final class LibghosttyRuntime: ObservableObject, LibghosttyConfigReloadNotice? @Published public private(set) var resolvedTerminalColorsBySurface: [UInt: TerminalResolvedColors] + @Published public private(set) var backgroundAppearance: + TerminalBackgroundAppearance = .opaque public let runtimeState: LibghosttyRuntimeState public let renderTracker = SurfaceRenderTracker() @@ -49,6 +52,8 @@ public final class LibghosttyRuntime: ObservableObject, public var needsConfirmQuit: Bool { false } + public func applyWindowBackgroundBlur(to _: NSWindow) {} + public var resolvedTerminalColors: TerminalResolvedColors? { nil } public func resolvedTerminalColors( diff --git a/Sources/TerminalUnavailable/TerminalSurfaceStubs.swift b/Sources/TerminalUnavailable/TerminalSurfaceStubs.swift index db3c822e..a64aa13c 100644 --- a/Sources/TerminalUnavailable/TerminalSurfaceStubs.swift +++ b/Sources/TerminalUnavailable/TerminalSurfaceStubs.swift @@ -91,7 +91,8 @@ public final class TerminalSurfaceView: ObservableObject { public var onPrimaryInteraction: (() -> Void)? public var onCloseRequest: (() -> Void)? public var shouldConfirmClose: (() -> Bool)? - @Published public var paneSplitErrorMessage: String? + @Published public var terminalOperationErrorMessage: String? + @Published public var terminalFindController = TerminalFindController.unavailable public var paneSplitShortcutHandler: ((TerminalPaneSplitShortcut) -> Void)? public var applicationShortcutsProvider: (() -> ResolvedApplicationShortcuts)? @@ -163,8 +164,16 @@ public final class TerminalSurfaceView: ObservableObject { _ = size } public func setParkedForPreview(_ parked: Bool) { + if parked { + terminalFindController.close() + } _ = parked } + + public func installLibghosttyFindController() { + terminalFindController = .unavailable + } + public func useExternalFindBackend() {} public func setPreviewRenderingSuspended(_ suspended: Bool) { _ = suspended } @@ -279,17 +288,24 @@ public final class TerminalSurfaceCoordinator: ObservableObject { } public func removeSurface(for key: SurfaceKey) { + surfaces.first { $0.key == key }?.view.terminalFindController.close() surfaces.removeAll { $0.key == key } } public func removeWorktreeSurfaces(worktreeID: UUID) { + surfaces.filter { $0.key.worktreeID == worktreeID } + .forEach { $0.view.terminalFindController.close() } surfaces.removeAll { $0.key.worktreeID == worktreeID } } public func removeConsoleSurfaces(hostID: UUID) { + surfaces.filter { + $0.key.hostID == hostID && $0.key.target == .console + }.forEach { $0.view.terminalFindController.close() } surfaces.removeAll { $0.key.hostID == hostID && $0.key.target == .console } } public func removeAll() { + surfaces.forEach { $0.view.terminalFindController.close() } surfaces.removeAll() } diff --git a/Sources/UI/CommandPaletteModel.swift b/Sources/UI/CommandPaletteModel.swift index 63a14952..d6c57969 100644 --- a/Sources/UI/CommandPaletteModel.swift +++ b/Sources/UI/CommandPaletteModel.swift @@ -157,6 +157,15 @@ public enum CommandPaletteModel { shortcut: shortcuts[.openApplicationLog], action: .showLogViewer ), + WorkspaceCommandItem( + id: "find-in-terminal", + title: "Find in Terminal", + subtitle: "Search the active terminal pane's history.", + keywords: ["find", "search", "terminal", "history"], + shortcutAction: .find, + shortcut: shortcuts[.find], + action: .applicationShortcut(.find) + ), WorkspaceCommandItem( id: "previous-sibling", title: "Previous Sibling", diff --git a/Tests/App/ApplicationShortcutMenuTests.swift b/Tests/App/ApplicationShortcutMenuTests.swift index 2f40dd7e..45ffcc2c 100644 --- a/Tests/App/ApplicationShortcutMenuTests.swift +++ b/Tests/App/ApplicationShortcutMenuTests.swift @@ -130,6 +130,28 @@ struct ApplicationShortcutMenuTests { ) == nil) } + @Test("the Application Log sheet keeps only the Find bindings") + func logViewerSheetKeepsFindBindings() { + #expect(!ApplicationShortcutMenuModel.sheetSuppressesBinding( + for: .find, + settingsPresented: false, + commandPalettePresented: false, + logViewerPresented: true + )) + #expect(ApplicationShortcutMenuModel.sheetSuppressesBinding( + for: .newWorktree, + settingsPresented: false, + commandPalettePresented: false, + logViewerPresented: true + )) + #expect(ApplicationShortcutMenuModel.sheetSuppressesBinding( + for: .find, + settingsPresented: true, + commandPalettePresented: false, + logViewerPresented: false + )) + } + @Test("menu-owned bindings remain registered across live eligibility changes") func menuOwnedBindingsRemainRegistered() throws { let binding = try ApplicationKeyBinding(parsing: "cmd+b") diff --git a/Tests/App/NativeHerdrSessionCoordinatorTests.swift b/Tests/App/NativeHerdrSessionCoordinatorTests.swift index 20412d61..4eaa1547 100644 --- a/Tests/App/NativeHerdrSessionCoordinatorTests.swift +++ b/Tests/App/NativeHerdrSessionCoordinatorTests.swift @@ -653,7 +653,7 @@ struct NativeHerdrSessionCoordinatorTests { await waitUntilMainActor { firstFinished.withLock { $0 } } #expect(starts.withLock { $0 } == 1) - #expect(store.surface.paneSplitErrorMessage == nil) + #expect(store.surface.terminalOperationErrorMessage == nil) } @Test("a fail-closed pane split invalidates the pooled connection") @@ -690,7 +690,7 @@ struct NativeHerdrSessionCoordinatorTests { handler(.right) await waitUntilMainActor { invalidations.withLock { $0 } > 0 } - #expect(store.surface.paneSplitErrorMessage != nil) + #expect(store.surface.terminalOperationErrorMessage != nil) } private var remoteHost: CommandHost { diff --git a/Tests/App/NativeTmuxSessionCoordinatorTests.swift b/Tests/App/NativeTmuxSessionCoordinatorTests.swift index e7786a13..106ed0bb 100644 --- a/Tests/App/NativeTmuxSessionCoordinatorTests.swift +++ b/Tests/App/NativeTmuxSessionCoordinatorTests.swift @@ -117,7 +117,7 @@ struct NativeTmuxSessionCoordinatorTests { "Control socket connect(/tmp/dead): Connection refused\n" ) }, - paneSplitErrorDuration: .seconds(5) + terminalOperationErrorDuration: .seconds(5) ) var readyCount = 0 coordinator.onSurfaceReady = { _ in readyCount += 1 } @@ -135,7 +135,7 @@ struct NativeTmuxSessionCoordinatorTests { handler(.down) await waitUntilMainActor { invalidations.load() > 0 } - #expect(store.surface.paneSplitErrorMessage != nil) + #expect(store.surface.terminalOperationErrorMessage != nil) } @Test("attachment reuses the version from binary resolution") @@ -250,6 +250,7 @@ struct NativeTmuxSessionCoordinatorTests { await waitUntilMainActor { readyCount == 1 } _ = coordinator.surface(handle: handle) await waitUntilMainActor { identityCommands.load().count == 1 } + #expect(!store.surface.terminalFindController.isAvailable) let bindingCommand = try #require(identityCommands.load().first) #expect(bindingCommand.contains("'list-clients' '-F'")) @@ -257,12 +258,13 @@ struct NativeTmuxSessionCoordinatorTests { let handler = try #require(store.surface.paneSplitShortcutHandler) handler(.right) #expect(splitCommands.load() == 0) - #expect(store.surface.paneSplitErrorMessage?.contains( + #expect(store.surface.terminalOperationErrorMessage?.contains( "client identity is unavailable" ) == true) releaseBinding.signal() await waitUntilMainActor { readyCount == 2 } + #expect(store.surface.terminalFindController.isAvailable) #expect( coordinator.attachedSessionIdentity(handle) == coordinatorSplitIdentity @@ -271,6 +273,237 @@ struct NativeTmuxSessionCoordinatorTests { await waitUntilMainActor { splitCommands.load() == 1 } } + @Test("Find refreshes the active pane before every operation") + func findRefreshesActivePane() async throws { + let identityLookups = LockedValue(0) + let findCommands = LockedValue<[String]>([]) + let store = RecordingNativeSessionSurfaceStore() + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + paneSplitter: supportedPaneSplitter { _, _, command in + guard command.contains( + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + ) else { return (0, "") } + identityLookups.withLock { $0 += 1 } + let lookup = identityLookups.load() + return ( + 0, + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY\t123\t789\t321" + + "\t/dev/ttys001\t$7\t456\t%\(lookup + 8)\n" + ) + }, + paneFinder: TmuxPaneFinder(runner: { _, _, command in + findCommands.withLock { $0.append(command) } + guard let start = command.range( + of: "GHOSTHUB_TMUX_FIND_STATE_" + )?.lowerBound else { return (0, "") } + let markerEnd = command.index(start, offsetBy: 61) + let marker = String(command[start ..< markerEnd]) + return (0, "\(marker)\t1\n") + }) + ) + var readyCount = 0 + coordinator.onSurfaceReady = { _ in readyCount += 1 } + let handle = coordinator.attach( + hostID: UUID(), + name: "changing-pane", + host: .local, + sessionIdentity: coordinatorSplitIdentity + ) + + await waitUntilMainActor { readyCount == 1 } + _ = coordinator.surface(handle: handle) + await waitUntilMainActor { + identityLookups.load() == 1 + && store.surface.terminalFindController.isAvailable + } + let controller = store.surface.terminalFindController + + controller.open() + controller.updateQuery("needle") + await waitUntilMainActor(timeout: .seconds(5)) { + findCommands.load().count == 1 + } + controller.findNext() + await waitUntilMainActor(timeout: .seconds(5)) { + findCommands.load().count == 2 + } + controller.close() + await waitUntilMainActor(timeout: .seconds(5)) { + findCommands.load().count == 3 + } + + #expect(identityLookups.load() == 4) + let commands = findCommands.load() + try #require(commands.count == 3) + #expect(commands[0].contains("%10")) + #expect(commands[1].contains("%11")) + #expect(commands[2].contains("%12")) + } + + @Test( + "Find invalidates remote failures while refreshing or searching", + arguments: [false, true] + ) + func findInvalidatesFailedRemoteConnection( + identityRefreshFails: Bool + ) async { + let invalidations = LockedValue(0) + let identityCalls = LockedValue(0) + let store = RecordingNativeSessionSurfaceStore() + let host = CommandHost.ssh(SSHHostInfo( + user: "dev", + hostname: "build.example.test", + port: nil + )) + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/local/bin/tmux") + }, + remoteConnectionProvider: { _, _ in + testKwtSSHAttachment(invalidate: { + invalidations.withLock { $0 += 1 } + }) + }, + paneSplitter: supportedPaneSplitter { _, _, command in + guard command.contains( + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + ) else { return (0, "") } + identityCalls.withLock { $0 += 1 } + if identityRefreshFails, identityCalls.load() > 1 { + return ( + 255, + "Control socket connect(/tmp/dead): Connection refused\n" + ) + } + return (0, coordinatorSplitClientOutput) + }, + paneFinder: TmuxPaneFinder(runner: { _, _, _ in + if identityRefreshFails { + return (1, "Find should not run after identity failure") + } + return ( + 255, + "Control socket connect(/tmp/dead): Connection refused\n" + ) + }), + terminalOperationErrorDuration: .seconds(10) + ) + var readyCount = 0 + coordinator.onSurfaceReady = { _ in readyCount += 1 } + let handle = coordinator.attach( + hostID: UUID(), + name: "failed-find", + host: host, + sessionIdentity: coordinatorSplitIdentity + ) + + await waitUntilMainActor { readyCount == 1 } + _ = coordinator.surface(handle: handle) + await waitUntilMainActor { + store.surface.terminalFindController.isAvailable + } + #expect( + coordinator.attachmentRouteIdentity(handle) + == "sha256:test-route" + ) + let controller = store.surface.terminalFindController + controller.open() + controller.updateQuery("needle") + + await waitUntilMainActor(timeout: .seconds(5)) { + invalidations.load() >= 1 + } + } + + @Test("a stale Find failure does not invalidate a replacement attachment") + func staleFindFailureKeepsReplacementConnection() async throws { + let connectionCount = LockedValue(0) + let invalidations = LockedValue<[Int]>([]) + let findGate = BlockingGate() + defer { findGate.open() } + let store = RecordingNativeSessionSurfaceStore() + let hostID = UUID() + let host = CommandHost.ssh(SSHHostInfo( + user: "dev", + hostname: "build.example.test", + port: nil + )) + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + remoteTmuxPathProvider: { _, _ in + successfulTmuxResolution("/usr/local/bin/tmux") + }, + remoteConnectionProvider: { _, _ in + var index = 0 + connectionCount.withLock { + $0 += 1 + index = $0 + } + let connectionIndex = index + return testKwtSSHAttachment( + routeIdentity: "sha256:route-\(connectionIndex)", + generation: UInt64(connectionIndex), + invalidate: { + invalidations.withLock { $0.append(connectionIndex) } + } + ) + }, + paneSplitter: supportedPaneSplitter { _, _, command in + guard command.contains( + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + ) else { return (0, "") } + return (0, coordinatorSplitClientOutput) + }, + paneFinder: TmuxPaneFinder(runner: { _, _, _ in + findGate.block() + return ( + 255, + "Control socket connect(/tmp/dead): Connection refused\n" + ) + }) + ) + let handle = coordinator.attach( + hostID: hostID, + name: "replaced-find", + host: host, + sessionIdentity: coordinatorSplitIdentity + ) + + await waitUntilMainActor { + coordinator.attachmentRouteIdentity(handle) == "sha256:route-1" + } + _ = coordinator.surface(handle: handle) + await waitUntilMainActor { + store.surface.terminalFindController.isAvailable + } + let controller = store.surface.terminalFindController + controller.open() + controller.updateQuery("needle") + await findGate.waitUntilBlocked() + + let close = try #require(store.surface.closeObservers[handle.id]) + close(true, nil) + let replacement = coordinator.attach( + hostID: hostID, + name: handle.name, + host: host, + sessionIdentity: coordinatorSplitIdentity + ) + #expect(replacement == handle) + await waitUntilMainActor { + coordinator.attachmentRouteIdentity(handle) == "sha256:route-2" + } + findGate.open() + await waitUntilMainActor { controller.failureMessage != nil } + + #expect(invalidations.load().isEmpty) + } + @Test("tmux older than 3.4 does not install pane split shortcuts") func oldTmuxDoesNotInstallSplitHandler() async { let store = RecordingNativeSessionSurfaceStore() @@ -310,6 +543,7 @@ struct NativeTmuxSessionCoordinatorTests { coordinator.requestAttachedSessionIdentity(handle) #expect(store.surface.paneSplitShortcutHandler == nil) + #expect(!store.surface.terminalFindController.isAvailable) #expect(!coordinator.supportsPaneSplitting(handle)) #expect( coordinator.attachedSessionIdentityResolution(handle) @@ -317,6 +551,48 @@ struct NativeTmuxSessionCoordinatorTests { ) } + @Test("initial client binding retries while the attachment is live") + func initialClientBindingRetries() async { + let clientLookups = LockedValue(0) + let store = RecordingNativeSessionSurfaceStore() + let coordinator = NativeTmuxSessionCoordinator( + terminalCoordinator: store, + tmuxPathProvider: { successfulTmuxResolution("/usr/bin/tmux") }, + paneSplitter: supportedPaneSplitter { _, _, command in + guard command.contains( + "GHOSTHUB_TMUX_SPLIT_CLIENT_IDENTITY" + ) else { return (0, "") } + var attempt = 0 + clientLookups.withLock { + $0 += 1 + attempt = $0 + } + return attempt == 1 + ? (1, "client token is not ready") + : (0, coordinatorSplitClientOutput) + }, + clientIdentityRetryDelays: [.zero] + ) + var readyCount = 0 + coordinator.onSurfaceReady = { _ in readyCount += 1 } + let handle = coordinator.attach( + hostID: UUID(), + name: "appearing", + host: .local, + sessionIdentity: coordinatorSplitIdentity + ) + + await waitUntilMainActor { readyCount == 1 } + _ = coordinator.surface(handle: handle) + await waitUntilMainActor { + clientLookups.load() == 2 + && store.surface.terminalFindController.isAvailable + } + + #expect(clientLookups.load() == 2) + #expect(store.surface.terminalFindController.isAvailable) + } + @Test("new named sessions use tmux create-or-attach mode") func namedSessionUsesCreateMode() async throws { let store = RecordingNativeSessionSurfaceStore() @@ -720,7 +996,7 @@ struct NativeTmuxSessionCoordinatorTests { calls.withLock { $0.append((host, arguments, command)) } return (1, "no space for new pane\n") }, - paneSplitErrorDuration: .milliseconds(100) + terminalOperationErrorDuration: .milliseconds(100) ) var readyCount = 0 coordinator.onSurfaceReady = { _ in readyCount += 1 } @@ -744,7 +1020,7 @@ struct NativeTmuxSessionCoordinatorTests { handler(.down) await waitUntilMainActor { calls.load().count == 1 } await waitUntilMainActor { - store.surface.paneSplitErrorMessage != nil + store.surface.terminalOperationErrorMessage != nil } let call = try #require(calls.load().first) @@ -761,14 +1037,14 @@ struct NativeTmuxSessionCoordinatorTests { #expect(call.2.contains("split-window")) #expect(call.2.contains("-v")) #expect(!call.2.contains("=release-work:")) - #expect(store.surface.paneSplitErrorMessage?.contains( + #expect(store.surface.terminalOperationErrorMessage?.contains( "release-work" ) == true) - #expect(store.surface.paneSplitErrorMessage?.contains( + #expect(store.surface.terminalOperationErrorMessage?.contains( "no space for new pane" ) == true) await waitUntilMainActor { - store.surface.paneSplitErrorMessage == nil + store.surface.terminalOperationErrorMessage == nil } } @@ -1041,7 +1317,7 @@ struct NativeTmuxSessionCoordinatorTests { $0 += 1 attempt = $0 } - if attempt == 1 { + if attempt <= 2 { return (1, "no clients yet") } return (0, coordinatorSplitClientOutput) @@ -1062,7 +1338,8 @@ struct NativeTmuxSessionCoordinatorTests { await waitUntilMainActor { readyCount == 1 } _ = coordinator.surface(handle: handle) - await waitUntilMainActor { clientLookups.load() == 1 } + await waitUntilMainActor { clientLookups.load() == 2 } + try await Task.sleep(for: .milliseconds(20)) coordinator.requestAttachedSessionIdentity(handle) await waitUntilMainActor { coordinator.attachedSessionIdentity(handle) @@ -1071,10 +1348,10 @@ struct NativeTmuxSessionCoordinatorTests { #expect(coordinator.supportsPaneSplitting(handle)) let handler = try #require(store.surface.paneSplitShortcutHandler) handler(.right) - #expect(store.surface.paneSplitErrorMessage == nil) + #expect(store.surface.terminalOperationErrorMessage == nil) await waitUntilMainActor { splitCommands.load() == 1 } - #expect(clientLookups.load() == 3) - #expect(store.surface.paneSplitErrorMessage == nil) + #expect(clientLookups.load() == 4) + #expect(store.surface.terminalOperationErrorMessage == nil) } @Test("capture revalidation observes a client session switch") @@ -1176,13 +1453,13 @@ struct NativeTmuxSessionCoordinatorTests { await waitUntilMainActor { splitCommands.load() == 1 } handler(.down) await waitUntilMainActor { - store.surface.paneSplitErrorMessage != nil + store.surface.terminalOperationErrorMessage != nil || splitCommands.load() == 2 } #expect(clientLookups.load() == 3) #expect(splitCommands.load() == 1) - #expect(store.surface.paneSplitErrorMessage?.contains( + #expect(store.surface.terminalOperationErrorMessage?.contains( "attached tmux session changed" ) == true) } @@ -1232,14 +1509,14 @@ struct NativeTmuxSessionCoordinatorTests { await waitUntilMainActor { splitCommands.load().count == 1 } handler(.down) await waitUntilMainActor { - store.surface.paneSplitErrorMessage != nil + store.surface.terminalOperationErrorMessage != nil || splitCommands.load().count == 2 } #expect(clientLookups.load() == 3) #expect(splitCommands.load().count == 2) #expect(splitCommands.load().last?.contains("'%10'") == true) - #expect(store.surface.paneSplitErrorMessage == nil) + #expect(store.surface.terminalOperationErrorMessage == nil) } @Test("an atomic pane movement rereads the client and retries once") @@ -1296,7 +1573,7 @@ struct NativeTmuxSessionCoordinatorTests { #expect(clientLookups.load() == 3) #expect(splitCommands.load().last?.contains("'%10'") == true) - #expect(store.surface.paneSplitErrorMessage == nil) + #expect(store.surface.terminalOperationErrorMessage == nil) } @Test("endpoint changes replace provisioning and active handles") diff --git a/Tests/App/SceneModelTestSupport.swift b/Tests/App/SceneModelTestSupport.swift index e367338b..4f3526c0 100644 --- a/Tests/App/SceneModelTestSupport.swift +++ b/Tests/App/SceneModelTestSupport.swift @@ -712,7 +712,8 @@ final class RecordingNativeSessionSurfaceStore: NativeSessionSurfaceStoring { @MainActor final class RecordingNativeSessionPaneSurface: NativeSessionPaneSurfacing { var blocksClipboardReads = false - var paneSplitErrorMessage: String? + var terminalOperationErrorMessage: String? + var terminalFindController = TerminalFindController.unavailable var hasEffectiveKeyboardFocus = false var paneSplitShortcutHandler: ((TerminalPaneSplitShortcut) -> Void)? /// Mutable so a test can fail one attach and let the next succeed, which is diff --git a/Tests/App/TerminalFindBarTests.swift b/Tests/App/TerminalFindBarTests.swift new file mode 100644 index 00000000..3a3cd879 --- /dev/null +++ b/Tests/App/TerminalFindBarTests.swift @@ -0,0 +1,43 @@ +import AppKit +import GhosthubTerminalSupport +import Testing +@testable import GhosthubApp + +@Suite("Terminal Find bar") +@MainActor +struct TerminalFindBarTests { + @Test("result status stays compact") + func statusText() { + #expect(TerminalFindBar.statusText(for: .idle) == nil) + #expect(TerminalFindBar.statusText(for: .noMatch) == "No matches") + #expect(TerminalFindBar.statusText( + for: .match(total: 5, selected: nil) + ) == "5 matches") + #expect(TerminalFindBar.statusText( + for: .match(total: 5, selected: 2) + ) == "2 of 5") + #expect(TerminalFindBar.statusText( + for: .match(total: nil, selected: nil) + ) == nil) + } + + @Test("field commands match Ghostty-style navigation") + func fieldCommands() { + #expect(TerminalFindBar.fieldCommand( + selector: #selector(NSResponder.insertNewline(_:)), + shift: false + ) == .next) + #expect(TerminalFindBar.fieldCommand( + selector: #selector(NSResponder.insertNewline(_:)), + shift: true + ) == .previous) + #expect(TerminalFindBar.fieldCommand( + selector: #selector(NSResponder.cancelOperation(_:)), + shift: false + ) == .close) + #expect(TerminalFindBar.fieldCommand( + selector: #selector(NSResponder.moveLeft(_:)), + shift: false + ) == nil) + } +} diff --git a/Tests/App/TmuxBinaryResolverTests.swift b/Tests/App/TmuxBinaryResolverTests.swift index 00177e2e..e267e7c5 100644 --- a/Tests/App/TmuxBinaryResolverTests.swift +++ b/Tests/App/TmuxBinaryResolverTests.swift @@ -8,6 +8,20 @@ import Testing @Suite("TmuxBinaryResolver") struct TmuxBinaryResolverTests { + @Test(arguments: [ + ("tmux 3.4", 3, 4), + ("tmux 3.5a", 3, 5), + ("tmux 3.5b", 3, 5), + ("tmux 3.6", 3, 6), + ("tmux 4.0", 4, 0), + ]) + func parsesTmuxVersion(_ output: String, _ major: Int, _ minor: Int) { + #expect( + TmuxVersion(output: output) + == TmuxVersion(major: major, minor: minor) + ) + } + @Test("parses the resolved path from shell output") func parsesPath() throws { let resolver = TmuxBinaryResolver(processRunner: { _, command in diff --git a/Tests/App/TmuxPaneFinderTests.swift b/Tests/App/TmuxPaneFinderTests.swift new file mode 100644 index 00000000..f549d38f --- /dev/null +++ b/Tests/App/TmuxPaneFinderTests.swift @@ -0,0 +1,311 @@ +import Foundation +import GhosthubTestSupport +import GhosthubTransport +import Synchronization +import Testing +@testable import GhosthubApp + +private let findClient = TmuxAttachedClientIdentity( + serverPID: "123", + clientPID: "789", + clientCreatedAt: "321", + clientTTY: "/dev/ttys001", + sessionID: "$7", + sessionCreatedAt: "456", + paneID: "%9" +) + +@Suite("tmux pane Find") +struct TmuxPaneFinderTests { + @Test("marked state distinguishes matches from no matches") + func parsesState() { + #expect(TmuxPaneFinder.parseState( + "noise\nGHOSTHUB_TMUX_FIND_STATE_token\t1\t5\t0\nwarning", + marker: "GHOSTHUB_TMUX_FIND_STATE_token", + includesCount: true + ) == .match(total: 5)) + #expect(TmuxPaneFinder.parseState( + "GHOSTHUB_TMUX_FIND_STATE_token\t0\t\t", + marker: "GHOSTHUB_TMUX_FIND_STATE_token", + includesCount: true + ) == .noMatch) + #expect(TmuxPaneFinder.parseState( + "GHOSTHUB_TMUX_FIND_STATE_token\t1\t5\t1", + marker: "GHOSTHUB_TMUX_FIND_STATE_token", + includesCount: true + ) == .match(total: nil)) + } + + @Test(arguments: [ + "", + "GHOSTHUB_TMUX_FIND_STATE_other\t1\t5\t0", + "GHOSTHUB_TMUX_FIND_STATE_token\tx\t5\t0", + "GHOSTHUB_TMUX_FIND_STATE_token\t1\t5\t0\textra", + ]) + func rejectsMalformedState(_ output: String) { + #expect(TmuxPaneFinder.parseState( + output, + marker: "GHOSTHUB_TMUX_FIND_STATE_token", + includesCount: true + ) == nil) + } + + @Test("copy-mode search syntax follows the tmux version boundary") + func versionedSearchRendering() { + let legacy = TmuxPaneFinder.action( + .search("-foo"), + target: target(version: .init(major: 3, minor: 5)), + stateMarker: "STATE" + ) + let modern = TmuxPaneFinder.action( + .search("-foo"), + target: target(version: .copyModeOptionParsing), + stateMarker: "STATE" + ) + + #expect(legacy.contains("'search-backward-text' '-foo'")) + #expect(!legacy.contains("'search-backward-text' '--' '-foo'")) + #expect(modern.contains("'search-backward-text' '--' '-foo'")) + } + + @Test("literal query survives the guarded shell command") + func literalQueryCommandIsShellSyntax() { + let query = "match;\"x\" #{pane_id} \\ q't $HOME ${x} -lead" + let command = TmuxPaneFinder.command( + .search(query), + target: target(version: .copyModeOptionParsing), + guardMarker: "GUARD", + stateMarker: "STATE", + hookIndex: 1_500_000_001 + ) + let syntax = AccountCommandRunner.runProcess( + executable: "/bin/sh", + arguments: ["-n", "-c", command], + timeout: 5 + ) + + #expect(syntax.status == 0, Comment(rawValue: syntax.stderr)) + #expect(command.contains("#{search_present}")) + #expect(command.contains("#{search_count}")) + #expect(command.contains("#{search_count_partial}")) + #expect(!command.contains("#{search_match}")) + } + + @Test("failures never disclose the query") + func fixedFailureMessage() async throws { + let query = "private-query-$HOME" + let command = Mutex(nil) + let finder = TmuxPaneFinder(runner: { _, _, rendered in + command.withLock { $0 = rendered } + return (1, query) + }) + + let result = await finder.perform( + .search(query), + target: target(version: .copyModeOptionParsing) + ) + let failure = try #require(result.failure) + + #expect(command.withLock { $0 }?.contains(query) == true) + #expect(failure.message == "tmux could not search this pane.") + #expect(!failure.message.contains(query)) + } + + @Test("real tmux searches pane history and cancels copy mode") + func realTmuxSearch() async throws { + guard case let .success(binary) = TmuxBinaryResolver() + .resolveTmuxBinary(), + let version = TmuxVersion(output: binary.version), + version >= .minimumFind + else { return } + let server = try makeTestTmuxServer( + tmuxPath: binary.path, + purpose: "find", + sessions: ["find"] + ) + defer { server.stop() } + let stateDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: stateDirectory) } + let ttyDirectory = stateDirectory.appendingPathComponent( + "tmux-clients", + isDirectory: true + ) + let token = UUID().uuidString.lowercased() + let client = try TestTmuxClient( + tmuxPath: binary.path, + socketName: server.socketName, + sessionName: "find", + clientToken: token, + clientTTYDirectory: ttyDirectory + ) + defer { client.stop() } + _ = try await client.publishedTTY() + + let output = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "send-keys", "-t", "find:", + "printf '\\156\\145\\145\\144\\154\\145 one\\n" + + "\\156\\145\\145\\144\\154\\145 two\\n" + + "\\156\\145\\145\\144\\154\\145 three\\n'", + "Enter", + ], + timeout: 5 + ) + #expect(output.status == 0) + for _ in 0 ..< 100 { + let capture = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "capture-pane", "-p", "-t", "find:", + ], + timeout: 5 + ) + if capture.stdout.contains("needle three") { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + + let identity = try await TmuxPaneSplitter().clientIdentity( + target: TmuxPaneSplitTarget( + host: .local, + tmuxPath: binary.path, + sessionName: "find", + socketName: server.socketName, + sshConnectionArguments: [], + clientToken: token, + clientTTYDirectory: ttyDirectory.path + ) + ).get() + let target = TmuxFindTarget( + host: .local, + tmuxPath: binary.path, + tmuxVersion: version, + sessionName: "find", + socketName: server.socketName, + sshConnectionArguments: [], + expectedClient: identity + ) + let finder = TmuxPaneFinder() + + let searched = await finder.perform(.search("needle"), target: target) + if version >= .searchCount { + #expect(try searched.get() == .match(total: 3)) + } else { + #expect(try searched.get() == .match(total: nil)) + } + #expect(try await finder.perform(.next, target: target).get() + != .noMatch) + #expect(try await finder.perform(.previous, target: target).get() + != .noMatch) + #expect(try await finder.perform(.cancel, target: target).get() == nil) + + let literal = "match;\"x\" #{pane_id} \\ q't $HOME ${x} -lead" + let literalOutput = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "send-keys", "-t", "find:", + "printf '%s\\n' \(shellQuotedCommandArgument(literal))", + ], + timeout: 5 + ) + #expect(literalOutput.status == 0) + let literalEnter = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "send-keys", "-t", "find:", "Enter", + ], + timeout: 5 + ) + #expect(literalEnter.status == 0) + for _ in 0 ..< 100 { + let capture = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "capture-pane", "-p", "-t", "find:", + ], + timeout: 5 + ) + if capture.stdout.contains(literal) { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + #expect(try await finder.perform(.search(literal), target: target).get() + != .noMatch) + #expect(try await finder.perform( + .search("absent-find-value"), + target: target + ).get() == .noMatch) + + let activeMode = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "display-message", "-p", "-t", "find:", "#{pane_in_mode}", + ], + timeout: 5 + ) + #expect(activeMode.stdout.trimmingCharacters( + in: .whitespacesAndNewlines + ) == "1") + #expect(try await finder.perform(.cancel, target: target).get() == nil) + + let mode = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "display-message", "-p", "-t", "find:", "#{pane_in_mode}", + ], + timeout: 5 + ) + #expect(mode.stdout.trimmingCharacters( + in: .whitespacesAndNewlines + ) == "0") + + _ = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "new-session", "-d", "-s", "keepalive", + ], + timeout: 5 + ) + _ = AccountCommandRunner.runProcess( + executable: binary.path, + arguments: [ + "-L", server.socketName, + "kill-session", "-t", "find", + ], + timeout: 5 + ) + let changed = await finder.perform(.search("needle"), target: target) + #expect(try #require(changed.failure).kind == .targetChanged) + } + + private func target(version: TmuxVersion) -> TmuxFindTarget { + TmuxFindTarget( + host: .local, + tmuxPath: "/opt/homebrew/bin/tmux", + tmuxVersion: version, + sessionName: "find-session", + socketName: "find-socket", + sshConnectionArguments: [], + expectedClient: findClient + ) + } +} + +private extension Result { + var failure: Failure? { + guard case let .failure(failure) = self else { return nil } + return failure + } +} diff --git a/Tests/App/TmuxPaneSplitterTests.swift b/Tests/App/TmuxPaneSplitterTests.swift index eab5eb32..3b946e50 100644 --- a/Tests/App/TmuxPaneSplitterTests.swift +++ b/Tests/App/TmuxPaneSplitterTests.swift @@ -6,7 +6,7 @@ import GhosthubTransport import Testing @testable import GhosthubApp -private let testSplitClient = TmuxPaneSplitClientIdentity( +private let testSplitClient = TmuxAttachedClientIdentity( serverPID: "123", clientPID: "789", clientCreatedAt: "321", @@ -123,7 +123,7 @@ final class TestTmuxClient { } } -private func makeTestTmuxServer( +func makeTestTmuxServer( tmuxPath: String, purpose: String, sessions: [String], @@ -1062,6 +1062,16 @@ struct TmuxPaneSplitterTests { @Test("validation and split share one exact-client tmux queue") func posixCommands() { + let guarded = TmuxAttachedClientGuard.command( + tmuxPath: "/opt/homebrew/bin/tmux", + socketName: "kwt-pr-0123456789abcdef", + expectedClient: testSplitClient, + marker: "TEST_MISMATCH", + hookIndex: 1_500_000_000, + action: ["display-message", "-p", "split-ready"] + .map(shellQuotedCommandArgument) + .joined(separator: " ") + ) let right = TmuxPaneSplitter.command( tmuxPath: "/opt/homebrew/bin/tmux", socketName: "kwt-pr-0123456789abcdef", @@ -1089,17 +1099,19 @@ struct TmuxPaneSplitterTests { timeout: 5 ) + #expect(!guarded.contains("'list-clients' '-F'")) + #expect(guarded.contains("'refresh-client' '-t' '/dev/ttys001'")) + #expect(guarded.contains("'after-refresh-client[")) + #expect(guarded.contains("#{L:")) + #expect(guarded.contains("#{==:#{client_pid},789}")) + #expect(guarded.contains("#{==:#{client_created},321}")) + #expect(guarded.contains("#{==:#{pane_id},%9}")) + #expect(guarded.contains(shellQuotedCommandArgument("TEST_MISMATCH"))) + #expect(guarded.contains("set-hook")) + #expect(guarded.contains("-gu")) #expect(rightSyntax.status == 0, Comment(rawValue: rightSyntax.stderr)) - #expect(!right.contains("'list-clients' '-F'")) - #expect(right.contains("'refresh-client' '-t' '/dev/ttys001'")) - #expect(right.contains("'after-refresh-client[")) - #expect(right.contains("#{L:")) - #expect(right.contains("#{==:#{client_pid},789}")) - #expect(right.contains("#{==:#{client_created},321}")) - #expect(right.contains("#{==:#{pane_id},%9}")) #expect(right.contains("split-window")) #expect(right.contains("-h")) - #expect(right.contains(shellQuotedCommandArgument("TEST_MISMATCH"))) #expect(!right.contains("=review")) #expect(downSyntax.status == 0, Comment(rawValue: downSyntax.stderr)) #expect(!down.contains("'list-clients' '-F'")) diff --git a/Tests/App/WorkspaceApplicationShortcutTests.swift b/Tests/App/WorkspaceApplicationShortcutTests.swift index ad91ad12..c4fb376b 100644 --- a/Tests/App/WorkspaceApplicationShortcutTests.swift +++ b/Tests/App/WorkspaceApplicationShortcutTests.swift @@ -3,7 +3,11 @@ import Foundation import GhosthubPersistence import GhosthubTerminalSupport import GhosthubTestSupport +import GhosthubTmux +import GhosthubUI import GhosthubWorkspace +import Synchronization +import SwiftUI import Testing @testable import GhosthubApp @@ -16,6 +20,215 @@ private final class ShortcutFocusWindow: NSWindow { @Suite("Workspace application shortcuts", .serialized) @MainActor struct WorkspaceApplicationShortcutTests { + @Test("a presented log viewer takes Find priority over a borrowed session") + func presentedLogViewerTakesFindPriority() async throws { + let environment = try setupHostEnvironment() + let store = SceneTmuxSurfaceStoreStub() + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: store, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: WorkspaceTmuxTestSupport + .previewPaneSplitter(identity: identity) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "find-session" + ) + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: store) + await waitUntilMainActor { + store.surface.terminalFindController.isAvailable + } + let borrowedController = TerminalFindController( + isAvailable: true, + sessionProvider: { nil } + ) + store.surface.terminalFindController = borrowedController + + model.isFocusedWindow = true + model.isLogViewerPresented = true + _ = try #require(model.logViewerTerminalView()) + let logSurface = try #require( + model.terminalCoordinator.surfaceEntries().first { + $0.key.target == .logViewer + }?.view + ) + let logController = TerminalFindController( + isAvailable: true, + sessionProvider: { nil } + ) + logSurface.terminalFindController = logController + let logView = try #require(model.logViewerTerminalView()) + let hostingView = NSHostingView(rootView: logView) + hostingView.frame = NSRect(x: 0, y: 0, width: 800, height: 600) + let window = NSWindow( + contentRect: hostingView.frame, + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + var containsSearchField: ((NSView) -> Bool)! + containsSearchField = { view in + view is NSSearchField + || view.subviews.contains { containsSearchField($0) } + } + + #expect(model.performApplicationShortcut(.find)) + #expect(logController.isOpen) + #expect(!borrowedController.isOpen) + await waitUntilMainActor { + containsSearchField(hostingView) + } + #expect(containsSearchField(hostingView)) + + await model.shutdown() + } + + @Test("standalone Find routing survives responder changes") + func standaloneFindRoutingSurvivesResponderChanges() async throws { + let environment = try setupHostEnvironment() + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot + ) + model.isFocusedWindow = true + model.isLogViewerPresented = true + _ = try #require(model.logViewerTerminalView()) + let surface = try #require( + model.terminalCoordinator.surfaceEntries().first { + $0.key.target == .logViewer + }?.view + ) + let navigations = Mutex<[TerminalFindDirection]>([]) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { + TerminalFindSession( + search: { _, _ in + .success(.result(.match(total: 2, selected: nil))) + }, + navigate: { direction, _ in + navigations.withLock { $0.append(direction) } + return .success(.result(.match( + total: 2, + selected: nil + ))) + }, + close: { nil } + ) + } + ) + surface.terminalFindController = controller + + try #require(model.performApplicationShortcut(.find)) + controller.updateQuery("needle") + await waitUntilMainActor { controller.canNavigate } + model.isLogViewerPresented = false + + #expect(model.performApplicationShortcut(.findNext)) + await waitUntilMainActor { navigations.withLock { $0.count } == 1 } + #expect(navigations.withLock { $0 } == [.next]) + #expect(model.performApplicationShortcut(.hideFindBar)) + #expect(!controller.isOpen) + + await model.shutdown() + } + + @Test("Find shortcuts route to the active terminal and follow its state") + func findShortcutRouting() async throws { + let environment = try setupHostEnvironment() + let store = SceneTmuxSurfaceStoreStub() + let identity = TmuxSessionIdentity( + serverPID: "101", + sessionID: "$1", + createdAt: "1000" + ) + let model = try makeModel( + database: environment.database, + localHostID: environment.host.id, + snapshot: environment.snapshot, + nativeTmuxSurfaceStore: store, + nativeTmuxPathProvider: { + successfulTmuxResolution("/usr/bin/tmux") + }, + nativeTmuxPaneSplitter: WorkspaceTmuxTestSupport + .previewPaneSplitter(identity: identity) + ) + let selection = WorkspaceTmuxSessionSelection( + hostID: environment.host.id, + name: "find-session" + ) + model.openBorrowedTmuxSession(selection) + await launchActiveTmuxSurface(model, store: store) + await waitUntilMainActor { + store.surface.terminalFindController.isAvailable + } + + let navigations = Mutex<[TerminalFindDirection]>([]) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { + TerminalFindSession( + search: { _, _ in + .success(.result(.match(total: 3, selected: nil))) + }, + navigate: { direction, _ in + navigations.withLock { $0.append(direction) } + return .success(.result(.match( + total: 3, + selected: nil + ))) + }, + close: { nil } + ) + } + ) + store.surface.terminalFindController = controller + model.isFocusedWindow = true + + #expect(model.availablePaletteApplicationShortcuts.contains(.find)) + #expect(model.performApplicationShortcut(.find)) + #expect(controller.isOpen) + #expect(model.availablePaletteApplicationShortcuts.contains( + .hideFindBar + )) + controller.updateQuery("needle") + await waitUntilMainActor { controller.canNavigate } + #expect(model.availablePaletteApplicationShortcuts.isSuperset( + of: [.findNext, .findPrevious] + )) + #expect(model.performApplicationShortcut(.findNext)) + #expect(model.performApplicationShortcut(.findPrevious)) + await waitUntilMainActor { navigations.withLock { $0.count } == 2 } + #expect(navigations.withLock { $0 } == [.next, .previous]) + #expect(model.performApplicationShortcut(.hideFindBar)) + #expect(!controller.isOpen) + await waitUntilMainActor { + store.surface.keyboardFocusRequestCount == 1 + } + + controller.open() + model.hideBorrowedTmuxSession(selection) + #expect(!controller.isOpen) + await model.shutdown() + } + @Test("sibling availability requires a resolvable peer") func siblingAvailability() async throws { let host = HostSummary.fixture() diff --git a/Tests/App/WorkspaceTmuxPresentationTests.swift b/Tests/App/WorkspaceTmuxPresentationTests.swift index 51a5e210..eb16d47b 100644 --- a/Tests/App/WorkspaceTmuxPresentationTests.swift +++ b/Tests/App/WorkspaceTmuxPresentationTests.swift @@ -611,7 +611,7 @@ extension WorkspaceTmuxDiscoveryTests { } releaseResolution.signal() - await waitUntilMainActor(timeout: .seconds(3)) { + await waitUntilMainActor(timeout: .seconds(5)) { model.retainedBorrowedTmuxHandle(for: selection) == nil } #expect(model.activeBorrowedTmuxSelection == nil) diff --git a/Tests/App/WorkspaceTmuxProjectRemovalTests.swift b/Tests/App/WorkspaceTmuxProjectRemovalTests.swift index 8bec5882..a4f72378 100644 --- a/Tests/App/WorkspaceTmuxProjectRemovalTests.swift +++ b/Tests/App/WorkspaceTmuxProjectRemovalTests.swift @@ -815,6 +815,10 @@ extension WorkspaceTmuxDiscoveryTests { if attached { model.openBorrowedTmuxSession(selection) await launchActiveTmuxSurface(model, store: surfaceStore) + await waitUntilMainActor { + model.retainedBorrowedTmuxSessionIsConnected(selection) + && coordinator.scopes.isEmpty + } #expect(model.retainedBorrowedTmuxSessionIsConnected(selection)) } diff --git a/Tests/App/WorkspaceTmuxTestSupport.swift b/Tests/App/WorkspaceTmuxTestSupport.swift index 8a4acf9d..e9a6a1b0 100644 --- a/Tests/App/WorkspaceTmuxTestSupport.swift +++ b/Tests/App/WorkspaceTmuxTestSupport.swift @@ -1,5 +1,6 @@ import Foundation import GhosthubTerminal +import GhosthubTerminalSupport import GhosthubTmux import GhosthubUI import GhosthubWorkspace @@ -121,6 +122,8 @@ struct SceneModelRootHarness: View { @MainActor final class SceneTmuxPaneSurfaceStub: NativeSessionPaneSurfacing { var blocksClipboardReads = false + var terminalOperationErrorMessage: String? + var terminalFindController = TerminalFindController.unavailable var launchError: Error? var launchFailureIsRetryable = false var childExitCode: UInt32? @@ -128,6 +131,7 @@ final class SceneTmuxPaneSurfaceStub: NativeSessionPaneSurfacing { private(set) var lastObserverID: UUID? private(set) var previewGridSizes: [TmuxGridSize] = [] private(set) var clearPreviewGridCount = 0 + private(set) var keyboardFocusRequestCount = 0 @discardableResult func sizeForPreviewGrid(columns: Int, rows: Int) -> Bool { @@ -139,6 +143,10 @@ final class SceneTmuxPaneSurfaceStub: NativeSessionPaneSurfacing { clearPreviewGridCount += 1 } + func requestKeyboardFocus() { + keyboardFocusRequestCount += 1 + } + func registerSurfaceCloseObserver( id: UUID, onSurfaceClosed: @escaping (Bool, UInt32?) -> Void diff --git a/Tests/Terminal/LibghosttyFindTests.swift b/Tests/Terminal/LibghosttyFindTests.swift new file mode 100644 index 00000000..cdbbd675 --- /dev/null +++ b/Tests/Terminal/LibghosttyFindTests.swift @@ -0,0 +1,168 @@ +import GhosthubTerminalSupport +import Testing +@testable import GhosthubTerminal + +@Suite("libghostty Find callback routing") +struct LibghosttyFindTests { + @Test("an external backend rejects libghostty search actions") + func externalBackendRejectsLibghosttySearch() { + let registry = LibghosttyFindOperationRegistry() + let surfaceIdentity: UInt = 41 + + registry.setBackend(.external, for: surfaceIdentity) + #expect(registry.beginExternalOperation( + for: surfaceIdentity + ) == nil) + + registry.setBackend(.libghostty, for: surfaceIdentity) + #expect(registry.beginExternalOperation( + for: surfaceIdentity + ) != nil) + } + + @Test("a replacement query does not claim delayed callbacks") + func replacementWaitsForUpstreamReset() { + let registry = LibghosttyFindOperationRegistry() + let surfaceIdentity: UInt = 42 + let first = TerminalFindOperationToken() + let replacement = TerminalFindOperationToken() + + registry.prepareSearch( + first, + for: surfaceIdentity + ) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(0) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(-1) + ) == first) + registry.prepareSearch( + replacement, + for: surfaceIdentity + ) + + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(3) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(1) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(0) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(-1) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(2) + ) == replacement) + } + + @Test("a replacement preserves an incomplete reset boundary") + func replacementPreservesIncompleteResetBoundary() { + let registry = LibghosttyFindOperationRegistry() + let surfaceIdentity: UInt = 45 + let first = TerminalFindOperationToken() + let replacement = TerminalFindOperationToken() + + registry.prepareSearch(first, for: surfaceIdentity) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(0) + ) == first) + registry.prepareSearch(replacement, for: surfaceIdentity) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(-1) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(3) + ) == first) + + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(0) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(-1) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(2) + ) == replacement) + } + + @Test("queries queued before the first reset keep distinct operations") + func queuedQueriesUseDistinctResetBoundaries() { + let registry = LibghosttyFindOperationRegistry() + let surfaceIdentity: UInt = 43 + let first = TerminalFindOperationToken() + let replacement = TerminalFindOperationToken() + + registry.prepareSearch(first, for: surfaceIdentity) + registry.prepareSearch( + replacement, + for: surfaceIdentity + ) + + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(0) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(-1) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(4) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(0) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(-1) + ) == first) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(2) + ) == replacement) + } + + @Test("an external search supersedes a queued internal search") + func externalSearchSupersedesQueuedSearch() { + let registry = LibghosttyFindOperationRegistry() + let surfaceIdentity: UInt = 44 + let internalOperation = TerminalFindOperationToken() + + registry.prepareSearch(internalOperation, for: surfaceIdentity) + let externalOperation = registry.beginExternalOperation( + for: surfaceIdentity + ) + + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(0) + ) == internalOperation) + #expect(registry.operation( + for: surfaceIdentity, + callback: .selected(-1) + ) == internalOperation) + #expect(registry.operation( + for: surfaceIdentity, + callback: .total(4) + ) == externalOperation) + } +} diff --git a/Tests/TerminalSmoke/TerminalSurfacePreviewTests.swift b/Tests/TerminalSmoke/TerminalSurfacePreviewTests.swift index 7b6700f6..bcdfd5af 100644 --- a/Tests/TerminalSmoke/TerminalSurfacePreviewTests.swift +++ b/Tests/TerminalSmoke/TerminalSurfacePreviewTests.swift @@ -50,6 +50,132 @@ final class TerminalSurfacePreviewTests: XCTestCase { XCTAssertEqual(errno, ESRCH) } + func testLibghosttyFindSearchesSurfaceOutput() async throws { + let view = try makeSurface() + view.installLibghosttyFindController() + let window = hostInWindow(view) + defer { window.orderOut(nil) } + XCTAssertTrue(view.injectOutput(Data( + "needle one\r\nneedle two\r\nneedle three\r\n".utf8 + ))) + let viewportText = { () -> String in + guard let surface = view.surfaceHandle else { return "" } + var text = ghostty_text_s() + let selection = ghostty_selection_s( + top_left: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ), + bottom_right: ghostty_point_s( + tag: GHOSTTY_POINT_VIEWPORT, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ), + rectangle: false + ) + guard ghostty_surface_read_text(surface, selection, &text) else { + return "" + } + defer { ghostty_surface_free_text(surface, &text) } + return String(cString: text.text) + } + let outputDeadline = Date().addingTimeInterval(5) + while !viewportText().contains("needle three"), + Date() < outputDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + XCTAssertTrue(viewportText().contains("needle three")) + + let controller = view.terminalFindController + controller.open() + controller.updateQuery("needle") + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if case .match(total: 3, selected: _) = controller.result { + break + } + try await Task.sleep(for: .milliseconds(20)) + } + + guard case let .match(total, selected) = controller.result else { + return XCTFail("expected libghostty to publish a Find match") + } + XCTAssertEqual(total, 3) + XCTAssertNil(selected) + controller.findNext() + let navigationDeadline = Date().addingTimeInterval(3) + while Date() < navigationDeadline { + if case .match(total: 3, selected: .some) = controller.result { + break + } + try await Task.sleep(for: .milliseconds(20)) + } + guard case let .match(_, navigatedSelection) = controller.result else { + return XCTFail("expected libghostty to retain Find results") + } + XCTAssertNotNil(navigatedSelection) + controller.close() + XCTAssertFalse(controller.isOpen) + XCTAssertEqual(controller.result, .idle) + } + + func testBorrowedTmuxFindBarAppearsWhenControllerOpens() throws { + let surface = try makeSurface() + let controller = TerminalFindController( + isAvailable: true, + sessionProvider: { nil } + ) + surface.terminalFindController = controller + let presented = BorrowedTmuxSessionView( + handle: BorrowedTmuxSessionHandle( + id: UUID(), + hostID: UUID(), + name: "find-rendering", + surfaceID: UUID() + ), + hostName: "This Mac", + isRemoteHost: false, + connectionState: .connected, + surface: { surface }, + onCloseRequest: {}, + onRetryRequest: {}, + onHostSettingsRequest: {} + ) + let hostingView = NSHostingView(rootView: presented) + hostingView.frame = NSRect(x: 0, y: 0, width: 960, height: 640) + let window = NSWindow( + contentRect: hostingView.frame, + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + controller.open() + let deadline = Date().addingTimeInterval(1) + while !containsSearchField(in: hostingView), Date() < deadline { + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + + XCTAssertTrue(containsSearchField(in: hostingView)) + } + + func testParkingClosesFindBeforeHidingTheSurface() throws { + let view = try makeSurface() + view.installLibghosttyFindController() + view.terminalFindController.open() + + view.setParkedForPreview(true) + + XCTAssertFalse(view.terminalFindController.isOpen) + XCTAssertTrue(view.isParkedForPreview) + } + func testSnapshotProducesGPUFrameWithoutMutatingSurface() async throws { let view = try makeSurface() let window = hostInWindow(view) @@ -1480,6 +1606,11 @@ final class TerminalSurfacePreviewTests: XCTestCase { return window } + private func containsSearchField(in view: NSView) -> Bool { + view is NSSearchField + || view.subviews.contains { containsSearchField(in: $0) } + } + private func waitForIOSurface( in view: TerminalSurfaceView, matching pixelSize: SurfacePixelSize? = nil diff --git a/Tests/TerminalSupport/ApplicationShortcutTests.swift b/Tests/TerminalSupport/ApplicationShortcutTests.swift index e765968a..3f98aa1b 100644 --- a/Tests/TerminalSupport/ApplicationShortcutTests.swift +++ b/Tests/TerminalSupport/ApplicationShortcutTests.swift @@ -65,6 +65,10 @@ struct ApplicationShortcutTests { .importPullRequest: "cmd+shift+i", .newTmuxSession: nil, .newHerdrSession: nil, + .find: "cmd+f", + .findNext: "cmd+g", + .findPrevious: "cmd+shift+g", + .hideFindBar: "cmd+shift+f", .splitRight: "cmd+d", .splitDown: "cmd+shift+d", .reloadConfiguration: "cmd+shift+,", @@ -75,6 +79,19 @@ struct ApplicationShortcutTests { for action in ApplicationShortcutAction.allCases { #expect(resolved[action]?.configValue == expected[action]!) } + + let findDefinitions = Dictionary(uniqueKeysWithValues: + ApplicationShortcutCatalog.definitions + .filter { + [.find, .findNext, .findPrevious, .hideFindBar] + .contains($0.action) + } + .map { ($0.action, ($0.title, $0.settingsGroup)) }) + #expect(findDefinitions[.find]?.0 == "Find…") + #expect(findDefinitions[.findNext]?.0 == "Find Next") + #expect(findDefinitions[.findPrevious]?.0 == "Find Previous") + #expect(findDefinitions[.hideFindBar]?.0 == "Hide Find Bar") + #expect(findDefinitions.values.allSatisfy { $0.1 == .application }) } @Test( diff --git a/Tests/TerminalSupport/TerminalFindControllerTests.swift b/Tests/TerminalSupport/TerminalFindControllerTests.swift new file mode 100644 index 00000000..05c9b2b3 --- /dev/null +++ b/Tests/TerminalSupport/TerminalFindControllerTests.swift @@ -0,0 +1,427 @@ +import Foundation +import Testing +@testable import GhosthubTerminalSupport + +@Suite("Terminal Find controller") +@MainActor +struct TerminalFindControllerTests { + @Test("latest query wins while one backend request is active") + func latestQueryWins() async { + let gate = FindTestGate() + let recorder = FindSessionRecorder(firstSearchGate: gate) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("n") + await expectEventually { await recorder.searches == ["n"] } + controller.updateQuery("ne") + controller.updateQuery("needle") + await gate.open() + await expectEventually { await recorder.searches.count == 2 } + + #expect(await recorder.searches == ["n", "needle"]) + #expect(controller.query == "needle") + #expect(await recorder.maximumActiveCalls == 1) + } + + @Test("a replacement query cancels the active backend request") + func replacementQueryCancelsActiveRequest() async { + let recorder = FindSessionRecorder( + firstSearchDelay: .milliseconds(250) + ) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("first") + await expectEventually { await recorder.searches == ["first"] } + controller.updateQuery("second") + await expectEventually { + await recorder.searches == ["first", "second"] + } + + #expect(await recorder.cancelledSearches == ["first"]) + #expect(controller.query == "second") + } + + @Test("stale completion cannot overwrite the current result") + func staleCompletionIsIgnored() async { + let gate = FindTestGate() + let recorder = FindSessionRecorder( + firstSearchGate: gate, + results: [ + "first": .match(total: 1, selected: nil), + "second": .match(total: 2, selected: nil), + ] + ) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("first") + await expectEventually { await recorder.searches == ["first"] } + controller.updateQuery("second") + await gate.open() + await expectEventually { + controller.result == .match(total: 2, selected: nil) + } + + #expect(controller.result == .match(total: 2, selected: nil)) + } + + @Test("no match disables navigation") + func noMatchDisablesNavigation() async { + let recorder = FindSessionRecorder(results: ["missing": .noMatch]) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("missing") + await expectEventually { controller.result == .noMatch } + controller.findNext() + controller.findPrevious() + await Task.yield() + + #expect(!controller.canNavigate) + #expect(await recorder.navigations.isEmpty) + } + + @Test("close invalidates work and ends the backend session once") + func closeEndsSession() async { + let gate = FindTestGate() + let recorder = FindSessionRecorder(firstSearchGate: gate) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("needle") + await expectEventually { await recorder.searches == ["needle"] } + controller.close() + controller.close() + await gate.open() + await expectEventually { await recorder.closeCount == 1 } + + #expect(!controller.isOpen) + #expect(controller.result == .idle) + #expect(await recorder.closeCount == 1) + } + + @Test("closing Find cancels the active backend request") + func closeCancelsActiveRequest() async { + let recorder = FindSessionRecorder( + firstSearchDelay: .milliseconds(250) + ) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("needle") + await expectEventually { await recorder.searches == ["needle"] } + controller.close() + await expectEventually { await recorder.closeCount == 1 } + + #expect(await recorder.cancelledSearches == ["needle"]) + #expect(!controller.isOpen) + } + + @Test("callback-pending work completes from the backend callback") + func callbackCompletesPendingWork() async { + let recorder = FindSessionRecorder( + responses: ["needle": .awaitingCallback] + ) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("needle") + await expectEventually { + await recorder.searchTokens["needle"] != nil + } + let operation = await recorder.searchTokens["needle"]! + controller.publishBackendSelected(0, operation: operation) + controller.publishBackendTotal(3, operation: operation) + + #expect(controller.result == .match(total: 3, selected: 1)) + #expect(!controller.isWorking) + } + + @Test("a search waits for its total callback") + func searchWaitsForTotalCallback() async { + let recorder = FindSessionRecorder( + responses: ["needle": .awaitingCallback] + ) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("needle") + await expectEventually { + await recorder.searchTokens["needle"] != nil + } + let operation = await recorder.searchTokens["needle"]! + controller.publishBackendSelected(-1, operation: operation) + + #expect(controller.isWorking) + #expect(controller.result == .idle) + + controller.publishBackendTotal(3, operation: operation) + + #expect(controller.result == .match(total: 3, selected: nil)) + #expect(!controller.isWorking) + } + + @Test("navigation waits for its selected callback") + func navigationWaitsForSelectedCallback() async { + let recorder = FindSessionRecorder( + results: ["needle": .match(total: 3, selected: nil)], + navigationResponse: .awaitingCallback + ) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("needle") + await expectEventually { + controller.result == .match(total: 3, selected: nil) + } + controller.findNext() + await expectEventually { await recorder.navigationTokens.count == 1 } + let operation = await recorder.navigationTokens[0] + controller.publishBackendTotal(4, operation: operation) + + #expect(controller.isWorking) + #expect(controller.result == .match(total: 4, selected: nil)) + + controller.publishBackendSelected(1, operation: operation) + + #expect(controller.result == .match(total: 4, selected: 2)) + #expect(!controller.isWorking) + } + + @Test("a delayed callback from an earlier query is ignored") + func delayedEarlierQueryCallbackIsIgnored() async { + let recorder = FindSessionRecorder(responses: [ + "first": .awaitingCallback, + "second": .awaitingCallback, + ]) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("first") + await expectEventually { + await recorder.searchTokens["first"] != nil + } + let firstOperation = await recorder.searchTokens["first"]! + controller.updateQuery("second") + controller.publishBackendTotal(1, operation: firstOperation) + + #expect(controller.query == "second") + #expect(controller.result == .idle) + await expectEventually { + await recorder.searchTokens["second"] != nil + } + let secondOperation = await recorder.searchTokens["second"]! + controller.publishBackendSelected(1, operation: secondOperation) + controller.publishBackendTotal(2, operation: secondOperation) + + #expect(controller.result == .match(total: 2, selected: 2)) + #expect(!controller.isWorking) + } + + @Test("an external search replaces a callback-pending operation") + func externalSearchReplacesPendingCallback() async { + let recorder = FindSessionRecorder(responses: [ + "internal": .awaitingCallback, + ]) + let controller = TerminalFindController( + isAvailable: true, + debounce: .zero, + sessionProvider: { recorder.session } + ) + + controller.open() + controller.updateQuery("internal") + await expectEventually { + await recorder.searchTokens["internal"] != nil + } + let internalOperation = await recorder.searchTokens["internal"]! + let externalOperation = TerminalFindOperationToken() + controller.backendDidOpen( + query: "external", + operation: externalOperation + ) + controller.publishBackendTotal(99, operation: internalOperation) + controller.publishBackendSelected(1, operation: externalOperation) + + #expect(controller.result == .idle) + + controller.publishBackendTotal(4, operation: externalOperation) + controller.publishBackendSelected(0, operation: internalOperation) + + #expect(controller.query == "external") + #expect(controller.result == .match(total: 4, selected: 2)) + #expect(!controller.isWorking) + } +} + +private actor FindTestGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + isOpen = true + let currentWaiters = waiters + waiters.removeAll() + for waiter in currentWaiters { + waiter.resume() + } + } +} + +private actor FindSessionRecorder { + private let firstSearchGate: FindTestGate? + private let firstSearchDelay: Duration? + private let results: [String: TerminalFindResult] + private let responses: [String: TerminalFindBackendResponse] + private let navigationResponse: TerminalFindBackendResponse + private(set) var searches: [String] = [] + private(set) var searchTokens: [String: TerminalFindOperationToken] = [:] + private(set) var cancelledSearches: [String] = [] + private(set) var navigations: [TerminalFindDirection] = [] + private(set) var navigationTokens: [TerminalFindOperationToken] = [] + private(set) var closeCount = 0 + private var activeCalls = 0 + private(set) var maximumActiveCalls = 0 + + init( + firstSearchGate: FindTestGate? = nil, + firstSearchDelay: Duration? = nil, + results: [String: TerminalFindResult] = [:], + responses: [String: TerminalFindBackendResponse] = [:], + navigationResponse: TerminalFindBackendResponse = .result( + .match(total: 1, selected: nil) + ) + ) { + self.firstSearchGate = firstSearchGate + self.firstSearchDelay = firstSearchDelay + self.results = results + self.responses = responses + self.navigationResponse = navigationResponse + } + + nonisolated var session: TerminalFindSession { + TerminalFindSession( + search: { [self] query, operation in + await search(query, operation: operation) + }, + navigate: { [self] direction, operation in + await navigate(direction, operation: operation) + }, + close: { [self] in await close() } + ) + } + + private func search( + _ query: String, + operation: TerminalFindOperationToken + ) async -> Result { + beginCall() + defer { endCall() } + searches.append(query) + searchTokens[query] = operation + if searches.count == 1, let firstSearchGate { + await firstSearchGate.wait() + } + if searches.count == 1, let firstSearchDelay { + do { + try await Task.sleep(for: firstSearchDelay) + } catch { + cancelledSearches.append(query) + } + } + return .success( + responses[query] + ?? .result(results[query] ?? .match(total: 1, selected: nil)) + ) + } + + private func navigate( + _ direction: TerminalFindDirection, + operation: TerminalFindOperationToken + ) -> Result { + beginCall() + navigations.append(direction) + navigationTokens.append(operation) + endCall() + return .success(navigationResponse) + } + + private func close() -> TerminalFindFailure? { + beginCall() + closeCount += 1 + endCall() + return nil + } + + private func beginCall() { + activeCalls += 1 + maximumActiveCalls = max(maximumActiveCalls, activeCalls) + } + + private func endCall() { + activeCalls -= 1 + } +} + +@MainActor +private func expectEventually( + _ condition: @escaping @MainActor @Sendable () async -> Bool, + sourceLocation: SourceLocation = #_sourceLocation +) async { + for _ in 0 ..< 1_000 { + if await condition() { + return + } + await Task.yield() + } + Issue.record("Condition did not become true.", sourceLocation: sourceLocation) +} diff --git a/Tests/UI/CommandPaletteModelTests.swift b/Tests/UI/CommandPaletteModelTests.swift index 648fd97b..0f41067f 100644 --- a/Tests/UI/CommandPaletteModelTests.swift +++ b/Tests/UI/CommandPaletteModelTests.swift @@ -87,15 +87,24 @@ struct CommandPaletteModelTests { func sceneDependentCommandsFollowAvailability() { let commands = makeCommandPaletteCommands( availableApplicationShortcuts: [ + .find, .nextSibling, .splitDown, ] ) + commands.expectCommandContains( + title: "Find in Terminal", shortcut: .find + ) commands.expectCommandNotContains(title: "Previous Sibling") commands.expectCommandContains(title: "Next Sibling") commands.expectCommandNotContains(title: "Split Right") commands.expectCommandContains(title: "Split Down") + + let unavailable = makeCommandPaletteCommands( + availableApplicationShortcuts: [] + ) + unavailable.expectCommandNotContains(title: "Find in Terminal") } @Test("worktree commands follow persisted sidebar order") diff --git a/docs/architecture.md b/docs/architecture.md index 8c8d7c23..ef825062 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,6 +62,15 @@ the session because psmux has no non-sizing preview-client mode. Preview rendering reuses that session's retained client rather than creating a second client or reconstructing tmux panes, layout, or history. +Terminal Find is also backend-owned. Ordinary terminal surfaces use +libghostty's search actions. POSIX tmux 3.4 and newer uses an exact-client and +exact-pane guarded copy-mode command, with totals available from tmux 3.5 when +the server reports a complete count. Herdr, Zellij, psmux, and older tmux +versions remain unavailable instead of searching only the disposable client +buffer. Swift owns the Find bar and request serialization, but it never reads +pane history, match text, or highlighting state. Search queries are +memory-only and excluded from logs and fixed user-facing failures. + After an attachment reaches the connected state, Ghosthub keeps that exact session warm for the remainder of the app launch. One app-scoped, in-memory activity controller serves every workspace window. It samples only warm diff --git a/docs/terminal-sessions.md b/docs/terminal-sessions.md index eac8afd8..7ba09278 100644 --- a/docs/terminal-sessions.md +++ b/docs/terminal-sessions.md @@ -72,6 +72,27 @@ in-window pointer position because keyboard events do not carry a reliable mouse location. Libghostty removes the override before matching its Command link binding. Tmux therefore does not capture link highlighting or activation, and users do not need to hold Shift. + +### Active-pane Find + +Command-F searches the complete history of the active terminal pane without +copying that history into Ghosthub. Standalone shells delegate search, +highlighting, selection, and viewport movement to libghostty. POSIX tmux 3.4 +and newer delegates the same work to tmux copy mode after fencing the command +to the attached server, session, client, and pane. Tmux 3.5 and newer can also +return an exact total when `search_count_partial` is zero; tmux 3.4 reports +only whether a match exists. Tmux 3.6 and newer receives the copy-mode `--` +option terminator, while 3.4 through releases older than 3.6 use the legacy +command shape. + +Tmux owns wrapping and the first step after a search direction changes. Copy +mode and its viewport are pane-wide, so another client attached to the same +pane can observe or cancel the search. Parking a preview, replacing an +attachment, disconnecting, or closing the surface ends Ghosthub's Find +session. Herdr, Zellij, Windows psmux, and tmux older than 3.4 do not expose +Find because their command surfaces cannot accept a literal query with the +same backend-owned history contract. Queries stay in memory, are not logged, +and never appear in operation diagnostics. Each remote POSIX presentation obtains one multiplexed lease from kwt before its first probe and holds it through terminal exit. Tmux path discovery, attach, pane splitting, and identity revalidation use that same frozen route diff --git a/tools/tests/test_demo_scripts.py b/tools/tests/test_demo_scripts.py index eff81258..b5711087 100644 --- a/tools/tests/test_demo_scripts.py +++ b/tools/tests/test_demo_scripts.py @@ -24,6 +24,7 @@ WEBSITE_ASSET_NAMES = ( "hero.png", "guide-sessions.png", + "guide-find.png", "guide-session-previews.png", "guide-session-activity.png", "guide-hosts.png", @@ -1332,6 +1333,9 @@ def test_offline_asset_reuse_requires_synced_provenance( assert result.returncode == expected_code if not trusted: assert "is missing or stale" in result.stderr + else: + published = script.parents[1] / "docs" / "content" / "assets" + assert all((published / name).is_file() for name in WEBSITE_ASSET_NAMES) def test_fetched_asset_ref_is_authoritative_and_atomic(tmp_path: Path) -> None: diff --git a/website/demo/assets/demohost.m b/website/demo/assets/demohost.m index 1020f5b4..58b5b6cb 100644 --- a/website/demo/assets/demohost.m +++ b/website/demo/assets/demohost.m @@ -196,6 +196,18 @@ static BOOL DemoScrollDetail(CGFloat delta) { return nil; } +static NSSearchField *DemoFindSearchField(NSView *view) { + if ([view isKindOfClass:NSSearchField.class]) { + NSSearchField *field = (NSSearchField *)view; + if ([field.placeholderString isEqualToString:@"Find"]) return field; + } + for (NSView *subview in view.subviews) { + NSSearchField *match = DemoFindSearchField(subview); + if (match != nil) return match; + } + return nil; +} + static NSView *DemoTitlebarView( NSWindow *window, NSString *identifier) { NSView *titlebar = @@ -659,6 +671,36 @@ - (void)input:(NSNotification *)notification { }); }); }); + } else if ([action isEqualToString:@"find"]) { + NSWindow *window = DemoRootWindow(); + if (window == nil) { + [self acknowledge:requestID + success:NO + message:@"Find has no active workspace window"]; + return; + } + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, 250 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + if (!DemoInsertText(text)) { + [self acknowledge:requestID + success:NO + message:@"Find field did not accept text"]; + return; + } + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, 100 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + NSSearchField *field = DemoFindSearchField(window.contentView); + BOOL matched = field != nil && + [field.stringValue isEqualToString:expectKind]; + [self acknowledge:requestID + success:matched + message:matched + ? @"Find matched the requested state" + : @"Find result did not match the requested state"]; + }); + }); } else if ([action isEqualToString:@"text"]) { BOOL inserted = DemoInsertText(text); [self acknowledge:requestID diff --git a/website/demo/shoot.sh b/website/demo/shoot.sh index 9ba1ed37..89da1cf3 100755 --- a/website/demo/shoot.sh +++ b/website/demo/shoot.sh @@ -173,6 +173,27 @@ capture_state() { sips -g pixelWidth -g pixelHeight "$out_dir/$name" | tail -2 } +capture_find() { + demo_input click "80,820" + sleep 10 + demo_input expect-window-title "agentsview--add-session-filters" + demo_input click "600,600" + sleep 0.5 + palette "find in terminal" + demo_input find "filters" false "filters" + sleep 1 + capture_state guide-find.png + demo_input escape + sleep 1 +} + +if [[ "${GHOSTHUB_DEMO_FIND_ONLY:-}" == "1" ]]; then + echo "==> guide: active-pane Find" + capture_find + echo "captured active-pane Find website asset -> $out_dir" + exit 0 +fi + if [[ "${GHOSTHUB_DEMO_ALWAYS_LIVE_PREVIEW_ONLY:-}" == "1" ]]; then echo "==> guide: Always Live tmux session previews" sleep 1 @@ -373,6 +394,9 @@ demo_input escape demo_input click "32,489" sleep 0.5 +echo "==> guide: active-pane Find" +capture_find + if [[ "${GHOSTHUB_DEMO_SKIP_SESSION_PREVIEWS:-}" != "1" ]]; then echo "==> guide: opened tmux session previews" palette "add-session-filters" diff --git a/website/docs/content/keyboard-shortcuts.md b/website/docs/content/keyboard-shortcuts.md index 3c37819f..2ea4c2c4 100644 --- a/website/docs/content/keyboard-shortcuts.md +++ b/website/docs/content/keyboard-shortcuts.md @@ -54,6 +54,10 @@ numbered sibling navigation instead. | Split the active tmux or Herdr pane down | ++shift+cmd+d++ | | Reload configuration | ++shift+cmd+comma++ | | Open application log | ++option+cmd+l++ | +| Find in the active terminal | ++cmd+f++ | +| Find next, toward older history | ++cmd+g++ | +| Find previous, toward newer history | ++shift+cmd+g++ | +| Hide the Find bar | ++shift+cmd+f++ | New tmux Session, New Herdr Session, and New Zellij Session remain available in menus and the Command Palette but are unbound by default. @@ -110,6 +114,18 @@ Choose **Ghosthub → Reload Configuration** after editing the file manually. ## Multiplexer ownership +Command-F opens a compact Find bar for standalone terminals and supported +POSIX tmux sessions. Return and Command-G request the next match toward older +history; Shift-Return and Shift-Command-G request the previous match toward +newer history. Escape, Shift-Command-F, or the close button ends Find. + +Standalone libghostty search follows Ghostty.app's newest-to-oldest, +non-wrapping behavior. Tmux owns wrapping and the first step after changing +direction. Tmux also owns pane-wide copy mode and the viewport, so another +client attached to the same pane can see or cancel the search. Find requires +tmux 3.4 or newer. Herdr, Zellij, Windows psmux, and older tmux versions leave +Find unavailable rather than searching only the visible client output. + Choose **File → Split Right** or **File → Split Down** if you prefer menus. Ghosthub asks the active multiplexer to split its focused pane directly, so custom prefixes and key bindings keep working. The shortcuts require a diff --git a/website/docs/content/sessions.md b/website/docs/content/sessions.md index 877c47de..94d53d17 100644 --- a/website/docs/content/sessions.md +++ b/website/docs/content/sessions.md @@ -54,6 +54,16 @@ Native Windows/psmux keeps its existing mouse-reporting limitation. Hold ++cmd++ while pointing at a highlighted terminal link, then click to open it in the default macOS application. +Press ++cmd+f++ to search the complete active pane history in a standalone +terminal or a POSIX tmux 3.4-or-newer session. You can also run **Find in +Terminal** from the Command Palette. Ghosthub shows the query and navigation +controls, while libghostty or tmux owns matching, highlights, and viewport +movement. Tmux copy mode is pane-wide, so other attached clients can observe +or cancel it. Herdr, Zellij, Windows psmux, and older tmux versions do not offer +partial client-buffer search. + +![Ghosthub Find bar searching the complete history of an active tmux pane](assets/guide-find.png) + Switching to another host, worktree, or session hides an opened tmux terminal without detaching it. Each workspace keeps every tmux session you explicitly open connected, and returning to one reuses the same terminal and client. diff --git a/website/scripts/sync-assets.sh b/website/scripts/sync-assets.sh index 2340bfda..d249d31b 100755 --- a/website/scripts/sync-assets.sh +++ b/website/scripts/sync-assets.sh @@ -17,6 +17,7 @@ mkdir -p src/assets assets=( hero.png guide-sessions.png + guide-find.png guide-session-previews.png guide-session-activity.png guide-hosts.png