effective-progress 0.5.0 → 0.5.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.
@@ -1,121 +0,0 @@
1
- import { Writable } from "node:stream";
2
- import { Clock, Context, Effect, Layer, Ref } from "effect";
3
- import { render, type Instance } from "ink";
4
- import type { ProgressTerminalService } from "../terminal";
5
- import type { TaskSnapshot, TaskStore } from "../types";
6
- import { ProgressApp } from "./app";
7
- import { toTaskRows } from "./model";
8
-
9
- const RENDER_INTERVAL_MILLIS = 100;
10
-
11
- export interface InkRendererService {
12
- readonly run: (
13
- storeRef: Ref.Ref<TaskStore>,
14
- dirtyRef: Ref.Ref<boolean>,
15
- terminal: ProgressTerminalService,
16
- isTTY: boolean,
17
- ) => Effect.Effect<void>;
18
- }
19
-
20
- const hasRunningSpinners = (tasks: ReadonlyArray<TaskSnapshot>): boolean =>
21
- tasks.some(
22
- (task) => task.status === "running" && task.units._tag === "IndeterminateTaskUnits",
23
- );
24
-
25
- const createInkWritable = (terminal: ProgressTerminalService): Writable =>
26
- new Writable({
27
- write(chunk, _encoding, callback) {
28
- try {
29
- const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : `${chunk}`;
30
- Effect.runSync(terminal.writeStderr(text));
31
- callback();
32
- } catch (error) {
33
- callback(error as Error);
34
- }
35
- },
36
- });
37
-
38
- const makeDefaultInkRenderer = (): InkRendererService => ({
39
- run: (storeRef, dirtyRef, terminal, isTTY) =>
40
- Effect.gen(function* () {
41
- const output = createInkWritable(terminal);
42
- let instance: Instance | undefined;
43
- let tick = 0;
44
- let rendererActive = false;
45
-
46
- const renderStore = (
47
- store: TaskStore,
48
- now: number,
49
- terminalColumns: number | undefined,
50
- ) =>
51
- Effect.sync(() => {
52
- const app = (
53
- <ProgressApp
54
- rows={toTaskRows(store)}
55
- now={now}
56
- tick={tick}
57
- isTTY={isTTY}
58
- terminalColumns={terminalColumns}
59
- />
60
- );
61
- if (instance === undefined) {
62
- instance = render(app, {
63
- stdout: output as unknown as NodeJS.WriteStream,
64
- stderr: output as unknown as NodeJS.WriteStream,
65
- patchConsole: true,
66
- exitOnCtrlC: false,
67
- debug: false,
68
- });
69
- return;
70
- }
71
-
72
- instance.rerender(app);
73
- });
74
-
75
- const renderLoop = Effect.gen(function* () {
76
- rendererActive = true;
77
-
78
- while (true) {
79
- const dirty = yield* Ref.getAndSet(dirtyRef, false);
80
- const store = yield* Ref.get(storeRef);
81
- const tasks = Array.from(store.tasks.values()).filter(
82
- (task) => !(task.transient && task.status !== "running"),
83
- );
84
- const shouldRender = dirty || hasRunningSpinners(tasks);
85
-
86
- if (shouldRender) {
87
- const now = yield* Clock.currentTimeMillis;
88
- const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
89
- yield* renderStore(store, now, terminalColumns);
90
- }
91
-
92
- tick += 1;
93
- yield* Effect.sleep(RENDER_INTERVAL_MILLIS);
94
- }
95
- }).pipe(
96
- Effect.ensuring(
97
- Effect.gen(function* () {
98
- if (rendererActive) {
99
- const store = yield* Ref.get(storeRef);
100
- const now = yield* Clock.currentTimeMillis;
101
- const terminalColumns = isTTY ? yield* terminal.stderrColumns : undefined;
102
- yield* renderStore(store, now, terminalColumns);
103
- }
104
-
105
- yield* Effect.sync(() => {
106
- instance?.unmount();
107
- });
108
- }),
109
- ),
110
- );
111
-
112
- return yield* renderLoop;
113
- }),
114
- });
115
-
116
- export class InkRenderer extends Context.Tag("stromseng.dev/effective-progress/InkRenderer")<
117
- InkRenderer,
118
- InkRendererService
119
- >() {
120
- static readonly Default = Layer.succeed(InkRenderer, InkRenderer.of(makeDefaultInkRenderer()));
121
- }
@@ -1,53 +0,0 @@
1
- import { Box } from "ink";
2
- import { DEFAULT_BAR_WIDTH, type SharedColumnWidths } from "./layout";
3
- import type { TaskRowModel } from "./types";
4
- import {
5
- AmountColumn,
6
- BarColumn,
7
- DescriptionColumn,
8
- ElapsedColumn,
9
- EtaColumn,
10
- } from "./columns";
11
-
12
- export interface TaskRowProps {
13
- readonly row: TaskRowModel;
14
- readonly now: number;
15
- readonly tick: number;
16
- readonly isTTY: boolean;
17
- readonly widths: SharedColumnWidths;
18
- }
19
-
20
- export const TaskRow = ({ row, now, tick, isTTY, widths }: TaskRowProps) => {
21
- const props = {
22
- task: row.task,
23
- tree: row.tree,
24
- now,
25
- tick,
26
- isTTY,
27
- showTree: widths.showTree,
28
- } as const;
29
-
30
- return (
31
- <Box flexDirection="row" minWidth={widths.row}>
32
- <Box width={widths.description} flexShrink={1} marginRight={1}>
33
- <DescriptionColumn {...props} />
34
- </Box>
35
- {widths.bar > 0 ? (
36
- <Box width={widths.bar} flexShrink={0} marginRight={1}>
37
- <BarColumn {...props} width={Math.max(1, Math.min(widths.bar, DEFAULT_BAR_WIDTH))} />
38
- </Box>
39
- ) : null}
40
- <Box width={widths.amount} flexShrink={0} marginRight={1}>
41
- <AmountColumn {...props} />
42
- </Box>
43
- <Box width={widths.elapsed} flexShrink={0} marginRight={1}>
44
- <ElapsedColumn {...props} />
45
- </Box>
46
- {widths.eta > 0 ? (
47
- <Box width={widths.eta} flexShrink={0}>
48
- <EtaColumn {...props} />
49
- </Box>
50
- ) : null}
51
- </Box>
52
- );
53
- };
@@ -1,62 +0,0 @@
1
- import type { OrderedTask, TaskTreeInfo } from "./types";
2
-
3
- const treeAncestorPrefix = (tree: TaskTreeInfo): string =>
4
- tree.ancestorHasNextSibling
5
- .slice(1)
6
- .map((hasNext) => (hasNext ? "│ " : " "))
7
- .join("");
8
-
9
- export const renderTreePrefix = (tree: TaskTreeInfo): string => {
10
- if (tree.depth <= 0) {
11
- return "";
12
- }
13
-
14
- const ancestor = treeAncestorPrefix(tree);
15
- return `${ancestor}${tree.hasNextSibling ? "├─ " : "└─ "}`;
16
- };
17
-
18
- export const computeTreeInfo = (
19
- ordered: ReadonlyArray<OrderedTask>,
20
- ): ReadonlyArray<OrderedTask & { readonly tree: TaskTreeInfo }> => {
21
- const hasNextSiblingByIndex: Array<boolean> = Array.from({ length: ordered.length }, () => false);
22
-
23
- for (let i = 0; i < ordered.length; i++) {
24
- const depth = ordered[i]!.depth;
25
- for (let j = i + 1; j < ordered.length; j++) {
26
- const candidateDepth = ordered[j]!.depth;
27
- if (candidateDepth < depth) {
28
- break;
29
- }
30
- if (candidateDepth === depth) {
31
- hasNextSiblingByIndex[i] = true;
32
- break;
33
- }
34
- }
35
- }
36
-
37
- const ancestorStateByDepth: Array<boolean> = [];
38
-
39
- return ordered.map((entry, index) => {
40
- const depth = entry.depth;
41
- ancestorStateByDepth.length = depth;
42
-
43
- const hasChildren =
44
- index + 1 < ordered.length &&
45
- ordered[index + 1] !== undefined &&
46
- ordered[index + 1]!.depth > depth;
47
-
48
- const tree: TaskTreeInfo = {
49
- depth,
50
- hasNextSibling: hasNextSiblingByIndex[index] ?? false,
51
- hasChildren,
52
- ancestorHasNextSibling: [...ancestorStateByDepth],
53
- };
54
-
55
- ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
56
-
57
- return {
58
- ...entry,
59
- tree,
60
- };
61
- });
62
- };
@@ -1,18 +0,0 @@
1
- import type { TaskSnapshot } from "../types";
2
-
3
- export interface TaskTreeInfo {
4
- readonly depth: number;
5
- readonly hasNextSibling: boolean;
6
- readonly hasChildren: boolean;
7
- readonly ancestorHasNextSibling: ReadonlyArray<boolean>;
8
- }
9
-
10
- export interface OrderedTask {
11
- readonly snapshot: TaskSnapshot;
12
- readonly depth: number;
13
- }
14
-
15
- export interface TaskRowModel {
16
- readonly task: TaskSnapshot;
17
- readonly tree: TaskTreeInfo;
18
- }
package/src/runtime.ts DELETED
@@ -1,396 +0,0 @@
1
- import { Clock, Context, Effect, Exit, FiberRef, Layer, Option, Ref } from "effect";
2
- import { dual } from "effect/Function";
3
- import { InkRenderer } from "./ink-renderer";
4
- import { ProgressTerminal } from "./terminal";
5
- import type {
6
- AddTaskOptions,
7
- ProgressService,
8
- RenderRow,
9
- TaskStore,
10
- UpdateTaskOptions,
11
- } from "./types";
12
- import {
13
- DeterminateTaskUnits,
14
- IndeterminateTaskUnits,
15
- Task,
16
- TaskId,
17
- TaskSnapshot,
18
- } from "./types";
19
-
20
- const updatedSnapshot = (snapshot: TaskSnapshot, options: UpdateTaskOptions): TaskSnapshot => {
21
- const currentUnits = snapshot.units;
22
- const units = (() => {
23
- if (options.total !== undefined) {
24
- if (options.total <= 0) {
25
- return new IndeterminateTaskUnits({ spinnerFrame: 0 });
26
- }
27
-
28
- const completed =
29
- options.completed ??
30
- (currentUnits._tag === "DeterminateTaskUnits" ? currentUnits.completed : 0);
31
-
32
- return new DeterminateTaskUnits({
33
- completed: Math.max(0, completed),
34
- total: Math.max(0, options.total),
35
- });
36
- }
37
-
38
- if (currentUnits._tag === "DeterminateTaskUnits") {
39
- if (options.completed === undefined) {
40
- return currentUnits;
41
- }
42
-
43
- return new DeterminateTaskUnits({
44
- completed: Math.max(0, options.completed),
45
- total: currentUnits.total,
46
- });
47
- }
48
-
49
- return currentUnits;
50
- })();
51
-
52
- return new TaskSnapshot({
53
- id: snapshot.id,
54
- parentId: snapshot.parentId,
55
- description: options.description ?? snapshot.description,
56
- status: snapshot.status,
57
- transient: options.transient ?? snapshot.transient,
58
- units,
59
- startedAt: snapshot.startedAt,
60
- completedAt: snapshot.completedAt,
61
- });
62
- };
63
-
64
- const withTransient = (snapshot: TaskSnapshot, transient: boolean): TaskSnapshot =>
65
- new TaskSnapshot({
66
- id: snapshot.id,
67
- parentId: snapshot.parentId,
68
- description: snapshot.description,
69
- status: snapshot.status,
70
- transient,
71
- units: snapshot.units,
72
- startedAt: snapshot.startedAt,
73
- completedAt: snapshot.completedAt,
74
- });
75
-
76
- const findInsertionIndex = (
77
- renderOrder: ReadonlyArray<RenderRow>,
78
- parentId: TaskId | null,
79
- ): { index: number; depth: number } => {
80
- if (parentId === null) {
81
- return { index: renderOrder.length, depth: 0 };
82
- }
83
- const parentIdx = renderOrder.findIndex((row) => row.id === parentId);
84
- if (parentIdx === -1) return { index: renderOrder.length, depth: 0 };
85
- const parentDepth = renderOrder[parentIdx]!.depth;
86
- let i = parentIdx + 1;
87
- while (i < renderOrder.length && renderOrder[i]!.depth > parentDepth) i++;
88
- return { index: i, depth: parentDepth + 1 };
89
- };
90
-
91
- const removeFromRenderOrder = (
92
- renderOrder: ReadonlyArray<RenderRow>,
93
- taskId: TaskId,
94
- ): ReadonlyArray<RenderRow> => {
95
- const idx = renderOrder.findIndex((row) => row.id === taskId);
96
- if (idx === -1) return renderOrder;
97
- const taskDepth = renderOrder[idx]!.depth;
98
- let end = idx + 1;
99
- while (end < renderOrder.length && renderOrder[end]!.depth > taskDepth) end++;
100
- const next = [...renderOrder];
101
- next.splice(idx, end - idx);
102
- return next;
103
- };
104
-
105
- const makeProgressService = Effect.gen(function* () {
106
- const terminal = yield* ProgressTerminal;
107
- const inkRenderer = yield* InkRenderer;
108
- const outerConsole = yield* Effect.console;
109
- const isTTY = yield* terminal.isTTY;
110
-
111
- const nextTaskIdRef = yield* Ref.make(0);
112
- const storeRef = yield* Ref.make<TaskStore>({
113
- tasks: new Map<TaskId, TaskSnapshot>(),
114
- renderOrder: [],
115
- });
116
- const dirtyRef = yield* Ref.make(true);
117
- const currentParentRef = yield* FiberRef.make(Option.none<TaskId>());
118
- const scope = yield* Effect.scope;
119
-
120
- const markDirty = Ref.set(dirtyRef, true);
121
- const log = (...args: ReadonlyArray<unknown>) =>
122
- args.length === 0 ? Effect.void : outerConsole.log(...args);
123
-
124
- yield* Effect.forkIn(inkRenderer.run(storeRef, dirtyRef, terminal, isTTY), scope);
125
- // Let the renderer fiber start so queued logs are reliably flushed on scope teardown.
126
- yield* Effect.sleep("0 millis");
127
-
128
- const addTask = (options: AddTaskOptions) =>
129
- Effect.gen(function* () {
130
- const resolvedParentId =
131
- options.parentId === undefined
132
- ? yield* FiberRef.get(currentParentRef)
133
- : Option.some(options.parentId);
134
- const taskId = TaskId(yield* Ref.updateAndGet(nextTaskIdRef, (id) => id + 1));
135
- const units =
136
- options.total === undefined || options.total <= 0
137
- ? new IndeterminateTaskUnits({ spinnerFrame: 0 })
138
- : new DeterminateTaskUnits({ completed: 0, total: Math.max(0, options.total) });
139
- const store = yield* Ref.get(storeRef);
140
- const parentSnapshot = Option.isSome(resolvedParentId)
141
- ? store.tasks.get(resolvedParentId.value)
142
- : undefined;
143
-
144
- const now = yield* Clock.currentTimeMillis;
145
- const parentIdValue = Option.getOrNull(resolvedParentId);
146
- const snapshot = new TaskSnapshot({
147
- id: taskId,
148
- parentId: parentIdValue,
149
- description: options.description,
150
- status: "running",
151
- transient: parentSnapshot?.transient ?? options.transient ?? false,
152
- units,
153
- startedAt: now,
154
- completedAt: null,
155
- });
156
-
157
- yield* Ref.update(storeRef, (s) => {
158
- const nextTasks = new Map(s.tasks);
159
- nextTasks.set(taskId, snapshot);
160
- const { index, depth } = findInsertionIndex(s.renderOrder, parentIdValue);
161
- const nextOrder = [...s.renderOrder];
162
- nextOrder.splice(index, 0, { id: taskId, depth });
163
- return { tasks: nextTasks, renderOrder: nextOrder };
164
- });
165
- yield* markDirty;
166
-
167
- return taskId;
168
- });
169
-
170
- const updateTask = (taskId: TaskId, options: UpdateTaskOptions) =>
171
- Ref.update(storeRef, (store) => {
172
- const snapshot = store.tasks.get(taskId);
173
- if (!snapshot) return store;
174
- const nextTasks = new Map(store.tasks);
175
- const nextSnapshot = updatedSnapshot(snapshot, options);
176
- nextTasks.set(taskId, nextSnapshot);
177
-
178
- if (options.transient !== undefined) {
179
- for (const [candidateId, candidate] of store.tasks.entries()) {
180
- if (candidateId === taskId) {
181
- continue;
182
- }
183
-
184
- let parentId = candidate.parentId;
185
- let isDescendant = false;
186
- while (parentId !== null) {
187
- if (parentId === taskId) {
188
- isDescendant = true;
189
- break;
190
- }
191
- parentId = store.tasks.get(parentId)?.parentId ?? null;
192
- }
193
-
194
- if (isDescendant) {
195
- nextTasks.set(candidateId, withTransient(candidate, nextSnapshot.transient));
196
- }
197
- }
198
- }
199
-
200
- return { tasks: nextTasks, renderOrder: store.renderOrder };
201
- }).pipe(Effect.zipRight(markDirty));
202
-
203
- const advanceTask = (taskId: TaskId, amount = 1) =>
204
- Ref.update(storeRef, (store) => {
205
- const snapshot = store.tasks.get(taskId);
206
- if (!snapshot) return store;
207
-
208
- const units =
209
- snapshot.units._tag === "DeterminateTaskUnits"
210
- ? new DeterminateTaskUnits({
211
- completed: Math.min(snapshot.units.total, snapshot.units.completed + amount),
212
- total: snapshot.units.total,
213
- })
214
- : new IndeterminateTaskUnits({
215
- spinnerFrame: Math.max(0, snapshot.units.spinnerFrame + amount),
216
- });
217
-
218
- const nextTasks = new Map(store.tasks);
219
- nextTasks.set(
220
- taskId,
221
- new TaskSnapshot({
222
- id: snapshot.id,
223
- parentId: snapshot.parentId,
224
- description: snapshot.description,
225
- status: snapshot.status,
226
- transient: snapshot.transient,
227
- units,
228
- startedAt: snapshot.startedAt,
229
- completedAt: snapshot.completedAt,
230
- }),
231
- );
232
-
233
- return { tasks: nextTasks, renderOrder: store.renderOrder };
234
- }).pipe(Effect.zipRight(markDirty));
235
-
236
- const completeTask = (taskId: TaskId) =>
237
- Effect.gen(function* () {
238
- const now = yield* Clock.currentTimeMillis;
239
- yield* Ref.update(storeRef, (store) => {
240
- const snapshot = store.tasks.get(taskId);
241
- if (!snapshot) return store;
242
-
243
- const nextTasks = new Map(store.tasks);
244
- if (snapshot.transient) {
245
- nextTasks.delete(taskId);
246
- return {
247
- tasks: nextTasks,
248
- renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
249
- };
250
- }
251
-
252
- nextTasks.set(
253
- taskId,
254
- new TaskSnapshot({
255
- id: snapshot.id,
256
- parentId: snapshot.parentId,
257
- description: snapshot.description,
258
- status: "done",
259
- transient: snapshot.transient,
260
- units:
261
- snapshot.units._tag === "DeterminateTaskUnits"
262
- ? new DeterminateTaskUnits({
263
- completed: snapshot.units.total,
264
- total: snapshot.units.total,
265
- })
266
- : snapshot.units,
267
- startedAt: snapshot.startedAt,
268
- completedAt: now,
269
- }),
270
- );
271
- return { tasks: nextTasks, renderOrder: store.renderOrder };
272
- });
273
- yield* markDirty;
274
- });
275
-
276
- const failTask = (taskId: TaskId) =>
277
- Effect.gen(function* () {
278
- const now = yield* Clock.currentTimeMillis;
279
- yield* Ref.update(storeRef, (store) => {
280
- const snapshot = store.tasks.get(taskId);
281
- if (!snapshot) return store;
282
-
283
- const nextTasks = new Map(store.tasks);
284
- if (snapshot.transient) {
285
- nextTasks.delete(taskId);
286
- return {
287
- tasks: nextTasks,
288
- renderOrder: removeFromRenderOrder(store.renderOrder, taskId),
289
- };
290
- }
291
-
292
- nextTasks.set(
293
- taskId,
294
- new TaskSnapshot({
295
- id: snapshot.id,
296
- parentId: snapshot.parentId,
297
- description: snapshot.description,
298
- status: "failed",
299
- transient: snapshot.transient,
300
- units: snapshot.units,
301
- startedAt: snapshot.startedAt,
302
- completedAt: now,
303
- }),
304
- );
305
- return { tasks: nextTasks, renderOrder: store.renderOrder };
306
- });
307
- yield* markDirty;
308
- });
309
-
310
- const getTask = (taskId: TaskId) =>
311
- Ref.get(storeRef).pipe(Effect.map((store) => Option.fromNullable(store.tasks.get(taskId))));
312
-
313
- const listTasks = Ref.get(storeRef).pipe(Effect.map((store) => Array.from(store.tasks.values())));
314
-
315
- const runTask: ProgressService["runTask"] = dual(
316
- 2,
317
- <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
318
- Effect.gen(function* () {
319
- const inheritedParentId = yield* FiberRef.get(currentParentRef);
320
- const resolvedParentId =
321
- options.parentId === undefined ? inheritedParentId : Option.some(options.parentId);
322
-
323
- const taskId = yield* addTask({
324
- ...options,
325
- parentId: Option.isSome(resolvedParentId) ? resolvedParentId.value : undefined,
326
- transient: options.transient,
327
- });
328
-
329
- return yield* Effect.locally(
330
- Effect.provideService(effect, Task, taskId),
331
- currentParentRef,
332
- Option.some(taskId),
333
- );
334
- }),
335
- );
336
-
337
- const withTask: ProgressService["withTask"] = dual(
338
- 2,
339
- <A, E, R>(effect: Effect.Effect<A, E, R>, options: AddTaskOptions) =>
340
- runTask(
341
- Effect.gen(function* () {
342
- const taskId = yield* Task;
343
- const exit = yield* Effect.exit(effect);
344
-
345
- if (Exit.isSuccess(exit)) {
346
- yield* completeTask(taskId);
347
- } else {
348
- yield* failTask(taskId);
349
- }
350
-
351
- return yield* Exit.match(exit, {
352
- onFailure: Effect.failCause,
353
- onSuccess: Effect.succeed,
354
- });
355
- }),
356
- options,
357
- ),
358
- );
359
-
360
- const service: ProgressService = {
361
- addTask,
362
- updateTask,
363
- advanceTask,
364
- completeTask,
365
- failTask,
366
- log,
367
- getTask,
368
- listTasks,
369
- runTask,
370
- withTask,
371
- };
372
-
373
- return Progress.of(service);
374
- });
375
-
376
- export class Progress extends Context.Tag("stromseng.dev/effective-progress/Progress")<
377
- Progress,
378
- ProgressService
379
- >() {
380
- static readonly Default = Layer.unwrapEffect(
381
- Effect.gen(function* () {
382
- const terminalOption = yield* Effect.serviceOption(ProgressTerminal);
383
- const inkRendererOption = yield* Effect.serviceOption(InkRenderer);
384
- let layer: Layer.Layer<Progress, never, any> = Layer.scoped(Progress, makeProgressService);
385
-
386
- if (Option.isNone(inkRendererOption)) {
387
- layer = layer.pipe(Layer.provide(InkRenderer.Default));
388
- }
389
- if (Option.isNone(terminalOption)) {
390
- layer = layer.pipe(Layer.provide(ProgressTerminal.Default));
391
- }
392
-
393
- return layer as Layer.Layer<Progress, never, never>;
394
- }),
395
- );
396
- }