fix: fresh connect uses resize+snapshot instead of full history replay; wire onInput
This commit is contained in:
parent
044a4920bb
commit
994b450fe4
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue