fix: fresh connect uses resize+snapshot instead of full history replay; wire onInput

This commit is contained in:
jay 2026-05-16 03:46:24 +02:00
parent 044a4920bb
commit 994b450fe4
2 changed files with 67 additions and 17 deletions

View File

@ -35,6 +35,10 @@ public final class SessionConnection: ObservableObject {
/// Emits every JSON frame received from the server. /// Emits every JSON frame received from the server.
public let stateEvents = PassthroughSubject<ServerToClient, Never>() public let stateEvents = PassthroughSubject<ServerToClient, Never>()
/// Emits snapshot content ready to feed directly into SwiftTerm:
/// ESC[H + ESC[2J (clear+home) followed by the pane's current content.
public let snapshots = PassthroughSubject<Data, Never>()
/// Tracks the WebSocket lifecycle. /// Tracks the WebSocket lifecycle.
@Published public private(set) var connectionState: ConnectionState = .disconnected @Published public private(set) var connectionState: ConnectionState = .disconnected
@ -97,23 +101,40 @@ public final class SessionConnection: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
// JSON frames `stateEvents` subject. // JSON frames `stateEvents` + snapshot converter.
ws.incomingJSON ws.incomingJSON
.sink { [weak self] frame in .sink { [weak self] frame in
self?.stateEvents.send(frame) guard let self else { return }
if case .snapshot(_, let base64) = frame {
// Decode base64 text, prepend clear+home, normalise line endings.
if let raw = Data(base64Encoded: base64),
let text = String(data: raw, encoding: .utf8) {
let header = "\u{1B}[H\u{1B}[2J" // cursor home + clear screen
let body = text.replacingOccurrences(of: "\n", with: "\r\n")
self.snapshots.send(Data((header + body).utf8))
}
}
self.stateEvents.send(frame)
} }
.store(in: &cancellables) .store(in: &cancellables)
// Once connected, send the resume frame. // Once connected, send the appropriate opening frame.
//
// lastSeq == nil fresh attach: live output already flows; caller
// will send resize then snapshotRequest. No frame sent here.
// lastSeq != nil reconnect after gap: replay missed output.
ws.connectionState ws.connectionState
.filter { $0 == .connected } .filter { $0 == .connected }
.first() .first()
.sink { [weak self, weak ws, lastSeq] _ in .sink { [weak self, weak ws, lastSeq] _ in
guard let self, let ws else { return } guard let self, let ws else { return }
Task { @MainActor [self, ws, lastSeq] in if let seq = lastSeq {
try? await ws.send(.resume(lastSeq: lastSeq)) Task { @MainActor [self, ws, seq] in
_ = self // silence unused-capture warning try? await ws.send(.resume(lastSeq: seq))
_ = self
}
} }
// fresh connect: caller drives resize snapshotRequest
} }
.store(in: &cancellables) .store(in: &cancellables)

View File

@ -62,7 +62,7 @@ struct MainTerminalView: View {
statusText = "Connecting to \(sessionId)" statusText = "Connecting to \(sessionId)"
let conn = SessionConnection(id: sessionId, credential: credential) let conn = SessionConnection(id: sessionId, credential: credential)
// Wire stream terminal // Wire live ANSI stream terminal
conn.stream conn.stream
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [terminalVC] data in .sink { [terminalVC] data in
@ -70,6 +70,14 @@ struct MainTerminalView: View {
} }
.store(in: &cancellables) .store(in: &cancellables)
// Wire snapshots terminal (already contains ESC[H + ESC[2J prefix)
conn.snapshots
.receive(on: DispatchQueue.main)
.sink { [terminalVC] data in
terminalVC.feed(data: data)
}
.store(in: &cancellables)
// Wire connection state status text // Wire connection state status text
conn.$connectionState conn.$connectionState
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -88,17 +96,38 @@ struct MainTerminalView: View {
Task { try? await conn.send(.resize(cols: cols, rows: rows)) } Task { try? await conn.send(.resize(cols: cols, rows: rows)) }
} }
connection = conn // Wire keyboard input from SwiftTerm sidecar.
await conn.resume(from: conn.scrollback.sizeBytes > 0 terminalVC.onInput = { [weak conn] data in
? ResumeCursor().lastSeq(for: sessionId) guard let conn,
: nil) let text = String(data: data, encoding: .utf8) else { return }
Task { try? await conn.send(.keys(data: text)) }
// Send the current terminal size immediately after connecting
// so tmux resizes before the first output frame arrives.
let (cols, rows) = terminalVC.terminalSize
if cols > 0 && rows > 0 {
try? await conn.send(.resize(cols: cols, rows: rows))
} }
// On first connection: resize brief pause (SIGWINCH propagation)
// snapshot. This avoids replaying history at the wrong size.
conn.$connectionState
.filter { $0 == .connected }
.first()
.receive(on: DispatchQueue.main)
.sink { [weak conn, terminalVC] _ in
Task { @MainActor in
let (cols, rows) = terminalVC.terminalSize
if cols > 0 && rows > 0 {
try? await conn?.send(.resize(cols: cols, rows: rows))
}
// Let tmux propagate SIGWINCH before snapshotting.
try? await Task.sleep(nanoseconds: 250_000_000)
try? await conn?.send(.snapshotRequest)
}
}
.store(in: &cancellables)
connection = conn
let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0
? ResumeCursor().lastSeq(for: sessionId)
: nil
await conn.resume(from: lastSeq)
} catch { } catch {
statusText = "Error: \(error.localizedDescription)" statusText = "Error: \(error.localizedDescription)"
} }