@tn3270/gui 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,531 @@
1
+ // packages/frontend/dist/transferForm.js
2
+ var TRANSFER_FIELDS = [
3
+ { id: "direction", label: "Direction", kind: "cycle", values: ["receive", "send"], width: 0 },
4
+ { id: "host", label: "Host", kind: "cycle", values: ["tso", "vm"], width: 0 },
5
+ { id: "localFile", label: "Local file", kind: "text", values: [], width: 40 },
6
+ { id: "hostFile", label: "Host file", kind: "text", values: [], width: 40 },
7
+ { id: "mode", label: "Mode", kind: "cycle", values: ["binary", "ascii"], width: 0 },
8
+ { id: "exist", label: "Exist", kind: "cycle", values: ["keep", "replace", "append"], width: 0 },
9
+ { id: "cr", label: "Cr", kind: "cycle", values: ["", "auto", "remove", "add", "keep"], width: 0 },
10
+ { id: "recfm", label: "Recfm", kind: "cycle", values: ["", "fixed", "variable", "undefined"], width: 0 },
11
+ { id: "lrecl", label: "Lrecl", kind: "numeric", values: [], width: 5 },
12
+ { id: "blksize", label: "Blksize", kind: "numeric", values: [], width: 5 }
13
+ ];
14
+ function directionLabel(value) {
15
+ if (value === "send")
16
+ return "send: local to host";
17
+ if (value === "receive")
18
+ return "receive: host to local";
19
+ return value;
20
+ }
21
+ var FIELD_BY_ID = new Map(TRANSFER_FIELDS.map((f) => [f.id, f]));
22
+ function newTransferForm() {
23
+ return {
24
+ values: {
25
+ direction: "send",
26
+ host: "tso",
27
+ localFile: "",
28
+ hostFile: "",
29
+ mode: "binary",
30
+ exist: "keep",
31
+ cr: "",
32
+ recfm: "",
33
+ lrecl: "",
34
+ blksize: ""
35
+ },
36
+ selected: 0
37
+ };
38
+ }
39
+ function valuesFor(id, values) {
40
+ const field = FIELD_BY_ID.get(id);
41
+ if (field === void 0)
42
+ return [];
43
+ if (id === "recfm" && values.host === "vm") {
44
+ return field.values.filter((v) => v !== "undefined");
45
+ }
46
+ return field.values;
47
+ }
48
+ function cycleField(state, id, delta) {
49
+ const field = FIELD_BY_ID.get(id);
50
+ if (field === void 0 || field.kind !== "cycle")
51
+ return state;
52
+ const options = valuesFor(id, state.values);
53
+ const at = options.indexOf(state.values[id]);
54
+ const next = options[((at + delta) % options.length + options.length) % options.length];
55
+ return clearInapplicable({
56
+ ...state,
57
+ values: { ...state.values, [id]: next ?? "" },
58
+ error: void 0
59
+ });
60
+ }
61
+ function setFieldText(state, id, text) {
62
+ const field = FIELD_BY_ID.get(id);
63
+ if (field === void 0 || field.kind === "cycle")
64
+ return state;
65
+ if (field.kind === "numeric" && !/^[0-9]*$/.test(text))
66
+ return state;
67
+ if (text.length > field.width && field.kind === "numeric")
68
+ return state;
69
+ return { ...state, values: { ...state.values, [id]: text }, error: void 0 };
70
+ }
71
+ function applicable(id, values) {
72
+ switch (id) {
73
+ // On a receive the dataset already exists, and x3270 silently drops these
74
+ // (`ft.c:702`). Silently dropping is exactly what a form should make visible.
75
+ case "recfm":
76
+ return values.direction === "send";
77
+ // `Lrecl=80` with no `Recfm` "emits nothing at all" (`ft.c:704,721-726`), so the
78
+ // field would be a lie while Recfm is unset.
79
+ case "lrecl":
80
+ return values.direction === "send" && values.recfm !== "";
81
+ // CMS files have no block size.
82
+ case "blksize":
83
+ return values.direction === "send" && values.recfm !== "" && values.host !== "vm";
84
+ // Carriage-return translation is meaningless on a binary transfer.
85
+ case "cr":
86
+ return values.mode === "ascii";
87
+ default:
88
+ return true;
89
+ }
90
+ }
91
+ function clearInapplicable(state) {
92
+ const values = { ...state.values };
93
+ let changed = true;
94
+ while (changed) {
95
+ changed = false;
96
+ for (const f of TRANSFER_FIELDS) {
97
+ if (!applicable(f.id, values) && values[f.id] !== "") {
98
+ values[f.id] = "";
99
+ changed = true;
100
+ }
101
+ }
102
+ if (values.host === "vm" && values.recfm === "undefined") {
103
+ values.recfm = "";
104
+ changed = true;
105
+ }
106
+ }
107
+ return { ...state, values };
108
+ }
109
+ function formKeywords(state) {
110
+ const out = [];
111
+ const keyword = {
112
+ direction: "Direction",
113
+ host: "Host",
114
+ localFile: "LocalFile",
115
+ hostFile: "HostFile",
116
+ mode: "Mode",
117
+ exist: "Exist",
118
+ cr: "Cr",
119
+ recfm: "Recfm",
120
+ lrecl: "Lrecl",
121
+ blksize: "Blksize"
122
+ };
123
+ for (const f of TRANSFER_FIELDS) {
124
+ const value = state.values[f.id];
125
+ if (value === "" || !applicable(f.id, state.values))
126
+ continue;
127
+ out.push(`${keyword[f.id]}=${value}`);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ // packages/canvas/dist/transferUi.js
133
+ function caretAfterEdit(remembered, typed, shown) {
134
+ const clamp = (n) => Math.max(0, Math.min(n, shown.length));
135
+ if (remembered.start === null)
136
+ return { start: shown.length, end: shown.length };
137
+ const dropped = typed.length - shown.length;
138
+ const start = clamp(remembered.start - dropped);
139
+ const end = clamp((remembered.end ?? remembered.start) - dropped);
140
+ return { start, end: Math.max(start, end) };
141
+ }
142
+ var HELP = "Choose a local file and name the host file, then Start.";
143
+ function createTransferUi(deps) {
144
+ let state = newTransferForm();
145
+ let isRunning = false;
146
+ const view = () => {
147
+ const out = [];
148
+ for (const f of TRANSFER_FIELDS) {
149
+ if (!applicable(f.id, state.values))
150
+ continue;
151
+ const options = f.kind === "cycle" ? offered(f.id) : [];
152
+ out.push({
153
+ id: f.id,
154
+ label: f.label,
155
+ kind: f.kind,
156
+ value: state.values[f.id],
157
+ options,
158
+ // ONE MAPPING FOR EVERY CYCLE FIELD rather than a special case for `direction`:
159
+ // `directionLabel` returns anything it does not recognise unchanged, so this is the
160
+ // identity for the rest. Built HERE rather than in each renderer because both `<select>`
161
+ // builders would otherwise carry the same decision, which is how two front ends drift.
162
+ optionLabels: options.map((o) => f.id === "direction" ? directionLabel(o) : o)
163
+ });
164
+ }
165
+ return out;
166
+ };
167
+ const offered = (id) => {
168
+ const field = TRANSFER_FIELDS.find((f) => f.id === id);
169
+ if (field === void 0)
170
+ return [];
171
+ const seen = [];
172
+ let probe = state;
173
+ for (let i = 0; i < field.values.length + 1; i++) {
174
+ const v = probe.values[id];
175
+ if (seen.includes(v))
176
+ break;
177
+ seen.push(v);
178
+ probe = cycleField(probe, id, 1);
179
+ }
180
+ return field.values.filter((v) => seen.includes(v));
181
+ };
182
+ const redraw = () => {
183
+ deps.render(view());
184
+ };
185
+ const show = () => {
186
+ if (state.error !== void 0) {
187
+ deps.setStatus(state.error);
188
+ return;
189
+ }
190
+ deps.setStatus(HELP);
191
+ };
192
+ redraw();
193
+ show();
194
+ return {
195
+ values: () => state.values,
196
+ running: () => isRunning,
197
+ cycle(id, delta) {
198
+ if (isRunning)
199
+ return;
200
+ state = cycleField(state, id, delta);
201
+ redraw();
202
+ show();
203
+ },
204
+ type(id, text) {
205
+ if (isRunning)
206
+ return;
207
+ state = setFieldText(state, id, text);
208
+ redraw();
209
+ show();
210
+ },
211
+ async browseLocal() {
212
+ if (isRunning)
213
+ return;
214
+ const chosen = await deps.browse(state.values.direction);
215
+ if (isRunning)
216
+ return;
217
+ if (chosen === void 0)
218
+ return;
219
+ state = setFieldText(state, "localFile", chosen);
220
+ redraw();
221
+ show();
222
+ },
223
+ /**
224
+ * Submit the form, ANNOUNCING THE TRANSFER BEFORE THE AWAIT RATHER THAN AFTER IT.
225
+ *
226
+ * ## THE INVARIANT: AN ENDING THAT HAS ALREADY ARRIVED MUST NOT BE UNDONE BY THE SUBMIT THAT
227
+ * ## STARTED IT
228
+ *
229
+ * This method used to `await deps.submit(...)` and only THEN arm `isRunning` and paint
230
+ * `transferring`. That is the renderer's copy of the bug `transferWindow.ts`'s `startRun`
231
+ * documents at length, and it is REACHABLE for the same reason: `startTransfer` can call
232
+ * `onDone` BEFORE IT RETURNS -- a whole DFT transfer finishing inside
233
+ * `session.sendAID(AID.ENTER)`'s record handling, `transferRun.ts:377`'s
234
+ * `if (ended) return { ok: true };`, driven against a real `Session` by
235
+ * `tui/test/transferRun.test.ts`'s "notices a DFT transfer that finished BEFORE the first
236
+ * wait". The controller's `finish` then runs INSIDE the `ipcMain.handle` handler and issues
237
+ * `webContents.send('transfer:done', ...)`, and that send reaches the renderer BEFORE the
238
+ * `invoke()` promise resolves -- measured in real Electron 44, 20 runs out of 20.
239
+ *
240
+ * So `finished()` ran first and this method's tail then clobbered it: a transfer that had
241
+ * SUCCEEDED reported as permanently in progress, `Cancel` was dead (this object's
242
+ * `requestCancel` passed its own `isRunning` check and main's answered
243
+ * `if (run === undefined) return;`), and `Start` was dead for the window's life. Measured
244
+ * against the built module: `setRunning false` / `setStatus "done: 3 bytes"` /
245
+ * `setRunning true` / `setStatus "transferring"`, leaving `running() === true`.
246
+ *
247
+ * ## THE FIX IS THE SHAPE THIS MODULE'S OTHER HALF ALREADY USES, WITH NO NEW STATE
248
+ *
249
+ * `isRunning` is armed, and the whole "we are transferring" announcement made, BEFORE the
250
+ * await. The tail is then EMPTY on the success path -- there is nothing left to clobber with
251
+ * -- and `isRunning` doubles as the latch: `finished()` clears it, so the refusal arm below is
252
+ * CONDITIONAL on this submit still being the one the form is waiting for. That is exactly
253
+ * `startRun`'s placeholder-then-conditional-assignment, and it adds no second word for "is a
254
+ * transfer running"; a renderer-side generation counter would have been one more fact to keep
255
+ * in step with main's, and nothing here could falsify it.
256
+ *
257
+ * TWO SIBLING HAZARDS CLOSE WITH IT, both from the same await:
258
+ *
259
+ * - A `transfer:progress` arriving during the await used to be DROPPED by `progress()`'s
260
+ * `if (!isRunning) return;`, and then painted over by the tail's `transferring` even if it
261
+ * had not been. Both ends are fixed: the flag is already armed when it arrives, and the
262
+ * tail no longer paints.
263
+ * - A SECOND Start during the await used to pass the guard below -- two `invoke`s for one
264
+ * button -- and came back as main's 'a transfer is already running', a message about the
265
+ * operator's OWN transfer. The early arming refuses the second press here instead.
266
+ */
267
+ async start() {
268
+ if (isRunning)
269
+ return;
270
+ isRunning = true;
271
+ state = { ...state, error: void 0 };
272
+ deps.setRunning(true);
273
+ deps.setStatus("transferring");
274
+ let refusal;
275
+ try {
276
+ const result = await deps.submit(formKeywords(state));
277
+ if (!result.ok)
278
+ refusal = result.error ?? "transfer refused";
279
+ } catch (err) {
280
+ refusal = `transfer refused: ${err instanceof Error ? err.message : String(err)}`;
281
+ }
282
+ if (!isRunning)
283
+ return;
284
+ if (refusal === void 0)
285
+ return;
286
+ isRunning = false;
287
+ deps.setRunning(false);
288
+ state = { ...state, error: refusal };
289
+ show();
290
+ },
291
+ requestCancel() {
292
+ if (!isRunning)
293
+ return;
294
+ deps.cancel();
295
+ },
296
+ progress(text) {
297
+ if (!isRunning)
298
+ return;
299
+ deps.setStatus(text);
300
+ },
301
+ finished(result) {
302
+ isRunning = false;
303
+ deps.setRunning(false);
304
+ if (result.ok) {
305
+ deps.setStatus(`done: ${result.bytes ?? 0} bytes`);
306
+ return;
307
+ }
308
+ deps.setStatus(result.error ?? "transfer failed");
309
+ }
310
+ };
311
+ }
312
+
313
+ // packages/gui/dist/transferBoot.js
314
+ var bridge = window.tn3270transfer;
315
+ var fields = document.querySelector("#fields");
316
+ var status = document.querySelector("#status");
317
+ var startBtn = document.querySelector("#start");
318
+ var cancelBtn = document.querySelector("#cancel");
319
+ if (fields === null || status === null || startBtn === null || cancelBtn === null) {
320
+ throw new Error("transfer.html is missing one of #fields, #status, #start, #cancel");
321
+ }
322
+ var running = false;
323
+ var rememberFocus = () => {
324
+ const active = document.activeElement;
325
+ const tagged = active instanceof HTMLInputElement || active instanceof HTMLSelectElement || active instanceof HTMLButtonElement;
326
+ if (!tagged)
327
+ return void 0;
328
+ const field = active.dataset["field"];
329
+ if (field === void 0)
330
+ return void 0;
331
+ const role = active.dataset["role"] === "browse" ? "browse" : "value";
332
+ if (active instanceof HTMLInputElement) {
333
+ return {
334
+ field,
335
+ role,
336
+ start: active.selectionStart,
337
+ end: active.selectionEnd,
338
+ typed: active.value
339
+ };
340
+ }
341
+ return { field, role, start: null, end: null, typed: "" };
342
+ };
343
+ var restoreFocus = (memo) => {
344
+ if (memo === void 0)
345
+ return;
346
+ const el = fields.querySelector(`[data-field="${memo.field}"][data-role="${memo.role}"]`);
347
+ if (el instanceof HTMLButtonElement) {
348
+ el.focus();
349
+ return;
350
+ }
351
+ if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLSelectElement))
352
+ return;
353
+ el.focus();
354
+ if (!(el instanceof HTMLInputElement))
355
+ return;
356
+ const { start, end } = caretAfterEdit(memo, memo.typed, el.value);
357
+ el.setSelectionRange(start, end);
358
+ };
359
+ var ui = createTransferUi({
360
+ render(list) {
361
+ const memo = rememberFocus();
362
+ fields.replaceChildren();
363
+ for (const f of list) {
364
+ const row = document.createElement("div");
365
+ row.className = "row";
366
+ const label = document.createElement("label");
367
+ label.textContent = f.label;
368
+ row.append(label);
369
+ if (f.kind === "cycle") {
370
+ const select = document.createElement("select");
371
+ select.dataset["field"] = f.id;
372
+ select.dataset["role"] = "value";
373
+ for (const [i, opt] of f.options.entries()) {
374
+ const o = document.createElement("option");
375
+ o.value = opt;
376
+ o.textContent = opt === "" ? "(host default)" : f.optionLabels[i] ?? opt;
377
+ if (opt === f.value)
378
+ o.selected = true;
379
+ select.append(o);
380
+ }
381
+ select.disabled = running;
382
+ select.addEventListener("change", () => {
383
+ const from = f.options.indexOf(f.value);
384
+ const to = f.options.indexOf(select.value);
385
+ if (from >= 0 && to >= 0 && to !== from)
386
+ ui.cycle(f.id, to - from);
387
+ });
388
+ row.append(select);
389
+ } else {
390
+ const input = document.createElement("input");
391
+ input.type = "text";
392
+ input.dataset["field"] = f.id;
393
+ input.dataset["role"] = "value";
394
+ input.value = f.value;
395
+ input.disabled = running;
396
+ input.addEventListener("input", () => {
397
+ ui.type(f.id, input.value);
398
+ });
399
+ row.append(input);
400
+ }
401
+ if (f.id === "localFile") {
402
+ const browse = document.createElement("button");
403
+ browse.textContent = "Browse\u2026";
404
+ browse.dataset["field"] = f.id;
405
+ browse.dataset["role"] = "browse";
406
+ browse.disabled = running;
407
+ browse.addEventListener("click", () => {
408
+ void ui.browseLocal();
409
+ });
410
+ row.append(browse);
411
+ } else {
412
+ row.append(document.createElement("span"));
413
+ }
414
+ fields.append(row);
415
+ }
416
+ restoreFocus(memo);
417
+ },
418
+ setStatus(text) {
419
+ status.textContent = text;
420
+ },
421
+ /**
422
+ * Push the running state to the DOM, and to the flag `render` reads.
423
+ *
424
+ * ## WHY `render` STILL CONSULTS `running` -- DEFENSE IN DEPTH, NOT THE PRIMARY GUARD
425
+ *
426
+ * The interleaving this covers is `browseLocal`'s: it checks `isRunning` before its `await
427
+ * deps.browse(...)` and redraws after, so a dialog that is not modal to this window lets the
428
+ * operator press Start while it is open and a late resolution rebuild every control mid-transfer.
429
+ * Measured in real Electron with a deferred `browse`.
430
+ *
431
+ * THE PRIMARY FIX IS IN `transferUi.ts`: a second `isRunning` check immediately after that await,
432
+ * which also stops the two worse halves of the same bug (a redraw's `show()` clobbering the live
433
+ * progress line, and `setFieldText` mutating the model mid-transfer). With that in place, this
434
+ * assignment is arguably unreachable -- almost every render happens while idle, since `cycle` and
435
+ * `type` return early when running and `start`/`progress`/`finished` never redraw.
436
+ *
437
+ * KEPT ANYWAY, DELIBERATELY. It is the enablement third of the hazard, and a pair like this is
438
+ * invisible to single mutation: removing either half leaves the other half's tests green, which
439
+ * this repo has now recorded twice. So neither half should be deleted on the evidence of a green
440
+ * suite. The same note is on the post-await check in `transferUi.ts`, at the other end of the
441
+ * pair, because whoever deletes one will be reading only that end.
442
+ */
443
+ setRunning(isRunning) {
444
+ running = isRunning;
445
+ startBtn.disabled = isRunning;
446
+ cancelBtn.disabled = !isRunning;
447
+ for (const el of fields.querySelectorAll("input, select, button")) {
448
+ el.disabled = isRunning;
449
+ }
450
+ },
451
+ browse: (direction) => bridge.browse(direction),
452
+ submit: (keywords) => bridge.submit(keywords),
453
+ cancel: () => {
454
+ bridge.cancel();
455
+ }
456
+ });
457
+ startBtn.addEventListener("click", () => {
458
+ void ui.start();
459
+ });
460
+ cancelBtn.addEventListener("click", () => {
461
+ ui.requestCancel();
462
+ });
463
+ bridge.onProgress((text) => {
464
+ ui.progress(text);
465
+ });
466
+ bridge.onDone((r) => {
467
+ ui.finished(r);
468
+ });
469
+ window.__tn3270SetField = (id, text) => {
470
+ ui.type(id, text);
471
+ return ui.values()[id] ?? "";
472
+ };
473
+ window.__tn3270FieldKind = (id) => {
474
+ const el = fields.querySelector(`[data-field="${id}"][data-role="value"]`);
475
+ if (el instanceof HTMLSelectElement)
476
+ return "cycle";
477
+ if (el instanceof HTMLInputElement)
478
+ return "text";
479
+ return "";
480
+ };
481
+ window.__tn3270CycleField = (id, to) => {
482
+ const field = id;
483
+ for (let i = 0; i < 12; i++) {
484
+ if (ui.values()[field] === to)
485
+ break;
486
+ ui.cycle(field, 1);
487
+ }
488
+ return ui.values()[field] ?? "";
489
+ };
490
+ window.__tn3270Submit = async () => {
491
+ await ui.start();
492
+ const status2 = document.querySelector("#status")?.textContent ?? "";
493
+ return { ok: ui.running(), status: status2 };
494
+ };
495
+ window.__tn3270AwaitDone = async (budgetMs) => {
496
+ const deadline = Date.now() + budgetMs;
497
+ while (ui.running() && Date.now() < deadline) {
498
+ await new Promise((r) => {
499
+ setTimeout(r, 250);
500
+ });
501
+ }
502
+ return {
503
+ running: ui.running(),
504
+ status: document.querySelector("#status")?.textContent ?? "",
505
+ timedOut: ui.running()
506
+ };
507
+ };
508
+ window.__tn3270SampleStatus = async (budgetMs, everyMs) => {
509
+ const seen = [];
510
+ const deadline = Date.now() + budgetMs;
511
+ for (; ; ) {
512
+ const text = document.querySelector("#status")?.textContent ?? "";
513
+ if (seen[seen.length - 1] !== text)
514
+ seen.push(text);
515
+ if (!ui.running() || Date.now() >= deadline)
516
+ return seen;
517
+ await new Promise((r) => {
518
+ setTimeout(r, everyMs);
519
+ });
520
+ }
521
+ };
522
+ window.__tn3270ClickCancel = () => {
523
+ const btn = document.querySelector("#cancel");
524
+ const statusBefore = document.querySelector("#status")?.textContent ?? "";
525
+ if (btn === null)
526
+ return { enabled: false, statusBefore };
527
+ const enabled = !btn.disabled;
528
+ btn.click();
529
+ return { enabled, statusBefore };
530
+ };
531
+ //# sourceMappingURL=transferBoot.js.map