Skip to content

Commit 0f1f479

Browse files
committed
feat: introduce concurrent export for improved performance
1 parent bde7df1 commit 0f1f479

5 files changed

Lines changed: 218 additions & 80 deletions

File tree

Sources/ExFigCLI/Batch/BatchConfigRunner.swift

Lines changed: 140 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// swiftlint:disable file_length
12
import ExFigConfig
23
import FigmaAPI
34
import Foundation
@@ -37,6 +38,7 @@ struct SubcommandConfigExporter: ConfigExportPerforming {
3738
try await runExports(options: options, client: client, ui: ui)
3839
}
3940

41+
// swiftlint:disable function_body_length cyclomatic_complexity
4042
private func runExports(
4143
options: ExFigOptions,
4244
client: Client,
@@ -47,65 +49,115 @@ struct SubcommandConfigExporter: ConfigExportPerforming {
4749
let heavyFaultTolerance = makeHeavyFaultToleranceOptions()
4850
let params = options.params
4951

52+
enum StepResult: Sendable {
53+
case colors(ExportStats)
54+
case icons(ExportStats)
55+
case images(ExportStats)
56+
case typography(ExportStats)
57+
}
58+
5059
var stats = ExportStats.zero
5160

52-
// Colors export
53-
if hasColorsConfig(params) {
54-
let cmd = makeColors(options: options, cacheOptions: cacheOpts, faultToleranceOptions: faultTolerance)
55-
let result = try await cmd.performExportWithResult(
56-
client: client,
57-
ui: ui,
58-
context: configContext.with(assetType: .colors)
59-
)
60-
stats += ExportStats(colors: result.count, fileVersions: result.fileVersions)
61-
}
61+
try await withThrowingTaskGroup(of: StepResult.self) { [self] group in
62+
if hasColorsConfig(params) {
63+
group.addTask { [self] in
64+
let cmd = makeColors(
65+
options: options,
66+
cacheOptions: cacheOpts,
67+
faultToleranceOptions: faultTolerance
68+
)
69+
let result = try await cmd.performExportWithResult(
70+
client: client,
71+
ui: ui,
72+
context: configContext.with(assetType: .colors)
73+
)
74+
return .colors(ExportStats(colors: result.count, fileVersions: result.fileVersions))
75+
}
76+
}
6277

63-
// Icons export
64-
if hasIconsConfig(params) {
65-
let cmd = makeIcons(options: options, cacheOptions: cacheOpts, faultToleranceOptions: heavyFaultTolerance)
66-
let result = try await cmd.performExportWithResult(
67-
client: client,
68-
ui: ui,
69-
context: configContext.with(assetType: .icons)
70-
)
71-
stats += ExportStats(
72-
icons: result.count,
73-
computedNodeHashes: result.computedHashes,
74-
granularCacheStats: result.granularCacheStats,
75-
fileVersions: result.fileVersions
76-
)
77-
}
78+
if hasIconsConfig(params) {
79+
group.addTask { [self] in
80+
let cmd = makeIcons(
81+
options: options, cacheOptions: cacheOpts, faultToleranceOptions: heavyFaultTolerance
82+
)
83+
let result = try await cmd.performExportWithResult(
84+
client: client,
85+
ui: ui,
86+
context: configContext.with(assetType: .icons)
87+
)
88+
return .icons(ExportStats(
89+
icons: result.count,
90+
computedNodeHashes: result.computedHashes,
91+
granularCacheStats: result.granularCacheStats,
92+
fileVersions: result.fileVersions
93+
))
94+
}
95+
}
7896

79-
// Images export
80-
if hasImagesConfig(params) {
81-
let cmd = makeImages(options: options, cacheOptions: cacheOpts, faultToleranceOptions: heavyFaultTolerance)
82-
let result = try await cmd.performExportWithResult(
83-
client: client,
84-
ui: ui,
85-
context: configContext.with(assetType: .images)
86-
)
87-
stats += ExportStats(
88-
images: result.count,
89-
computedNodeHashes: result.computedHashes,
90-
granularCacheStats: result.granularCacheStats,
91-
fileVersions: result.fileVersions
92-
)
93-
}
97+
if hasImagesConfig(params) {
98+
group.addTask { [self] in
99+
let cmd = makeImages(
100+
options: options, cacheOptions: cacheOpts, faultToleranceOptions: heavyFaultTolerance
101+
)
102+
let result = try await cmd.performExportWithResult(
103+
client: client,
104+
ui: ui,
105+
context: configContext.with(assetType: .images)
106+
)
107+
return .images(ExportStats(
108+
images: result.count,
109+
computedNodeHashes: result.computedHashes,
110+
granularCacheStats: result.granularCacheStats,
111+
fileVersions: result.fileVersions
112+
))
113+
}
114+
}
94115

95-
// Typography export
96-
if hasTypographyConfig(params) {
97-
let cmd = makeTypography(options: options, cacheOptions: cacheOpts, faultToleranceOptions: faultTolerance)
98-
let result = try await cmd.performExportWithResult(
99-
client: client,
100-
ui: ui,
101-
context: configContext.with(assetType: .typography)
102-
)
103-
stats += ExportStats(typography: result.count, fileVersions: result.fileVersions)
116+
if hasTypographyConfig(params) {
117+
group.addTask { [self] in
118+
let cmd = makeTypography(
119+
options: options, cacheOptions: cacheOpts, faultToleranceOptions: faultTolerance
120+
)
121+
let result = try await cmd.performExportWithResult(
122+
client: client,
123+
ui: ui,
124+
context: configContext.with(assetType: .typography)
125+
)
126+
return .typography(ExportStats(typography: result.count, fileVersions: result.fileVersions))
127+
}
128+
}
129+
130+
for try await stepResult in group {
131+
switch stepResult {
132+
case let .colors(s):
133+
stats += s
134+
if let cb = configContext.stepCompletionCallback {
135+
await cb(.colors, s.colors)
136+
}
137+
case let .icons(s):
138+
stats += s
139+
if let cb = configContext.stepCompletionCallback {
140+
await cb(.icons, s.icons)
141+
}
142+
case let .images(s):
143+
stats += s
144+
if let cb = configContext.stepCompletionCallback {
145+
await cb(.images, s.images)
146+
}
147+
case let .typography(s):
148+
stats += s
149+
if let cb = configContext.stepCompletionCallback {
150+
await cb(.typography, s.typography)
151+
}
152+
}
153+
}
104154
}
105155

106156
return stats
107157
}
108158

159+
// swiftlint:enable function_body_length cyclomatic_complexity
160+
109161
private func makeCacheOptions() -> CacheOptions {
110162
var options = CacheOptions()
111163
options.cache = cache
@@ -209,6 +261,26 @@ struct BatchConfigRunner: Sendable {
209261
_testExporter = exporter
210262
}
211263

264+
// swiftlint:disable cyclomatic_complexity
265+
266+
/// Count the number of active asset types in a config (colors, icons, images, typography).
267+
static func countActiveAssetTypes(_ params: ExFig.ModuleImpl?) -> Int {
268+
guard let params else { return 0 }
269+
let checks: [Bool] = [
270+
params.common?.colors != nil || params.common?.variablesColors != nil
271+
|| params.ios?.colors != nil || params.android?.colors != nil
272+
|| params.flutter?.colors != nil || params.web?.colors != nil,
273+
params.ios?.icons != nil || params.android?.icons != nil
274+
|| params.flutter?.icons != nil || params.web?.icons != nil,
275+
params.ios?.images != nil || params.android?.images != nil
276+
|| params.flutter?.images != nil || params.web?.images != nil,
277+
params.ios?.typography != nil || params.android?.typography != nil,
278+
]
279+
return checks.filter { $0 }.count
280+
}
281+
282+
// swiftlint:enable cyclomatic_complexity
283+
212284
// swiftlint:disable:next function_body_length
213285
func process(
214286
configFile: ConfigFile,
@@ -268,11 +340,28 @@ struct BatchConfigRunner: Sendable {
268340
nil
269341
}
270342

343+
// Create step completion callback that routes to batch progress view
344+
let stepCallback: ConfigExecutionContext.StepCompletionCallback? =
345+
if let pv = progressView {
346+
{ (assetType: ConfigExecutionContext.AssetType, count: Int) in
347+
await pv.completeExportStep(name: configName, assetType: assetType, count: count)
348+
}
349+
} else {
350+
nil
351+
}
352+
353+
// Set total steps before export starts
354+
if let pv = progressView {
355+
let totalSteps = Self.countActiveAssetTypes(options.params)
356+
await pv.setTotalSteps(name: configName, total: totalSteps)
357+
}
358+
271359
// Create per-config execution context (passed explicitly, no TaskLocal nesting)
272360
let configContext = ConfigExecutionContext(
273361
configId: configFile.name,
274362
configPriority: configPriority,
275-
downloadProgressCallback: downloadCallback
363+
downloadProgressCallback: downloadCallback,
364+
stepCompletionCallback: stepCallback
276365
)
277366

278367
// Use test exporter if provided, otherwise create real exporter
@@ -296,15 +385,8 @@ struct BatchConfigRunner: Sendable {
296385
ui: ui
297386
)
298387

299-
// Update progress view with final counts or log success
388+
// Mark config as succeeded (step progress already updated incrementally)
300389
if let progressView {
301-
await progressView.updateProgress(
302-
name: configFile.name,
303-
colors: stats.colors > 0 ? (stats.colors, stats.colors) : nil,
304-
icons: stats.icons > 0 ? (stats.icons, stats.icons) : nil,
305-
images: stats.images > 0 ? (stats.images, stats.images) : nil,
306-
typography: stats.typography > 0 ? (stats.typography, stats.typography) : nil
307-
)
308390
await progressView.succeedConfig(name: configFile.name)
309391
} else {
310392
ui.success("Completed: \(configFile.name)")

Sources/ExFigCLI/Batch/BatchContext.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ struct ConfigExecutionContext: Sendable {
6666
/// Parameters: (assetType, current, total)
6767
typealias DownloadProgressCallback = @Sendable (AssetType, Int, Int) async -> Void
6868

69+
/// Callback type for reporting step (asset type) completion to batch progress view.
70+
/// Parameters: (assetType, exportedCount)
71+
typealias StepCompletionCallback = @Sendable (AssetType, Int) async -> Void
72+
6973
/// Config identifier for progress tracking and logging.
7074
let configId: String
7175

@@ -80,6 +84,10 @@ struct ConfigExecutionContext: Sendable {
8084
/// in `BatchProgressView.updateProgress()`.
8185
let downloadProgressCallback: DownloadProgressCallback?
8286

87+
/// Callback to report step (asset type) completion to batch progress view.
88+
/// Called from `runExports()` after each parallel export step finishes.
89+
let stepCompletionCallback: StepCompletionCallback?
90+
8391
/// Asset types that can be processed.
8492
enum AssetType: String, Sendable {
8593
case colors
@@ -92,22 +100,25 @@ struct ConfigExecutionContext: Sendable {
92100
configId: String,
93101
configPriority: Int = 0,
94102
assetType: AssetType? = nil,
95-
downloadProgressCallback: DownloadProgressCallback? = nil
103+
downloadProgressCallback: DownloadProgressCallback? = nil,
104+
stepCompletionCallback: StepCompletionCallback? = nil
96105
) {
97106
self.configId = configId
98107
self.configPriority = configPriority
99108
self.assetType = assetType
100109
self.downloadProgressCallback = downloadProgressCallback
110+
self.stepCompletionCallback = stepCompletionCallback
101111
}
102112

103-
/// Returns a copy with different asset type, inheriting the progress callback
104-
/// which routes updates to the correct asset type slot automatically.
113+
/// Returns a copy with different asset type, inheriting the progress callbacks
114+
/// which route updates to the correct asset type slot automatically.
105115
func with(assetType: AssetType) -> ConfigExecutionContext {
106116
ConfigExecutionContext(
107117
configId: configId,
108118
configPriority: configPriority,
109119
assetType: assetType,
110-
downloadProgressCallback: downloadProgressCallback
120+
downloadProgressCallback: downloadProgressCallback,
121+
stepCompletionCallback: stepCompletionCallback
111122
)
112123
}
113124
}

Sources/ExFigCLI/Context/IconsExportContextImpl.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache {
144144
files: files,
145145
fileDownloader: fileDownloader,
146146
context: configExecutionContext
147-
) { current, total in
147+
) { current, _ in
148148
progress.update(current: current)
149-
// Report to batch progress if in batch mode
150-
if let callback = configExecutionContext?.downloadProgressCallback,
151-
let assetType = configExecutionContext?.assetType
152-
{
153-
Task { await callback(assetType, current, total) }
154-
}
155149
}
156150
}
157151
}

Sources/ExFigCLI/Context/ImagesExportContextImpl.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,14 +150,8 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache {
150150
files: files,
151151
fileDownloader: fileDownloader,
152152
context: configExecutionContext
153-
) { current, total in
153+
) { current, _ in
154154
progress.update(current: current)
155-
// Report to batch progress if in batch mode
156-
if let callback = configExecutionContext?.downloadProgressCallback,
157-
let assetType = configExecutionContext?.assetType
158-
{
159-
Task { await callback(assetType, current, total) }
160-
}
161155
}
162156
}
163157
}

0 commit comments

Comments
 (0)