Repository navigation
iOS: fix View as Text showing empty #6736
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,5 @@ | ||||||||||||||||||
| import CmuxMobileDiagnostics | ||||||||||||||||||
| import CmuxMobileTerminalKit | ||||||||||||||||||
| import GhosttyKit | ||||||||||||||||||
| import UIKit | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -85,23 +87,60 @@ extension GhosttySurfaceView { | |||||||||||||||||
| // sheet's honest empty state over silently copying that stale text. | ||||||||||||||||||
| // If the same terminal is mounted in several scenes the contents are | ||||||||||||||||||
| // identical, so the lowest-keyed visible match keeps the pick | ||||||||||||||||||
| // deterministic. | ||||||||||||||||||
| let matchingView = registeredSurfaceViews | ||||||||||||||||||
| // deterministic. The eligibility rule itself lives in | ||||||||||||||||||
| // `CopyableTerminalTextSelection` so it is host-testable without UIKit; | ||||||||||||||||||
| // this just maps the live registry onto that pure predicate. | ||||||||||||||||||
| // | ||||||||||||||||||
| // The resolve happens on the main actor at the call site (the menu tap), | ||||||||||||||||||
| // BEFORE the sheet finishes presenting, so the surface is still | ||||||||||||||||||
| // window-attached and visible when the predicate runs — a sheet | ||||||||||||||||||
| // presentation that briefly drops the presenter's window/alpha can no | ||||||||||||||||||
| // longer turn the live surface into a miss. | ||||||||||||||||||
| let orderedViews = registeredSurfaceViews | ||||||||||||||||||
| .sorted { $0.key < $1.key } | ||||||||||||||||||
| .compactMap(\.value.value) | ||||||||||||||||||
| .first { candidate in | ||||||||||||||||||
| candidate.hostSurfaceID == surfaceID && candidate.surface != nil | ||||||||||||||||||
| && candidate.window != nil && !candidate.isHidden | ||||||||||||||||||
| && candidate.alpha > 0.01 | ||||||||||||||||||
| } | ||||||||||||||||||
| guard let surface = matchingView?.surface else { return nil } | ||||||||||||||||||
| let candidates = orderedViews.map { view in | ||||||||||||||||||
| CopyableTerminalTextSelection.Candidate( | ||||||||||||||||||
| hostSurfaceID: view.hostSurfaceID, | ||||||||||||||||||
| hasSurface: view.surface != nil, | ||||||||||||||||||
| hasWindow: view.window != nil, | ||||||||||||||||||
| isHidden: view.isHidden, | ||||||||||||||||||
| alpha: Double(view.alpha) | ||||||||||||||||||
| ) | ||||||||||||||||||
| } | ||||||||||||||||||
| let chosen = CopyableTerminalTextSelection.chosenIndex(from: candidates, for: surfaceID) | ||||||||||||||||||
| .map { orderedViews[$0] } | ||||||||||||||||||
|
|
||||||||||||||||||
| #if DEBUG | ||||||||||||||||||
| let candidateSummary = candidates | ||||||||||||||||||
| .map { "[id=\($0.hostSurfaceID ?? "nil") surf=\($0.hasSurface) win=\($0.hasWindow) hidden=\($0.isHidden) a=\(String(format: "%.2f", $0.alpha))]" } | ||||||||||||||||||
| .joined(separator: " ") | ||||||||||||||||||
| MobileDebugLog.anchormux( | ||||||||||||||||||
| "viewAsText.pick want=\(surfaceID) count=\(candidates.count) chosen=\(chosen == nil ? "none" : "yes") \(candidateSummary)" | ||||||||||||||||||
| ) | ||||||||||||||||||
| #endif | ||||||||||||||||||
|
|
||||||||||||||||||
| guard let surface = chosen?.surface else { return nil } | ||||||||||||||||||
| let handle = CopyableTextSurfaceHandle(surface: surface) | ||||||||||||||||||
| return await withCheckedContinuation { continuation in | ||||||||||||||||||
| outputQueue.async { | ||||||||||||||||||
| // SCREEN = scrollback + all written rows. Fall back to the | ||||||||||||||||||
| // viewport-only read if the screen read fails outright. | ||||||||||||||||||
| let text = surfaceText(handle.surface, pointTag: GHOSTTY_POINT_SCREEN) | ||||||||||||||||||
| ?? surfaceText(handle.surface, pointTag: GHOSTTY_POINT_VIEWPORT) | ||||||||||||||||||
| // SCREEN = scrollback + all written rows. `surfaceText` returns a | ||||||||||||||||||
| // non-nil empty string for a zero-byte range, so a plain `??` | ||||||||||||||||||
| // never fell back when SCREEN read empty-but-ok. Route both reads | ||||||||||||||||||
| // through the pure decision so a nil OR empty SCREEN still tries | ||||||||||||||||||
| // VIEWPORT before the sheet gives up. | ||||||||||||||||||
| let screen = surfaceText(handle.surface, pointTag: GHOSTTY_POINT_SCREEN) | ||||||||||||||||||
| let viewport = surfaceText(handle.surface, pointTag: GHOSTTY_POINT_VIEWPORT) | ||||||||||||||||||
| let text = CopyableTerminalTextSelection.resolvedText(screen: screen, viewport: viewport) | ||||||||||||||||||
|
Comment on lines
+132
to
+134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| #if DEBUG | ||||||||||||||||||
| func describe(_ value: String?) -> String { | ||||||||||||||||||
| guard let value else { return "nil" } | ||||||||||||||||||
| return value.isEmpty ? "empty" : "\(value.count)chars" | ||||||||||||||||||
| } | ||||||||||||||||||
| MobileDebugLog.anchormux( | ||||||||||||||||||
| "viewAsText.read screen=\(describe(screen)) viewport=\(describe(viewport)) resolved=\(describe(text))" | ||||||||||||||||||
| ) | ||||||||||||||||||
| #endif | ||||||||||||||||||
| continuation.resume(returning: text) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import Foundation | ||
|
|
||
| /// Pure selection + fallback logic for the iOS "View as Text" capture, split | ||
| /// out of `GhosttySurfaceRegistry` so the two decisions that actually decide | ||
| /// whether the sheet shows text or its empty state are host-testable without | ||
| /// libghostty, UIKit, or a live surface. | ||
| /// | ||
| /// Two things were silently making the sheet show "No terminal text available" | ||
| /// even with visible output: | ||
| /// | ||
| /// 1. The candidate predicate is id-scoped AND visibility-scoped. If the chosen | ||
| /// surface is re-resolved from the registry *after* the sheet has begun | ||
| /// presenting, a transient `window == nil` / `isHidden` / `alpha` drop on the | ||
| /// presenter can exclude the one live surface, yielding nil. The fix moves the | ||
| /// resolve to tap time (see `GhosttySurfaceRegistry`), but the predicate is | ||
| /// still the single source of truth and is unit-tested here. | ||
| /// 2. `surfaceText` returns a non-nil empty string when the range has zero bytes, | ||
| /// so `screen ?? viewport` never fell back when SCREEN read empty-but-ok. The | ||
| /// `resolvedText` decision below treats nil OR empty SCREEN as "try VIEWPORT". | ||
| public enum CopyableTerminalTextSelection { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Rule Used: Flag new ambient global state in production Swift:... (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| /// Inputs describing a registered surface view for the eligibility decision. | ||
| /// Mirrors the runtime fields read off `GhosttySurfaceView` (`hostSurfaceID`, | ||
| /// `surface != nil`, `window != nil`, `isHidden`, `alpha`) as plain values so | ||
| /// the rule is testable without a UIKit view. | ||
| public struct Candidate: Equatable, Sendable { | ||
| public let hostSurfaceID: String? | ||
| public let hasSurface: Bool | ||
| public let hasWindow: Bool | ||
| public let isHidden: Bool | ||
| public let alpha: Double | ||
|
|
||
| public init( | ||
| hostSurfaceID: String?, | ||
| hasSurface: Bool, | ||
| hasWindow: Bool, | ||
| isHidden: Bool, | ||
| alpha: Double | ||
| ) { | ||
| self.hostSurfaceID = hostSurfaceID | ||
| self.hasSurface = hasSurface | ||
| self.hasWindow = hasWindow | ||
| self.isHidden = isHidden | ||
| self.alpha = alpha | ||
| } | ||
| } | ||
|
|
||
| /// Whether `candidate` is the live, on-screen surface for `surfaceID` and so | ||
| /// may back the "View as Text" capture. | ||
| /// | ||
| /// The id scoping keeps a second visible surface (another iPad scene, an | ||
| /// in-flight transition) from leaking a different workspace's terminal into | ||
| /// the capture; `hasSurface` keeps a dismantling view (registry-resident with | ||
| /// a non-nil pointer until its queued dispose runs) from contributing stale | ||
| /// text; the window/hidden/alpha gates keep an off-screen surface out. | ||
| public static func isEligible(_ candidate: Candidate, for surfaceID: String) -> Bool { | ||
| candidate.hostSurfaceID == surfaceID | ||
| && candidate.hasSurface | ||
| && candidate.hasWindow | ||
| && !candidate.isHidden | ||
| && candidate.alpha > 0.01 | ||
| } | ||
|
|
||
| /// The deterministic pick from the registered candidates: the lowest-keyed | ||
| /// eligible match. When the same terminal is mounted in several scenes the | ||
| /// contents are identical, so the lowest-keyed visible match keeps the pick | ||
| /// stable. `candidates` must be supplied lowest-key-first. | ||
| /// | ||
| /// - Returns: The index of the chosen candidate in `candidates`, or nil when | ||
| /// none is eligible. | ||
| public static func chosenIndex( | ||
| from candidates: [Candidate], | ||
| for surfaceID: String | ||
| ) -> Int? { | ||
| candidates.firstIndex { isEligible($0, for: surfaceID) } | ||
| } | ||
|
|
||
| /// The text the sheet should show given the SCREEN and VIEWPORT reads. | ||
| /// | ||
| /// `surfaceText` returns nil on a failed read and a non-nil empty string for | ||
| /// a zero-byte range, so a plain `screen ?? viewport` never fell back when | ||
| /// SCREEN read empty-but-ok. Here a SCREEN result that is nil OR empty falls | ||
| /// through to VIEWPORT, and only a VIEWPORT result that is also nil/empty | ||
| /// yields nil (the honest empty state). | ||
| /// | ||
| /// - Parameters: | ||
| /// - screen: The SCREEN (scrollback + all rows) read, nil on failure. | ||
| /// - viewport: The VIEWPORT (visible rows) read, nil on failure. | ||
| /// - Returns: The non-empty text to show, or nil when neither has content. | ||
| public static func resolvedText(screen: String?, viewport: String?) -> String? { | ||
| if let screen, !screen.isEmpty { return screen } | ||
| if let viewport, !viewport.isEmpty { return viewport } | ||
| return nil | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import CmuxMobileTerminalKit | ||
|
|
||
| @Suite("CopyableTerminalTextSelection: View as Text pick + fallback") | ||
| struct CopyableTerminalTextSelectionTests { | ||
| typealias Candidate = CopyableTerminalTextSelection.Candidate | ||
|
|
||
| private func visible(_ id: String?) -> Candidate { | ||
| Candidate(hostSurfaceID: id, hasSurface: true, hasWindow: true, isHidden: false, alpha: 1) | ||
| } | ||
|
|
||
| @Test("id-scoped, on-screen surface is eligible") | ||
| func eligibleMatch() { | ||
| #expect(CopyableTerminalTextSelection.isEligible(visible("surface:1"), for: "surface:1")) | ||
| } | ||
|
|
||
| @Test("a different id is never eligible") | ||
| func excludedByID() { | ||
| #expect(!CopyableTerminalTextSelection.isEligible(visible("surface:2"), for: "surface:1")) | ||
| #expect(!CopyableTerminalTextSelection.isEligible(visible(nil), for: "surface:1")) | ||
| } | ||
|
|
||
| @Test("a dismantling view with no surface is excluded") | ||
| func excludedWhenNoSurface() { | ||
| let dismantling = Candidate( | ||
| hostSurfaceID: "surface:1", hasSurface: false, hasWindow: true, isHidden: false, alpha: 1 | ||
| ) | ||
| #expect(!CopyableTerminalTextSelection.isEligible(dismantling, for: "surface:1")) | ||
| } | ||
|
|
||
| @Test("off-window / hidden / transparent surfaces are excluded") | ||
| func excludedWhenOffScreen() { | ||
| let detached = Candidate( | ||
| hostSurfaceID: "surface:1", hasSurface: true, hasWindow: false, isHidden: false, alpha: 1 | ||
| ) | ||
| let hidden = Candidate( | ||
| hostSurfaceID: "surface:1", hasSurface: true, hasWindow: true, isHidden: true, alpha: 1 | ||
| ) | ||
| let transparent = Candidate( | ||
| hostSurfaceID: "surface:1", hasSurface: true, hasWindow: true, isHidden: false, alpha: 0 | ||
| ) | ||
| #expect(!CopyableTerminalTextSelection.isEligible(detached, for: "surface:1")) | ||
| #expect(!CopyableTerminalTextSelection.isEligible(hidden, for: "surface:1")) | ||
| #expect(!CopyableTerminalTextSelection.isEligible(transparent, for: "surface:1")) | ||
| } | ||
|
|
||
| @Test("chosenIndex returns the first (lowest-keyed) eligible match") | ||
| func chosenIndexDeterministic() { | ||
| let candidates = [ | ||
| visible("surface:2"), // wrong id | ||
| visible("surface:1"), // first eligible | ||
| visible("surface:1"), // also eligible, but later | ||
| ] | ||
| #expect(CopyableTerminalTextSelection.chosenIndex(from: candidates, for: "surface:1") == 1) | ||
| } | ||
|
|
||
| @Test("chosenIndex is nil when nothing is eligible") | ||
| func chosenIndexNone() { | ||
| let candidates = [visible("surface:2"), visible(nil)] | ||
| #expect(CopyableTerminalTextSelection.chosenIndex(from: candidates, for: "surface:1") == nil) | ||
| } | ||
|
|
||
| @Test("non-empty SCREEN wins") | ||
| func screenWins() { | ||
| #expect( | ||
| CopyableTerminalTextSelection.resolvedText(screen: "scrollback", viewport: "visible") | ||
| == "scrollback" | ||
| ) | ||
| } | ||
|
|
||
| @Test("empty-string SCREEN falls back to VIEWPORT") | ||
| func emptyScreenFallsBackToViewport() { | ||
| // The exact reported bug: SCREEN read OK but zero-byte (non-nil ""), so a | ||
| // plain `screen ?? viewport` would keep the empty string and the sheet | ||
| // showed its empty state even though the viewport had content. | ||
| #expect( | ||
| CopyableTerminalTextSelection.resolvedText(screen: "", viewport: "visible text") | ||
| == "visible text" | ||
| ) | ||
| } | ||
|
|
||
| @Test("nil SCREEN falls back to VIEWPORT") | ||
| func nilScreenFallsBackToViewport() { | ||
| #expect( | ||
| CopyableTerminalTextSelection.resolvedText(screen: nil, viewport: "visible text") | ||
| == "visible text" | ||
| ) | ||
| } | ||
|
|
||
| @Test("both empty/nil yields nil (honest empty state)") | ||
| func bothEmptyYieldsNil() { | ||
| #expect(CopyableTerminalTextSelection.resolvedText(screen: "", viewport: "") == nil) | ||
| #expect(CopyableTerminalTextSelection.resolvedText(screen: nil, viewport: nil) == nil) | ||
| #expect(CopyableTerminalTextSelection.resolvedText(screen: "", viewport: nil) == nil) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onDismisscallback releases the task reference but does not callcancel()on it. If the user dismisses the sheet before theoutputQueueread completes, the unstructuredTaskcontinues running until it finishes (the surface read acquires the surface lock), andcapture.valuein the sheet's already-cancelled.taskis simply dropped. For a user-triggered, bounded read this is harmless, but explicitly cancelling is the safer idiom and signals intent to cooperating callers.