- Store source files as named CustomInstrumentFile rows per def - Render sidebar with disclosure groups - Drive editor via Monaco project models with activePath - Validate Add/Rename/Delete dialogs and submit on Enter - Fork installed hook packs into editable custom instruments - Reload instances on def change; clear stale compat reasons live - Focus editor on selection (native + Monaco)
113 lines
3.6 KiB
Swift
113 lines
3.6 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
@Observable
|
|
@MainActor
|
|
public final class CustomInstrumentLibrary {
|
|
public private(set) var defs: [CustomInstrumentDef] = []
|
|
public private(set) var filesByDef: [UUID: [CustomInstrumentFile]] = [:]
|
|
|
|
@ObservationIgnored public var observers: [@MainActor () -> Void] = []
|
|
@ObservationIgnored private var defsObservation: StoreObservation?
|
|
@ObservationIgnored private var filesObservation: StoreObservation?
|
|
|
|
public init() {}
|
|
|
|
public func start(store: ProjectStore) {
|
|
defs = Self.normalizeAndPersistIfChanged(
|
|
(try? store.fetchCustomInstrumentDefs()) ?? [],
|
|
store: store
|
|
)
|
|
filesByDef = Dictionary(
|
|
grouping: (try? store.fetchAllCustomInstrumentFiles()) ?? [],
|
|
by: \.defID
|
|
)
|
|
defsObservation = store.observeCustomInstrumentDefs { [weak self, store] defs in
|
|
let healed = Self.normalizeAndPersistIfChanged(defs, store: store)
|
|
Task { @MainActor in
|
|
self?.applyDefsChange(healed)
|
|
}
|
|
}
|
|
filesObservation = store.observeCustomInstrumentFiles { [weak self] grouped in
|
|
Task { @MainActor in
|
|
self?.applyFilesChange(grouped)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func def(withId id: UUID) -> CustomInstrumentDef? {
|
|
defs.first { $0.id == id }
|
|
}
|
|
|
|
public func files(forDefID id: UUID) -> [CustomInstrumentFile] {
|
|
filesByDef[id] ?? []
|
|
}
|
|
|
|
public func file(defID: UUID, path: String) -> CustomInstrumentFile? {
|
|
filesByDef[defID]?.first { $0.path == path }
|
|
}
|
|
|
|
public func bundle(forDefID id: UUID) -> CustomInstrumentBundle? {
|
|
guard let def = def(withId: id) else { return nil }
|
|
return CustomInstrumentBundle(def: def, files: files(forDefID: id))
|
|
}
|
|
|
|
public func descriptors() -> [InstrumentDescriptor] {
|
|
defs.map(descriptor(for:))
|
|
}
|
|
|
|
public func descriptor(for def: CustomInstrumentDef) -> InstrumentDescriptor {
|
|
let defID = def.id
|
|
let initialFeatures = initialFeatureStates(for: def)
|
|
return InstrumentDescriptor(
|
|
id: "custom:\(defID.uuidString)",
|
|
kind: .custom,
|
|
sourceIdentifier: defID.uuidString,
|
|
displayName: def.name,
|
|
icon: def.icon,
|
|
compatibility: def.compatibility,
|
|
makeInitialConfigJSON: {
|
|
CustomInstrumentConfig(defID: defID, features: initialFeatures).encode()
|
|
}
|
|
)
|
|
}
|
|
|
|
public static func initialFeatureStates(for def: CustomInstrumentDef) -> [String: FeatureState] {
|
|
Dictionary(uniqueKeysWithValues: def.features.map {
|
|
($0.id, FeatureState(enabled: $0.enabledByDefault, value: $0.schema.defaultValue))
|
|
})
|
|
}
|
|
|
|
private func initialFeatureStates(for def: CustomInstrumentDef) -> [String: FeatureState] {
|
|
Self.initialFeatureStates(for: def)
|
|
}
|
|
|
|
nonisolated private static func normalizeAndPersistIfChanged(
|
|
_ defs: [CustomInstrumentDef],
|
|
store: ProjectStore
|
|
) -> [CustomInstrumentDef] {
|
|
defs.map { def in
|
|
var copy = def
|
|
copy.normalize()
|
|
if copy != def {
|
|
try? store.save(copy)
|
|
}
|
|
return copy
|
|
}
|
|
}
|
|
|
|
private func applyDefsChange(_ defs: [CustomInstrumentDef]) {
|
|
self.defs = defs
|
|
notifyObservers()
|
|
}
|
|
|
|
private func applyFilesChange(_ filesByDef: [UUID: [CustomInstrumentFile]]) {
|
|
self.filesByDef = filesByDef
|
|
notifyObservers()
|
|
}
|
|
|
|
private func notifyObservers() {
|
|
for observer in observers { observer() }
|
|
}
|
|
}
|