effective-progress 0.4.1 → 0.4.3

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
@@ -1,16 +1,15 @@
1
1
  import { Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
2
2
  import { dual } from "effect/Function";
3
3
  import { mergeWith } from "es-toolkit/object";
4
- import { formatWithOptions } from "node:util";
5
4
  import type { PartialDeep } from "type-fest";
6
- import { makeProgressConsole } from "./console";
7
- import { BuildStage, ColorStage, FrameRenderer, ShrinkStage } from "./renderer";
5
+ import { type BufferedConsoleCall, makeConsoleBridge } from "./console";
6
+ import { Columns, FrameRenderer } from "./renderer";
8
7
  import { ProgressTerminal } from "./terminal";
9
- import { Theme, type ThemeService } from "./theme";
10
8
  import type {
11
9
  AddTaskOptions,
12
10
  ProgressService,
13
11
  RenderRow,
12
+ RendererConfigShape,
14
13
  TaskStore,
15
14
  UpdateTaskOptions,
16
15
  } from "./types";
@@ -130,53 +129,67 @@ const removeFromRenderOrder = (
130
129
  return next;
131
130
  };
132
131
 
132
+ const withDefaultColumns = (rendererConfig: RendererConfigShape): RendererConfigShape => ({
133
+ ...rendererConfig,
134
+ columns: rendererConfig.columns.length > 0 ? rendererConfig.columns : Columns.defaults(),
135
+ });
136
+
133
137
  const makeProgressService = Effect.gen(function* () {
134
138
  const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
135
139
  const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
136
- const rendererConfig = decodeRendererConfigSync(
137
- mergeConfig(
138
- defaultRendererConfig,
139
- Option.isSome(rendererConfigOption) ? rendererConfigOption.value : undefined,
140
+
141
+ const rendererConfig = withDefaultColumns(
142
+ decodeRendererConfigSync(
143
+ mergeConfig(
144
+ defaultRendererConfig,
145
+ Option.isSome(rendererConfigOption) ? rendererConfigOption.value : undefined,
146
+ ),
140
147
  ),
141
148
  );
149
+
142
150
  const progressBarConfig = decodeProgressBarConfigSync(
143
151
  mergeConfig(
144
152
  defaultProgressBarConfig,
145
153
  Option.isSome(progressBarConfigOption) ? progressBarConfigOption.value : undefined,
146
154
  ),
147
155
  );
156
+
148
157
  const terminal = yield* ProgressTerminal;
149
158
  const frameRenderer = yield* FrameRenderer;
159
+ const outerConsole = yield* Effect.console;
150
160
  const isTTY = yield* terminal.isTTY;
151
- const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
152
161
 
153
162
  const nextTaskIdRef = yield* Ref.make(0);
154
163
  const storeRef = yield* Ref.make<TaskStore>({
155
164
  tasks: new Map<TaskId, TaskSnapshot>(),
156
165
  renderOrder: [],
157
- themes: new Map<TaskId, ThemeService>(),
158
166
  });
159
- const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
160
- const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
167
+ const pendingLogsRef = yield* Ref.make<ReadonlyArray<BufferedConsoleCall>>([]);
161
168
  const dirtyRef = yield* Ref.make(true);
162
169
  const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
163
170
  const scope = yield* Effect.scope;
164
171
 
172
+ const markDirty = Ref.set(dirtyRef, true);
173
+ const { replayLogs, progressConsole, log } = makeConsoleBridge(
174
+ outerConsole,
175
+ pendingLogsRef,
176
+ markDirty,
177
+ );
178
+
165
179
  yield* Effect.forkIn(
166
- frameRenderer.run({
180
+ frameRenderer.run(
167
181
  storeRef,
168
- logsRef,
169
182
  pendingLogsRef,
183
+ replayLogs,
170
184
  dirtyRef,
171
185
  terminal,
172
186
  isTTY,
173
187
  rendererConfig,
174
- maxRetainedLogLines,
175
- }),
188
+ ),
176
189
  scope,
177
190
  );
178
-
179
- const markDirty = Ref.set(dirtyRef, true);
191
+ // Let the renderer fiber start so queued logs are reliably flushed on scope teardown.
192
+ yield* Effect.sleep("0 millis");
180
193
 
181
194
  const addTask = (options: AddTaskOptions) =>
182
195
  Effect.gen(function* () {
@@ -184,7 +197,6 @@ const makeProgressService = Effect.gen(function* () {
184
197
  options.parentId === undefined
185
198
  ? yield* FiberRef.get(currentParentRef)
186
199
  : Option.some(options.parentId);
187
- const themeOption = yield* Effect.serviceOption(Theme);
188
200
  const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
189
201
  const units =
190
202
  options.total === undefined || options.total <= 0
@@ -219,11 +231,7 @@ const makeProgressService = Effect.gen(function* () {
219
231
  const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
220
232
  const nextOrder = [...s.renderOrder];
221
233
  nextOrder.splice(index, 0, { id: taskId, depth });
222
- const nextThemes = new Map(s.themes);
223
- if (Option.isSome(themeOption)) {
224
- nextThemes.set(taskId, themeOption.value);
225
- }
226
- return { tasks: nextTasks, renderOrder: nextOrder, themes: nextThemes };
234
+ return { tasks: nextTasks, renderOrder: nextOrder };
227
235
  });
228
236
  yield* markDirty;
229
237
 
@@ -260,7 +268,7 @@ const makeProgressService = Effect.gen(function* () {
260
268
  }
261
269
  }
262
270
 
263
- return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
271
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
264
272
  }).pipe(Effect.zipRight(markDirty));
265
273
 
266
274
  const advanceTask = (taskId: TaskId, amount = 1) =>
@@ -294,7 +302,7 @@ const makeProgressService = Effect.gen(function* () {
294
302
  }),
295
303
  );
296
304
 
297
- return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
305
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
298
306
  }).pipe(Effect.zipRight(markDirty));
299
307
 
300
308
  const completeTask = (taskId: TaskId) =>
@@ -307,12 +315,9 @@ const makeProgressService = Effect.gen(function* () {
307
315
  const nextTasks = new Map(store.tasks);
308
316
  if (snapshot.transient) {
309
317
  nextTasks.delete(taskId);
310
- const nextThemes = new Map(store.themes);
311
- nextThemes.delete(taskId);
312
318
  return {
313
319
  tasks: nextTasks,
314
320
  renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
315
- themes: nextThemes,
316
321
  };
317
322
  }
318
323
 
@@ -336,7 +341,7 @@ const makeProgressService = Effect.gen(function* () {
336
341
  completedAt: now,
337
342
  }),
338
343
  );
339
- return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
344
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
340
345
  });
341
346
  yield* markDirty;
342
347
  });
@@ -351,12 +356,9 @@ const makeProgressService = Effect.gen(function* () {
351
356
  const nextTasks = new Map(store.tasks);
352
357
  if (snapshot.transient) {
353
358
  nextTasks.delete(taskId);
354
- const nextThemes = new Map(store.themes);
355
- nextThemes.delete(taskId);
356
359
  return {
357
360
  tasks: nextTasks,
358
361
  renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
359
- themes: nextThemes,
360
362
  };
361
363
  }
362
364
 
@@ -374,42 +376,11 @@ const makeProgressService = Effect.gen(function* () {
374
376
  completedAt: now,
375
377
  }),
376
378
  );
377
- return { tasks: nextTasks, renderOrder: store.renderOrder, themes: store.themes };
379
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
378
380
  });
379
381
  yield* markDirty;
380
382
  });
381
383
 
382
- const appendLog = (args: ReadonlyArray<unknown>) =>
383
- Effect.gen(function* () {
384
- if (args.length === 0) {
385
- return;
386
- }
387
-
388
- // TODO: Might wanna replace this or make it configurable. Look for other options.
389
- const message = formatWithOptions(
390
- {
391
- colors: isTTY,
392
- depth: 6,
393
- },
394
- ...args,
395
- );
396
-
397
- yield* Ref.update(pendingLogsRef, (logs) => [...logs, message]);
398
- if (maxRetainedLogLines > 0) {
399
- yield* Ref.update(logsRef, (logs) => {
400
- const next = [...logs, message];
401
- if (next.length <= maxRetainedLogLines) {
402
- return next;
403
- }
404
- return next.slice(next.length - maxRetainedLogLines);
405
- });
406
- }
407
-
408
- yield* markDirty;
409
- });
410
-
411
- const log = (...args: ReadonlyArray<unknown>) => appendLog(args);
412
-
413
384
  const getTask = (taskId: TaskId) =>
414
385
  Ref.get(storeRef).pipe(Effect.map((store) => Option.fromNullable(store.tasks.get(taskId))));
415
386
 
@@ -419,7 +390,6 @@ const makeProgressService = Effect.gen(function* () {
419
390
  2,
420
391
  <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
421
392
  Effect.gen(function* () {
422
- const outerConsole = yield* Effect.console;
423
393
  const inheritedParentId = yield* FiberRef.get(currentParentRef);
424
394
  const resolvedParentId =
425
395
  options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
@@ -431,10 +401,7 @@ const makeProgressService = Effect.gen(function* () {
431
401
  });
432
402
 
433
403
  return yield* Effect.locally(
434
- Effect.withConsole(
435
- Effect.provideService(effect, Task, taskId),
436
- makeProgressConsole(log, outerConsole),
437
- ),
404
+ Effect.withConsole(Effect.provideService(effect, Task, taskId), progressConsole),
438
405
  currentParentRef,
439
406
  Option.some(taskId),
440
407
  );
@@ -486,29 +453,13 @@ export class Progress extends Context.Tag("stromseng.dev/effective-progress/Prog
486
453
  >() {
487
454
  static readonly Default = Layer.unwrapEffect(
488
455
  Effect.gen(function* () {
489
- const themeOption = yield* Effect.serviceOption(Theme);
490
456
  const terminalOption = yield* Effect.serviceOption(ProgressTerminal);
491
- const buildStageOption = yield* Effect.serviceOption(BuildStage);
492
- const shrinkStageOption = yield* Effect.serviceOption(ShrinkStage);
493
- const colorStageOption = yield* Effect.serviceOption(ColorStage);
494
457
  const frameRendererOption = yield* Effect.serviceOption(FrameRenderer);
495
458
  let layer: Layer.Layer<Progress, never, any> = Layer.scoped(Progress, makeProgressService);
496
459
 
497
460
  if (Option.isNone(frameRendererOption)) {
498
461
  layer = layer.pipe(Layer.provide(FrameRenderer.Default));
499
462
  }
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
463
  if (Option.isNone(terminalOption)) {
513
464
  layer = layer.pipe(Layer.provide(ProgressTerminal.Default));
514
465
  }
package/src/types.ts CHANGED
@@ -1,17 +1,34 @@
1
1
  import { Brand, Context, Effect, Option, Schema } from "effect";
2
2
  import type { PartialDeep } from "type-fest";
3
- import type { ThemeService } from "./theme";
3
+ import type { ProgressColumn } from "./renderer";
4
4
 
5
5
  export const RendererConfigSchema = Schema.Struct({
6
6
  disableUserInput: Schema.Boolean,
7
7
  renderIntervalMillis: Schema.Number,
8
- maxLogLines: Schema.optional(Schema.Number),
9
8
  nonTtyUpdateStep: Schema.Number,
10
- determinateTaskLayout: Schema.Literal("single-line", "two-lines"),
11
- maxTaskWidth: Schema.optional(Schema.Number),
9
+ width: Schema.Union(Schema.Number, Schema.Literal("fullwidth")),
10
+ columnGap: Schema.Number,
11
+ columns: Schema.Array(Schema.Unknown),
12
12
  });
13
- export type RendererConfigShape = typeof RendererConfigSchema.Type;
14
- export const decodeRendererConfigSync = Schema.decodeUnknownSync(RendererConfigSchema);
13
+ export type RendererConfigShape = {
14
+ readonly disableUserInput: boolean;
15
+ readonly renderIntervalMillis: number;
16
+ readonly nonTtyUpdateStep: number;
17
+ readonly width: number | "fullwidth";
18
+ readonly columnGap: number;
19
+ readonly columns: ReadonlyArray<ProgressColumn | string>;
20
+ };
21
+ const decodeRendererConfigSchemaSync = Schema.decodeUnknownSync(RendererConfigSchema);
22
+ export const decodeRendererConfigSync = (input: unknown): RendererConfigShape => {
23
+ if (typeof input === "object" && input !== null && "determinateTaskLayout" in input) {
24
+ throw new Error("determinateTaskLayout has been removed. Use columns instead.");
25
+ }
26
+ if (typeof input === "object" && input !== null && "maxTaskWidth" in input) {
27
+ throw new Error("maxTaskWidth has been removed. Use width on RendererConfig instead.");
28
+ }
29
+
30
+ return decodeRendererConfigSchemaSync(input) as RendererConfigShape;
31
+ };
15
32
 
16
33
  export const ProgressBarConfigSchema = Schema.Struct({
17
34
  spinnerFrames: Schema.NonEmptyArray(Schema.String),
@@ -27,9 +44,10 @@ export const decodeProgressBarConfigSync = Schema.decodeUnknownSync(ProgressBarC
27
44
  export const defaultRendererConfig: RendererConfigShape = {
28
45
  disableUserInput: true,
29
46
  renderIntervalMillis: 100, // 10 FPS
30
- maxLogLines: 0,
31
47
  nonTtyUpdateStep: 5,
32
- determinateTaskLayout: "single-line",
48
+ width: 120,
49
+ columnGap: 1,
50
+ columns: [],
33
51
  };
34
52
 
35
53
  export const defaultProgressBarConfig: ProgressBarConfigShape = {
@@ -115,7 +133,6 @@ export interface RenderRow {
115
133
  export interface TaskStore {
116
134
  readonly tasks: Map<TaskId, TaskSnapshot>;
117
135
  readonly renderOrder: ReadonlyArray<RenderRow>;
118
- readonly themes: Map<TaskId, ThemeService>;
119
136
  }
120
137
 
121
138
  export interface ProgressService {
package/src/theme.ts DELETED
@@ -1,87 +0,0 @@
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
- }