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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand All @@ -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(),
Expand Down Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down
Loading
Loading