effective-progress 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/effective-progress)](https://www.npmjs.com/package/effective-progress)
4
4
 
5
5
  > [!WARNING]
6
- > Pre-`1.0.0`, breaking changes may happen in any release. SemVer guarantees will begin at `1.0.0`.
6
+ > Pre-`1.0.0`, breaking changes may happen in any release. SemVer guarantees will begin at `1.0.0`.
7
7
  > I recommend using only the `Progress.all` and `Progress.forEach` APIs for now, as they will likely change the least. The lower-level APIs for manual progress bar control are more likely to see breaking changes as I iterate on the design.
8
8
  >
9
9
  > Please open an issue or reach out if you have any questions or want to contribute!
@@ -115,6 +115,22 @@ const configured = program.pipe(
115
115
  Effect.runPromise(configured);
116
116
  ```
117
117
 
118
+ Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
119
+
120
+ ```ts
121
+ yield *
122
+ progress.withTask(Effect.sleep("1 second"), {
123
+ description: "Worker pipeline",
124
+ progressbar: {
125
+ barWidth: 20,
126
+ spinnerFrames: [".", "o", "O", "0"],
127
+ colors: {
128
+ spinner: { kind: "named", value: "magentaBright" },
129
+ },
130
+ },
131
+ });
132
+ ```
133
+
118
134
  ## Terminal service and mocking
119
135
 
120
136
  `effective-progress` now exposes a `ProgressTerminal` service that controls terminal detection and I/O:
@@ -125,7 +141,7 @@ Effect.runPromise(configured);
125
141
  - `writeStderr(text)`
126
142
  - `withRawInputCapture(effect)`
127
143
 
128
- You can provide a mock in tests instead of monkeypatching global process streams:
144
+ You can provide a mock if you want to alter the behavior of terminal detection or if you want to capture the output for testing:
129
145
 
130
146
  ```ts
131
147
  import { Effect } from "effect";
@@ -139,45 +155,23 @@ const mockTerminal: Progress.ProgressTerminalService = {
139
155
  withRawInputCapture: (effect) => effect,
140
156
  };
141
157
 
142
- const program = Progress.withTask({ description: "work" }, Effect.sleep("100 millis")).pipe(
158
+ const program = Progress.task(Effect.sleep("100 millis"), { description: "work" }).pipe(
143
159
  Effect.provideService(Progress.ProgressTerminal, mockTerminal),
144
160
  );
145
161
  ```
146
162
 
147
- ## Migration note
163
+ ## Manual task control
148
164
 
149
- `RendererConfig.isTTY` has been removed.
150
- TTY mode is now sourced from `ProgressTerminal.isTTY`.
151
-
152
- Task-level `progressbar` config is optional and inherits from its parent task (or from global `ProgressBarConfig` for root tasks):
165
+ For manual usage, `task` captures logs implicitly and provides the current `Task` context:
153
166
 
154
167
  ```ts
155
- yield *
156
- progress.withTask(
157
- {
158
- description: "Worker pipeline",
159
- progressbar: {
160
- barWidth: 20,
161
- spinnerFrames: [".", "o", "O", "0"],
162
- colors: {
163
- spinner: { kind: "named", value: "magentaBright" },
164
- },
165
- },
166
- },
167
- Effect.sleep("1 second"),
168
- );
169
- ```
170
-
171
- For manual usage, `withTask` captures logs implicitly and provides the current `Task` context:
172
-
173
- ```ts
174
- const program = Progress.withTask(
175
- { description: "Manual task" },
168
+ const program = Progress.task(
176
169
  Effect.gen(function* () {
177
170
  const currentTask = yield* Progress.Task;
178
171
  yield* Console.log("This log is rendered through progress output", { taskId: currentTask });
179
172
  yield* Effect.sleep("1 second");
180
173
  }),
174
+ { description: "Manual task" },
181
175
  );
182
176
  ```
183
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effective-progress",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Effect-first terminal progress bars with nested multibar support",
5
5
  "homepage": "https://github.com/stromseng/effective-progress#readme",
6
6
  "bugs": {
package/src/api.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Effect } from "effect";
1
+ import { Effect, Exit } from "effect";
2
+ import { dual } from "effect/Function";
2
3
  import type { Concurrency } from "effect/Types";
3
4
  import { Progress, provideProgressService } from "./runtime";
4
5
  import { Task } from "./types";
@@ -20,9 +21,10 @@ export type AllOptions = Omit<TrackOptions, "total"> & EffectAllExecutionOptions
20
21
  export type AllReturn<
21
22
  Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
22
23
  O extends EffectAllExecutionOptions,
23
- > =
24
- [Effect.All.ReturnTuple<Arg, Effect.All.IsDiscard<O>, Effect.All.ExtractMode<O>>] extends
25
- [Effect.Effect<infer A, infer E, infer R>] ? Effect.Effect<A, E, Exclude<R, Progress | Task>>
24
+ > = [Effect.All.ReturnTuple<Arg, Effect.All.IsDiscard<O>, Effect.All.ExtractMode<O>>] extends [
25
+ Effect.Effect<infer A, infer E, infer R>,
26
+ ]
27
+ ? Effect.Effect<A, E, Exclude<R, Progress | Task>>
26
28
  : never;
27
29
 
28
30
  export interface ForEachExecutionOptions extends EffectExecutionOptions {
@@ -31,81 +33,143 @@ export interface ForEachExecutionOptions extends EffectExecutionOptions {
31
33
 
32
34
  export type ForEachOptions = TrackOptions & ForEachExecutionOptions;
33
35
 
34
- export const withTask = <A, E, R>(
35
- options: AddTaskOptions,
36
- effect: Effect.Effect<A, E, R>,
37
- ): Effect.Effect<A, E, Exclude<R, Progress | Task>> =>
38
- provideProgressService(
39
- Effect.gen(function* () {
40
- const progress = yield* Progress;
41
- return yield* progress.withTask(options, effect);
42
- }),
43
- ) as Effect.Effect<A, E, Exclude<R, Progress | Task>>;
36
+ export const task: {
37
+ <A, E, R>(
38
+ effect: Effect.Effect<A, E, R>,
39
+ options: AddTaskOptions,
40
+ ): Effect.Effect<A, E, Exclude<R, Progress | Task>>;
41
+ <A, E, R>(
42
+ options: AddTaskOptions,
43
+ ): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Progress | Task>>;
44
+ } = dual(
45
+ 2,
46
+ <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
47
+ provideProgressService(
48
+ Effect.gen(function* () {
49
+ const progress = yield* Progress;
50
+ return yield* progress.withTask(effect, options);
51
+ }),
52
+ ) as Effect.Effect<A, E, Exclude<R, Progress | Task>>,
53
+ );
44
54
 
45
- export const all = <
46
- const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
47
- O extends EffectAllExecutionOptions,
48
- >(
49
- effects: Arg,
50
- options: Omit<TrackOptions, "total"> & O,
51
- ): AllReturn<Arg, O> =>
52
- provideProgressService(
53
- Effect.gen(function* () {
54
- const progress = yield* Progress;
55
- return yield* progress.withTask(
56
- {
57
- description: options.description,
58
- total: effects.length,
59
- transient: options.transient,
60
- progressbar: options.progressbar,
61
- },
62
- Effect.gen(function* () {
63
- const taskId = yield* Task;
64
- return yield* Effect.all(
65
- effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
66
- {
67
- concurrency: options.concurrency,
68
- batching: options.batching,
69
- discard: options.discard,
70
- mode: options.mode,
71
- concurrentFinalizers: options.concurrentFinalizers,
72
- },
73
- );
74
- }),
75
- );
76
- }),
77
- ) as AllReturn<Arg, O>;
55
+ export const all: {
56
+ <
57
+ const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
58
+ O extends EffectAllExecutionOptions,
59
+ >(
60
+ effects: Arg,
61
+ options: Omit<TrackOptions, "total"> & O,
62
+ ): AllReturn<Arg, O>;
63
+ <O extends EffectAllExecutionOptions>(
64
+ options: Omit<TrackOptions, "total"> & O,
65
+ ): <const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>>(
66
+ effects: Arg,
67
+ ) => AllReturn<Arg, O>;
68
+ } = dual(
69
+ 2,
70
+ <
71
+ const Arg extends ReadonlyArray<Effect.Effect<any, any, any>>,
72
+ O extends EffectAllExecutionOptions,
73
+ >(
74
+ effects: Arg,
75
+ options: Omit<TrackOptions, "total"> & O,
76
+ ) =>
77
+ provideProgressService(
78
+ Effect.gen(function* () {
79
+ const progress = yield* Progress;
80
+ return yield* progress.runTask(
81
+ Effect.gen(function* () {
82
+ const taskId = yield* Task;
83
+ const exit = yield* Effect.exit(
84
+ Effect.all(
85
+ effects.map((effect) => Effect.tap(effect, () => progress.advanceTask(taskId, 1))),
86
+ {
87
+ concurrency: options.concurrency,
88
+ batching: options.batching,
89
+ discard: options.discard,
90
+ mode: options.mode,
91
+ concurrentFinalizers: options.concurrentFinalizers,
92
+ },
93
+ ),
94
+ );
95
+
96
+ if (Exit.isSuccess(exit)) {
97
+ yield* progress.completeTask(taskId);
98
+ } else {
99
+ yield* progress.failTask(taskId);
100
+ }
101
+
102
+ return yield* Exit.match(exit, {
103
+ onFailure: Effect.failCause,
104
+ onSuccess: Effect.succeed,
105
+ });
106
+ }),
107
+ {
108
+ description: options.description,
109
+ total: effects.length,
110
+ transient: options.transient,
111
+ progressbar: options.progressbar,
112
+ },
113
+ );
114
+ }),
115
+ ) as AllReturn<Arg, O>,
116
+ );
117
+
118
+ export const forEach: {
119
+ <A, B, E, R>(
120
+ iterable: Iterable<A>,
121
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
122
+ options: ForEachOptions,
123
+ ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
124
+ <A, B, E, R>(
125
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
126
+ options: ForEachOptions,
127
+ ): (iterable: Iterable<A>) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
128
+ } = dual(
129
+ 3,
130
+ <A, B, E, R>(
131
+ iterable: Iterable<A>,
132
+ f: (item: A, index: number) => Effect.Effect<B, E, R>,
133
+ options: ForEachOptions,
134
+ ) =>
135
+ provideProgressService(
136
+ Effect.gen(function* () {
137
+ const progress = yield* Progress;
138
+
139
+ return yield* progress.runTask(
140
+ Effect.gen(function* () {
141
+ const taskId = yield* Task;
142
+ const exit = yield* Effect.exit(
143
+ Effect.forEach(
144
+ iterable,
145
+ (item, index) => Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
146
+ {
147
+ concurrency: options.concurrency,
148
+ batching: options.batching,
149
+ discard: options.discard,
150
+ concurrentFinalizers: options.concurrentFinalizers,
151
+ },
152
+ ),
153
+ );
78
154
 
79
- export const forEach = <A, B, E, R>(
80
- iterable: Iterable<A>,
81
- f: (item: A, index: number) => Effect.Effect<B, E, R>,
82
- options: ForEachOptions,
83
- ): Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>> =>
84
- provideProgressService(
85
- Effect.gen(function* () {
86
- const progress = yield* Progress;
155
+ if (Exit.isSuccess(exit)) {
156
+ yield* progress.completeTask(taskId);
157
+ } else {
158
+ yield* progress.failTask(taskId);
159
+ }
87
160
 
88
- return yield* progress.withTask(
89
- {
90
- description: options.description,
91
- total: options.total ?? inferTotal(iterable),
92
- transient: options.transient,
93
- progressbar: options.progressbar,
94
- },
95
- Effect.gen(function* () {
96
- const taskId = yield* Task;
97
- return yield* Effect.forEach(
98
- iterable,
99
- (item, index) =>
100
- Effect.tap(f(item, index), () => progress.advanceTask(taskId, 1)),
101
- {
102
- concurrency: options.concurrency,
103
- batching: options.batching,
104
- discard: options.discard,
105
- concurrentFinalizers: options.concurrentFinalizers,
106
- },
107
- );
108
- }),
109
- );
110
- }),
111
- ) as Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>;
161
+ return yield* Exit.match(exit, {
162
+ onFailure: Effect.failCause,
163
+ onSuccess: Effect.succeed,
164
+ });
165
+ }),
166
+ {
167
+ description: options.description,
168
+ total: options.total ?? inferTotal(iterable),
169
+ transient: options.transient,
170
+ progressbar: options.progressbar,
171
+ },
172
+ );
173
+ }),
174
+ ) as Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Progress | Task>>,
175
+ );
package/src/console.ts CHANGED
@@ -12,7 +12,7 @@ export const makeProgressConsole = (
12
12
 
13
13
  const delegate = (effect: Effect.Effect<void, never, never>) => effect;
14
14
 
15
- return {
15
+ return Console.Console.of({
16
16
  [Console.TypeId]: Console.TypeId,
17
17
  assert(condition, ...args) {
18
18
  return condition ? Effect.void : log("Assertion failed:", ...args);
@@ -81,5 +81,5 @@ export const makeProgressConsole = (
81
81
  unsafeLog(...args);
82
82
  },
83
83
  },
84
- };
84
+ });
85
85
  };
package/src/renderer.ts CHANGED
@@ -5,8 +5,8 @@ import {
5
5
  ProgressBarColorsSchema,
6
6
  } from "./colors";
7
7
  import type { ProgressTerminalService } from "./terminal";
8
- import type { ProgressBarConfigShape, RendererConfigShape } from "./types";
9
- import { DeterminateTaskUnits, TaskId, TaskSnapshot } from "./types";
8
+ import type { ProgressBarConfigShape, RendererConfigShape, TaskStore } from "./types";
9
+ import { DeterminateTaskUnits, TaskSnapshot } from "./types";
10
10
 
11
11
  const HIDE_CURSOR = "\x1b[?25l";
12
12
  const SHOW_CURSOR = "\x1b[?25h";
@@ -33,7 +33,7 @@ const buildTaskLine = (
33
33
  tick: number,
34
34
  colors: CompiledProgressBarColors,
35
35
  ): string => {
36
- const progressbar = snapshot.progressbar;
36
+ const progressbar = snapshot.config;
37
37
  const prefix = `${" ".repeat(depth)}- ${snapshot.description}: `;
38
38
 
39
39
  if (snapshot.status === "failed") {
@@ -57,31 +57,8 @@ const buildTaskLine = (
57
57
  return `${prefix}${colors.spinner(frame)}`;
58
58
  };
59
59
 
60
- const orderTasksForRender = (
61
- tasks: ReadonlyArray<TaskSnapshot>,
62
- ): ReadonlyArray<{ snapshot: TaskSnapshot; depth: number }> => {
63
- const byParent = new Map<number | null, Array<TaskSnapshot>>();
64
- for (const task of tasks) {
65
- const bucket = byParent.get(task.parentId) ?? [];
66
- bucket.push(task);
67
- byParent.set(task.parentId, bucket);
68
- }
69
-
70
- const ordered: Array<{ snapshot: TaskSnapshot; depth: number }> = [];
71
- const visit = (parentId: number | null, depth: number) => {
72
- const children = byParent.get(parentId) ?? [];
73
- for (const child of children) {
74
- ordered.push({ snapshot: child, depth });
75
- visit(child.id, depth + 1);
76
- }
77
- };
78
-
79
- visit(null, 0);
80
- return ordered;
81
- };
82
-
83
60
  export const runProgressServiceRenderer = (
84
- tasksRef: Ref.Ref<Map<TaskId, TaskSnapshot>>,
61
+ storeRef: Ref.Ref<TaskStore>,
85
62
  logsRef: Ref.Ref<ReadonlyArray<string>>,
86
63
  pendingLogsRef: Ref.Ref<ReadonlyArray<string>>,
87
64
  dirtyRef: Ref.Ref<boolean>,
@@ -187,14 +164,16 @@ export const runProgressServiceRenderer = (
187
164
  const renderFrame = (mode: "tick" | "final") =>
188
165
  Effect.gen(function* () {
189
166
  const drainedLogs = yield* Ref.getAndSet(pendingLogsRef, []);
190
- const snapshots = Array.from((yield* Ref.get(tasksRef)).values()).filter(
191
- (task) => !(task.transient && task.status !== "running"),
192
- );
193
- const ordered = orderTasksForRender(snapshots);
167
+ const store = yield* Ref.get(storeRef);
168
+ const ordered = store.renderOrder.flatMap((row) => {
169
+ const snapshot = store.tasks.get(row.id);
170
+ if (!snapshot || (snapshot.transient && snapshot.status !== "running")) return [];
171
+ return [{ snapshot, depth: row.depth }];
172
+ });
194
173
  const frameTick = mode === "final" ? tick + 1 : tick;
195
174
  const taskLines = ordered.map(({ snapshot, depth }) => {
196
175
  const lineTick = isTTY ? frameTick : 0;
197
- return buildTaskLine(snapshot, depth, lineTick, getCompiledColors(snapshot.progressbar));
176
+ return buildTaskLine(snapshot, depth, lineTick, getCompiledColors(snapshot.config));
198
177
  });
199
178
 
200
179
  if (isTTY) {
@@ -248,7 +227,7 @@ export const runProgressServiceRenderer = (
248
227
 
249
228
  while (true) {
250
229
  const dirty = yield* Ref.getAndSet(dirtyRef, false);
251
- const tasks = Array.from((yield* Ref.get(tasksRef)).values()).filter(
230
+ const tasks = Array.from((yield* Ref.get(storeRef)).tasks.values()).filter(
252
231
  (task) => !(task.transient && task.status !== "running"),
253
232
  );
254
233
  const hasActiveSpinners = tasks.some(
package/src/runtime.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
2
+ import { dual } from "effect/Function";
2
3
  import { mergeWith } from "es-toolkit/object";
3
4
  import { formatWithOptions } from "node:util";
4
5
  import type { PartialDeep } from "type-fest";
5
6
  import { makeProgressConsole } from "./console";
6
7
  import { runProgressServiceRenderer } from "./renderer";
7
8
  import { ProgressTerminal } from "./terminal";
8
- import type { AddTaskOptions, ProgressService, UpdateTaskOptions } from "./types";
9
+ import type {
10
+ AddTaskOptions,
11
+ ProgressService,
12
+ RenderRow,
13
+ TaskStore,
14
+ UpdateTaskOptions,
15
+ } from "./types";
9
16
  import {
10
17
  decodeProgressBarConfigSync,
11
18
  decodeRendererConfigSync,
@@ -19,7 +26,6 @@ import {
19
26
  TaskId,
20
27
  TaskSnapshot,
21
28
  } from "./types";
22
- import { inferTotal } from "./utils";
23
29
 
24
30
  const mergeConfig = <T extends Record<PropertyKey, any>>(
25
31
  base: T,
@@ -75,10 +81,39 @@ const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): Ta
75
81
  status: snapshot.status,
76
82
  transient: options.transient ?? snapshot.transient,
77
83
  units,
78
- progressbar: snapshot.progressbar,
84
+ config: snapshot.config,
79
85
  });
80
86
  };
81
87
 
88
+ const findInsertionIndex = (
89
+ renderOrder: ReadonlyArray<RenderRow>,
90
+ parentId: TaskId | null,
91
+ ): { index: number; depth: number } => {
92
+ if (parentId === null) {
93
+ return { index: renderOrder.length, depth: 0 };
94
+ }
95
+ const parentIdx = renderOrder.findIndex((row) => row.id === parentId);
96
+ if (parentIdx === -1) return { index: renderOrder.length, depth: 0 };
97
+ const parentDepth = renderOrder[parentIdx]!.depth;
98
+ let i = parentIdx + 1;
99
+ while (i < renderOrder.length && renderOrder[i]!.depth > parentDepth) i++;
100
+ return { index: i, depth: parentDepth + 1 };
101
+ };
102
+
103
+ const removeFromRenderOrder = (
104
+ renderOrder: ReadonlyArray<RenderRow>,
105
+ taskId: TaskId,
106
+ ): ReadonlyArray<RenderRow> => {
107
+ const idx = renderOrder.findIndex((row) => row.id === taskId);
108
+ if (idx === -1) return renderOrder;
109
+ const taskDepth = renderOrder[idx]!.depth;
110
+ let end = idx + 1;
111
+ while (end < renderOrder.length && renderOrder[end]!.depth > taskDepth) end++;
112
+ const next = [...renderOrder];
113
+ next.splice(idx, end - idx);
114
+ return next;
115
+ };
116
+
82
117
  const makeProgressService = Effect.gen(function* () {
83
118
  const rendererConfigOption = yield* Effect.serviceOption(RendererConfig);
84
119
  const progressBarConfigOption = yield* Effect.serviceOption(ProgressBarConfig);
@@ -100,7 +135,10 @@ const makeProgressService = Effect.gen(function* () {
100
135
  const maxRetainedLogLines = Math.max(0, Math.floor(rendererConfig.maxLogLines ?? 0));
101
136
 
102
137
  const nextTaskIdRef = yield* Ref.make(0);
103
- const tasksRef = yield* Ref.make(new Map<TaskId, TaskSnapshot>());
138
+ const storeRef = yield* Ref.make<TaskStore>({
139
+ tasks: new Map<TaskId, TaskSnapshot>(),
140
+ renderOrder: [],
141
+ });
104
142
  const logsRef = yield* Ref.make<ReadonlyArray<string>>([]);
105
143
  const pendingLogsRef = yield* Ref.make<ReadonlyArray<string>>([]);
106
144
  const dirtyRef = yield* Ref.make(true);
@@ -109,7 +147,7 @@ const makeProgressService = Effect.gen(function* () {
109
147
 
110
148
  yield* Effect.forkIn(
111
149
  runProgressServiceRenderer(
112
- tasksRef,
150
+ storeRef,
113
151
  logsRef,
114
152
  pendingLogsRef,
115
153
  dirtyRef,
@@ -125,7 +163,7 @@ const makeProgressService = Effect.gen(function* () {
125
163
 
126
164
  const addTask = (options: AddTaskOptions) =>
127
165
  Effect.gen(function* () {
128
- const parentId =
166
+ const resolvedParentId =
129
167
  options.parentId === undefined
130
168
  ? yield* FiberRef.get(currentParentRef)
131
169
  : Option.some(options.parentId);
@@ -134,27 +172,33 @@ const makeProgressService = Effect.gen(function* () {
134
172
  options.total === undefined || options.total <= 0
135
173
  ? new IndeterminateTaskUnits({ spinnerFrame: 0 })
136
174
  : new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
137
- const tasks = yield* Ref.get(tasksRef);
138
- const parentSnapshot = Option.isSome(parentId) ? tasks.get(parentId.value) : undefined;
139
- const inheritedProgressBarConfig = parentSnapshot?.progressbar ?? progressBarConfig;
175
+ const store = yield* Ref.get(storeRef);
176
+ const parentSnapshot = Option.isSome(resolvedParentId)
177
+ ? store.tasks.get(resolvedParentId.value)
178
+ : undefined;
179
+ const inheritedProgressBarConfig = parentSnapshot?.config ?? progressBarConfig;
140
180
  const resolvedProgressBarConfig = decodeProgressBarConfigSync(
141
181
  mergeConfig(inheritedProgressBarConfig, options.progressbar),
142
182
  );
143
183
 
184
+ const parentIdValue = Option.getOrNull(resolvedParentId);
144
185
  const snapshot = new TaskSnapshot({
145
186
  id: taskId,
146
- parentId: Option.getOrNull(parentId),
187
+ parentId: parentIdValue,
147
188
  description: options.description,
148
189
  status: "running",
149
190
  transient: options.transient ?? false,
150
191
  units,
151
- progressbar: resolvedProgressBarConfig,
192
+ config: resolvedProgressBarConfig,
152
193
  });
153
194
 
154
- yield* Ref.update(tasksRef, (tasks) => {
155
- const next = new Map(tasks);
156
- next.set(taskId, snapshot);
157
- return next;
195
+ yield* Ref.update(storeRef, (s) => {
196
+ const nextTasks = new Map(s.tasks);
197
+ nextTasks.set(taskId, snapshot);
198
+ const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
199
+ const nextOrder = [...s.renderOrder];
200
+ nextOrder.splice(index, 0, { id: taskId, depth });
201
+ return { tasks: nextTasks, renderOrder: nextOrder };
158
202
  });
159
203
  yield* markDirty;
160
204
 
@@ -162,25 +206,19 @@ const makeProgressService = Effect.gen(function* () {
162
206
  });
163
207
 
164
208
  const updateTask = (taskId: TaskId, options: UpdateTaskOptions) =>
165
- Ref.update(tasksRef, (tasks) => {
166
- const snapshot = tasks.get(taskId);
167
- if (!snapshot) {
168
- return tasks;
169
- }
170
-
171
- const next = new Map(tasks);
172
- next.set(taskId, updatedSnapshot(snapshot, options));
173
- return next;
209
+ Ref.update(storeRef, (store) => {
210
+ const snapshot = store.tasks.get(taskId);
211
+ if (!snapshot) return store;
212
+ const nextTasks = new Map(store.tasks);
213
+ nextTasks.set(taskId, updatedSnapshot(snapshot, options));
214
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
174
215
  }).pipe(Effect.zipRight(markDirty));
175
216
 
176
217
  const advanceTask = (taskId: TaskId, amount = 1) =>
177
- Ref.update(tasksRef, (tasks) => {
178
- const snapshot = tasks.get(taskId);
179
- if (!snapshot) {
180
- return tasks;
181
- }
218
+ Ref.update(storeRef, (store) => {
219
+ const snapshot = store.tasks.get(taskId);
220
+ if (!snapshot) return store;
182
221
 
183
- const next = new Map(tasks);
184
222
  const units =
185
223
  snapshot.units._tag === "DeterminateTaskUnits"
186
224
  ? new DeterminateTaskUnits({
@@ -191,7 +229,8 @@ const makeProgressService = Effect.gen(function* () {
191
229
  spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount),
192
230
  });
193
231
 
194
- next.set(
232
+ const nextTasks = new Map(store.tasks);
233
+ nextTasks.set(
195
234
  taskId,
196
235
  new TaskSnapshot({
197
236
  id: snapshot.id,
@@ -200,27 +239,25 @@ const makeProgressService = Effect.gen(function* () {
200
239
  status: snapshot.status,
201
240
  transient: snapshot.transient,
202
241
  units,
203
- progressbar: snapshot.progressbar,
242
+ config: snapshot.config,
204
243
  }),
205
244
  );
206
245
 
207
- return next;
246
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
208
247
  }).pipe(Effect.zipRight(markDirty));
209
248
 
210
249
  const completeTask = (taskId: TaskId) =>
211
- Ref.update(tasksRef, (tasks) => {
212
- const snapshot = tasks.get(taskId);
213
- if (!snapshot) {
214
- return tasks;
215
- }
250
+ Ref.update(storeRef, (store) => {
251
+ const snapshot = store.tasks.get(taskId);
252
+ if (!snapshot) return store;
216
253
 
217
- const next = new Map(tasks);
254
+ const nextTasks = new Map(store.tasks);
218
255
  if (snapshot.transient) {
219
- next.delete(taskId);
220
- return next;
256
+ nextTasks.delete(taskId);
257
+ return { tasks: nextTasks, renderOrder: removeFromRenderOrder(store.renderOrder, taskId) };
221
258
  }
222
259
 
223
- next.set(
260
+ nextTasks.set(
224
261
  taskId,
225
262
  new TaskSnapshot({
226
263
  id: snapshot.id,
@@ -235,26 +272,24 @@ const makeProgressService = Effect.gen(function* () {
235
272
  total: snapshot.units.total,
236
273
  })
237
274
  : snapshot.units,
238
- progressbar: snapshot.progressbar,
275
+ config: snapshot.config,
239
276
  }),
240
277
  );
241
- return next;
278
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
242
279
  }).pipe(Effect.zipRight(markDirty));
243
280
 
244
281
  const failTask = (taskId: TaskId) =>
245
- Ref.update(tasksRef, (tasks) => {
246
- const snapshot = tasks.get(taskId);
247
- if (!snapshot) {
248
- return tasks;
249
- }
282
+ Ref.update(storeRef, (store) => {
283
+ const snapshot = store.tasks.get(taskId);
284
+ if (!snapshot) return store;
250
285
 
251
- const next = new Map(tasks);
286
+ const nextTasks = new Map(store.tasks);
252
287
  if (snapshot.transient) {
253
- next.delete(taskId);
254
- return next;
288
+ nextTasks.delete(taskId);
289
+ return { tasks: nextTasks, renderOrder: removeFromRenderOrder(store.renderOrder, taskId) };
255
290
  }
256
291
 
257
- next.set(
292
+ nextTasks.set(
258
293
  taskId,
259
294
  new TaskSnapshot({
260
295
  id: snapshot.id,
@@ -263,10 +298,10 @@ const makeProgressService = Effect.gen(function* () {
263
298
  status: "failed",
264
299
  transient: snapshot.transient,
265
300
  units: snapshot.units,
266
- progressbar: snapshot.progressbar,
301
+ config: snapshot.config,
267
302
  }),
268
303
  );
269
- return next;
304
+ return { tasks: nextTasks, renderOrder: store.renderOrder };
270
305
  }).pipe(Effect.zipRight(markDirty));
271
306
 
272
307
  const appendLog = (args: ReadonlyArray<unknown>) =>
@@ -275,6 +310,7 @@ const makeProgressService = Effect.gen(function* () {
275
310
  return;
276
311
  }
277
312
 
313
+ // TODO: Might wanna replace this or make it configurable. Look for other options.
278
314
  const message = formatWithOptions(
279
315
  {
280
316
  colors: isTTY,
@@ -297,67 +333,61 @@ const makeProgressService = Effect.gen(function* () {
297
333
  yield* markDirty;
298
334
  });
299
335
 
300
- const log = (...args: ReadonlyArray<unknown>) =>
301
- Effect.gen(function* () {
302
- yield* appendLog(args);
303
- });
336
+ const log = (...args: ReadonlyArray<unknown>) => appendLog(args);
304
337
 
305
338
  const getTask = (taskId: TaskId) =>
306
- Ref.get(tasksRef).pipe(Effect.map((tasks) => Option.fromNullable(tasks.get(taskId))));
339
+ Ref.get(storeRef).pipe(Effect.map((store) => Option.fromNullable(store.tasks.get(taskId))));
307
340
 
308
- const listTasks = Ref.get(tasksRef).pipe(Effect.map((tasks) => Array.from(tasks.values())));
341
+ const listTasks = Ref.get(storeRef).pipe(Effect.map((store) => Array.from(store.tasks.values())));
309
342
 
310
- const withTask: ProgressService["withTask"] = (options, effect) =>
311
- Effect.gen(function* () {
312
- const outerConsole = yield* Effect.console;
313
- const inheritedParentId = yield* FiberRef.get(currentParentRef);
314
- const resolvedParentId =
315
- options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
316
-
317
- const taskId = yield* addTask({
318
- ...options,
319
- parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : undefined,
320
- transient: options.transient ?? Option.isSome(resolvedParentId),
321
- });
343
+ const runTask: ProgressService["runTask"] = dual(
344
+ 2,
345
+ <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
346
+ Effect.gen(function* () {
347
+ const outerConsole = yield* Effect.console;
348
+ const inheritedParentId = yield* FiberRef.get(currentParentRef);
349
+ const resolvedParentId =
350
+ options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
351
+
352
+ const taskId = yield* addTask({
353
+ ...options,
354
+ parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : undefined,
355
+ transient: options.transient ?? Option.isSome(resolvedParentId),
356
+ });
322
357
 
323
- const exit = yield* Effect.exit(
324
- Effect.locally(
358
+ return yield* Effect.locally(
325
359
  Effect.withConsole(
326
360
  Effect.provideService(effect, Task, taskId),
327
361
  makeProgressConsole(log, outerConsole),
328
362
  ),
329
363
  currentParentRef,
330
364
  Option.some(taskId),
331
- ),
332
- );
333
-
334
- if (Exit.isSuccess(exit)) {
335
- yield* completeTask(taskId);
336
- } else {
337
- yield* failTask(taskId);
338
- }
339
-
340
- return yield* Exit.match(exit, {
341
- onFailure: Effect.failCause,
342
- onSuccess: Effect.succeed,
343
- });
344
- });
345
-
346
- const trackIterable: ProgressService["trackIterable"] = (iterable, options, f) =>
347
- withTask(
348
- {
349
- description: options.description,
350
- total: options.total ?? inferTotal(iterable),
351
- transient: options.transient,
352
- progressbar: options.progressbar,
353
- },
354
- Effect.gen(function* () {
355
- const taskId = yield* Task;
356
- return yield* Effect.forEach(iterable, (item, index) =>
357
- Effect.tap(f(item, index), () => advanceTask(taskId, 1)),
358
365
  );
359
366
  }),
360
- );
367
+ );
368
+
369
+ const withTask: ProgressService["withTask"] = dual(
370
+ 2,
371
+ <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
372
+ runTask(
373
+ Effect.gen(function* () {
374
+ const taskId = yield* Task;
375
+ const exit = yield* Effect.exit(effect);
376
+
377
+ if (Exit.isSuccess(exit)) {
378
+ yield* completeTask(taskId);
379
+ } else {
380
+ yield* failTask(taskId);
381
+ }
382
+
383
+ return yield* Exit.match(exit, {
384
+ onFailure: Effect.failCause,
385
+ onSuccess: Effect.succeed,
386
+ });
387
+ }),
388
+ options,
389
+ ),
390
+ );
361
391
 
362
392
  const service: ProgressService = {
363
393
  addTask,
@@ -368,8 +398,8 @@ const makeProgressService = Effect.gen(function* () {
368
398
  log,
369
399
  getTask,
370
400
  listTasks,
401
+ runTask,
371
402
  withTask,
372
- trackIterable,
373
403
  };
374
404
 
375
405
  return Progress.of(service);
package/src/terminal.ts CHANGED
@@ -5,9 +5,7 @@ export interface ProgressTerminalService {
5
5
  readonly stderrRows: Effect.Effect<number | undefined>;
6
6
  readonly stderrColumns: Effect.Effect<number | undefined>;
7
7
  readonly writeStderr: (text: string) => Effect.Effect<void>;
8
- readonly withRawInputCapture: <A, E, R>(
9
- effect: Effect.Effect<A, E, R>,
10
- ) => Effect.Effect<A, E, R>;
8
+ readonly withRawInputCapture: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
11
9
  }
12
10
 
13
11
  const withRawInputCapture: ProgressTerminalService["withRawInputCapture"] = (effect) =>
package/src/types.ts CHANGED
@@ -102,9 +102,19 @@ export class TaskSnapshot extends Schema.TaggedClass<TaskSnapshot>()("TaskSnapsh
102
102
  status: TaskStatusSchema,
103
103
  transient: Schema.Boolean,
104
104
  units: TaskUnitsSchema,
105
- progressbar: ProgressBarConfigSchema,
105
+ config: ProgressBarConfigSchema,
106
106
  }) {}
107
107
 
108
+ export interface RenderRow {
109
+ readonly id: TaskId;
110
+ readonly depth: number;
111
+ }
112
+
113
+ export interface TaskStore {
114
+ readonly tasks: Map<TaskId, TaskSnapshot>;
115
+ readonly renderOrder: ReadonlyArray<RenderRow>;
116
+ }
117
+
108
118
  export interface ProgressService {
109
119
  readonly addTask: (options: AddTaskOptions) => Effect.Effect<TaskId>;
110
120
  readonly updateTask: (taskId: TaskId, options: UpdateTaskOptions) => Effect.Effect<void>;
@@ -114,15 +124,24 @@ export interface ProgressService {
114
124
  readonly log: (...args: ReadonlyArray<unknown>) => Effect.Effect<void>;
115
125
  readonly getTask: (taskId: TaskId) => Effect.Effect<Option.Option<TaskSnapshot>>;
116
126
  readonly listTasks: Effect.Effect<ReadonlyArray<TaskSnapshot>>;
117
- readonly withTask: <A, E, R>(
118
- options: AddTaskOptions,
119
- effect: Effect.Effect<A, E, R>,
120
- ) => Effect.Effect<A, E, Exclude<R, Task>>;
121
- readonly trackIterable: <A, B, E, R>(
122
- iterable: Iterable<A>,
123
- options: TrackOptions,
124
- f: (item: A, index: number) => Effect.Effect<B, E, R>,
125
- ) => Effect.Effect<ReadonlyArray<B>, E, Exclude<R, Task>>;
127
+ readonly runTask: {
128
+ <A, E, R>(
129
+ effect: Effect.Effect<A, E, R>,
130
+ options: AddTaskOptions,
131
+ ): Effect.Effect<A, E, Exclude<R, Task>>;
132
+ <A, E, R>(
133
+ options: AddTaskOptions,
134
+ ): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Task>>;
135
+ };
136
+ readonly withTask: {
137
+ <A, E, R>(
138
+ effect: Effect.Effect<A, E, R>,
139
+ options: AddTaskOptions,
140
+ ): Effect.Effect<A, E, Exclude<R, Task>>;
141
+ <A, E, R>(
142
+ options: AddTaskOptions,
143
+ ): (effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Task>>;
144
+ };
126
145
  }
127
146
 
128
147
  export class Task extends Context.Tag("stromseng.dev/Task")<Task, TaskId>() {}