Files
luma/Sources/LumaCore/CustomInstrumentLibrary.swift
Ole André Vadla Ravnås d1606523b5 Support multi-file custom instruments
- 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)
2026-05-13 08:54:48 +02:00

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() }
}
}