Keep Sheets draft updates local and decode sync off the UI actor
7 files changed,
+143
−56
native/apple/Sources/SheetsKit/SheetEditingControls.swift+48 −0native/apple/Sources/SheetsKit/SheetLive.swift+20 −10native/apple/Sources/SheetsKit/SheetModel.swift+11 −5native/apple/Sources/SheetsKit/SheetWireWorker.swift+19 −0native/apple/Sources/SheetsKit/SheetWrites.swift+4 −8native/apple/Sources/SheetsKit/SheetsWorkspace.swift+2 −33native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift+39 −0
CI passed — run details
diff --git a/native/apple/Sources/SheetsKit/SheetEditingControls.swift b/native/apple/Sources/SheetsKit/SheetEditingControls.swift| new file mode 100644 |
| index 0000000..03a50d0 |
| --- /dev/null |
| +++ b/native/apple/Sources/SheetsKit/SheetEditingControls.swift |
| @@ -0,0 +1,48 @@ |
| +import SwiftUI |
| + |
| +// Draft observation stays in these small views, away from the grid hierarchy. |
| +struct SheetFormulaBar: View { |
| + @Bindable var model: SheetModel |
| + var formulaFocused: FocusState<Bool>.Binding |
| + var body: some View { |
| + HStack { |
| + TextField("Cell or range", text: $model.rangeText).frame(width: 110).onSubmit { |
| + model.selectRange() |
| + }.accessibilityLabel("Cell or range, for example A1:B5") |
| + Text("fx").foregroundStyle(.secondary) |
| + TextField("Cell contents or formula", text: $model.draft).focused(formulaFocused).disabled( |
| + !model.mayWrite || model.busy || !model.failedWrites.isEmpty |
| + ) |
| + .autocorrectionDisabled() |
| + #if os(iOS) |
| + .textInputAutocapitalization(.never) |
| + #endif |
| + .onSubmit { Task { await model.commit() } } |
| + .onKeyPress(.escape) { |
| + model.discard() |
| + return .handled |
| + } |
| + .onKeyPress(.tab) { |
| + Task { await model.commit(moveRight: true) } |
| + return .handled |
| + } |
| + if model.dirty { |
| + Button(sheetsMacString("Save")) { Task { await model.commit() } }.disabled(model.busy) |
| + Button("Discard") { model.discard() }.disabled(model.busy) |
| + } |
| + }.textFieldStyle(.roundedBorder).padding(8) |
| + } |
| +} |
| + |
| +struct SheetStatusBar: View { |
| + var model: SheetModel |
| + var body: some View { |
| + HStack { |
| + Text(model.page?.Sheet.TabName ?? "Sheet") |
| + Spacer() |
| + if model.busy { ProgressView().controlSize(.small) } |
| + Text(model.dirty ? "Unsaved cell edit" : model.notice) |
| + Text(model.live).foregroundStyle(.secondary) |
| + }.font(.caption).padding(8) |
| + } |
| +} |
diff --git a/native/apple/Sources/SheetsKit/SheetLive.swift b/native/apple/Sources/SheetsKit/SheetLive.swift| index dced629..45de52d 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetLive.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetLive.swift |
| @@ -3,37 +3,47 @@ import Foundation |
| @MainActor extension SheetModel { |
| func watch() async { |
| guard page != nil else { return } |
| - let identity = page?.Sheet.ID |
| - while !Task.isCancelled && page?.Sheet.ID == identity { |
| + let source = grid |
| + while !Task.isCancelled && grid == source { |
| live = "Connecting" |
| do { |
| // Subscribe before fetching the snapshot in onOpen; buffered deltas |
| // then reconcile by version once the initial fetch finishes. |
| let parser = SheetEventParser() |
| try await client.stream( |
| - grid + "/stream", |
| + source + "/stream", |
| onOpen: { [weak self] in |
| - await self?.refresh() |
| - await self?.setLive() |
| + await self?.streamOpened(source: source) |
| } |
| ) { [weak self] line in |
| let event = await parser.receive(line) |
| guard let event else { return } |
| - await self?.received(event) |
| + await self?.received(event, source: source) |
| } |
| - } catch is CancellationError { return } catch { live = "Reconnecting" } |
| + } catch is CancellationError { return } catch { |
| + guard !Task.isCancelled, grid == source else { return } |
| + live = "Reconnecting" |
| + } |
| if live == "Access changed" || live == "Paused" { return } |
| do { try await Task.sleep(for: .seconds(2)) } catch { return } |
| } |
| } |
| - private func setLive() { live = "Live" } |
| - private func received(_ event: SheetEvent) async { |
| + private func streamOpened(source: String) async { |
| + guard !Task.isCancelled, grid == source else { return } |
| + await refresh() |
| + guard !Task.isCancelled, grid == source else { return } |
| + live = "Live" |
| + } |
| + func received(_ event: SheetEvent, source: String) async { |
| + guard !Task.isCancelled, grid == source else { return } |
| switch event.name { |
| case "cells": |
| live = "Live" |
| - if let response = try? JSONDecoder().decode(GridResponse.self, from: Data(event.data.utf8)) { |
| + if let response = try? await wire.decode(Data(event.data.utf8)) { |
| + guard !Task.isCancelled, grid == source else { return } |
| await accept(response) |
| } else { |
| + guard !Task.isCancelled, grid == source else { return } |
| await refresh() |
| } |
| case "resync": await refresh() |
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift| index 2ad4dc7..560ac95 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetModel.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetModel.swift |
| @@ -29,6 +29,7 @@ import RastrilloApple |
| var showTable = false |
| var copied: [[CellWrite]]? |
| var copiedText = "" |
| + let wire = SheetWireWorker() |
| let client: NativeHTTP |
| init(client: NativeHTTP) { self.client = client } |
| var base: String { "/sheets/\(page?.Sheet.ID ?? 0)" } |
| @@ -65,7 +66,8 @@ import RastrilloApple |
| } catch { self.error = error.localizedDescription } |
| } |
| func refresh() async { |
| - guard let sheetID = page?.Sheet.ID else { return } |
| + guard page != nil else { return } |
| + let source = grid |
| do { |
| var updated: [Coordinate: Cell] = [:] |
| var formats: [String: CellStyle] = [:] |
| @@ -81,8 +83,8 @@ import RastrilloApple |
| guard status == 200 else { |
| throw NativeClientError.message("The sheet could not be refreshed.") |
| } |
| - let response = try JSONDecoder().decode(GridResponse.self, from: data) |
| - guard page?.Sheet.ID == sheetID else { return } |
| + let response = try await wire.decode(data) |
| + guard !Task.isCancelled, grid == source else { return } |
| fetchedVersion = min(fetchedVersion, response.version) |
| for cell in response.cells ?? [] { updated[cell.coordinate] = cell } |
| formats.merge(response.formats ?? [:]) { _, new in new } |
| @@ -106,9 +108,13 @@ import RastrilloApple |
| await refresh() |
| return |
| } |
| + var nextCells = cells |
| + for cell in response.cells ?? [] { nextCells[cell.coordinate] = cell } |
| + var nextStyles = styles |
| + nextStyles.merge(response.formats ?? [:]) { _, new in new } |
| version = response.version |
| - for cell in response.cells ?? [] { cells[cell.coordinate] = cell } |
| - styles.merge(response.formats ?? [:]) { _, new in new } |
| + cells = nextCells |
| + styles = nextStyles |
| if let sizes = response.sizes { self.sizes = sizes } |
| refreshDraftIfClean() |
| } |
diff --git a/native/apple/Sources/SheetsKit/SheetWireWorker.swift b/native/apple/Sources/SheetsKit/SheetWireWorker.swift| new file mode 100644 |
| index 0000000..ca7a6fc |
| --- /dev/null |
| +++ b/native/apple/Sources/SheetsKit/SheetWireWorker.swift |
| @@ -0,0 +1,19 @@ |
| +import Foundation |
| + |
| +// Network awaits alone do not move JSON processing off the main actor. |
| +actor SheetWireWorker { |
| + func decode(_ data: Data) throws -> GridResponse { |
| + try JSONDecoder().decode(GridResponse.self, from: data) |
| + } |
| + |
| + func encode(_ writes: [CellWrite], startingAt offset: Int) throws -> (data: Data, count: Int) { |
| + struct Batch: Encodable { let cells: [CellWrite] } |
| + var count = min(500, writes.count - offset) |
| + var data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count]))) |
| + while data.count > 1 << 20 && count > 1 { |
| + count /= 2 |
| + data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count]))) |
| + } |
| + return (data, count) |
| + } |
| +} |
diff --git a/native/apple/Sources/SheetsKit/SheetWrites.swift b/native/apple/Sources/SheetsKit/SheetWrites.swift| index 1f6384f..375e005 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetWrites.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetWrites.swift |
| @@ -17,20 +17,16 @@ import RastrilloApple |
| defer { busy = false } |
| var offset = 0 |
| do { |
| - struct Batch: Encodable { let cells: [CellWrite] } |
| while offset < writes.count { |
| - var count = min(500, writes.count - offset) |
| - var data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count]))) |
| - while data.count > 1 << 20 && count > 1 { |
| - count /= 2 |
| - data = try JSONEncoder().encode(Batch(cells: Array(writes[offset..<offset + count]))) |
| - } |
| + let batch = try await wire.encode(writes, startingAt: offset) |
| + let count = batch.count |
| + let data = batch.data |
| let (bytes, status) = try await client.response( |
| grid + "/cells/batch", method: "POST", body: data, maxBytes: 64 << 20) |
| guard (200..<300).contains(status) else { |
| throw NativeClientError.message("The server did not confirm the edit.") |
| } |
| - let response = try JSONDecoder().decode(GridResponse.self, from: bytes) |
| + let response = try await wire.decode(bytes) |
| await accept(response) |
| offset += count |
| } |
diff --git a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift| index 41263ee..77cffab 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift |
| @@ -223,32 +223,7 @@ import SwiftUI |
| .frame(maxWidth: .infinity, alignment: .leading) |
| }.padding(8) |
| #endif |
| - HStack { |
| - TextField("Cell or range", text: $model.rangeText).frame(width: 110).onSubmit { |
| - model.selectRange() |
| - }.accessibilityLabel("Cell or range, for example A1:B5") |
| - Text("fx").foregroundStyle(.secondary) |
| - TextField("Cell contents or formula", text: $model.draft).focused($formulaFocused).disabled( |
| - !model.mayWrite || model.busy || !model.failedWrites.isEmpty |
| - ) |
| - .autocorrectionDisabled() |
| - #if os(iOS) |
| - .textInputAutocapitalization(.never) |
| - #endif |
| - .onSubmit { Task { await model.commit() } } |
| - .onKeyPress(.escape) { |
| - model.discard() |
| - return .handled |
| - } |
| - .onKeyPress(.tab) { |
| - Task { await model.commit(moveRight: true) } |
| - return .handled |
| - } |
| - if model.dirty { |
| - Button(sheetsMacString("Save")) { Task { await model.commit() } }.disabled(model.busy) |
| - Button("Discard") { model.discard() }.disabled(model.busy) |
| - } |
| - }.textFieldStyle(.roundedBorder).padding(8) |
| + SheetFormulaBar(model: model, formulaFocused: $formulaFocused) |
| if !model.failedWrites.isEmpty { |
| HStack { |
| Button("Retry pending edits") { Task { await model.retryWrites() } }.disabled(model.busy) |
| @@ -267,13 +242,7 @@ import SwiftUI |
| } else { |
| SheetGrid(model: model, beginEditing: { formulaFocused = true }) |
| } |
| - HStack { |
| - Text(model.page?.Sheet.TabName ?? "Sheet") |
| - Spacer() |
| - if model.busy { ProgressView().controlSize(.small) } |
| - Text(model.dirty ? "Unsaved cell edit" : model.notice) |
| - Text(model.live).foregroundStyle(.secondary) |
| - }.font(.caption).padding(8) |
| + SheetStatusBar(model: model) |
| if model.page?.Truncated == true { |
| Text("This grid shows up to 500 rows and 200 columns. Export includes the complete sheet.") |
| .font(.caption).padding(4) |
diff --git a/native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift b/native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift| new file mode 100644 |
| index 0000000..3331883 |
| --- /dev/null |
| +++ b/native/apple/Tests/SheetsKitTests/SheetWireWorkerTests.swift |
| @@ -0,0 +1,39 @@ |
| +import Foundation |
| +import Testing |
| +import RastrilloApple |
| +@testable import SheetsKit |
| + |
| +@Test func backgroundBatchesRespectSizeAndRetainEveryCell() async throws { |
| + let worker = SheetWireWorker() |
| + let writes = (0..<620).map { CellWrite(r: $0, c: 0, raw: String(repeating: "é", count: 4096)) } |
| + var offset = 0 |
| + while offset < writes.count { |
| + let batch = try await worker.encode(writes, startingAt: offset) |
| + #expect(batch.count > 0 && batch.count <= 500) |
| + #expect(batch.data.count <= 1 << 20) |
| + let object = try #require(JSONSerialization.jsonObject(with: batch.data) as? [String: Any]) |
| + let cells = try #require(object["cells"] as? [[String: Any]]) |
| + #expect(cells.count == batch.count) |
| + for (index, cell) in cells.enumerated() { |
| + #expect(cell["r"] as? Int == offset + index) |
| + #expect(cell["raw"] as? String == writes[offset + index].raw) |
| + } |
| + offset += batch.count |
| + } |
| + let response = try await worker.decode(Data(#"{"version":42,"cells":[{"r":3,"c":2,"raw":"=1+1","display":"2"}]}"#.utf8)) |
| + #expect(response.version == 42) |
| + #expect(response.cells?.first?.raw == "=1+1") |
| + await #expect(throws: (any Error).self) { try await worker.decode(Data("invalid".utf8)) } |
| +} |
| + |
| +@MainActor @Test func oldStreamCannotClearOrOverwriteCurrentSheet() async { |
| + let model = SheetModel(client: NativeHTTP(origin: URL(string: "http://localhost")!)) |
| + let point = Coordinate(r: 0, c: 0) |
| + model.cells[point] = Cell(r: 0, c: 0, raw: "current", display: "current") |
| + model.live = "Live" |
| + await model.received(SheetEvent(name: "bye", data: "access-changed"), source: "/api/sheets/old/tabs/old") |
| + await model.received(SheetEvent(name: "cells", data: #"{"version":99,"cells":[{"r":0,"c":0,"raw":"old","display":"old"}]}"#), source: "/api/sheets/old/tabs/old") |
| + #expect(model.cells[point]?.raw == "current") |
| + #expect(model.version == 0) |
| + #expect(model.live == "Live") |
| +} |