Skip to main content

max / makenotwork

Describe the picked-file queue, and let it say its own progress The last bespoke shape in the version uploader. A row was built by addFileRow with six inline styles, its label input was found by a hand-written class, and the run drew a second copy of the whole list inside the progress panel so it had somewhere to put a status. The queue is now a repeating question whose slots come from the picker above it, each named by its file and asking for the label that names its platform. quasi 0.96 is what made that sayable: Adds::Elsewhere for slots the question does not make itself, Instance::called for a slot named by what is in it, Progress for the per-slot status. The second list goes with it. The form stays on screen during the run and each slot says where its own work has got to, which is one list agreeing with itself instead of two.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_013vDpLixQiknHhfHiGxFWo7
Author: Max Johnson <me@maxj.phd> · 2026-09-01 17:25 UTC
Signed with PGP, not checked
Commit: 70a29bc4b2fa7f4a3d7e413bd66d27d2c1fcd65b
Parent: c478c93
6 files changed, +155 insertions, -115 deletions
M server/Cargo.lock +16 -16
@@ -6636,7 +6636,7 @@
6636 6636
6637 6637 [[package]]
6638 6638 name = "quasi-axum"
6639 - version = "0.94.0"
6639 + version = "0.96.0"
6640 6640 dependencies = [
6641 6641 "axum",
6642 6642 "http 1.4.2",
@@ -6647,7 +6647,7 @@
6647 6647
6648 6648 [[package]]
6649 6649 name = "quasi-basics"
6650 - version = "0.94.0"
6650 + version = "0.96.0"
6651 6651 dependencies = [
6652 6652 "makeover-layout",
6653 6653 "quasi-router",
@@ -6655,7 +6655,7 @@
6655 6655
6656 6656 [[package]]
6657 6657 name = "quasi-http"
6658 - version = "0.94.0"
6658 + version = "0.96.0"
6659 6659 dependencies = [
6660 6660 "form_urlencoded",
6661 6661 "http 1.4.2",
@@ -6664,7 +6664,7 @@
6664 6664
6665 6665 [[package]]
6666 6666 name = "quasi-router"
6667 - version = "0.94.0"
6667 + version = "0.96.0"
6668 6668 dependencies = [
6669 6669 "makeover-layout",
6670 6670 ]
@@ -6687,7 +6687,7 @@
6687 6687
6688 6688 [[package]]
6689 6689 name = "quasi-webview"
6690 - version = "0.94.0"
6690 + version = "0.96.0"
6691 6691 dependencies = [
6692 6692 "docengine",
6693 6693 "makeover-layout",
@@ -10761,11 +10761,11 @@
10761 10761
10762 10762 [[patch.unused]]
10763 10763 name = "quasi-immediate"
10764 - version = "0.94.0"
10764 + version = "0.96.0"
10765 10765
10766 10766 [[patch.unused]]
10767 10767 name = "quasi-notifs"
10768 - version = "0.94.0"
10768 + version = "0.96.0"
10769 10769
10770 10770 [[patch.unused]]
10771 10771 name = "quasi-store"
@@ -10773,7 +10773,15 @@
10773 10773
10774 10774 [[patch.unused]]
10775 10775 name = "quasi-tauri"
10776 - version = "0.94.0"
10776 + version = "0.96.0"
10777 +
10778 + [[patch.unused]]
10779 + name = "synckit-client"
10780 + version = "0.10.0"
10781 +
10782 + [[patch.unused]]
10783 + name = "synckit-config"
10784 + version = "0.2.0"
10777 10785
10778 10786 [[patch.unused]]
10779 10787 name = "kberg"
@@ -10786,11 +10794,3 @@
10786 10794 [[patch.unused]]
10787 10795 name = "painhours"
10788 10796 version = "0.1.0"
10789 -
10790 - [[patch.unused]]
10791 - name = "synckit-client"
10792 - version = "0.10.0"
10793 -
10794 - [[patch.unused]]
10795 - name = "synckit-config"
10796 - version = "0.2.0"
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.95" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.96" }
167 167 # The description vocabulary quasi's screen types are built from. Pinned here
168 168 # rather than reached through quasi-router's re-export because a described
169 169 # screen names FieldKind and Tone directly; it has to track what quasi-router
@@ -179,13 +179,13 @@
179 179 # For the request head the per-viewer state factory reads. axum re-exports it,
180 180 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
181 181 http = "1.3.1"
182 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.95" }
183 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.95" }
182 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.96" }
183 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.96" }
184 184 # The first-party widget set. The carousel was proved here against three pages
185 185 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
186 186 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
187 187 # sits above quasi-router rather than inside the description suite.
188 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.95" }
188 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.96" }
189 189
190 190 # Git source browser
191 191 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -277,7 +277,7 @@
277 277 # Written from the crate rather than copied into the repo for the reason the
278 278 # constants exist: a stale copy in a static directory is a silently dead bulk
279 279 # bar, and the version has to be the one the emitter agrees with.
280 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.95" }
280 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.96" }
281 281
282 282 [dev-dependencies]
283 283 tower = { version = "0.5.3", features = ["util"] }
@@ -61,18 +61,45 @@
61 61 speedEl: document.getElementById('version-upload-speed'),
62 62 });
63 63
64 - // The picker is the described field's own input: what it takes and how
65 - // many is `upload_field::version_queue`, and the rows below it are this
66 - // file's, because a queue with a label per row is not one file going to
67 - // one place.
68 - var versionFileInput = document.getElementById('version-files');
69 - var fileRows = document.getElementById('version-file-rows');
64 + // The picker and the slots it fills are both described now
65 + // (`upload_field::version_queue` and `version_file_queue`), and
66 + // `quasi-repeat.js` makes a slot per picked file. What it cannot know
67 + // is what a File is called or what to guess for its label, so it
68 + // announces each slot and that is filled here.
69 + var fileQueue = document.getElementById('version-file');
70 70
71 - if (versionFileInput) {
72 - versionFileInput.addEventListener('change', function() {
73 - for (var i = 0; i < this.files.length; i++) addFileRow(this.files[i]);
74 - this.value = '';
75 - });
71 + document.addEventListener('quasi:repeat:took', function(event) {
72 + var slot = event.detail.slot;
73 + var file = event.detail.file;
74 + if (!fileQueue || !fileQueue.contains(slot)) return;
75 +
76 + // The File itself rides on the slot. Nothing else holds the queue:
77 + // the slots are the queue, so removing one removes its file and
78 + // the renumbering `quasi-repeat.js` does is not this file's
79 + // problem any more.
80 + slot.file = file;
81 +
82 + var named = slot.querySelector('label[for]');
83 + if (named) named.textContent = file.name;
84 + slot.setAttribute('data-repeat-named', '');
85 +
86 + var label = slot.querySelector('input[type="text"]');
87 + if (label && !label.value) label.value = guessLabel(file.name);
88 + });
89 +
90 + /** The slots standing now, in order, each with its file and label. */
91 + function pickedFiles() {
92 + if (!fileQueue) return [];
93 + return [].slice.call(fileQueue.querySelectorAll('[data-repeat-at]'))
94 + .filter(function(slot) { return slot.file; })
95 + .map(function(slot) {
96 + var input = slot.querySelector('input[type="text"]');
97 + return {
98 + file: slot.file,
99 + slot: slot,
100 + label: input ? input.value.trim() : ''
101 + };
102 + });
76 103 }
77 104
78 105 function guessLabel(name) {
@@ -85,50 +112,26 @@
85 112 return '';
86 113 }
87 114
88 - function addFileRow(file) {
89 - var idx = fileQueue.length;
90 - fileQueue.push({ file: file, idx: idx });
91 - var tr = document.createElement('tr');
92 - tr.dataset.idx = idx;
93 - tr.style.borderBottom = '1px solid var(--border)';
94 - tr.innerHTML =
95 - '<td style="padding: 0.4rem 0.5rem 0.4rem 0; font-size: 0.85rem; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="' + escapeHtml(file.name) + '">' + escapeHtml(file.name) + '</td>' +
96 - '<td style="padding: 0.4rem 0.5rem;"><input type="text" class="version-label-input" data-idx="' + idx + '" value="' + escapeHtml(guessLabel(file.name)) + '" placeholder="e.g., macOS (arm)" style="width: 100%; padding: 0.25rem 0.4rem; font-size: 0.85rem;"></td>' +
97 - '<td style="padding: 0.4rem 0.5rem;"><button type="button" class="btn-secondary version-remove-file" data-idx="' + idx + '" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;">Remove</button></td>';
98 - fileRows.appendChild(tr);
99 -
100 - tr.querySelector('.version-remove-file').addEventListener('click', function() {
101 - fileQueue[idx] = null;
102 - tr.remove();
103 - });
104 - }
105 -
106 115 // Upload all button
107 116 createBtn.addEventListener('click', function() {
108 117 var versionNumber = document.getElementById('new-version-number').value.trim();
109 118 var changelog = document.getElementById('version-changelog').value.trim();
110 - var entries = fileQueue.filter(function(e) { return e !== null; });
119 + var entries = pickedFiles();
111 120
112 121 if (!versionNumber) { showToast('Please enter a version number.'); return; }
113 122 if (entries.length === 0) { showToast('Please add at least one file.'); return; }
114 123
115 124 this.disabled = true;
116 125 this.textContent = 'Uploading...';
117 - document.getElementById('new-version-form').classList.add('hidden');
118 126 document.getElementById('version-upload-progress').classList.remove('hidden');
119 127
120 - // Build queue display
121 - var queueEl = document.getElementById('version-upload-queue');
122 - queueEl.innerHTML = '';
128 + // The queue stays on screen and says its own progress, one slot at
129 + // a time, which is what `Progress` is for. The second copy of this
130 + // list that used to live inside the progress panel is gone with it.
123 131 for (var q = 0; q < entries.length; q++) {
124 - var li = document.createElement('div');
125 - li.id = 'queue-item-' + entries[q].idx;
126 - li.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0; font-size: 0.85rem;';
127 - var labelInput = document.querySelector('.version-label-input[data-idx="' + entries[q].idx + '"]');
128 - var labelText = labelInput ? labelInput.value.trim() : '';
129 - var displayName = escapeHtml(entries[q].file.name) + (labelText ? ' (' + escapeHtml(labelText) + ')' : '');
130 - li.innerHTML = '<span class="queue-status" style="width: 1.5em; text-align: center; opacity: 0.5;">-</span><span style="flex: 1;">' + displayName + '</span><span class="queue-size" style="opacity: 0.5;">' + formatSize(entries[q].file.size) + '</span>';
131 - queueEl.appendChild(li);
132 + entries[q].slot.querySelectorAll('input, button').forEach(function(control) {
133 + control.disabled = true;
134 + });
132 135 }
133 136
134 137 uploadSequentially(entries, 0, versionNumber, changelog);
@@ -140,13 +143,14 @@
140 143 return bytes + ' B';
141 144 }
142 145
143 - function updateQueueStatus(idx, status) {
144 - var el = document.getElementById('queue-item-' + idx);
145 - if (!el) return;
146 - var s = el.querySelector('.queue-status');
147 - if (status === 'uploading') { s.textContent = '...'; s.style.opacity = '1'; }
148 - else if (status === 'done') { s.textContent = 'OK'; s.style.opacity = '0.7'; el.style.opacity = '0.6'; }
149 - else if (status === 'error') { s.textContent = '!'; s.style.color = 'var(--error, #c0392b)'; s.style.opacity = '1'; }
146 + /**
147 + * Where a slot's own work has got to, in the renderer's own word for
148 + * it. `working`, `done` and `failed` are what `Progress` emits, so the
149 + * stylesheet has one thing to read whether the server described the
150 + * state or this set it.
151 + */
152 + function slotProgress(entry, progress) {
153 + if (entry.slot) entry.slot.setAttribute('data-repeat-progress', progress);
150 154 }
151 155
152 156 function uploadSequentially(entries, i, versionNumber, changelog) {
@@ -163,10 +167,9 @@
163 167 }
164 168
165 169 var entry = entries[i];
166 - var labelInput = document.querySelector('.version-label-input[data-idx="' + entry.idx + '"]');
167 - var label = labelInput ? labelInput.value.trim() : '';
170 + var label = entry.label;
168 171
169 - updateQueueStatus(entry.idx, 'uploading');
172 + slotProgress(entry, 'working');
170 173 uploader.filenameEl.textContent = entry.file.name + (entries.length > 1 ? ' (' + (i + 1) + '/' + entries.length + ')' : '');
171 174
172 175 fetch(createRoute, {
@@ -224,11 +227,11 @@
224 227 if (confirmData && confirmData.pending_review) {
225 228 showToast('Version upload held for review: our scanner flagged it.', 'warning');
226 229 }
227 - updateQueueStatus(entry.idx, 'done');
230 + slotProgress(entry, 'done');
228 231 uploadSequentially(entries, i + 1, versionNumber, changelog);
229 232 })
230 233 .catch(function(err) {
231 - updateQueueStatus(entry.idx, 'error');
234 + slotProgress(entry, 'failed');
232 235 showVersionError(err.message || 'Upload failed');
233 236 });
234 237 }
@@ -9210,34 +9210,21 @@
9210 9210 gap: var(--gap-section);
9211 9211 }
9212 9212
9213 - .item-version-upload-file-table {
9214 - width: 100%;
9215 - border-collapse: collapse;
9216 - margin-top: var(--gap-peer);
9213 + /* Where one slot's own work has got to, in the three words `Progress` emits.
9214 + Here rather than in makeover because the hook is a data attribute
9215 + `quasi-webview` writes rather than a class makeover names, and this is its
9216 + only consumer. A second host wanting it is the reason to move it. */
9217 + .field-repeat-slot[data-repeat-progress="working"] {
9218 + opacity: 0.75;
9217 9219 }
9218 9220
9219 - .item-version-upload-file-table thead tr {
9220 - text-align: left;
9221 - font-size: var(--text-fine);
9222 - opacity: 0.7;
9223 - text-transform: uppercase;
9224 - letter-spacing: 0.03em;
9221 + .field-repeat-slot[data-repeat-progress="done"] {
9222 + opacity: 0.55;
9225 9223 }
9226 9224
9227 - .item-version-upload-file-table th:first-child {
9228 - padding: var(--gap-peer) var(--gap-peer) var(--gap-peer) 0;
9229 - }
9230 -
9231 - .item-version-upload-file-table th + th {
9232 - padding: var(--gap-peer);
9233 - }
9234 -
9235 - .item-version-upload-file-table th:last-child {
9236 - width: 40px;
9237 - }
9238 -
9239 - .item-version-upload-queue {
9240 - margin-bottom: var(--gap-section);
9225 + .field-repeat-slot[data-repeat-progress="failed"] {
9226 + border-left: 2px solid var(--danger);
9227 + padding-left: var(--gap-peer);
9241 9228 }
9242 9229
9243 9230 .item-version-upload-speed {
@@ -38,7 +38,7 @@
38 38 //! live here once and each surface is named for what it is.
39 39
40 40 use makeover_layout::{Family, FieldKind};
41 - use quasi_router::{Accepted, Action, Field, Node};
41 + use quasi_router::{Accepted, Action, Field, Node, Repeat};
42 42
43 43 /// Every suffix a version file may carry.
44 44 ///
@@ -227,6 +227,35 @@
227 227 html
228 228 }
229 229
230 + /// The files the reader has picked, one slot each, with the label that names
231 + /// the platform it is for.
232 + ///
233 + /// The shape `7f04f751` counted and could not say, until quasi 0.96 said it.
234 + /// Three members carry it and each answers a different half of what the
235 + /// hand-written table was doing:
236 + ///
237 + /// - [`Repeat::added_by`] names [`version_queue`]'s picker as where the slots
238 + /// come from, so no renderer offers an add control. A blank row here is
239 + /// nothing a reader can fill: there is no way to type a file.
240 + /// - [`Instance::called`] names each slot by its file, set by the host as it
241 + /// takes them, because only the picker knows what a `File` is called. The
242 + /// ordinal would have put "File 2" where "track-arm.dmg" goes.
243 + /// - [`Progress`] is the per-slot status the run sets, which is what the
244 + /// second copy of this list inside the progress panel used to draw.
245 + ///
246 + /// The question itself is the label, and it is the only thing here that is
247 + /// answered: the file name is what the slot *is*, and the bytes go to S3
248 + /// through a presigned PUT rather than through this form.
249 + #[must_use]
250 + pub fn version_file_queue() -> String {
251 + use quasi_axum::Serves as _;
252 +
253 + let mut label = Field::new(FieldKind::Text, "version-file", "File");
254 + label.placeholder = Some("e.g., macOS (arm)".to_owned());
255 + let field = label.repeating(Repeat::new().added_by("version-files").removing("Remove"));
256 + quasi_webview::Webview::new().fragment(&Node::field(field))
257 + }
258 +
230 259 /// The control that starts the run, and the address it starts at.
231 260 ///
232 261 /// [`Action::by_host`] for the reason every other upload here is: the host
@@ -250,6 +279,30 @@
250 279
251 280 #[cfg(test)]
252 281 mod tests {
282 + /// The three members `7f04f751` waited on, on the surface that wanted all
283 + /// of them. Read together they are the conversion: before this the rows
284 + /// were built by `addFileRow` with inline styles, the label input was
285 + /// found by a hand-written class, and nothing said a slot could fail.
286 + #[test]
287 + fn the_picked_file_queue_says_where_its_slots_come_from_and_names_them_by_file() {
288 + let html = super::version_file_queue();
289 +
290 + // The slots come from the picker above, so no renderer offers an add
291 + // control: there is no blank a reader could fill.
292 + assert!(
293 + html.contains(r#"data-repeat-add-from="version-files""#),
294 + "{html}"
295 + );
296 + // The add control itself, which is a different attribute from the one
297 + // above and would otherwise match it as a prefix.
298 + assert!(!html.contains("field-repeat-add"), "{html}");
299 + // The question that is actually answered is the label.
300 + assert!(html.contains(r#"data-repeat="version-file""#), "{html}");
301 + assert!(html.contains("data-repeat-slots"), "{html}");
302 + // And the blank the host clones per picked file.
303 + assert!(html.contains("<template"), "{html}");
304 + }
305 +
253 306 /// The four axes `f7261a5a` settled, on the surface that has all of them.
254 307 /// Read together they are the whole point of the conversion: before this
255 308 /// the accept list was an attribute in Askama, the destination was a
@@ -652,7 +705,13 @@
652 705 .render()
653 706 .expect("render the uploader");
654 707
655 - assert_eq!(html.matches("<button").count(), 3, "{html}");
708 + // Three on the surface, plus the Remove inside the queue's blank
709 + // `<template>`: inert markup until the picker clones it into a slot,
710 + // and the described queue's own rather than a fourth control anyone
711 + // wrote here. The queue offers no add control at all, which is the
712 + // point of `Adds::Elsewhere`.
713 + assert_eq!(html.matches("<button").count(), 4, "{html}");
714 + assert!(!html.contains("field-repeat-add"), "{html}");
656 715 assert!(
657 716 html.contains(
658 717 r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""#
@@ -40,19 +40,11 @@
40 40 which is also the order the described field's own label reads in. -#}
41 41 {{ crate::quasi::upload_field::version_queue()|safe }}
42 42
43 - {#- The one part of the form still written out: a row per picked file
44 - with a label field in it. That shape has no member yet and the
45 - counting behind that is in `crate::quasi::item_files`. -#}
46 - <table class="item-version-upload-file-table" id="version-file-table">
47 - <thead>
48 - <tr>
49 - <th>File</th>
50 - <th>Label</th>
51 - <th></th>
52 - </tr>
53 - </thead>
54 - <tbody id="version-file-rows"></tbody>
55 - </table>
43 + {#- A slot per picked file, each named by its file and asking for the
44 + label that names its platform:
45 + `crate::quasi::upload_field::version_file_queue`. The slots come
46 + from the picker above, which is why no add control is drawn. -#}
47 + {{ crate::quasi::upload_field::version_file_queue()|safe }}
56 48
57 49 {#- Where the run begins and that it waits:
58 50 `crate::quasi::upload_field::version_upload_all`. The host makes
@@ -83,7 +75,6 @@
83 75
84 76 <!-- Shared upload progress/status (outside both forms so always visible) -->
85 77 <div class="upload-progress hidden" id="version-upload-progress">
86 - <div class="item-version-upload-queue" id="version-upload-queue"></div>
87 78 <div class="progress-info">
88 79 <span id="version-upload-filename">filename.zip</span>
89 80 <span id="version-upload-percent">0%</span>