diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift index ab1b56cb8ab6..b2ec9e8c8884 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Focus/CreationInputCoordinator.swift @@ -72,7 +72,7 @@ final class CreationInputCoordinator { } /// `window` closes: its claim goes with it. - func windowClosed(_ window: NSWindow?) { if let window { claims.end(inWindow: window.windowNumber) } } + func windowClosed(_ window: NSWindow?) { if let window { claims.end(inWindow: window.windowNumber); asking[window.windowNumber] = nil } } // its questions go with it /// Whether `window`'s claim holds now (``CreationKeyboardClaims/holds(in:shows:intents:lastInput:)``). private func claimHolds(in window: NSWindow) -> Bool { guard !claims.isEmpty else { return false } @@ -154,43 +154,41 @@ final class CreationInputCoordinator { } } - /// A person's close of `tabs` waits for an answer (the close warning's read): when it takes the focused live terminal (never a cancelled stand-in), or comes while that terminal's close still reads (two quick Cmd-Ws), keys typed meanwhile are the close's (P0 close-then-type). + /// A person's close of `tabs` waits for an answer (the close warning's read): when it takes the focused live terminal (never a cancelled stand-in), or while that terminal's close reads, the tab its pane selects now (a quick second Cmd-W), keys typed meanwhile are the close's (P0 close-then-type). Another terminal's close (its close button) opens no lane: its Cancel must never type them into it. /// `hides`: the close takes its tabs out of the strip while it reads, so every key typed in the window meanwhile is /// the close's, whatever the focus moved to (`CreationInputBuffer.openClose(closing:machine:takesEveryKey:)`). func beginClose(of tabs: [TabModel], in window: NSWindow?, hides: Bool = false) -> CreationInputBuffer.Ticket? { let live = tabs.filter(\.isLiveTerminal).map(\.id) guard let window, ActionRunScope.viewChangeAllowed(), let controller = router?.focus(for: window).0, let focused = controller.focus.state.terminalTab, !live.isEmpty, - live.contains(focused) || buffers[window.windowNumber]?.isClosing(tab: focused) == true else { return nil } + live.contains(focused) || buffers[window.windowNumber]?.isClosing(tab: focused) == true && controller.focus.state.resolved.pane.flatMap({ controller.content?.paneController(key: $0)?.stripModel.selectedID?.rawValue }).map(live.contains) == true else { return nil } return buffer(in: window).openClose(closing: live.contains(focused) ? [focused] + live.filter { $0 != focused } : live, machine: controller.content?.daemon.machineID, takesEveryKey: hides) } - /// Keys of closes that ended while a close's question was up, by window: they wait for its answer with its own keys. - private var asking: [Int: [NSEvent]] = [:] + /// By window, while close questions are up (how many): the keys of closes that ended meanwhile, which wait for the answer with its own keys. + private var asking: [Int: (events: [NSEvent], questions: Int)] = [:] /// The close asks the person: its keys wait for the answer outside the hold (the question takes its own keys). func parkClose(_ ticket: CreationInputBuffer.Ticket?, in window: NSWindow?) -> CreationInputBuffer.ParkedClose? { guard let window else { return nil } - if asking[window.windowNumber] == nil { asking[window.windowNumber] = [] } + asking[window.windowNumber, default: (events: [], questions: 0)].questions += 1 guard let ticket, let parked = buffers[window.windowNumber]?.parkClose(ticket) else { return .init(events: [], closing: "", machine: nil) } scheduleFlush(in: window) return parked } - /// The close ran: its keys (`parked` first) go to the terminal focused now, once it has the keyboard. The person - /// cancelled it (`stays`): its parked keys go to the terminal that stays now, by that terminal, wherever the focus is. - /// A close that ends while another close's question is up waits for that answer; a gone window takes its keys with it. + /// The close ran: its keys (`parked` first) go to the terminal focused now, once it has the keyboard. The person cancelled it (`stays`): its parked keys go to the terminal that stays now, by that terminal, wherever the focus is. + /// A close that ends while another close's question is up waits for that answer, as the hold's keys do (``flush(in:force:end:)``), which go on after it; a gone window takes its keys with it. func endClose(_ ticket: CreationInputBuffer.Ticket?, parked: CreationInputBuffer.ParkedClose? = nil, stays: Bool = false, in window: NSWindow?) { guard let window else { return } let number = window.windowNumber - if parked == nil, asking[number] != nil { - if let ticket, let more = buffers[number]?.parkClose(ticket) { asking[number]?.append(contentsOf: more.events); scheduleFlush(in: window) } - return - } - let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking.removeValue(forKey: number) ?? []), closing: $0.closing, machine: $0.machine) } - if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { - let lane = CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine) - return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: lane), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) + if parked == nil, asking[number] != nil, let ticket, let more = buffers[number]?.parkClose(ticket) { asking[number]?.events.append(contentsOf: more.events) } + if parked == nil, asking[number] != nil { return } // the answer lets these keys go on (``flush(in:force:end:)`` waits for it) + let parked = parked.map { CreationInputBuffer.ParkedClose(events: $0.events + (asking[number]?.events ?? []), closing: $0.closing, machine: $0.machine) } + // The question is answered: the keys the hold kept meanwhile go on, once no other close's question is up. + defer { if parked != nil { asking[number]?.events = []; asking[number]?.questions -= 1; if asking[number]?.questions == 0 { asking[number] = nil }; scheduleFlush(in: window) } } + if stays, let parked, !parked.closing.isEmpty, !parked.events.isEmpty { // Cancel: the keys go to the terminal that stays + return _ = runAgain(CreationInputBuffer.heldKeys(parked.events, for: CreationInputBuffer.HeldLane(tab: parked.closing, machine: parked.machine)), from: buffers[number] ?? CreationInputBuffer(began: ContinuousClock.now), in: window) } let controller = router?.focus(for: window).0 guard let ticket = ticket ?? parked.flatMap({ $0.events.isEmpty ? nil : buffer(in: window).openClose(closing: [$0.closing], machine: $0.machine) }) else { return } @@ -214,6 +212,7 @@ final class CreationInputCoordinator { let shell = controller?.window ?? window // A held key runs again: never captured twice; a person's claim still keeps one typed for no terminal from the window. if replaying.on { return !replaying.lane && kind == .content && keepsFromWindow(event, in: shell) } + if asking[shell.windowNumber] != nil { return false } // a close's question is up: a key typed now is its own, the held keys wait for its answer // The focus names a terminal whose surface is not shown yet (a reopened window, a workspace shown // cold): the key waits for it, at most `holdLimit`, and goes to that terminal (REOPEN-F4); a second one gets its own lane. if buffers[shell.windowNumber].map({ $0.awaitedTab != nil || $0.personLeftEveryCreation }) ?? true, kind == .content, let controller, ActionRunScope.viewChangeAllowed(), @@ -358,7 +357,7 @@ final class CreationInputCoordinator { /// `force`: the hold ends now (its limit, focus left the content), whatever it waited for. /// `end`: why a forced end happened (``CreationHoldLog/end(in:forced:)``). private func flush(in window: NSWindow, force: Bool = false, end forcedEnd: CreationHoldEnd = .limitReached) { - guard let buffer = buffers[window.windowNumber] else { return } + guard let buffer = buffers[window.windowNumber], asking[window.windowNumber] == nil else { return } // a close's question is up: the keys wait for its answer, never typed into it (a Return would press Close) guard force || buffer.isResolved else { // Before every reply, only a creation whose early view has the keyboard takes its own keys (a focus settle). if let lane = router?.focus(for: window).0.flatMap({ Self.keyboardTerminal($0, window).flatMap(buffer.earlyLane(tab:)) }) { _ = runAgain(buffer.take(lane: lane), from: buffer, in: window) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift index 93275ea09fbe..b2f6eece6088 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDaemonService/DebugCloseReadHold.swift @@ -53,6 +53,15 @@ public final class DebugCloseReadHold { return resumed.count } + /// Lets only the newest waiting read go on and keeps the hold armed (a test orders two closes' answers); false when + /// none waits. + @discardableResult + public func releaseNewest() -> Bool { + guard !waiting.isEmpty else { return false } + waiting.removeLast().resume() + return true + } + /// `debug.close.hold` and `debug.close.release`. public var methods: [ControlMethod] { [ diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift index 452a6ff370c8..340e450b4783 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/CmdWIdleTerminalClosesInItsTurnTests.swift @@ -204,8 +204,116 @@ struct CmdWIdleTerminalClosesInItsTurnTests { } } + /// (Review of #31875, Low) The close button of another terminal, one that does not have the focus, pressed while a + /// hidden close reads: that close never takes the keys typed meanwhile. They go to the terminal that has the focus + /// (the neighbour the hide showed), never into the other terminal when its program asks and the person cancels. + @Test func aCloseOfAnUnfocusedTerminalDuringTheReadNeverTakesTheHeldKeys() async throws { + try await Self.withFocusedTerminal { t in + let neighbour = try await Self.newTerminal(t) + let running = try await Self.newTerminal(t) // runs cat: its close asks + _ = try await t.connection.request(SendInputRequest(surface: running.surface, text: "cat\r")) + try await Self.waitRunning("cat", t, surface: running.surface) + t.pane.select(TabID(t.tab.id)) + let window = try #require(t.pane.view.window) + let focused = { t.services.keyRouter.focus(for: window).0?.focus.state.terminalTab } + try await PinnedPageTests.until({ focused() == t.tab.id }, report: { "the closing terminal never had the focus: \(focused() ?? "none")" }) + let hold = t.services.daemon.closeReadHold + hold.arm() + defer { hold.release() } + try Self.press("w", .command, keyCode: 13, in: window, t) // the idle terminal's Cmd-W: hidden, it reads + try await PinnedPageTests.until({ focused() == neighbour.id && hold.held == 1 }, + report: { "focus \(focused() ?? "none"), neighbour \(neighbour.id), reads held \(hold.held)" }) + let nonce = String(UInt32.random(in: 100_000...999_999)), before = "kq\(nonce)", after = "zz\(nonce)" + for character in before { try Self.press(String(character), in: window, t) } + t.pane.handle(.close(TabID(running.id), source: .mouse)) // its close button: the running terminal hides and reads + try await PinnedPageTests.until({ hold.held == 2 }, report: { "the running terminal's close did not read: \(hold.held) held" }) + for character in after { try Self.press(String(character), in: window, t) } + hold.release() + try await PinnedPageTests.until({ Self.question() != nil }, report: { "no question for the running terminal" }) + _ = CmuxDialogCenter.shared.press(try #require(Self.question()).id, button: "cancel") + let typed = try? await t.connection.request(WaitForTextRequest(surface: neighbour.surface, pattern: before + after, timeoutMs: 10_000), + timeout: .seconds(15)) + let screen = (try? await t.connection.request(ReadScreenRequest(surface: running.surface), timeout: .seconds(5)))?.text ?? "" + #expect(typed?.matched == true, "the keys typed during the read did not reach the focused terminal: \(typed?.text.suffix(300) ?? "no answer")") + #expect(!screen.contains(before) && !screen.contains(after), "the cancelled close's terminal got the focused one's keys: \(screen.suffix(300))") + #expect(t.store.tab(surface: running.surface) != nil, "the terminal running cat closed with no answer") + } + } + + /// (Review of #32291, the reads in the other order) The running terminal's question shows before the idle terminal's + /// read ends: that close ends while the question asks, and Cancel still types only the question's own keys into the + /// terminal it asked about. The keys typed for the focused neighbour go to it. + @Test func aCloseThatEndsWhileAnotherTerminalAsksKeepsItsKeysForTheFocusedTerminal() async throws { + try await Self.withFocusedTerminal { t in + let neighbour = try await Self.newTerminal(t) + let running = try await Self.newTerminal(t) // runs cat: its close asks + _ = try await t.connection.request(SendInputRequest(surface: running.surface, text: "cat\r")) + try await Self.waitRunning("cat", t, surface: running.surface) + t.pane.select(TabID(t.tab.id)) + let window = try #require(t.pane.view.window) + let focused = { t.services.keyRouter.focus(for: window).0?.focus.state.terminalTab } + try await PinnedPageTests.until({ focused() == t.tab.id }, report: { "the closing terminal never had the focus: \(focused() ?? "none")" }) + let hold = t.services.daemon.closeReadHold + hold.arm() + defer { hold.release() } + try Self.press("w", .command, keyCode: 13, in: window, t) // the idle terminal's Cmd-W: hidden, it reads + try await PinnedPageTests.until({ focused() == neighbour.id && hold.held == 1 }, + report: { "focus \(focused() ?? "none"), neighbour \(neighbour.id), reads held \(hold.held)" }) + let nonce = String(UInt32.random(in: 100_000...999_999)), typed = "rv\(nonce)" + for character in typed { try Self.press(String(character), in: window, t) } + t.pane.handle(.close(TabID(running.id), source: .mouse)) // its close button: the running terminal hides and reads + try await PinnedPageTests.until({ hold.held == 2 }, report: { "the running terminal's close did not read: \(hold.held) held" }) + hold.releaseNewest() // the running terminal answers first: its question shows while the idle one still reads + try await PinnedPageTests.until({ Self.question() != nil }, report: { "no question for the running terminal" }) + hold.release() // the idle terminal's close ends while the question asks + try await PinnedPageTests.until({ !TabCloseGuard.asking.contains(t.tab.id) }, report: { "the idle close never ended: asking \(TabCloseGuard.asking)" }) + _ = CmuxDialogCenter.shared.press(try #require(Self.question()).id, button: "cancel") + let back = try? await t.connection.request(WaitForTextRequest(surface: neighbour.surface, pattern: typed, timeoutMs: 10_000), timeout: .seconds(15)) + let screen = (try? await t.connection.request(ReadScreenRequest(surface: running.surface), timeout: .seconds(5)))?.text ?? "" + #expect(back?.matched == true, "the keys typed for the focused terminal did not reach it: \(back?.text.suffix(300) ?? "no answer")") + #expect(!screen.contains(typed), "Cancel typed another close's keys into the terminal it asked about: \(screen.suffix(300))") + } + } + + /// (Review of #32291) While the question shows, the held keys wait for its answer, but a key typed at the question is + /// the question's: Escape through the app's key path cancels it, and the line held during the read comes back whole + /// to the terminal that stays. + @Test func escapeAtTheQuestionCancelsItAndTheHeldLineComesBack() async throws { + try await Self.withFocusedTerminal { t in + try await Self.input("cat\r", t) + try await Self.waitRunning("cat", t) + _ = try await Self.newTerminal(t) // the neighbour the hide shows + t.pane.select(TabID(t.tab.id)) + try await PinnedPageTests.until({ t.pane.stripModel.selectedID?.rawValue == t.tab.id }) + let hold = t.services.daemon.closeReadHold + hold.arm() + defer { hold.release() } + let window = try #require(t.pane.view.window) + try Self.press("w", .command, keyCode: 13, in: window, t) + try await PinnedPageTests.until({ hold.held == 1 }, report: { "the close did not read" }) + let nonce = String(UInt32.random(in: 100_000...999_999)) + for character in "es\(nonce)" { try Self.press(String(character), in: window, t) } + hold.release() + try await PinnedPageTests.until({ Self.question() != nil }, report: { "no close question" }) + try Self.press("\u{1B}", keyCode: 53, in: window, t) + try await PinnedPageTests.until({ Self.question() == nil && !TabCloseGuard.asking.contains(t.tab.id) }, + report: { "Escape did not answer the question: asking \(TabCloseGuard.asking)" }) + #expect(t.pane.orderedIDs.map(\.rawValue).contains(t.tab.id), "Escape closed the terminal running cat") + let back = try? await t.connection.request(WaitForTextRequest(surface: t.tab.surface, pattern: "es\(nonce)", timeoutMs: 10_000), + timeout: .seconds(15)) + #expect(back?.matched == true, "the line held during the read did not come back to the terminal that stays") + } + } + // MARK: Fixture + /// A new terminal tab at the end of `t`'s pane, once its strip lists it. + private static func newTerminal(_ t: Terminal) async throws -> TabModel { + let surface = try await t.connection.newTab(in: t.pane.pane.handle).surface + try await PinnedPageTests.until({ t.store.tab(surface: surface).map { t.pane.orderedIDs.map(\.rawValue).contains($0.id) } == true }) + return try #require(t.store.tab(surface: surface)) + } + private struct Terminal { let services: AppServices let connection: DaemonConnection @@ -252,12 +360,12 @@ struct CmdWIdleTerminalClosesInItsTurnTests { _ = try await t.connection.request(SendInputRequest(surface: t.tab.surface, text: text)) } - /// Waits until the daemon names `program` as the terminal's foreground program (nil: its shell only). - private static func waitRunning(_ program: String?, _ t: Terminal) async throws { + /// Waits until the daemon names `program` as the foreground program of the terminal (`surface`, else `t`'s; nil: its shell only). + private static func waitRunning(_ program: String?, _ t: Terminal, surface: SurfaceID? = nil) async throws { var last = "no answer" for _ in 0..<100 { // Only an answer counts: a read that did not answer is not "its shell only". - if let answer = await t.connection.readProcessInfo(t.tab.surface, timeout: .seconds(1)) { + if let answer = await t.connection.readProcessInfo(surface ?? t.tab.surface, timeout: .seconds(1)) { if answer.runningProgram == program { return } last = answer.runningProgram ?? "its shell only" } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RunningProgramsStandInTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RunningProgramsStandInTests.swift new file mode 100644 index 000000000000..67f1bd66045d --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/RunningProgramsStandInTests.swift @@ -0,0 +1,18 @@ +@testable import CmuxNextApp +@testable import CmuxNextDaemon +import CmuxNextDaemonService +import Testing + +/// Review of #31875 (Low): `DaemonService.runningPrograms(of:unanswered:)` never asks a stand-in (a terminal still +/// opening). A stand-in has no process, and the daemon refuses a read of its surface, so a close that counts "no answer" +/// as running (`unanswered`, the hidden Cmd-W's read) asked "Still running: " for a terminal that runs nothing. +@MainActor @Suite struct RunningProgramsStandInTests { + @Test func aStandInIsNeverAskedAndCountsAsNoProgram() async { + let standIn = TabModel(TabSnapshot(surface: ProvisionalTab().surface, kind: .pty, title: "opening")) + let live = TabModel(TabSnapshot(surface: SurfaceID(rawValue: 7), kind: .pty, title: "live")) + // No connection: every terminal asked counts as `unanswered` says, so only the stand-in's exclusion keeps it out. + let asked = await DaemonService().runningPrograms(of: [standIn, live]) { $0.title } + #expect(asked.keys.sorted { $0.rawValue < $1.rawValue } == [live.surface], "a stand-in was asked: \(asked)") + #expect(asked.programNames == ["live"], "a stand-in counts as a running program: \(asked.programNames)") + } +}