oficina / sheets Public

Keep Sheets draft updates local and decode sync off the UI actor

Paul Campbell pushed by paul@keymail.dev 736d34f0865fea00e7db7efcc7b12fbdaac80a85 parent bc1330c
7 files changed, +143 −56
  • native/apple/Sources/SheetsKit/SheetEditingControls.swift +48 −0
  • native/apple/Sources/SheetsKit/SheetLive.swift +20 −10
  • native/apple/Sources/SheetsKit/SheetModel.swift +11 −5
  • native/apple/Sources/SheetsKit/SheetWireWorker.swift +19 −0
  • native/apple/Sources/SheetsKit/SheetWrites.swift +4 −8
  • native/apple/Sources/SheetsKit/SheetsWorkspace.swift +2 −33
  • native/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")
+}