task_progress 0.0.1

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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +21 -0
  4. data/MANUAL.md +851 -0
  5. data/README.md +158 -0
  6. data/RECIPES.md +413 -0
  7. data/config/importmap.rb +17 -0
  8. data/exe/task_progress +51 -0
  9. data/js/local_tasks.d.ts +44 -0
  10. data/js/local_tasks.js +229 -0
  11. data/js/package.json +31 -0
  12. data/js/paths.d.ts +5 -0
  13. data/js/paths.js +7 -0
  14. data/js/react_task_tree.d.ts +49 -0
  15. data/js/react_task_tree.js +123 -0
  16. data/js/render_queue.d.ts +24 -0
  17. data/js/render_queue.js +79 -0
  18. data/js/row_view.d.ts +25 -0
  19. data/js/row_view.js +69 -0
  20. data/js/stimulus_controller.d.ts +10 -0
  21. data/js/stimulus_controller.js +137 -0
  22. data/js/task_progress_clock.d.ts +2 -0
  23. data/js/task_progress_clock.js +33 -0
  24. data/js/task_tree.css +51 -0
  25. data/js/task_tree.d.ts +111 -0
  26. data/js/task_tree.js +444 -0
  27. data/lib/task_progress/assertions.rb +49 -0
  28. data/lib/task_progress/batch.rb +23 -0
  29. data/lib/task_progress/branch.rb +74 -0
  30. data/lib/task_progress/buffered_output.rb +63 -0
  31. data/lib/task_progress/cancellations_app.rb +76 -0
  32. data/lib/task_progress/event.rb +46 -0
  33. data/lib/task_progress/execution_log_output.rb +183 -0
  34. data/lib/task_progress/fold.rb +18 -0
  35. data/lib/task_progress/html_renderer.rb +153 -0
  36. data/lib/task_progress/job_serializer.rb +92 -0
  37. data/lib/task_progress/journal.rb +35 -0
  38. data/lib/task_progress/log.rb +263 -0
  39. data/lib/task_progress/memory_cache.rb +31 -0
  40. data/lib/task_progress/memory_stream_store.rb +142 -0
  41. data/lib/task_progress/mount.rb +448 -0
  42. data/lib/task_progress/ndjson_output.rb +24 -0
  43. data/lib/task_progress/node.rb +52 -0
  44. data/lib/task_progress/poll.rb +87 -0
  45. data/lib/task_progress/publishing_output.rb +30 -0
  46. data/lib/task_progress/ractor_output.rb +23 -0
  47. data/lib/task_progress/rails_defaults.rb +46 -0
  48. data/lib/task_progress/railtie.rb +91 -0
  49. data/lib/task_progress/recording_output.rb +51 -0
  50. data/lib/task_progress/redis_stream_store.rb +131 -0
  51. data/lib/task_progress/squasher.rb +59 -0
  52. data/lib/task_progress/sse.rb +114 -0
  53. data/lib/task_progress/state.rb +319 -0
  54. data/lib/task_progress/stream_output.rb +222 -0
  55. data/lib/task_progress/stream_reader.rb +76 -0
  56. data/lib/task_progress/summary_output.rb +37 -0
  57. data/lib/task_progress/terminal_output.rb +241 -0
  58. data/lib/task_progress/token.rb +58 -0
  59. data/lib/task_progress/tracked_io.rb +68 -0
  60. data/lib/task_progress/transaction.rb +41 -0
  61. data/lib/task_progress/trees_app.rb +66 -0
  62. data/lib/task_progress/turbo_helper.rb +18 -0
  63. data/lib/task_progress/turbo_sse.rb +103 -0
  64. data/lib/task_progress/turbo_stream_output.rb +61 -0
  65. data/lib/task_progress/version.rb +7 -0
  66. data/lib/task_progress/wire.rb +211 -0
  67. data/lib/task_progress.rb +1036 -0
  68. data/sig/generated/task_progress/assertions.rbs +36 -0
  69. data/sig/generated/task_progress/batch.rbs +33 -0
  70. data/sig/generated/task_progress/branch.rbs +47 -0
  71. data/sig/generated/task_progress/buffered_output.rbs +38 -0
  72. data/sig/generated/task_progress/cancellations_app.rbs +41 -0
  73. data/sig/generated/task_progress/event.rbs +20 -0
  74. data/sig/generated/task_progress/execution_log_output.rbs +93 -0
  75. data/sig/generated/task_progress/fold.rbs +25 -0
  76. data/sig/generated/task_progress/html_renderer.rbs +71 -0
  77. data/sig/generated/task_progress/journal.rbs +25 -0
  78. data/sig/generated/task_progress/log.rbs +140 -0
  79. data/sig/generated/task_progress/memory_cache.rbs +28 -0
  80. data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
  81. data/sig/generated/task_progress/mount.rbs +200 -0
  82. data/sig/generated/task_progress/ndjson_output.rbs +18 -0
  83. data/sig/generated/task_progress/node.rbs +66 -0
  84. data/sig/generated/task_progress/poll.rbs +50 -0
  85. data/sig/generated/task_progress/publishing_output.rbs +24 -0
  86. data/sig/generated/task_progress/ractor_output.rbs +17 -0
  87. data/sig/generated/task_progress/rails_defaults.rbs +33 -0
  88. data/sig/generated/task_progress/recording_output.rbs +41 -0
  89. data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
  90. data/sig/generated/task_progress/squasher.rbs +20 -0
  91. data/sig/generated/task_progress/sse.rbs +73 -0
  92. data/sig/generated/task_progress/state.rbs +165 -0
  93. data/sig/generated/task_progress/stream_output.rbs +142 -0
  94. data/sig/generated/task_progress/stream_reader.rbs +46 -0
  95. data/sig/generated/task_progress/summary_output.rbs +19 -0
  96. data/sig/generated/task_progress/terminal_output.rbs +117 -0
  97. data/sig/generated/task_progress/token.rbs +41 -0
  98. data/sig/generated/task_progress/tracked_io.rbs +48 -0
  99. data/sig/generated/task_progress/transaction.rbs +33 -0
  100. data/sig/generated/task_progress/trees_app.rbs +40 -0
  101. data/sig/generated/task_progress/turbo_helper.rbs +14 -0
  102. data/sig/generated/task_progress/turbo_sse.rbs +69 -0
  103. data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
  104. data/sig/generated/task_progress/version.rbs +6 -0
  105. data/sig/generated/task_progress/wire.rbs +102 -0
  106. data/sig/generated/task_progress.rbs +553 -0
  107. metadata +178 -0
@@ -0,0 +1,44 @@
1
+ import type { Batch, Node, Status, Path } from "./task_tree.js"
2
+
3
+ export interface LocalTaskOptions {
4
+ total?: number
5
+ weight?: number
6
+ expect?: number
7
+ indeterminate?: boolean
8
+ unit?: string
9
+ cancelable?: boolean
10
+ autofinish?: boolean
11
+ }
12
+
13
+ /** Tasks that run in this browser, grafted under a server task with TaskTree#graft */
14
+ export class LocalTasks {
15
+ constructor(name: string, options?: LocalTaskOptions & { clock?: () => number })
16
+ readonly log: string
17
+ readonly root: LocalTask
18
+ subscribe(fn: (batch: Batch) => void): () => void
19
+ squash(): Batch
20
+ at(path: Path): LocalTask | null
21
+ }
22
+
23
+ export class LocalTask {
24
+ readonly path: Path
25
+ readonly node: Node
26
+ readonly status: Status
27
+ readonly finished: boolean
28
+ readonly canceled: boolean
29
+ /** Aborts when the task is canceled - for fetch, say */
30
+ readonly signal: AbortSignal
31
+ subtask(name: string, options?: LocalTaskOptions): LocalTask | null
32
+ advance(by?: number, options?: { message?: string }): this
33
+ tick(options?: { message?: string }): this
34
+ update(percent: number, options?: { message?: string }): this
35
+ note(message: string): this
36
+ setTotal(total: number): this
37
+ done(): this
38
+ fail(error?: unknown): this
39
+ cancel(reason?: string): this
40
+ onCancel(handler: (reason?: string) => void): this
41
+ whenFinished(): Promise<Status>
42
+ }
43
+
44
+ export function trackUpload(xhr: XMLHttpRequest, task: LocalTask, options?: { abortOnCancel?: boolean }): LocalTask
data/js/local_tasks.js ADDED
@@ -0,0 +1,229 @@
1
+ // Tasks that run in this browser - hashing files before an upload, say - written the way the
2
+ // server writes its own: every change a batch, numbered, folded into a tree. Graft them under a
3
+ // server task with TaskTree#graft and the renderers draw them as its children.
4
+ //
5
+ // const work = new LocalTasks("Hash files", { cancelable: true })
6
+ // tree.graft(serverTask.path, work)
7
+ // const file = work.root.subtask("video.mp4", { total: blob.size, unit: "bytes" })
8
+ // file.onCancel(() => controller.abort())
9
+ // file.advance(chunk.byteLength)
10
+ //
11
+ // The rules are the server's: a counted task is done at its total, done() closes everything
12
+ // still open under it, fail() and cancel() cancel it. Changing a task that has ended does
13
+ // nothing - check `canceled` in loops that would otherwise carry on.
14
+ //
15
+ // Ids start with "_", which a server id never does, so a graft cannot clash with the tree it
16
+ // is grafted to, and a task can tell it runs here.
17
+
18
+ import { TaskTree } from "./task_tree.js"
19
+
20
+ const FINISHED = new Set(["done", "failed", "canceled"])
21
+ const randomId = (length) => "_" + Array.from({ length }, () => Math.floor(Math.random() * 36).toString(36)).join("")
22
+
23
+ const attributes = ({ name, total, weight, expect, indeterminate, unit, cancelable }) =>
24
+ Object.fromEntries(Object.entries({ name, total, weight, expect, indeterminate: indeterminate || undefined, unit, cancelable: cancelable || undefined })
25
+ .filter(([, value]) => value !== undefined && value !== null))
26
+
27
+ export class LocalTasks {
28
+ constructor(name, { clock = () => Date.now() / 1000, autofinish = true, ...options } = {}) {
29
+ this.log = randomId(10)
30
+ this.seq = 0
31
+ this.clock = clock
32
+ this.tree = new TaskTree({ clock })
33
+ this.listeners = new Set()
34
+ this.handlers = new Map() // key -> [onCancel callbacks]
35
+ this.waiting = new Map() // key -> [whenFinished resolvers]
36
+ this.signals = new Map() // key -> AbortSignal, made on first ask
37
+ this.autofinish = new Map()
38
+ this.commit([[[], "open", attributes({ name, ...options })]])
39
+ this.autofinish.set("", autofinish)
40
+ this.root = new LocalTask(this, [])
41
+ }
42
+
43
+ // Calls fn(batch) with every batch from now on. Returns a function that stops it
44
+ subscribe(fn) {
45
+ this.listeners.add(fn)
46
+ return () => this.listeners.delete(fn)
47
+ }
48
+
49
+ // The tree so far as one batch of puts, prev 0 - what a graft starts from
50
+ squash() {
51
+ const ops = [...this.tree.each()].map((node) => [node.path, "put", putValue(node)])
52
+ return { v: 1, log: this.log, prev: 0, seq: this.seq, at: this.clock(), ops }
53
+ }
54
+
55
+ commit(ops) {
56
+ if (ops.length === 0) return
57
+ const batch = { v: 1, log: this.log, prev: this.seq, seq: this.seq + 1, at: this.clock(), ops }
58
+ this.tree.apply(batch)
59
+ this.seq = batch.seq
60
+ for (const listener of this.listeners) listener(batch)
61
+ for (const [path, op, reason] of ops) {
62
+ if (op !== "cancel" && op !== "done" && op !== "fail") continue
63
+ const key = path.join("-")
64
+ const handlers = this.handlers.get(key) ?? []
65
+ this.handlers.delete(key)
66
+ if (op === "cancel") for (const handler of handlers) handler(reason ?? null)
67
+ for (const resolve of this.waiting.get(key) ?? []) resolve(this.tree.get(path).status)
68
+ this.waiting.delete(key)
69
+ }
70
+ }
71
+
72
+ newId() {
73
+ let id
74
+ do id = randomId(4)
75
+ while ([...this.tree.nodes.values()].some((node) => node.path[node.path.length - 1] === id))
76
+ return id
77
+ }
78
+
79
+ // The task at `path`, or null
80
+ at(path) { return this.tree.get(path) ? new LocalTask(this, path) : null }
81
+
82
+ // Every task under `path` still open, deepest first
83
+ openUnder(path) {
84
+ const node = this.tree.get(path)
85
+ return [...this.tree.each(node)].slice(1).filter((n) => !FINISHED.has(n.status)).map((n) => n.path).reverse()
86
+ }
87
+ }
88
+
89
+ class LocalTask {
90
+ constructor(tasks, path) {
91
+ this.tasks = tasks
92
+ this.path = path
93
+ }
94
+
95
+ get node() { return this.tasks.tree.get(this.path) }
96
+ get status() { return this.node.status }
97
+ get finished() { return FINISHED.has(this.status) }
98
+ get canceled() { return this.status === "canceled" }
99
+
100
+ subtask(name, { autofinish = true, ...options } = {}) {
101
+ if (this.finished) return null
102
+ const path = [...this.path, this.tasks.newId()]
103
+ this.tasks.commit([[path, "open", attributes({ name, ...options })]])
104
+ this.tasks.autofinish.set(path.join("-"), autofinish)
105
+ return new LocalTask(this.tasks, path)
106
+ }
107
+
108
+ advance(by = 1, { message } = {}) {
109
+ if (this.finished) return this
110
+ const ops = [[this.path, "add", by]]
111
+ if (message != null) ops.push([this.path, "note", message])
112
+ const { count, total } = this.node
113
+ if (this.tasks.autofinish.get(this.path.join("-")) && total != null && count + by >= total) ops.push(...this.#closing("done"))
114
+ this.tasks.commit(ops)
115
+ return this
116
+ }
117
+
118
+ tick(options) { return this.advance(1, options) }
119
+
120
+ update(percent, { message } = {}) {
121
+ if (this.finished) return this
122
+ const ops = message != null ? [[this.path, "note", message]] : []
123
+ ops.push(...(percent >= 100 ? this.#closing("done") : [[this.path, "set", percent]]))
124
+ this.tasks.commit(ops)
125
+ return this
126
+ }
127
+
128
+ note(message) {
129
+ if (!this.finished) this.tasks.commit([[this.path, "note", message]])
130
+ return this
131
+ }
132
+
133
+ // An AbortSignal that aborts, with the reason, once this task is canceled. Nothing is wired
134
+ // to it: a cancel stops only what you hand it to - `fetch(url, { signal: task.signal })`
135
+ get signal() {
136
+ const key = this.path.join("-")
137
+ if (!this.tasks.signals.has(key)) {
138
+ const controller = new AbortController()
139
+ this.tasks.signals.set(key, controller.signal)
140
+ this.onCancel((reason) => controller.abort(reason ?? "Canceled"))
141
+ }
142
+ return this.tasks.signals.get(key)
143
+ }
144
+
145
+ // Resolves with "done", "failed" or "canceled" once the task ends - at once, if it has
146
+ whenFinished() {
147
+ if (this.finished) return Promise.resolve(this.status)
148
+ const key = this.path.join("-")
149
+ return new Promise((resolve) => this.tasks.waiting.set(key, [...(this.tasks.waiting.get(key) ?? []), resolve]))
150
+ }
151
+
152
+ // For a total only learned once the work has started - the size of an upload, say
153
+ setTotal(total) {
154
+ if (!this.finished) this.tasks.commit([[this.path, "meta", { total }]])
155
+ return this
156
+ }
157
+
158
+ done() {
159
+ if (!this.finished) this.tasks.commit(this.#closing("done"))
160
+ return this
161
+ }
162
+
163
+ fail(error) {
164
+ if (this.finished) return this
165
+ const value = { class: error?.name ?? "Error", message: String(error?.message ?? error ?? `${this.node.name} failed`) }
166
+ this.tasks.commit([...this.tasks.openUnder(this.path).map((path) => [path, "cancel"]), [this.path, "fail", value]])
167
+ return this
168
+ }
169
+
170
+ cancel(reason) {
171
+ if (!this.finished) this.tasks.commit(this.#closing("cancel", reason))
172
+ return this
173
+ }
174
+
175
+ // Runs once this task is canceled - at once, if it already was. Hook an AbortController here
176
+ onCancel(handler) {
177
+ if (this.canceled) handler(this.node.message)
178
+ else if (!this.finished) {
179
+ const key = this.path.join("-")
180
+ this.tasks.handlers.set(key, [...(this.tasks.handlers.get(key) ?? []), handler])
181
+ }
182
+ return this
183
+ }
184
+
185
+ // done closes what is still open under it with a done of its own, cancel with a cancel
186
+ #closing(op, value) {
187
+ const own = op === "cancel" && value != null ? [this.path, op, value] : [this.path, op]
188
+ return [...this.tasks.openUnder(this.path).map((path) => (value != null ? [path, op, value] : [path, op])), own]
189
+ }
190
+ }
191
+
192
+ // Tracks an XMLHttpRequest's upload with a task: its count is the bytes sent, its total the
193
+ // body's size, learned from the first progress event. A 2xx response finishes it; any other
194
+ // status, a network error or a timeout fails it, and an abort cancels it. Call it before
195
+ // `xhr.send`, which is when the progress events start.
196
+ //
197
+ // It only reports. Whether canceling the task - its cancel button, or the server task it is
198
+ // grafted under ending badly - stops the upload is yours to say: `abortOnCancel: true` aborts
199
+ // the request then
200
+ //
201
+ // const xhr = new XMLHttpRequest()
202
+ // xhr.open("PUT", url)
203
+ // trackUpload(xhr, work.root.subtask(file.name, { unit: "bytes", cancelable: true }), { abortOnCancel: true })
204
+ // xhr.send(file)
205
+ export function trackUpload(xhr, task, { abortOnCancel = false } = {}) {
206
+ // All the bytes out is not the upload done, so the count reaching the total must not finish it
207
+ task.tasks.autofinish.set(task.path.join("-"), false)
208
+ let sent = 0
209
+ xhr.upload.addEventListener("progress", (event) => {
210
+ if (event.lengthComputable && task.node.total == null) task.setTotal(event.total)
211
+ if (event.loaded > sent) task.advance(event.loaded - sent)
212
+ sent = Math.max(sent, event.loaded)
213
+ })
214
+ xhr.addEventListener("load", () => {
215
+ if (xhr.status >= 200 && xhr.status < 300) task.done()
216
+ else task.fail(new Error(`The server answered ${xhr.status}${xhr.statusText ? ` ${xhr.statusText}` : ""}`))
217
+ })
218
+ xhr.addEventListener("error", () => task.fail(new Error("The upload failed - the network, or the server going away")))
219
+ xhr.addEventListener("timeout", () => task.fail(new Error("The upload timed out")))
220
+ xhr.addEventListener("abort", () => task.cancel("Aborted"))
221
+ if (abortOnCancel) task.onCancel(() => xhr.abort())
222
+ return task
223
+ }
224
+
225
+ const FIELDS = ["name", "total", "count", "percent", "weight", "expect", "indeterminate", "unit", "cancelable", "attempt", "message", "status", "error", "stale", "opened_at", "started_at", "finished_at"]
226
+
227
+ function putValue(node) {
228
+ return Object.fromEntries(FIELDS.filter((field) => node[field] !== null && node[field] !== undefined && node[field] !== false).map((field) => [field, node[field]]))
229
+ }
data/js/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "task_progress",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "description": "The browser half of the task_progress gem, vendored from it - see `task_progress vendor`",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": { "type": "git", "url": "git+https://github.com/julik/task_progress.git", "directory": "js" },
9
+ "homepage": "https://github.com/julik/task_progress",
10
+ "exports": {
11
+ ".": { "types": "./task_tree.d.ts", "default": "./task_tree.js" },
12
+ "./react": { "types": "./react_task_tree.d.ts", "default": "./react_task_tree.js" },
13
+ "./stimulus": { "types": "./stimulus_controller.d.ts", "default": "./stimulus_controller.js" },
14
+ "./local_tasks": { "types": "./local_tasks.d.ts", "default": "./local_tasks.js" },
15
+ "./row_view": { "types": "./row_view.d.ts", "default": "./row_view.js" },
16
+ "./clock": { "types": "./task_progress_clock.d.ts", "default": "./task_progress_clock.js" },
17
+ "./task_tree.css": "./task_tree.css"
18
+ },
19
+ "files": ["*.js", "*.d.ts", "*.css"],
20
+ "sideEffects": ["./task_progress_clock.js", "*.css"],
21
+ "peerDependencies": {
22
+ "@hotwired/stimulus": ">=3",
23
+ "react": ">=18",
24
+ "react-dom": ">=18"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "@hotwired/stimulus": { "optional": true },
28
+ "react": { "optional": true },
29
+ "react-dom": { "optional": true }
30
+ }
31
+ }
data/js/paths.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** A task's place in the tree: its ancestors' ids, then its own - `[]` is the root */
2
+ export type Path = string[]
3
+ /** A path's key - its ids joined with a dash, `""` for the root */
4
+ export function pathKey(path: Path): string
5
+ export function pathFromKey(key: string): Path
data/js/paths.js ADDED
@@ -0,0 +1,7 @@
1
+ // A path as one string: its ids joined with a dash. Ids are [0-9a-z], so a dash never occurs in
2
+ // one, and the key needs no escaping in a URL, a class name or a data attribute. The root's key
3
+ // is the empty string - where it has to be seen, call it "root". As a DOM id, prefix it: an id
4
+ // that starts with a digit needs CSS.escape in a selector
5
+ export const pathKey = (path) => path.join("-")
6
+
7
+ export const pathFromKey = (key) => (key === "" ? [] : key.split("-"))
@@ -0,0 +1,49 @@
1
+ import type { ReactElement } from "react"
2
+ import type { TaskTree, Progress, Path } from "./task_tree.js"
3
+ import type { RowView } from "./row_view.js"
4
+ import type { LocalTasks } from "./local_tasks.js"
5
+
6
+ export type { Progress } from "./task_tree.js"
7
+ export type { RowView } from "./row_view.js"
8
+
9
+ export interface Graft { path: Path; local: LocalTasks }
10
+
11
+ export interface FollowOptions {
12
+ transport?: "auto" | "sse" | "poll"
13
+ /** Seconds between polls */
14
+ poll?: number
15
+ /** Seconds to long-poll */
16
+ wait?: number
17
+ grafts?: Graft[]
18
+ /** Called once, when the root is done - `() => router.reload({ only: ["video"] })` */
19
+ onComplete?: (tree: TaskTree) => void
20
+ /** Called once, when the root failed or was canceled */
21
+ onFail?: (tree: TaskTree) => void
22
+ }
23
+
24
+ export interface UseTaskTree {
25
+ tree: TaskTree | null
26
+ rows: RowView[]
27
+ /** Asks for the row's task to be canceled; resolves false if it could not ask */
28
+ cancel(row: RowView): Promise<boolean | undefined> | boolean | undefined
29
+ canCancel: boolean
30
+ }
31
+
32
+ /** Follows what TaskProgress.for_page hands a page, the url of an SSE stream or Poll endpoint, or tasks running in this browser */
33
+ export function useTaskTree(source: Progress | string | LocalTasks | null | undefined, options?: FollowOptions): UseTaskTree
34
+
35
+ export interface TaskTreeViewProps extends FollowOptions {
36
+ progress?: Progress
37
+ url?: string
38
+ /** Tasks running in this browser, drawn on their own */
39
+ local?: LocalTasks
40
+ cancelUrl?: string
41
+ cancelToken?: string
42
+ /** Return false to release the button */
43
+ onCancel?: (row: RowView) => unknown
44
+ }
45
+
46
+ /** The rows, for an element of yours with the `tree` class around them */
47
+ export function TaskTreeView(props: TaskTreeViewProps): ReactElement | null
48
+
49
+ export function createTaskTreeRoot(element: Element): { render(props: TaskTreeViewProps): void; unmount(): void }
@@ -0,0 +1,123 @@
1
+ import React, { useEffect, useRef, useState } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+ import { followEventSourceAt, followLocal, followPollingAt, followProgress, requestCancel } from "./task_tree.js"
4
+ import { rowView, CANCEL_ICON } from "./row_view.js"
5
+
6
+ // No JSX, so it runs as it is - in a Vite app, or straight off an import map with no build step
7
+ const h = React.createElement
8
+
9
+ const NO_GRAFTS = []
10
+ const FINISHED = new Set(["done", "failed", "canceled"])
11
+
12
+ // Follows a tree: what TaskProgress.for_page hands a page - { events, poll, cancel, snapshot },
13
+ // drawn from its snapshot at once - or the url of a TaskProgress::SSE stream, or of a
14
+ // TaskProgress::Poll endpoint given `poll` (seconds between polls) or `wait` (seconds to
15
+ // long-poll). `transport` picks for a for_page object, see followProgress. Or a LocalTasks, for
16
+ // tasks that run in this browser alone - uploads, say - drawn as they change, with no server.
17
+ //
18
+ // Returns { tree, rows, cancel }: the tree, what to draw (see rowView), and a function that asks
19
+ // for a row's task to be canceled - a task running in this browser is canceled here. `onComplete`
20
+ // and `onFail` are called once, when the root has finished one way or the other - for an
21
+ // Inertia page, `onComplete: () => router.reload({ only: ["video"] })`. `grafts` - [{ path, local }]
22
+ // - draws tasks running in this browser under server tasks, see TaskTree#graft
23
+ export function useTaskTree(source, { transport, poll = 0, wait = 0, grafts = NO_GRAFTS, onComplete, onFail } = {}) {
24
+ const [state, setState] = useState({ tree: null, version: 0 })
25
+ const callbacks = useRef({})
26
+ callbacks.current = { onComplete, onFail }
27
+ const local = typeof source?.subscribe === "function" ? source : null
28
+ const progress = typeof source === "string" || local ? null : source
29
+ const url = typeof source === "string" ? source : null
30
+ // A for_page object is a new object every render; what it points at is what matters
31
+ const followKey = local ? local.log : progress ? `${progress.events} ${progress.poll}` : url
32
+ useEffect(() => {
33
+ setState({ tree: null, version: 0 })
34
+ if (!followKey) return
35
+ let told = false
36
+ const draw = (tree) => {
37
+ setState((s) => ({ tree, version: s.version + 1 }))
38
+ const status = tree.root?.status
39
+ if (told || !FINISHED.has(status)) return
40
+ told = true
41
+ if (status === "done") callbacks.current.onComplete?.(tree)
42
+ else callbacks.current.onFail?.(tree)
43
+ }
44
+ const following = local
45
+ ? followLocal(local, draw)
46
+ : progress
47
+ ? followProgress(progress, draw, { transport, poll: poll || 1, wait })
48
+ : poll || wait ? followPollingAt(url, draw, { poll: poll || 1, wait }) : followEventSourceAt(url, draw)
49
+ return () => following.close()
50
+ }, [followKey, transport, poll, wait]) // eslint-disable-line react-hooks/exhaustive-deps
51
+ // Keyed on what the grafts are, not on the array: one written inline is a new array every
52
+ // render, and grafting anew draws again - which would render again, every frame, for ever
53
+ const graftsKey = grafts.map(({ path, local }) => `${path.join("-")}:${local.log}`).join(" ")
54
+ useEffect(() => {
55
+ if (!state.tree) return
56
+ const ungrafts = grafts.map(({ path, local }) => state.tree.graft(path, local))
57
+ return () => ungrafts.forEach((ungraft) => ungraft())
58
+ }, [state.tree, graftsKey]) // eslint-disable-line react-hooks/exhaustive-deps
59
+ const { tree } = state
60
+ const rows = tree ? [...tree.rows()].map((node) => rowView(tree, node)) : []
61
+ const cancelUrl = progress?.cancel
62
+ const cancel = async (row) => {
63
+ if (row.local) return tree?.cancelLocal(row.path, "Canceled in this browser")
64
+ if (!cancelUrl) return false
65
+ try { return (await requestCancel(cancelUrl, row.path)).ok } catch { return false }
66
+ }
67
+ return { tree, rows, cancel: cancelUrl ? cancel : (row) => (row.local ? cancel(row) : false), canCancel: !!cancelUrl }
68
+ }
69
+
70
+ // A cancel button, right of the bar. It stays pressed until the cancel comes down the stream -
71
+ // the row is then not cancelable any more, and the button hides. Hidden, it keeps its place
72
+ function CancelButton({ row, onCancel, offered }) {
73
+ const [pressed, setPressed] = useState(false)
74
+ const press = async () => {
75
+ setPressed(true)
76
+ if ((await onCancel(row)) === false) setPressed(false)
77
+ }
78
+ return h("button", {
79
+ type: "button", className: "cancel", hidden: !(offered && row.cancelable), disabled: pressed,
80
+ "aria-label": `Cancel ${row.name}`, onClick: press, dangerouslySetInnerHTML: { __html: CANCEL_ICON }
81
+ })
82
+ }
83
+
84
+ function Row({ row, onCancel, offered }) {
85
+ return h("div", { className: `row ${row.status}${row.stale ? " stale" : ""}${row.folded ? " folded" : ""}` },
86
+ h("div", { className: "label", style: { paddingLeft: row.depth * 18 } },
87
+ h("span", { className: "icon", dangerouslySetInnerHTML: { __html: row.icon } }),
88
+ h("span", { className: "name" }, row.name),
89
+ h("span", { className: "message" }, row.message)),
90
+ h("div", { className: `bar${row.indeterminate ? " indeterminate" : ""}` }, h("span", { style: { width: `${row.percent}%` } })),
91
+ h(CancelButton, { row, onCancel, offered }),
92
+ h("div", { className: "measure" }, row.measure))
93
+ }
94
+
95
+ // The tree as rows, styled by task_tree.css. Give it `progress` - what TaskProgress.for_page
96
+ // hands a page - or a `url`, as useTaskTree takes them, or `local`, a LocalTasks. With a `url`, `cancelUrl` and
97
+ // `cancelToken` say where cancel buttons ask; with `progress` they ask where it says. For
98
+ // anything else, pass `onCancel(row)` - return false from it to release the button. `onComplete`
99
+ // and `onFail` are called once the root has finished
100
+ export function TaskTreeView({ progress, url, local, cancelUrl, cancelToken, onCancel, onComplete, onFail, grafts, transport, poll, wait }) {
101
+ const { tree, rows, cancel, canCancel } = useTaskTree(local ?? progress ?? url, { transport, poll, wait, grafts, onComplete, onFail })
102
+ if (!tree) return null
103
+ const remote = !progress && cancelUrl && (async (row) => {
104
+ try { return (await requestCancel(cancelUrl, row.path, { token: cancelToken })).ok } catch { return false }
105
+ })
106
+ const onPress = onCancel ?? ((row) => (row.local || !remote ? cancel(row) : remote(row)))
107
+ // A server task can only be asked to cancel where there is somewhere to ask
108
+ const offered = (row) => !!(onCancel || canCancel || remote || row.local)
109
+ // Rows alone, for an element of yours with the `tree` class around them
110
+ return h(React.Fragment, null, rows.map((row) => h(Row, { key: row.key, row, onCancel: onPress, offered: offered(row) })))
111
+ }
112
+
113
+ // For pages that are not React apps, as React's own createRoot: render it into an element, and
114
+ // render again with other props - another url, `poll` - to change what it follows
115
+ //
116
+ // createTaskTreeRoot(element).render({ progress }) // or { url: "/jobs/42/progress" }
117
+ export function createTaskTreeRoot(element) {
118
+ const root = createRoot(element)
119
+ return {
120
+ render: (props) => root.render(h(TaskTreeView, props)),
121
+ unmount: () => root.unmount()
122
+ }
123
+ }
@@ -0,0 +1,24 @@
1
+ import type { TaskTree } from "./task_tree.js"
2
+
3
+ /** What changed since the last draw - see "Drawing once a frame" in the MANUAL */
4
+ export interface Changes {
5
+ batches: number
6
+ /** Keys of the nodes changed, and every one above them */
7
+ touched: Set<string>
8
+ /** What a renderer must not miss, in order: done, fail, cancel, say, reset, gap */
9
+ events: Array<{ op: string; path: string[]; value?: unknown }>
10
+ /** Rows came, went or moved */
11
+ structure: boolean
12
+ local: boolean
13
+ }
14
+
15
+ export type Schedule = (draw: () => void) => void
16
+ export const nextFrame: Schedule
17
+
18
+ export class RenderQueue {
19
+ constructor(tree: TaskTree, onFrame: (tree: TaskTree, changes: Changes) => void, options?: { schedule?: Schedule })
20
+ apply(batch: Batch): ApplyOutcome
21
+ close(): void
22
+ }
23
+
24
+ import type { Batch, ApplyOutcome } from "./task_tree.js"
@@ -0,0 +1,79 @@
1
+ // A stream can carry thousands of batches a second, far more than a screen can show. Every batch is
2
+ // still folded into the tree as it arrives - the seq chain and the parity with Ruby depend on that -
3
+ // but the renderer gets called at most once a frame, with what changed since the last call.
4
+ //
5
+ // A set, add or note only matters for the state it leaves behind, so those collapse into the paths
6
+ // they touched. What a renderer must hear about even when a later batch in the same frame hides it
7
+ // goes into `events`, in order: done, fail (with the error) and cancel, say lines (the tree never holds
8
+ // them), and the stream starting over (reset) or breaking off (gap).
9
+
10
+ import { pathKey as key } from "./paths.js"
11
+
12
+ // requestAnimationFrame follows the display and pauses in a background tab; Node has neither
13
+ export const nextFrame = globalThis.requestAnimationFrame
14
+ ? (fn) => globalThis.requestAnimationFrame(fn)
15
+ : (fn) => setTimeout(fn, 16)
16
+
17
+ const STRUCTURAL = new Set(["open", "put", "prune"])
18
+ const NOTABLE = new Set(["done", "fail", "cancel", "say"])
19
+
20
+ const blankChanges = () => ({ batches: 0, touched: new Set(), events: [], structure: false, local: false })
21
+
22
+ export class RenderQueue {
23
+ // onFrame(tree, changes) - changes is { batches, touched, events, structure }. `structure` says
24
+ // rows came, went or moved, so a renderer that patches rows in place has to lay them out again
25
+ constructor(tree, onFrame, { schedule = nextFrame } = {}) {
26
+ this.tree = tree
27
+ this.onFrame = onFrame
28
+ this.schedule = schedule
29
+ this.changes = blankChanges()
30
+ this.pending = false
31
+ this.closed = false
32
+ // What a graft of browser tasks changes is drawn the same way, in the same frames
33
+ tree.onLocalChange = (ops) => {
34
+ for (const [path, op, value] of ops) this.#note(path, op, value, null)
35
+ this.changes.local = true
36
+ this.#request()
37
+ }
38
+ }
39
+
40
+ apply(batch) {
41
+ const result = this.tree.apply(batch)
42
+ const changes = this.changes
43
+ changes.batches++
44
+ if (batch.prev === 0) {
45
+ changes.events.push({ op: "reset", seq: batch.seq })
46
+ changes.structure = true
47
+ }
48
+ if (result === "gap") changes.events.push({ op: "gap", seq: batch.seq, prev: batch.prev })
49
+ if (result === "applied") for (const [path, op, value] of batch.ops) this.#note(path, op, value, batch.seq)
50
+ this.#request()
51
+ return result
52
+ }
53
+
54
+ // Hands over what has piled up right away, without waiting for the frame
55
+ flush() {
56
+ this.pending = false
57
+ if (this.closed) return
58
+ const changes = this.changes
59
+ if (changes.batches === 0 && !changes.local) return
60
+ this.changes = blankChanges()
61
+ this.onFrame(this.tree, changes)
62
+ }
63
+
64
+ // A frame already asked for must not draw an old stream into a renderer that moved on
65
+ close() { this.closed = true }
66
+
67
+ #note(path, op, value, seq) {
68
+ // Percents roll up, so a change anywhere shows in every row above it too
69
+ for (let length = path.length; length >= 0; length--) this.changes.touched.add(key(path.slice(0, length)))
70
+ if (STRUCTURAL.has(op)) this.changes.structure = true
71
+ if (NOTABLE.has(op)) this.changes.events.push({ op, path, value: value ?? null, seq })
72
+ }
73
+
74
+ #request() {
75
+ if (this.pending || this.closed) return
76
+ this.pending = true
77
+ this.schedule(() => this.flush())
78
+ }
79
+ }
data/js/row_view.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { TaskTree, Node, Fold, Status, Path } from "./task_tree.js"
2
+
3
+ /** What one row shows */
4
+ export interface RowView {
5
+ key: string
6
+ path: Path
7
+ depth: number
8
+ name: string
9
+ status: Status
10
+ stale: boolean
11
+ /** Runs in this browser, see LocalTasks */
12
+ local: boolean
13
+ cancelable: boolean
14
+ /** Siblings folded into this one row */
15
+ folded?: boolean
16
+ percent: number
17
+ indeterminate: boolean
18
+ measure: string
19
+ /** HTML for the icon */
20
+ icon: string
21
+ message: string
22
+ }
23
+
24
+ export const CANCEL_ICON: string
25
+ export function rowView(tree: TaskTree, node: Node | Fold): RowView