effective-progress 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -3,10 +3,10 @@ import { dual } from "effect/Function";
3
3
  import { mergeWith } from "es-toolkit/object";
4
4
  import { formatWithOptions } from "node:util";
5
5
  import type { PartialDeep } from "type-fest";
6
- import { Colorizer, type ColorizerService } from "./colors";
7
6
  import { makeProgressConsole } from "./console";
8
- import { runProgressServiceRenderer } from "./renderer";
7
+ import { BuildStage, ColorStage, FrameRenderer, ShrinkStage } from "./renderer";
9
8
  import { ProgressTerminal } from "./terminal";
9
+ import { Theme, type ThemeService } from "./theme";
10
10
  import type {
11
11
  AddTaskOptions,
12
12
  ProgressService,
@@ -88,6 +88,19 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
88
88
  });
89
89
  };
90
90
 
91
+ const withTransient = (snapshot: TaskSnapshot, transient: boolean): TaskSnapshot =>
92
+ new TaskSnapshot({
93
+ id: snapshot.id,
94
+ parentId: snapshot.parentId,
95
+ description: snapshot.description,
96
+ status: snapshot.status,
97
+ transient,
98
+ units: snapshot.units,
99
+ config: snapshot.config,
100
+ startedAt: snapshot.startedAt,
101
+ completedAt: snapshot.completedAt,
102
+ });
103
+
91
104
  const findInsertionIndex = (
92
105
  renderOrder: ReadonlyArray<RenderRow>,
93
106
  parentId: TaskId | null,
@@ -133,6 +146,7 @@ const makeProgressService = Effect.gen(function* () {
133
146
  ),
134
147
  );
135
148
  const terminal = yield* ProgressTerminal;
149
+ const frameRenderer = yield* FrameRenderer;
136
150
  const isTTY = yield* terminal.isTTY;
137
151
  const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
138
152
 
@@ -140,7 +154,7 @@ const makeProgressService = Effect.gen(function* () {
140
154
  const storeRef = yield* Ref.make<TaskStore>({
141
155
  tasks: new Map<TaskId, TaskSnapshot>(),
142
156
  renderOrder: [],
143
- colorizers: new Map<TaskId, ColorizerService>(),
157
+ themes: new Map<TaskId, ThemeService>(),
144
158
  });
145
159
  const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
146
160
  const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
@@ -149,7 +163,7 @@ const makeProgressService = Effect.gen(function* () {
149
163
  const scope = yield* Effect.scope;
150
164
 
151
165
  yield* Effect.forkIn(
152
- runProgressServiceRenderer(
166
+ frameRenderer.run({
153
167
  storeRef,
154
168
  logsRef,
155
169
  pendingLogsRef,
@@ -158,7 +172,7 @@ const makeProgressService = Effect.gen(function* () {
158
172
  isTTY,
159
173
  rendererConfig,
160
174
  maxRetainedLogLines,
161
- ),
175
+ }),
162
176
  scope,
163
177
  );
164
178
 
@@ -170,7 +184,7 @@ const makeProgressService = Effect.gen(function* () {
170
184
  options.parentId === undefined
171
185
  ? yield* FiberRef.get(currentParentRef)
172
186
  : Option.some(options.parentId);
173
- const colorizerOption = yield* Effect.serviceOption(Colorizer);
187
+ const themeOption = yield* Effect.serviceOption(Theme);
174
188
  const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
175
189
  const units =
176
190
  options.total === undefined || options.total <= 0
@@ -192,7 +206,7 @@ const makeProgressService = Effect.gen(function* () {
192
206
  parentId: parentIdValue,
193
207
  description: options.description,
194
208
  status: "running",
195
- transient: options.transient ?? false,
209
+ transient: parentSnapshot?.transient ?? options.transient ?? false,
196
210
  units,
197
211
  config: resolvedProgressBarConfig,
198
212
  startedAt: now,
@@ -205,11 +219,11 @@ const makeProgressService = Effect.gen(function* () {
205
219
  const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
206
220
  const nextOrder = [...s.renderOrder];
207
221
  nextOrder.splice(index, 0, { id: taskId, depth });
208
- const nextColorizers = new Map(s.colorizers);
209
- if (Option.isSome(colorizerOption)) {
210
- nextColorizers.set(taskId, colorizerOption.value);
222
+ const nextThemes = new Map(s.themes);
223
+ if (Option.isSome(themeOption)) {
224
+ nextThemes.set(taskId, themeOption.value);
211
225
  }
212
- return { tasks: nextTasks, renderOrder: nextOrder, colorizers: nextColorizers };
226
+ return { tasks: nextTasks, renderOrder: nextOrder, themes: nextThemes };
213
227
  });
214
228
  yield* markDirty;
215
229
 
@@ -221,8 +235,32 @@ const makeProgressService = Effect.gen(function* () {
221
235
  const snapshot = store.tasks.get(taskId);
222
236
  if (!snapshot) return store;
223
237
  const nextTasks = new Map(store.tasks);
224
- nextTasks.set(taskId, updatedSnapshot(snapshot, options));
225
- return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
238
+ const nextSnapshot = updatedSnapshot(snapshot, options);
239
+ nextTasks.set(taskId, nextSnapshot);
240
+
241
+ if (options.transient !== undefined) {
242
+ for (const [candidateId, candidate] of store.tasks.entries()) {
243
+ if (candidateId === taskId) {
244
+ continue;
245
+ }
246
+
247
+ let parentId = candidate.parentId;
248
+ let isDescendant = false;
249
+ while (parentId !== null) {
250
+ if (parentId === taskId) {
251
+ isDescendant = true;
252
+ break;
253
+ }
254
+ parentId = store.tasks.get(parentId)?.parentId ?? null;
255
+ }
256
+
257
+ if (isDescendant) {
258
+ nextTasks.set(candidateId, withTransient(candidate, nextSnapshot.transient));
259
+ }
260
+ }
261
+ }
262
+
263
+ return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
226
264
  }).pipe(Effect.zipRight(markDirty));
227
265
 
228
266
  const advanceTask = (taskId: TaskId, amount = 1) =>
@@ -256,7 +294,7 @@ const makeProgressService = Effect.gen(function* () {
256
294
  }),
257
295
  );
258
296
 
259
- return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
297
+ return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
260
298
  }).pipe(Effect.zipRight(markDirty));
261
299
 
262
300
  const completeTask = (taskId: TaskId) =>
@@ -269,12 +307,12 @@ const makeProgressService = Effect.gen(function* () {
269
307
  const nextTasks = new Map(store.tasks);
270
308
  if (snapshot.transient) {
271
309
  nextTasks.delete(taskId);
272
- const nextColorizers = new Map(store.colorizers);
273
- nextColorizers.delete(taskId);
310
+ const nextThemes = new Map(store.themes);
311
+ nextThemes.delete(taskId);
274
312
  return {
275
313
  tasks: nextTasks,
276
314
  renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
277
- colorizers: nextColorizers,
315
+ themes: nextThemes,
278
316
  };
279
317
  }
280
318
 
@@ -298,7 +336,7 @@ const makeProgressService = Effect.gen(function* () {
298
336
  completedAt: now,
299
337
  }),
300
338
  );
301
- return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
339
+ return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
302
340
  });
303
341
  yield* markDirty;
304
342
  });
@@ -313,12 +351,12 @@ const makeProgressService = Effect.gen(function* () {
313
351
  const nextTasks = new Map(store.tasks);
314
352
  if (snapshot.transient) {
315
353
  nextTasks.delete(taskId);
316
- const nextColorizers = new Map(store.colorizers);
317
- nextColorizers.delete(taskId);
354
+ const nextThemes = new Map(store.themes);
355
+ nextThemes.delete(taskId);
318
356
  return {
319
357
  tasks: nextTasks,
320
358
  renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
321
- colorizers: nextColorizers,
359
+ themes: nextThemes,
322
360
  };
323
361
  }
324
362
 
@@ -336,7 +374,7 @@ const makeProgressService = Effect.gen(function* () {
336
374
  completedAt: now,
337
375
  }),
338
376
  );
339
- return { tasks: nextTasks, renderOrder: store.renderOrder, colorizers: store.colorizers };
377
+ return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
340
378
  });
341
379
  yield* markDirty;
342
380
  });
@@ -389,7 +427,7 @@ const makeProgressService = Effect.gen(function* () {
389
427
  const taskId = yield* addTask({
390
428
  ...options,
391
429
  parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : undefined,
392
- transient: options.transient ?? Option.isSome(resolvedParentId),
430
+ transient: options.transient,
393
431
  });
394
432
 
395
433
  return yield* Effect.locally(
@@ -448,13 +486,34 @@ export class Progress extends Context.Tag("stromseng.dev/effective-progress/Prog
448
486
  >() {
449
487
  static readonly Default = Layer.unwrapEffect(
450
488
  Effect.gen(function* () {
451
- const colorizerOption = yield* Effect.serviceOption(Colorizer);
489
+ const themeOption = yield* Effect.serviceOption(Theme);
452
490
  const terminalOption = yield* Effect.serviceOption(ProgressTerminal);
453
- const base = Layer.scoped(Progress, makeProgressService);
454
- return base.pipe(
455
- Option.isNone(colorizerOption) ? Layer.provide(Colorizer.Default) : (l) => l,
456
- Option.isNone(terminalOption) ? Layer.provide(ProgressTerminal.Default) : (l) => l,
457
- );
491
+ const buildStageOption = yield* Effect.serviceOption(BuildStage);
492
+ const shrinkStageOption = yield* Effect.serviceOption(ShrinkStage);
493
+ const colorStageOption = yield* Effect.serviceOption(ColorStage);
494
+ const frameRendererOption = yield* Effect.serviceOption(FrameRenderer);
495
+ let layer: Layer.Layer<Progress, never, any> = Layer.scoped(Progress, makeProgressService);
496
+
497
+ if (Option.isNone(frameRendererOption)) {
498
+ layer = layer.pipe(Layer.provide(FrameRenderer.Default));
499
+ }
500
+ if (Option.isNone(colorStageOption)) {
501
+ layer = layer.pipe(Layer.provide(ColorStage.Default));
502
+ }
503
+ if (Option.isNone(shrinkStageOption)) {
504
+ layer = layer.pipe(Layer.provide(ShrinkStage.Default));
505
+ }
506
+ if (Option.isNone(buildStageOption)) {
507
+ layer = layer.pipe(Layer.provide(BuildStage.Default));
508
+ }
509
+ if (Option.isNone(themeOption)) {
510
+ layer = layer.pipe(Layer.provide(Theme.Default));
511
+ }
512
+ if (Option.isNone(terminalOption)) {
513
+ layer = layer.pipe(Layer.provide(ProgressTerminal.Default));
514
+ }
515
+
516
+ return layer as Layer.Layer<Progress, never, never>;
458
517
  }),
459
518
  );
460
519
  }
package/src/theme.ts ADDED
@@ -0,0 +1,87 @@
1
+ import chalk, { type ChalkInstance } from "chalk";
2
+ import { Context, Effect, Layer } from "effect";
3
+
4
+ export type ThemeRole =
5
+ | "plain"
6
+ | "barFill"
7
+ | "barEmpty"
8
+ | "barBracket"
9
+ | "spinner"
10
+ | "statusDone"
11
+ | "statusFailed"
12
+ | "text"
13
+ | "units"
14
+ | "eta"
15
+ | "elapsed"
16
+ | "treeConnector";
17
+
18
+ export type ThemeStyle = (text: string) => string;
19
+
20
+ export interface ThemeService {
21
+ readonly styles: Readonly<Record<ThemeRole, ThemeStyle>>;
22
+ readonly depthPalette?: (depth: number, role: ThemeRole) => ThemeStyle | undefined;
23
+ }
24
+
25
+ export class Theme extends Context.Tag("stromseng.dev/effective-progress/Theme")<
26
+ Theme,
27
+ ThemeService
28
+ >() {
29
+ static readonly Default = Layer.succeed(
30
+ Theme,
31
+ Theme.of({
32
+ styles: {
33
+ plain: (text) => text,
34
+ barFill: chalk.blue,
35
+ barEmpty: chalk.white.dim,
36
+ barBracket: chalk.white.dim,
37
+ spinner: chalk.yellow,
38
+ statusDone: chalk.green,
39
+ statusFailed: chalk.red,
40
+ text: (text) => text,
41
+ units: chalk.whiteBright,
42
+ eta: chalk.gray,
43
+ elapsed: chalk.gray,
44
+ treeConnector: chalk.gray,
45
+ },
46
+ }),
47
+ );
48
+
49
+ static readonly Rainbow = Layer.unwrapEffect(
50
+ Effect.gen(function* () {
51
+ let colorNumber = 17;
52
+ yield* Effect.fork(
53
+ Effect.gen(function* () {
54
+ while (true) {
55
+ colorNumber = (colorNumber + 1) % 256;
56
+ yield* Effect.sleep("200 millis");
57
+ }
58
+ }),
59
+ );
60
+
61
+ const dynamic =
62
+ (instance: ChalkInstance): ThemeStyle =>
63
+ (text) =>
64
+ instance.ansi256(colorNumber)(text);
65
+
66
+ return Layer.succeed(
67
+ Theme,
68
+ Theme.of({
69
+ styles: {
70
+ plain: (text) => text,
71
+ barFill: dynamic(chalk),
72
+ barEmpty: dynamic(chalk.dim),
73
+ barBracket: dynamic(chalk),
74
+ spinner: dynamic(chalk),
75
+ statusDone: dynamic(chalk.bold),
76
+ statusFailed: dynamic(chalk.bold),
77
+ text: dynamic(chalk),
78
+ units: dynamic(chalk),
79
+ eta: dynamic(chalk),
80
+ elapsed: dynamic(chalk),
81
+ treeConnector: dynamic(chalk),
82
+ },
83
+ }),
84
+ );
85
+ }),
86
+ );
87
+ }
package/src/types.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { Brand, Context, Effect, Option, Schema } from "effect";
2
2
  import type { PartialDeep } from "type-fest";
3
- import type { ColorizerService } from "./colors";
3
+ import type { ThemeService } from "./theme";
4
4
 
5
5
  export const RendererConfigSchema = Schema.Struct({
6
6
  disableUserInput: Schema.Boolean,
7
7
  renderIntervalMillis: Schema.Number,
8
8
  maxLogLines: Schema.optional(Schema.Number),
9
9
  nonTtyUpdateStep: Schema.Number,
10
+ determinateTaskLayout: Schema.Literal("single-line", "two-lines"),
11
+ maxTaskWidth: Schema.optional(Schema.Number),
10
12
  });
11
13
  export type RendererConfigShape = typeof RendererConfigSchema.Type;
12
14
  export const decodeRendererConfigSync = Schema.decodeUnknownSync(RendererConfigSchema);
@@ -27,11 +29,12 @@ export const defaultRendererConfig: RendererConfigShape = {
27
29
  renderIntervalMillis: 100, // 10 FPS
28
30
  maxLogLines: 0,
29
31
  nonTtyUpdateStep: 5,
32
+ determinateTaskLayout: "single-line",
30
33
  };
31
34
 
32
35
  export const defaultProgressBarConfig: ProgressBarConfigShape = {
33
- spinnerFrames: ["-", "\\", "|", "/"],
34
- barWidth: 30,
36
+ spinnerFrames: ["", "", "", "", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
37
+ barWidth: 40,
35
38
  fillChar: "━",
36
39
  emptyChar: "─",
37
40
  leftBracket: "",
@@ -112,7 +115,7 @@ export interface RenderRow {
112
115
  export interface TaskStore {
113
116
  readonly tasks: Map<TaskId, TaskSnapshot>;
114
117
  readonly renderOrder: ReadonlyArray<RenderRow>;
115
- readonly colorizers: Map<TaskId, ColorizerService>;
118
+ readonly themes: Map<TaskId, ThemeService>;
116
119
  }
117
120
 
118
121
  export interface ProgressService {
package/src/colors.ts DELETED
@@ -1,30 +0,0 @@
1
- import chalk from "chalk";
2
- import { Context, Layer } from "effect";
3
-
4
- export interface ColorizerService {
5
- readonly fill: (text: string) => string;
6
- readonly empty: (text: string) => string;
7
- readonly brackets: (text: string) => string;
8
- readonly percent: (text: string) => string;
9
- readonly spinner: (text: string) => string;
10
- readonly done: (text: string) => string;
11
- readonly failed: (text: string) => string;
12
- }
13
-
14
- export class Colorizer extends Context.Tag("stromseng.dev/effective-progress/Colorizer")<
15
- Colorizer,
16
- ColorizerService
17
- >() {
18
- static readonly Default = Layer.succeed(
19
- Colorizer,
20
- Colorizer.of({
21
- fill: chalk.blue,
22
- empty: chalk.white.dim,
23
- brackets: chalk.white.dim,
24
- percent: chalk.white.bold,
25
- spinner: chalk.yellow,
26
- done: chalk.green,
27
- failed: chalk.red,
28
- }),
29
- );
30
- }