A notebook cell holds a Pharo snippet and what it produced, updated field by field and synced back when it is re-run. Trace blobs move to a store of their own, read through one loader. The method mark's editor keeps its own keys and clicks, so editing inside an opened body works like editing anywhere else.
191 lines
6.1 KiB
Swift
191 lines
6.1 KiB
Swift
import Foundation
|
|
|
|
/// A single pending mutation against a lab's notebook. Ops are the atomic
|
|
/// unit of the collaboration protocol: clients persist them into the
|
|
/// outbox as they happen, the server deduplicates by `opID`, applies, and
|
|
/// broadcasts the authoritative result back (including the server-stamped
|
|
/// `editors` and `position`).
|
|
public enum NotebookOp: Sendable {
|
|
case add(Add)
|
|
case update(Update)
|
|
case remove(Remove)
|
|
case reorder(Reorder)
|
|
|
|
public var opID: UUID {
|
|
switch self {
|
|
case .add(let a): return a.opID
|
|
case .update(let u): return u.opID
|
|
case .remove(let r): return r.opID
|
|
case .reorder(let r): return r.opID
|
|
}
|
|
}
|
|
|
|
public var entryID: UUID {
|
|
switch self {
|
|
case .add(let a): return a.entry.id
|
|
case .update(let u): return u.entryID
|
|
case .remove(let r): return r.entryID
|
|
case .reorder(let r): return r.entryID
|
|
}
|
|
}
|
|
|
|
public var kind: String {
|
|
switch self {
|
|
case .add: return "add"
|
|
case .update: return "update"
|
|
case .remove: return "remove"
|
|
case .reorder: return "reorder"
|
|
}
|
|
}
|
|
|
|
public struct Add: Sendable {
|
|
public let opID: UUID
|
|
public var entry: NotebookEntry
|
|
|
|
public init(opID: UUID = UUID(), entry: NotebookEntry) {
|
|
self.opID = opID
|
|
self.entry = entry
|
|
}
|
|
}
|
|
|
|
public struct Update: Sendable {
|
|
public let opID: UUID
|
|
public let entryID: UUID
|
|
public var title: String?
|
|
public var details: String?
|
|
public var processName: String?
|
|
|
|
public var pharoSnapshot: PharoSnapshot?
|
|
|
|
public init(
|
|
opID: UUID = UUID(),
|
|
entryID: UUID,
|
|
title: String? = nil,
|
|
details: String? = nil,
|
|
processName: String? = nil,
|
|
pharoSnapshot: PharoSnapshot? = nil
|
|
) {
|
|
self.opID = opID
|
|
self.entryID = entryID
|
|
self.title = title
|
|
self.details = details
|
|
self.processName = processName
|
|
self.pharoSnapshot = pharoSnapshot
|
|
}
|
|
|
|
public var changes: [String: String] {
|
|
var out: [String: String] = [:]
|
|
if let title { out["title"] = title }
|
|
if let details { out["details"] = details }
|
|
if let processName { out["process_name"] = processName }
|
|
return out
|
|
}
|
|
}
|
|
|
|
public struct Remove: Sendable {
|
|
public let opID: UUID
|
|
public let entryID: UUID
|
|
|
|
public init(opID: UUID = UUID(), entryID: UUID) {
|
|
self.opID = opID
|
|
self.entryID = entryID
|
|
}
|
|
}
|
|
|
|
public struct Reorder: Sendable {
|
|
public let opID: UUID
|
|
public let entryID: UUID
|
|
public var position: Double
|
|
|
|
public init(opID: UUID = UUID(), entryID: UUID, position: Double) {
|
|
self.opID = opID
|
|
self.entryID = entryID
|
|
self.position = position
|
|
}
|
|
}
|
|
|
|
/// Envelope payload for a `+op` notification. The accompanying bus
|
|
/// message carries binary data for `add` ops in the standard way.
|
|
public func toJSON() -> [String: Any] {
|
|
var obj: [String: Any] = [
|
|
"op_id": opID.uuidString,
|
|
"kind": kind,
|
|
]
|
|
switch self {
|
|
case .add(let a):
|
|
obj["entry"] = a.entry.toJSON()
|
|
case .update(let u):
|
|
obj["entry_id"] = u.entryID.uuidString
|
|
var changes: [String: Any] = u.changes
|
|
if let snapshot = u.pharoSnapshot,
|
|
let data = try? JSONEncoder().encode(snapshot),
|
|
let jsonObject = try? JSONSerialization.jsonObject(with: data)
|
|
{
|
|
changes["pharo_snapshot"] = jsonObject
|
|
}
|
|
obj["changes"] = changes
|
|
case .remove(let r):
|
|
obj["entry_id"] = r.entryID.uuidString
|
|
case .reorder(let r):
|
|
obj["entry_id"] = r.entryID.uuidString
|
|
obj["position"] = r.position
|
|
}
|
|
return obj
|
|
}
|
|
|
|
public static func fromJSON(_ obj: [String: Any], binaryData: [UInt8]?) -> NotebookOp? {
|
|
guard let opIDStr = obj["op_id"] as? String,
|
|
let opID = UUID(uuidString: opIDStr),
|
|
let kind = obj["kind"] as? String
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
switch kind {
|
|
case "add":
|
|
guard let entryObj = obj["entry"] as? [String: Any],
|
|
let entry = NotebookEntry.fromJSON(entryObj, binaryData: binaryData)
|
|
else { return nil }
|
|
return .add(Add(opID: opID, entry: entry))
|
|
|
|
case "update":
|
|
guard let entryIDStr = obj["entry_id"] as? String,
|
|
let entryID = UUID(uuidString: entryIDStr),
|
|
let changes = obj["changes"] as? [String: Any]
|
|
else { return nil }
|
|
var snapshot: PharoSnapshot? = nil
|
|
if let raw = changes["pharo_snapshot"],
|
|
let data = try? JSONSerialization.data(withJSONObject: raw),
|
|
let decoded = try? JSONDecoder().decode(PharoSnapshot.self, from: data)
|
|
{
|
|
snapshot = decoded
|
|
}
|
|
return .update(Update(
|
|
opID: opID,
|
|
entryID: entryID,
|
|
title: changes["title"] as? String,
|
|
details: changes["details"] as? String,
|
|
processName: changes["process_name"] as? String,
|
|
pharoSnapshot: snapshot
|
|
))
|
|
|
|
case "remove":
|
|
guard let entryIDStr = obj["entry_id"] as? String,
|
|
let entryID = UUID(uuidString: entryIDStr)
|
|
else { return nil }
|
|
return .remove(Remove(opID: opID, entryID: entryID))
|
|
|
|
case "reorder":
|
|
guard let entryIDStr = obj["entry_id"] as? String,
|
|
let entryID = UUID(uuidString: entryIDStr),
|
|
let position = (obj["position"] as? Double)
|
|
?? (obj["position"] as? NSNumber)?.doubleValue
|
|
else { return nil }
|
|
return .reorder(Reorder(opID: opID, entryID: entryID, position: position))
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|