@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,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../frontend/src/transferForm.ts", "../../canvas/src/transferUi.ts", "../src/transferBoot.ts"],
4
+ "sourcesContent": ["/**\n * The file-transfer form's MODEL: fields, values, cycle order and applicability.\n *\n * PURE, and the same split as the keypad: `frontend/src/keypad.ts` owns the button table\n * while `tui/src/keypadOverlay.ts` renders it. Here the model is the data and\n * `tui/src/transferOverlay.ts` draws it, which is what makes stage 3 (the GUI) cheap --\n * it writes a renderer and reuses all of this.\n *\n * ## THE CENTRAL RULE: THIS FORM NEVER VALIDATES\n *\n * It collects STRINGS and hands them to `parseTransferKeywords`. The applicability rules\n * below are ERGONOMICS -- they stop a user entering a doomed combination -- but authority\n * lives in exactly one place. If these rules and the validator's ever disagree, THE\n * VALIDATOR WINS and the user sees its message. That is what keeps the TUI and the CLI\n * from drifting on what is legal, and it means this form cannot invent a legal-looking\n * combination the engine rejects.\n *\n * ## WHY UNSET IS A VALUE\n *\n * `Cr` and `Recfm` start at `''`, and that is not the same as any of their values: an\n * unset `Recfm` emits no `RECFM` keyword at all and lets the host choose, which is the\n * current CLI behavior that works on both hosts. A two-state F/V toggle would make \"I did\n * not ask for record attributes\" unexpressible.\n */\n\n/** Which field. The ids are the tab order. */\nexport type TransferFieldId =\n | 'direction' | 'host' | 'localFile' | 'hostFile' | 'mode'\n | 'exist' | 'cr' | 'recfm' | 'lrecl' | 'blksize';\n\n/** How a field takes input. `cycle` uses left/right; the others take printables. */\nexport type TransferFieldKind = 'cycle' | 'text' | 'numeric';\n\nexport interface TransferField {\n readonly id: TransferFieldId;\n readonly label: string;\n readonly kind: TransferFieldKind;\n /**\n * For a cycle field, the values in cycle order, `''` included where unset is legal.\n * Empty for text and numeric fields.\n *\n * `recfm`'s list here is the TSO one; `cycleField` drops `undefined` for VM. That\n * asymmetry lives in the cycle function rather than in two tables, so the VM rule has\n * one home.\n */\n readonly values: readonly string[];\n /** Visible width for a text or numeric field. */\n readonly width: number;\n}\n\n/**\n * The fields in tab order.\n *\n * `lrecl` is 5 WIDE, NOT 3: `positiveInt` has no upper bound and TSO datasets\n * legitimately reach 32760, so a narrow field would silently prevent valid transfers.\n */\nexport const TRANSFER_FIELDS: readonly TransferField[] = [\n { id: 'direction', label: 'Direction', kind: 'cycle', values: ['receive', 'send'], width: 0 },\n { id: 'host', label: 'Host', kind: 'cycle', values: ['tso', 'vm'], width: 0 },\n { id: 'localFile', label: 'Local file', kind: 'text', values: [], width: 40 },\n { id: 'hostFile', label: 'Host file', kind: 'text', values: [], width: 40 },\n { id: 'mode', label: 'Mode', kind: 'cycle', values: ['binary', 'ascii'], width: 0 },\n { id: 'exist', label: 'Exist', kind: 'cycle', values: ['keep', 'replace', 'append'], width: 0 },\n { id: 'cr', label: 'Cr', kind: 'cycle', values: ['', 'auto', 'remove', 'add', 'keep'], width: 0 },\n { id: 'recfm', label: 'Recfm', kind: 'cycle', values: ['', 'fixed', 'variable', 'undefined'], width: 0 },\n { id: 'lrecl', label: 'Lrecl', kind: 'numeric', values: [], width: 5 },\n { id: 'blksize', label: 'Blksize', kind: 'numeric', values: [], width: 5 },\n];\n\n/**\n * The direction as the operator should READ it: both endpoints named, never a bare verb.\n *\n * ## THIS IS DISPLAY ONLY, AND THAT IS A HARD CONSTRAINT RATHER THAN A STYLE CHOICE\n *\n * `formKeywords` emits `Direction=${value}` from the STORED value, and `parseTransferKeywords`\n * validates against the literals `['receive', 'send']`. A renamed VALUE would emit\n * `Direction=send: local to host` and be refused by this project's own validator. So the stored\n * value stays `send`/`receive` and only what is DRAWN changes -- the same split the TUI's `shown()`\n * already uses for `''` -> `-`, and both `<select>` builders for `''` -> `(host default)`.\n *\n * ## WHY THIS EXISTS: IT COST A REAL FILE\n *\n * On 2026-10-07 the user -- an expert on this protocol, driving their own client -- read the bare\n * `send`/`receive` the wrong way round and OVERWROTE THEIR OWN `PROFILE EXEC A` on VM/370 under\n * `Exist=replace`. All four of this feature's guards defend against an EMPTY file reaching the\n * host; NONE covers a valid file hitting the wrong target. Naming the endpoints is the cheap half\n * of that gap. x3270 does not confirm a direction either, so the old behaviour was arguably\n * correct -- it was also one click from data loss.\n *\n * ## `local`, NOT `browser`\n *\n * The phrasing first proposed was `this browser`, which is right for the web gateway and WRONG for\n * the other two front ends: in the TUI and the Electron GUI, \"local\" is this machine's filesystem.\n * `local` is true in all three AND matches the `Local file` field label directly above it, so the\n * form reads as one statement rather than two vocabularies.\n *\n * ## NO ARROWS\n *\n * Fira Code and friends ligate `->` and `<-` -- `canvas/src/transferUi.ts` already took the arrows\n * out of a help line for exactly this -- and the TUI draws printable ASCII only (`tui/src/app.ts`),\n * so a real `->` or a U+2192 would force per-front-end text and defeat the point of one shared\n * mapping. `to` means the same thing in every renderer.\n *\n * AN UNRECOGNISED VALUE IS RETURNED UNCHANGED rather than falling through to `undefined`: every\n * renderer maps the values it offers through this, so a lookup without this property would draw a\n * blank menu entry if a third direction were ever added.\n */\nexport function directionLabel(value: string): string {\n if (value === 'send') return 'send: local to host';\n if (value === 'receive') return 'receive: host to local';\n return value;\n}\n\nconst FIELD_BY_ID = new Map(TRANSFER_FIELDS.map((f) => [f.id, f]));\n\n/** Every field's current string. `''` means unset, for every kind. */\nexport type TransferValues = Record<TransferFieldId, string>;\n\nexport interface TransferFormState {\n readonly values: TransferValues;\n /** Index into `TRANSFER_FIELDS`. */\n readonly selected: number;\n /**\n * The validator's message, when a submit was refused. Cleared on the next edit.\n *\n * Explicitly `| undefined` because `exactOptionalPropertyTypes` is on: without it the\n * spreads below cannot write `error: undefined` to clear it, and `app.ts` cannot\n * assign a `string | undefined` from a caught error into it either.\n */\n readonly error?: string | undefined;\n}\n\n/**\n * A fresh form.\n *\n * ## IT OPENS ON `send`, AND THE KEYWORD PARSER'S DEFAULT IS DELIBERATELY DIFFERENT\n *\n * The user's reason, recorded because it will look surprising to someone one day: **until VM has a\n * TCP/IP stack, this client is mostly used to GET SOFTWARE ONTO these systems** -- few people\n * actually develop software locally on a VM or MVS guest. Upload is the common case, so it is the\n * default.\n *\n * (The user's planned route to such a stack, which is the condition that would make this worth\n * revisiting: UTS -- v7 Unix -- plus an IUCV-alike backported to VM/370, a simple network driver\n * over a CTC pair cribbed from Linux on System/390 and z, and a small TCP stack running in UTS.\n * Hard on a PDP-11 for address-space reasons; not hard on S/370 with up to 16 MB. **If download\n * stops being the rare direction, reconsider this default.**)\n *\n * ## `parseTransferKeywords` STILL DEFAULTS TO `receive`, AND MUST\n *\n * That default applies when a SCRIPT's `Transfer(...)` omits `Direction=`. Flipping it would\n * silently invert every such script -- a run that downloaded would UPLOAD, with `Exist=replace`\n * reachable and no form to look at -- which is the same data-destruction shape this change exists\n * to prevent, moved somewhere worse. It is also x3270's own default, which this project matches\n * absent a documented reason to differ.\n *\n * **The asymmetry is the point:** an interactive form with a visible, self-describing control\n * (`directionLabel`) is a different safety situation from an omitted keyword in an unattended\n * script. `transfer.test.ts` pins the parser's default so this cannot be \"tidied\" into agreement.\n *\n * ## ONE KNOCK-ON, AND IT IS VISIBLE\n *\n * `applicable` shows `Recfm` only on a send, so a fresh form now draws ONE MORE FIELD than it used\n * to. The TUI's box height is computed from the applicable count, so this is a layout change\n * there, not merely a value change. 27 tests across four files asserted the old default; each had\n * its SETUP updated rather than its assertion weakened.\n */\nexport function newTransferForm(): TransferFormState {\n return {\n values: {\n direction: 'send', host: 'tso', localFile: '', hostFile: '',\n mode: 'binary', exist: 'keep', cr: '', recfm: '', lrecl: '', blksize: '',\n },\n selected: 0,\n };\n}\n\n/**\n * The values a cycle field offers, given the rest of the form.\n *\n * The ONE state-dependent case is `recfm` on VM: `transfer.ts` records that \"CMS supports\n * fixed and variable\", so `undefined` is not offered there. Kept here rather than as a\n * second table so the rule has one home.\n */\nfunction valuesFor(id: TransferFieldId, values: TransferValues): readonly string[] {\n const field = FIELD_BY_ID.get(id);\n if (field === undefined) return [];\n if (id === 'recfm' && values.host === 'vm') {\n return field.values.filter((v) => v !== 'undefined');\n }\n return field.values;\n}\n\n/**\n * Move a cycle field by `delta`, wrapping, and clear anything the move made inapplicable.\n *\n * A no-op on a text or numeric field, so a caller may route left/right unconditionally.\n */\nexport function cycleField(\n state: TransferFormState, id: TransferFieldId, delta: number,\n): TransferFormState {\n const field = FIELD_BY_ID.get(id);\n if (field === undefined || field.kind !== 'cycle') return state;\n const options = valuesFor(id, state.values);\n const at = options.indexOf(state.values[id]);\n // A value the current options no longer contain (Recfm=U when Host flipped to vm)\n // reads as index -1, and `(-1 + 1) % n` is 0 -- so it lands on the first option\n // rather than throwing. That is the same repair `clearInapplicable` performs.\n const next = options[(((at + delta) % options.length) + options.length) % options.length];\n return clearInapplicable({\n ...state,\n values: { ...state.values, [id]: next ?? '' },\n error: undefined,\n });\n}\n\n/** Replace a text or numeric field's contents. A no-op on a cycle field. */\nexport function setFieldText(\n state: TransferFormState, id: TransferFieldId, text: string,\n): TransferFormState {\n const field = FIELD_BY_ID.get(id);\n if (field === undefined || field.kind === 'cycle') return state;\n // A numeric field takes digits only. Refusing the character is better than accepting it\n // and failing at submit: the validator's `positiveInt` would reject it, but the user\n // would not find out until they pressed Enter.\n if (field.kind === 'numeric' && !/^[0-9]*$/.test(text)) return state;\n if (text.length > field.width && field.kind === 'numeric') return state;\n return { ...state, values: { ...state.values, [id]: text }, error: undefined };\n}\n\n/**\n * Is this field meaningful, given the rest of the form?\n *\n * All five rules are derived from the validator's own behavior, cited where they come\n * from. An inapplicable field is not drawn and cannot be selected.\n */\nexport function applicable(id: TransferFieldId, values: TransferValues): boolean {\n switch (id) {\n // On a receive the dataset already exists, and x3270 silently drops these\n // (`ft.c:702`). Silently dropping is exactly what a form should make visible.\n case 'recfm':\n return values.direction === 'send';\n // `Lrecl=80` with no `Recfm` \"emits nothing at all\" (`ft.c:704,721-726`), so the\n // field would be a lie while Recfm is unset.\n case 'lrecl':\n return values.direction === 'send' && values.recfm !== '';\n // CMS files have no block size.\n case 'blksize':\n return values.direction === 'send' && values.recfm !== '' && values.host !== 'vm';\n // Carriage-return translation is meaningless on a binary transfer.\n case 'cr':\n return values.mode === 'ascii';\n default:\n return true;\n }\n}\n\n/**\n * Clear every value a state change has made inapplicable.\n *\n * INAPPLICABLE VALUES ARE CLEARED, NOT RETAINED INVISIBLY. A hidden value that breaks a\n * later submit is the worse failure: the user cannot see the field, cannot edit it, and\n * gets a validator error naming a keyword they never typed.\n *\n * ITERATED TO A FIXED POINT, because the rules CHAIN: clearing `recfm` (on a flip to\n * receive) makes `lrecl` and `blksize` inapplicable in turn, and a single pass reading\n * the values it is midway through mutating gets the wrong answer depending on table\n * order. `blksize` also depends on `recfm`, which is cleared LATER in tab order, so a\n * one-pass loop leaves it set. Two passes suffice for the current rules; the loop does\n * not assume that.\n */\nfunction clearInapplicable(state: TransferFormState): TransferFormState {\n const values = { ...state.values };\n let changed = true;\n while (changed) {\n changed = false;\n for (const f of TRANSFER_FIELDS) {\n if (!applicable(f.id, values) && values[f.id] !== '') {\n // Cycle fields whose unset value is not `''` (direction, host, mode, exist) are\n // never inapplicable, so this only ever clears an optional field.\n values[f.id] = '';\n changed = true;\n }\n }\n // Recfm=U surviving a flip to Host=vm is the other repair: the value stays legal for\n // TSO, so `applicable` does not catch it, but the VM dialect cannot express it.\n // Inside the loop because clearing it can cascade to Lrecl on the next pass.\n if (values.host === 'vm' && values.recfm === 'undefined') {\n values.recfm = '';\n changed = true;\n }\n }\n return { ...state, values };\n}\n\n/**\n * The form as `Transfer()` keywords, ready for `parseTransferKeywords`.\n *\n * Only applicable, non-empty fields are emitted, so an unset `Recfm` contributes nothing\n * and the host chooses -- which is the whole reason unset is a distinct state.\n */\nexport function formKeywords(state: TransferFormState): string[] {\n const out: string[] = [];\n const keyword: Record<TransferFieldId, string> = {\n direction: 'Direction', host: 'Host', localFile: 'LocalFile', hostFile: 'HostFile',\n mode: 'Mode', exist: 'Exist', cr: 'Cr', recfm: 'Recfm',\n lrecl: 'Lrecl', blksize: 'Blksize',\n };\n for (const f of TRANSFER_FIELDS) {\n const value = state.values[f.id];\n if (value === '' || !applicable(f.id, state.values)) continue;\n out.push(`${keyword[f.id]}=${value}`);\n }\n return out;\n}\n\n/** The next selectable field index in `delta`'s direction, skipping inapplicable ones. */\nexport function moveField(state: TransferFormState, delta: number): TransferFormState {\n const n = TRANSFER_FIELDS.length;\n let at = state.selected;\n for (let i = 0; i < n; i++) {\n at = (((at + delta) % n) + n) % n;\n const field = TRANSFER_FIELDS[at];\n if (field !== undefined && applicable(field.id, state.values)) {\n return { ...state, selected: at };\n }\n }\n return state;\n}\n", "import {\n TRANSFER_FIELDS, applicable, cycleField, directionLabel, formKeywords, newTransferForm,\n setFieldText,\n type TransferFieldId, type TransferFormState, type TransferValues,\n} from '@tn3270/frontend/dist/transferForm.js';\n\n/**\n * MOVED HERE FROM `packages/gui` on 2026-10-06, for the same reason `keypadUi.ts` lives here:\n * `packages/web` needs this view and cannot depend on an Electron app. The move was\n * behaviour-preserving and the test came with it unchanged, which is the evidence.\n *\n * IT IMPORTS ONLY `@tn3270/frontend/dist/transferForm.js`, and must keep doing so -- see the\n * import-map section below for why that is the DEEP path and not the bare package name since\n * 2026-10-06. Electron's `dialog` and `ipcRenderer`\n * reach this form through `UiDeps` -- whose `browse()` and `submit()` are already\n * `Promise`-returning, which is why a browser needs no change to the interface at all. The GUI's\n * `browse()` resolves to a PATH; the web's resolves to a `File.name`. This module shows the string\n * and submits it, and cannot tell the difference.\n */\n\n/**\n * The transfer form's browser-side logic, with the DOM INJECTED.\n *\n * ## WHY THE DOM IS INJECTED AND NOT REACHED FOR\n *\n * `vitest.config.ts` sets `environment: 'node'`, so there is no `document` in any test in this\n * repo and jsdom is not a dependency. The same constraint produced the same shape in\n * `packages/web/src/bridgecore.ts`, which takes its socket as a dependency: the logic worth\n * testing -- which fields are shown, what the model holds, what is submitted -- needs no real\n * element, and injecting the few operations that do keeps every line of it reachable from a\n * unit test.\n *\n * ## THE MODEL IS THE AUTHORITY, AND THE CLEARING MUST HAPPEN THERE\n *\n * Every edit goes through `cycleField`/`setFieldText` from `@tn3270/frontend`, whose\n * `clearInapplicable` iterates to a fixed point because the applicability rules CHAIN. Doing\n * the hiding in the DOM and keeping the value here would let this front end submit a keyword\n * the TUI would have wiped -- two front ends disagreeing about what reaches a live host.\n *\n * ## A BROWSER LOADS THIS, AND AN IMPORT MAP IS WHAT MAKES THAT LEGAL\n *\n * The import above is a BARE-PACKAGE-ROOTED DEEP SPECIFIER and stays one, verbatim, in the\n * emitted JS -- deliberately, not by omission. `tsc` rewrites nothing (measured 2026-10-06:\n * `dist/transferUi.js` line 1 carries `from '@tn3270/frontend/dist/transferForm.js'`, and it is\n * the file's ONLY runtime import, the type-only ones having been erased).\n *\n * ## WHY THE DEEP PATH, AND NOT THE BARE `@tn3270/frontend` IT USED TO BE\n *\n * CHANGED 2026-10-06, when `packages/web` became the second consumer. That page's map had\n * ALREADY bound the bare `@tn3270/frontend` to `keypadView.js` for the keypad overlay, and an\n * import-map key without a trailing `/` matches EXACTLY -- so a bare specifier here would have\n * resolved to `keypadView.js`, a module that does not export `TRANSFER_FIELDS`. That is a\n * resolution failure presenting as a BLANK PAGE WITH NO ERROR in any console, which is the\n * failure family this whole docblock is about. One specifier cannot name two files, so the two\n * views are reached by two distinct specifiers.\n *\n * `packages/frontend/package.json`'s `exports` map gained `\"./dist/transferForm.js\"` in the same\n * change, and it had to: an `exports` map with only `\".\"` makes every deep path an error, and the\n * probe said so -- `TS2307: Cannot find module '@tn3270/frontend/dist/transferForm.js'` from\n * `tsc`, `ERR_PACKAGE_PATH_NOT_EXPORTED` from Node. A browser never consults `exports`, so this\n * is purely what keeps `tsc`, Node and the browser resolving the one same string.\n *\n * Each consuming document carries an import map that resolves that specifier to\n * `transferForm.js`. The browser applies the map BEFORE it fetches anything, so\n * `packages/frontend/dist/index.js` -- the package barrel -- is never requested, and `tls.js`'s\n * `node:net`/`node:tls`/`node:fs` imports, which no browser can resolve, are never reached. All\n * six names imported here are exported directly by `transferForm.js` (verified), so that single\n * entry satisfies the whole list.\n *\n * TWO DOCUMENTS DO THIS AS OF 2026-10-06: `packages/gui/transfer.html` for the Electron window,\n * and `packages/web/static/index.html` for the gateway. Both carry a key spelling this deep\n * specifier verbatim -- the GUI's was updated in the same change, because its single\n * `@tn3270/frontend` key stopped matching the moment this import grew a subpath, and a key that\n * no longer matches is that blank window again.\n *\n * The two maps are NOT copies of each other. The GUI's addresses name\n * `../frontend/dist/transferForm.js` by relative path, which works only because an Electron\n * window loads from `file://` where `..` is a real directory; a served page has no such thing,\n * so the gateway serves every module FLAT at `/` and its addresses are `./transferForm.js`.\n *\n * `transferForm.js` has ZERO runtime imports of its own (9139 bytes, measured 2026-10-01), so\n * the module graph closes at that single file. That is a FACT WITH A DATE, not an invariant:\n * `transferModule.test.ts` pins the property, and the day it expires this window goes blank\n * with no error in any console.\n *\n * SO DO NOT ADD A SECOND WORKSPACE IMPORT TO THIS FILE. A specifier no map entry names resolves\n * to nothing, and the failure arrives as a blank window rather than as an error -- in BOTH front\n * ends now, and each map would need its own new entry. Anything else this module needs comes in\n * through `UiDeps`.\n *\n * AN EARLIER VERSION OF THIS PARAGRAPH SAID \"the map has one entry\", which went stale the day\n * this module gained a second consumer; the GUI's map has carried two since 2026-10-06. The\n * advice was always the right advice and its stated reason had quietly stopped being a fact.\n *\n * No unit test in this file can detect any of that -- a fake DOM cannot fail to resolve a\n * module. The window must be LOADED and seen to paint, which is Task 8's Electron harness's\n * job; a blank-window-with-no-error is this repo's most-repeated failure, met five separate\n * ways already.\n */\n\n/** One field as the view needs it: what to draw, and what it currently holds. */\nexport interface UiField {\n readonly id: TransferFieldId;\n readonly label: string;\n readonly kind: 'cycle' | 'text' | 'numeric';\n readonly value: string;\n /** For a cycle field, the values it offers here; empty otherwise. */\n readonly options: readonly string[];\n /**\n * What to DRAW for each entry of `options`, positionally aligned with it.\n *\n * SEPARATE FROM `options` BECAUSE THE VALUE IS WHAT GETS SUBMITTED. `formKeywords` emits the\n * stored value, so a renderer must keep both: the value it reports back, and the text the\n * operator reads. Today only `direction` differs between the two -- `directionLabel` names\n * both endpoints, because the bare words cost the user a real file on 2026-10-07 -- and this\n * is the identity for every other field.\n *\n * ALWAYS THE SAME LENGTH AND ORDER AS `options`, so a renderer can zip them by index. The\n * `''` -> `(host default)` mapping stays in each renderer: that is about an EMPTY value rather\n * than a named one, and it is presentation rather than a shared decision.\n *\n * REQUIRED, NOT OPTIONAL. An optional property would let a renderer silently fall back to the\n * bare values, which is precisely the defect this exists to fix.\n */\n readonly optionLabels: readonly string[];\n}\n\n/**\n * Where the caret belongs after a rebuild, as a PURE FUNCTION OF THREE VALUES.\n *\n * ## WHY THIS LIVES HERE AND NOT IN `transferBoot.ts`\n *\n * It was in `transferBoot.ts`, inline, and it was WRONG -- measured in real Electron: `Lrecl`\n * holding `800`, caret at 1, type a non-digit `x`, and the caret ended at 2 when it should have\n * been 1. Unreachable by any test, because `transferBoot.ts` cannot be imported without a\n * `document`. It is arithmetic on three values and needs no element at all, so it belongs on this\n * side of the injected-DOM boundary, where the rest of the decidable logic already is and where a\n * unit test can reach it. `transferBoot.ts` keeps only the `setSelectionRange` call.\n *\n * This module rather than a new sibling file: a sibling would add an edge to the browser's import\n * graph, and every blank-window failure this repo has met came from that graph. Here there is no\n * new edge -- `transferBoot.ts` already imports this file. The file's stated job is \"browser-side\n * logic with the DOM injected\", and a caret calculation with its element removed is exactly that.\n *\n * ## THE CONTRACT IS DEFINED BY LENGTH DIFFERENCE, NOT BY \"ACCEPTED\" OR \"REFUSED\"\n *\n * This function cannot know WHY the two strings differ, so it does not try. The caller supplies:\n *\n * - `remembered`: the caret as the browser reported it, which is AFTER the browser applied the\n * keystroke and BEFORE the model saw it. That off-by-one-keystroke capture is the whole bug.\n * - `typed`: what the input was DISPLAYING at that moment, i.e. including that keystroke.\n * - `shown`: what the rebuilt element will display, i.e. what the model now holds.\n *\n * The caret moves left by however much the model dropped: `typed.length - shown.length`. An\n * accepted edit drops nothing and the caret stays where the browser put it. A refused edit drops\n * exactly the characters the browser had inserted, so the caret lands where it was before them.\n *\n * DEFINING IT AS \"REFUSED MEANS BACK EXACTLY ONE\" WOULD BE WRONG, and not only for the obvious\n * reason that a paste inserts many. It is also not implementable: `rememberFocus` reads the input\n * AFTER the keystroke, so it never observes the pre-keystroke string that such a rule would have\n * to compare against. The length difference is both correct for a paste and derivable from what\n * can actually be observed.\n *\n * ## WHAT IT DOES NOT HANDLE\n *\n * A model that CHANGED a value rather than accepting or refusing it -- same length, different\n * characters, or a rewrite that inserts as much as it removes. `setFieldText` and `cycleField`\n * only ever accept wholesale or return the state untouched, so no such edit exists today. If one\n * is ever added, this returns a caret that is numerically in range and semantically meaningless,\n * which is a real limit and not a safe one. Stated rather than papered over.\n */\nexport function caretAfterEdit(\n remembered: { readonly start: number | null; readonly end: number | null },\n typed: string,\n shown: string,\n): { start: number; end: number } {\n const clamp = (n: number): number => Math.max(0, Math.min(n, shown.length));\n // A `<select>` has a null selection, and so does an input the browser declined to report one\n // for. The end of the text is the least surprising place for a caret nobody specified.\n if (remembered.start === null) return { start: shown.length, end: shown.length };\n const dropped = typed.length - shown.length;\n const start = clamp(remembered.start - dropped);\n const end = clamp((remembered.end ?? remembered.start) - dropped);\n // A collapsed caret is the common case and an inverted range is not expressible, so a selection\n // whose ends crossed under clamping collapses to its start rather than throwing in the DOM.\n return { start, end: Math.max(start, end) };\n}\n\n/** Everything this module needs from the outside world. */\nexport interface UiDeps {\n /** Draw these fields, in this order. Called on every change. */\n render(fields: readonly UiField[]): void;\n /** The status line: an error, a progress report, or the help. */\n setStatus(text: string): void;\n /** Enable or disable the inputs, per the running state. */\n setRunning(running: boolean): void;\n /** Open a native file dialog. Resolves to a path, or undefined if canceled. */\n browse(direction: string): Promise<string | undefined>;\n /** Hand the keywords to main. A local refusal comes back as `ok: false`. */\n submit(keywords: readonly string[]): Promise<{ ok: boolean; error?: string }>;\n /** Abort a running transfer. */\n cancel(): void;\n}\n\nexport interface TransferUi {\n values(): TransferValues;\n running(): boolean;\n cycle(id: TransferFieldId, delta: number): void;\n type(id: TransferFieldId, text: string): void;\n browseLocal(): Promise<void>;\n start(): Promise<void>;\n requestCancel(): void;\n progress(text: string): void;\n finished(result: { ok: boolean; error?: string; bytes?: number }): void;\n}\n\nconst HELP = 'Choose a local file and name the host file, then Start.';\n\nexport function createTransferUi(deps: UiDeps): TransferUi {\n let state: TransferFormState = newTransferForm();\n let isRunning = false;\n\n /** Every applicable field, in tab order, with its current value and options. */\n const view = (): UiField[] => {\n const out: UiField[] = [];\n for (const f of TRANSFER_FIELDS) {\n if (!applicable(f.id, state.values)) continue;\n // The VM/Recfm asymmetry lives in the model's own cycle logic, so the options offered\n // here are derived by asking it rather than by re-filtering the table.\n const options = f.kind === 'cycle' ? offered(f.id) : [];\n out.push({\n id: f.id,\n label: f.label,\n kind: f.kind,\n value: state.values[f.id],\n options,\n // ONE MAPPING FOR EVERY CYCLE FIELD rather than a special case for `direction`:\n // `directionLabel` returns anything it does not recognise unchanged, so this is the\n // identity for the rest. Built HERE rather than in each renderer because both `<select>`\n // builders would otherwise carry the same decision, which is how two front ends drift.\n optionLabels: options.map((o) => (f.id === 'direction' ? directionLabel(o) : o)),\n });\n }\n return out;\n };\n\n /**\n * The values a cycle field offers, obtained by CYCLING THE MODEL rather than reading the\n * table -- then put back into the table's order.\n *\n * `valuesFor` is private to `transferForm.ts`, and the one state-dependent rule -- VM does\n * not offer `Recfm=undefined` -- lives inside it. Cycling from the current value until it\n * returns collects exactly what the model will accept, so this cannot drift from that rule.\n * Bounded by the field's own length, so a model that stopped wrapping cannot loop forever.\n *\n * THE SORT BACK INTO TABLE ORDER IS NOT COSMETIC. Cycling starts at the CURRENT value, so\n * the raw walk returns the list rotated: Recfm=variable yields\n * `['variable', 'undefined', '', 'fixed']`. A `<select>` built from that would reorder its\n * own menu every time the user picked an item. The cycle discovers WHICH values are legal\n * here; `TRANSFER_FIELDS` decides what order they are shown in.\n */\n const offered = (id: TransferFieldId): string[] => {\n const field = TRANSFER_FIELDS.find((f) => f.id === id);\n if (field === undefined) return [];\n const seen: string[] = [];\n let probe = state;\n for (let i = 0; i < field.values.length + 1; i++) {\n const v = probe.values[id];\n if (seen.includes(v)) break;\n seen.push(v);\n probe = cycleField(probe, id, 1);\n }\n // Filtering the TABLE by what the walk saw, rather than sorting `seen`, is what puts the\n // result in table order. Nothing can be lost: `cycleField` only ever assigns from\n // `valuesFor`, which is `field.values` or a filter of it.\n return field.values.filter((v) => seen.includes(v));\n };\n\n const redraw = (): void => { deps.render(view()); };\n\n const show = (): void => {\n if (state.error !== undefined) { deps.setStatus(state.error); return; }\n deps.setStatus(HELP);\n };\n\n redraw();\n show();\n\n return {\n values: () => state.values,\n running: () => isRunning,\n\n cycle(id, delta) {\n if (isRunning) return;\n state = cycleField(state, id, delta);\n redraw();\n show();\n },\n\n type(id, text) {\n if (isRunning) return;\n state = setFieldText(state, id, text);\n redraw();\n show();\n },\n\n async browseLocal() {\n if (isRunning) return;\n // The DIRECTION decides which dialog, which is why it is passed rather than inferred in\n // main: a send must choose an existing file (Open) and a receive names a destination\n // (Save). The dialog deliberately does NOT set `Exist` -- see the spec.\n const chosen = await deps.browse(state.values.direction);\n // THE `isRunning` CHECK IS DELIBERATELY DUPLICATED, because the `await` above is a\n // SUSPENSION POINT and the state can change across it. The check before it is not enough:\n // if the native dialog is not modal to this window the operator can press Start while it is\n // open, and a late resolution then lands in the middle of a running transfer. Measured in\n // real Electron with a deferred `browse`: the redraw's `show()` overwrote a live progress\n // line (\"128 bytes\") with the idle help text, and `setFieldText` mutated the form model\n // while the host was mid-transfer.\n //\n // THIS IS THE PRIMARY GUARD. `transferBoot.ts`'s `render` also reads a running flag when it\n // sets each control's `disabled`, which is now DEFENSE IN DEPTH rather than the primary\n // protection -- it covers the enablement third of the same hazard. Do not delete either half\n // on the evidence of a green suite: this repo has twice recorded that a defense-in-depth pair\n // is invisible to single mutation, because each half alone keeps the other's tests passing.\n if (isRunning) return;\n // A CANCELED DIALOG MUST NOT ERASE A TYPED PATH. `undefined` is \"the user changed their\n // mind\", and overwriting the field with it would destroy work for a misclick.\n if (chosen === undefined) return;\n state = setFieldText(state, 'localFile', chosen);\n redraw();\n show();\n },\n\n /**\n * Submit the form, ANNOUNCING THE TRANSFER BEFORE THE AWAIT RATHER THAN AFTER IT.\n *\n * ## THE INVARIANT: AN ENDING THAT HAS ALREADY ARRIVED MUST NOT BE UNDONE BY THE SUBMIT THAT\n * ## STARTED IT\n *\n * This method used to `await deps.submit(...)` and only THEN arm `isRunning` and paint\n * `transferring`. That is the renderer's copy of the bug `transferWindow.ts`'s `startRun`\n * documents at length, and it is REACHABLE for the same reason: `startTransfer` can call\n * `onDone` BEFORE IT RETURNS -- a whole DFT transfer finishing inside\n * `session.sendAID(AID.ENTER)`'s record handling, `transferRun.ts:377`'s\n * `if (ended) return { ok: true };`, driven against a real `Session` by\n * `tui/test/transferRun.test.ts`'s \"notices a DFT transfer that finished BEFORE the first\n * wait\". The controller's `finish` then runs INSIDE the `ipcMain.handle` handler and issues\n * `webContents.send('transfer:done', ...)`, and that send reaches the renderer BEFORE the\n * `invoke()` promise resolves -- measured in real Electron 44, 20 runs out of 20.\n *\n * So `finished()` ran first and this method's tail then clobbered it: a transfer that had\n * SUCCEEDED reported as permanently in progress, `Cancel` was dead (this object's\n * `requestCancel` passed its own `isRunning` check and main's answered\n * `if (run === undefined) return;`), and `Start` was dead for the window's life. Measured\n * against the built module: `setRunning false` / `setStatus \"done: 3 bytes\"` /\n * `setRunning true` / `setStatus \"transferring\"`, leaving `running() === true`.\n *\n * ## THE FIX IS THE SHAPE THIS MODULE'S OTHER HALF ALREADY USES, WITH NO NEW STATE\n *\n * `isRunning` is armed, and the whole \"we are transferring\" announcement made, BEFORE the\n * await. The tail is then EMPTY on the success path -- there is nothing left to clobber with\n * -- and `isRunning` doubles as the latch: `finished()` clears it, so the refusal arm below is\n * CONDITIONAL on this submit still being the one the form is waiting for. That is exactly\n * `startRun`'s placeholder-then-conditional-assignment, and it adds no second word for \"is a\n * transfer running\"; a renderer-side generation counter would have been one more fact to keep\n * in step with main's, and nothing here could falsify it.\n *\n * TWO SIBLING HAZARDS CLOSE WITH IT, both from the same await:\n *\n * - A `transfer:progress` arriving during the await used to be DROPPED by `progress()`'s\n * `if (!isRunning) return;`, and then painted over by the tail's `transferring` even if it\n * had not been. Both ends are fixed: the flag is already armed when it arrives, and the\n * tail no longer paints.\n * - A SECOND Start during the await used to pass the guard below -- two `invoke`s for one\n * button -- and came back as main's 'a transfer is already running', a message about the\n * operator's OWN transfer. The early arming refuses the second press here instead.\n */\n async start() {\n // A SECOND START WHILE ONE RUNS IS IGNORED, matching the TUI: two transfers would\n // interleave two machines' frames on one screen, and the host is answering only one.\n if (isRunning) return;\n // ARMED, AND ANNOUNCED, BEFORE THE AWAIT. See the docstring: everything this used to do\n // after `deps.submit` resolved is done here, where no ending can have overtaken it. The\n // submit IS in flight, so the form is honestly busy for the duration.\n isRunning = true;\n // THE ERROR FROM A PREVIOUS REFUSAL IS CLEARED HERE, because a submit that got through\n // makes the old message false and nothing else would ever clear it. Not during the\n // transfer -- `setStatus('transferring')` below is called directly, and every caller of\n // `show()` returns early while running -- but AFTERWARDS: the first edit the model\n // REJECTS (a non-digit into a numeric field, a cycle on a text field) returns the state\n // unchanged, so `error` is still set and `show()` resurfaces a stale complaint about a\n // transfer that succeeded. An accepted edit happens to clear it, which is what makes the\n // bug intermittent and worth pinning.\n state = { ...state, error: undefined };\n deps.setRunning(true);\n // NO ELLIPSIS: Fira Code and friends ligate `..` and `...`, the same hazard that took the\n // TUI's arrows out of `transferOverlay.ts`'s help line. The present participle carries the\n // sense without it, and `transferOverlay.test.ts` sweeps every phase for the pairs.\n deps.setStatus('transferring');\n /**\n * A REFUSAL, OR A SUBMIT THAT NEVER ANSWERED AT ALL.\n *\n * The `catch` is NOT decoration, and it is the one liability the early arming above\n * introduces. `deps.submit` is `ipcRenderer.invoke` (`transferPreload.cts`), which REJECTS\n * when main's handler throws -- and `transferWindow.ts`'s `submit` catches only\n * `buildCommand`, so a throw out of `deps.startTransfer` comes back here as a rejection\n * rather than as `{ ok: false }`. Before this method armed the flag early, such a rejection\n * left a form that was merely idle and still usable; with the flag armed it would leave one\n * FROZEN for the window's life, which is C1 reappearing through a different door. Reported\n * as a refusal, because from the form's seat that is what it is: nothing is running, and the\n * operator is owed the reason.\n */\n let refusal: string | undefined;\n try {\n const result = await deps.submit(formKeywords(state));\n if (!result.ok) refusal = result.error ?? 'transfer refused';\n } catch (err) {\n refusal = `transfer refused: ${err instanceof Error ? err.message : String(err)}`;\n }\n // THE LATCH, AND THE WHOLE OF THIS METHOD'S TAIL. A completion that arrived while the\n // submit was in flight has already run `finished()`, which cleared `isRunning`, reported\n // the real outcome and re-enabled the form. Nothing below may speak about this submit\n // after that: the ending is the truth and the submit is merely how it began.\n if (!isRunning) return;\n if (refusal === undefined) return;\n // A LOCAL REFUSAL LEAVES THE FORM OPEN AND POPULATED. Nothing reached the host, so the\n // user's next move is to fix one field -- a form that reset would make them retype ten.\n //\n // AND THE FORM GOES IDLE AGAIN, which is now this arm's job rather than something it never\n // had to do: `isRunning` was armed above, so a refusal that failed to clear it would freeze\n // the form for the window's life -- the very failure the early arming exists to prevent,\n // moved one path over.\n isRunning = false;\n deps.setRunning(false);\n state = { ...state, error: refusal };\n show();\n },\n\n requestCancel() {\n if (!isRunning) return;\n deps.cancel();\n },\n\n progress(text) {\n if (!isRunning) return;\n deps.setStatus(text);\n },\n\n finished(result) {\n isRunning = false;\n deps.setRunning(false);\n if (result.ok) {\n deps.setStatus(`done: ${result.bytes ?? 0} bytes`);\n return;\n }\n deps.setStatus(result.error ?? 'transfer failed');\n },\n };\n}\n", "import {\n caretAfterEdit, createTransferUi, type UiField,\n} from '@tn3270/canvas/dist/transferUi.js';\nimport type { TransferFieldId } from '@tn3270/frontend';\n\n/**\n * The browser entry point: real elements in, `createTransferUi` out.\n *\n * DELIBERATELY THIN AND DELIBERATELY UNTESTED, the same split as `renderer.ts` against\n * `blit.ts`. Everything decidable lives in `transferUi.ts`, which a unit test can reach\n * because its DOM is injected; this file is the part that cannot be unit-tested at all\n * (there is no `document` under vitest, and jsdom is not a dependency), so it must contain\n * no decision worth testing. If you find yourself adding an `if` about WHAT THE FORM MEANS\n * here, it belongs in `transferUi.ts`. The two `if`s that remain below are about real\n * elements -- focus and enablement -- which `transferUi.ts` cannot see by construction.\n *\n * `window.tn3270transfer` is the preload's bridge. A DIFFERENT NAME from the canvas\n * window's `window.tn3270`, and that is not cosmetic: `contextBridge.exposeInMainWorld`\n * defines a NON-WRITABLE property, so two bridges sharing a name in one process is a\n * measured hazard already recorded in `main.ts:161-164`.\n */\ndeclare global {\n interface Window {\n tn3270transfer: {\n browse(direction: string): Promise<string | undefined>;\n submit(keywords: readonly string[]): Promise<{ ok: boolean; error?: string }>;\n cancel(): void;\n onProgress(fn: (text: string) => void): void;\n onDone(fn: (r: { ok: boolean; error?: string; bytes?: number }) => void): void;\n };\n }\n}\n\nconst bridge = window.tn3270transfer;\nconst fields = document.querySelector<HTMLDivElement>('#fields');\nconst status = document.querySelector<HTMLDivElement>('#status');\nconst startBtn = document.querySelector<HTMLButtonElement>('#start');\nconst cancelBtn = document.querySelector<HTMLButtonElement>('#cancel');\nif (fields === null || status === null || startBtn === null || cancelBtn === null) {\n throw new Error('transfer.html is missing one of #fields, #status, #start, #cancel');\n}\n\n/**\n * The running state, mirrored here because `render` cannot ask the UI for it.\n *\n * ## DO NOT REPLACE THIS WITH `ui?.running()` -- IT THROWS\n *\n * `createTransferUi` calls `redraw()` before it returns (`transferUi.ts:152`), so `render` runs\n * SYNCHRONOUSLY while `const ui` is still in its temporal dead zone. Optional chaining does NOT\n * protect against a TDZ: `ui?.running()` throws `ReferenceError: Cannot access 'ui' before\n * initialization` exactly as `ui.running()` would, because `?.` guards `null`/`undefined` and a\n * TDZ binding is neither -- reading it at all is the error. Verified 2026-10-01 by running the\n * `const x = f(cb)` / `cb` reads `x?.y` shape under node: it throws.\n *\n * The cost of getting this wrong is the worst failure shape in this repo: the first render throws,\n * the module never finishes evaluating, no listener is ever attached, and the window is BLANK with\n * nothing in any console pointing at the cause.\n *\n * A mirrored flag has no such hazard, and it is not a second source of truth: `setRunning` is the\n * only writer and `transferUi.ts` is the only caller of `setRunning`, so this follows the UI\n * rather than racing it.\n */\nlet running = false;\n\n/**\n * The control that had focus, so a rebuild does not throw the caret on the floor.\n *\n * `field` is a `TransferFieldId` and not a `string`: the values only ever come from `UiField.id`,\n * so the selector interpolation below cannot be fed anything outside that 10-member union. That\n * was already true incidentally; this makes it structural, which is what keeps it true.\n *\n * `role` distinguishes the two controls that can share one field. `localFile` draws BOTH an input\n * and a Browse button, so the id alone is ambiguous -- and a selector matching on the id alone\n * returns the input, which is why a redraw with Browse focused used to land focus on `<body>`.\n */\ninterface FocusMemo {\n readonly field: TransferFieldId;\n readonly role: 'value' | 'browse';\n readonly start: number | null;\n readonly end: number | null;\n /**\n * What the element was DISPLAYING when its caret was read -- including the keystroke the\n * browser had already applied but the model had not yet seen. `caretAfterEdit` needs it to tell\n * how much the model dropped; without it the caret sits one position right after a refused\n * keystroke, which is the measured bug this field exists to fix.\n */\n readonly typed: string;\n}\n\n/**\n * Remember where the caret was before `render` destroys the element holding it.\n *\n * NOT A NICETY. `render` is called on EVERY model change and rebuilds `#fields` from scratch, and\n * a text field routes every keystroke through the model -- so without this, typing one character\n * into `Local file` removes the focused input from the document, focus falls back to `<body>`, and\n * the second character goes nowhere. The form would be unusable for its main job: naming two\n * files. The repair has to live here because it is about real elements, which `transferUi.ts` is\n * built never to touch.\n *\n * ## A `const` ARROW AND NOT A `function`, FOR TWO REASONS\n *\n * A hoisted `function` can be called before the null guard above runs, so TypeScript discards the\n * narrowing inside one: `restoreFocus` as a `function` fails with \"'fields' is possibly 'null'\"\n * (measured -- it was the one typecheck error this file produced). An arrow assigned after the\n * guard keeps it.\n *\n * And both must be declared ABOVE `const ui`, because `render` calls them synchronously during\n * `createTransferUi`. That is the same temporal-dead-zone constraint documented at `running`:\n * being a `const` is only safe here because the initializer has already run by then.\n */\nconst rememberFocus = (): FocusMemo | undefined => {\n const active = document.activeElement;\n const tagged = active instanceof HTMLInputElement || active instanceof HTMLSelectElement\n || active instanceof HTMLButtonElement;\n if (!tagged) return undefined;\n // Written by `render` from `UiField.id`, so the cast restates what the markup guarantees rather\n // than asserting anything new. An untagged element (the Start and Cancel buttons live outside\n // `#fields` and carry no `data-field`) returns undefined above.\n const field = active.dataset['field'] as TransferFieldId | undefined;\n if (field === undefined) return undefined;\n const role = active.dataset['role'] === 'browse' ? 'browse' : 'value';\n if (active instanceof HTMLInputElement) {\n return {\n field, role, start: active.selectionStart, end: active.selectionEnd, typed: active.value,\n };\n }\n // A `<select>` and a `<button>` have no caret. A null start tells `caretAfterEdit` not to\n // compute one, and `restoreFocus` does no caret work for either.\n return { field, role, start: null, end: null, typed: '' };\n};\n\n/** Put focus and caret back on the control that had them, if it is still drawn. */\nconst restoreFocus = (memo: FocusMemo | undefined): void => {\n if (memo === undefined) return;\n // BOTH attributes, because `localFile` draws two controls and matching on the field alone\n // returns the input -- which is how a redraw with Browse focused used to drop focus to `<body>`.\n const el = fields.querySelector(\n `[data-field=\"${memo.field}\"][data-role=\"${memo.role}\"]`,\n );\n if (el instanceof HTMLButtonElement) { el.focus(); return; }\n if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLSelectElement)) return;\n el.focus();\n if (!(el instanceof HTMLInputElement)) return;\n // The arithmetic is `caretAfterEdit`'s, in `transferUi.ts`, because it is a pure function of\n // three values and a unit test cannot reach this file (there is no `document` under vitest).\n // The inline version here was wrong: see that function's docstring for the measurement.\n const { start, end } = caretAfterEdit(memo, memo.typed, el.value);\n el.setSelectionRange(start, end);\n};\n\nconst ui = createTransferUi({\n render(list: readonly UiField[]) {\n const memo = rememberFocus();\n fields.replaceChildren();\n for (const f of list) {\n const row = document.createElement('div');\n row.className = 'row';\n const label = document.createElement('label');\n label.textContent = f.label;\n row.append(label);\n\n if (f.kind === 'cycle') {\n const select = document.createElement('select');\n select.dataset['field'] = f.id;\n select.dataset['role'] = 'value';\n for (const [i, opt] of f.options.entries()) {\n const o = document.createElement('option');\n o.value = opt;\n // THE VALUE IS SUBMITTED AND THE LABEL IS READ. `optionLabels` is positionally\n // aligned with `options` (`transferUi.ts` builds both together), so the index is the\n // join -- which is what lets `Direction` read `send: local to host` while still\n // submitting `Direction=send`.\n //\n // An empty value is a REAL state meaning \"I did not ask\", distinct from every\n // named value -- `transferForm.ts` explains why a two-state toggle would lose it --\n // and it keeps its own rendering, being presentation rather than a shared decision.\n o.textContent = opt === '' ? '(host default)' : (f.optionLabels[i] ?? opt);\n if (opt === f.value) o.selected = true;\n select.append(o);\n }\n select.disabled = running;\n select.addEventListener('change', () => {\n // Routed through the model as a CYCLE to the chosen value, so `clearInapplicable`\n // runs. Distance is computed from the options this field currently offers, which is\n // sound because `UiField.options` comes from `transferUi.ts`'s `offered()` -- the same\n // list, in the same order, that `cycleField` will index into.\n const from = f.options.indexOf(f.value);\n const to = f.options.indexOf(select.value);\n if (from >= 0 && to >= 0 && to !== from) ui.cycle(f.id, to - from);\n });\n row.append(select);\n } else {\n const input = document.createElement('input');\n input.type = 'text';\n input.dataset['field'] = f.id;\n input.dataset['role'] = 'value';\n input.value = f.value;\n input.disabled = running;\n input.addEventListener('input', () => { ui.type(f.id, input.value); });\n row.append(input);\n }\n\n if (f.id === 'localFile') {\n const browse = document.createElement('button');\n browse.textContent = 'Browse\u2026';\n // TAGGED SO FOCUS SURVIVES ITS OWN REDRAW, which is reachable on this button's main path:\n // click Browse, the dialog resolves, `browseLocal` redraws, this element is destroyed.\n // Measured before the tag: focus landed on `<body>`. `data-role` is what keeps it distinct\n // from the `localFile` input sharing the id.\n browse.dataset['field'] = f.id;\n browse.dataset['role'] = 'browse';\n browse.disabled = running;\n browse.addEventListener('click', () => { void ui.browseLocal(); });\n row.append(browse);\n } else {\n row.append(document.createElement('span'));\n }\n fields.append(row);\n }\n restoreFocus(memo);\n },\n setStatus(text: string) {\n status.textContent = text;\n },\n /**\n * Push the running state to the DOM, and to the flag `render` reads.\n *\n * ## WHY `render` STILL CONSULTS `running` -- DEFENSE IN DEPTH, NOT THE PRIMARY GUARD\n *\n * The interleaving this covers is `browseLocal`'s: it checks `isRunning` before its `await\n * deps.browse(...)` and redraws after, so a dialog that is not modal to this window lets the\n * operator press Start while it is open and a late resolution rebuild every control mid-transfer.\n * Measured in real Electron with a deferred `browse`.\n *\n * THE PRIMARY FIX IS IN `transferUi.ts`: a second `isRunning` check immediately after that await,\n * which also stops the two worse halves of the same bug (a redraw's `show()` clobbering the live\n * progress line, and `setFieldText` mutating the model mid-transfer). With that in place, this\n * assignment is arguably unreachable -- almost every render happens while idle, since `cycle` and\n * `type` return early when running and `start`/`progress`/`finished` never redraw.\n *\n * KEPT ANYWAY, DELIBERATELY. It is the enablement third of the hazard, and a pair like this is\n * invisible to single mutation: removing either half leaves the other half's tests green, which\n * this repo has now recorded twice. So neither half should be deleted on the evidence of a green\n * suite. The same note is on the post-await check in `transferUi.ts`, at the other end of the\n * pair, because whoever deletes one will be reading only that end.\n */\n setRunning(isRunning: boolean) {\n running = isRunning;\n startBtn.disabled = isRunning;\n cancelBtn.disabled = !isRunning;\n for (const el of fields.querySelectorAll('input, select, button')) {\n (el as HTMLInputElement).disabled = isRunning;\n }\n },\n browse: (direction: string) => bridge.browse(direction),\n submit: (keywords: readonly string[]) => bridge.submit(keywords),\n cancel: () => { bridge.cancel(); },\n});\n\nstartBtn.addEventListener('click', () => { void ui.start(); });\ncancelBtn.addEventListener('click', () => { ui.requestCancel(); });\nbridge.onProgress((text) => { ui.progress(text); });\nbridge.onDone((r) => { ui.finished(r); });\n\n/**\n * TWO HOOKS FOR THE HARNESS, and nothing else on `window`.\n *\n * `executeJavaScript` is how main drives this window without a mouse, the same way\n * `__tn3270ButtonCenter` lets `clicks.mjs` click a keypad button by label (`canvas/src/renderer.ts`).\n * They go through the SAME `ui` object a real click does, so a scenario cannot pass while the wiring\n * is broken -- which is exactly what a hook that manipulated the DOM directly would allow.\n *\n * ## THEY ADD NO DECISION TO THIS FILE, WHICH WAS THE CONSTRAINT\n *\n * This module's discipline is that it holds no decision worth testing, because no test can reach it.\n * The plan for these hooks broke that: its `__tn3270Submit` inferred success by STRING-MATCHING the\n * status line for `'transferring'` -- a literal duplicated out of `transferUi.ts`, a judgement about\n * what the form means, and unreachable by any test. `ui.running()` answers the same question\n * directly, is a method on the object already in hand, and is unit-tested at its own end\n * (`transferUi.test.ts`). So both hooks only READ `ui` and the DOM; nothing here decides anything.\n *\n * ## EACH RETURNS WHAT THE MODEL ACTUALLY HOLDS, NOT WHAT IT WAS ASKED FOR\n *\n * `__tn3270SetField` returns `ui.values()[id]` AFTER the edit, so a value the model refused -- a\n * non-digit into `Lrecl`, a cycle field, an id outside `TransferFieldId` -- comes back as what is\n * really there and the seam's report says so. A hook returning `void` would let a scenario pass over\n * a form that ignored every step.\n *\n * `__tn3270Submit` returns `running()` AND the status text: `running()` is the fact, and the text is\n * WHICH refusal, which is what distinguishes `not in 3270 mode` from a validator complaint from 'the\n * transfer window is not open'. Both come from the same places a user reads.\n */\ndeclare global {\n interface Window {\n __tn3270SetField(id: string, text: string): string;\n __tn3270FieldKind(id: string): string;\n __tn3270CycleField(id: string, to: string): string;\n __tn3270Submit(): Promise<{ ok: boolean; status: string }>;\n __tn3270AwaitDone(budgetMs: number): Promise<{\n running: boolean; status: string; timedOut: boolean;\n }>;\n __tn3270SampleStatus(budgetMs: number, everyMs: number): Promise<string[]>;\n __tn3270ClickCancel(): { enabled: boolean; statusBefore: string };\n }\n}\n\nwindow.__tn3270SetField = (id: string, text: string): string => {\n /**\n * THE CAST IS UNAVOIDABLE AND IT IS NARROW. `executeJavaScript` hands over a `string` -- the\n * boundary is JSON over IPC, where no union survives -- so something has to assert it, and\n * asserting it here is better than widening `TransferUi.type` to take any string and losing the\n * union everywhere else.\n *\n * IT IS NOT UNCHECKED, which is what makes it safe: `setFieldText` looks the id up in\n * `FIELD_BY_ID` and returns the state UNTOUCHED when it misses (`transferForm.ts:142-143`), and\n * `values()[id]` on an unknown key is `undefined`. So a bad id is a visible empty echo rather than\n * a crash or a silent success.\n */\n ui.type(id as TransferFieldId, text);\n return ui.values()[id as TransferFieldId] ?? '';\n};\n\n/**\n * Which kind of control a field is DRAWN as: `cycle`, `text`, or `''` if it is not on screen.\n *\n * READ FROM THE DOM, not from `TRANSFER_FIELDS`, and the reason is that the DOM is the better\n * oracle.\n *\n * THERE USED TO BE A SECOND, WEAKER REASON HERE AND IT WAS RETIRED ON 2026-10-06 AS FALSE, which\n * is worth a line because it read convincingly. It argued that this file's only runtime import was\n * `./transferUi.js` and that `transfer.html`'s map had ONE entry, so importing the field table\n * would need a second -- and an unresolved specifier blanks this window with no error in any\n * console, this repo's most-repeated failure. Both premises went stale when `transferUi.ts` moved\n * to `packages/canvas`: the runtime import is now `@tn3270/canvas/dist/transferUi.js` and the map\n * has TWO entries. More to the point the CONCLUSION was never true. `TRANSFER_FIELDS` is a\n * top-level export of `frontend/dist/transferForm.js`, which is precisely what the map's existing\n * `@tn3270/frontend` entry already names, and `transferUi.js` imports that very symbol through it\n * at runtime in this same window at 10/10. The field table needs NO new entry, and never did.\n *\n * The blank-window hazard is real and is covered by `transfer.html`'s own map comment; it just\n * does not bear on this function. The reason below is the one that does, and it stands alone.\n *\n * `render` draws a `<select>` for `kind === 'cycle'` and an `<input>` for everything else, so\n * asking the document answers \"what did the form actually draw\" rather than \"what does the table\n * say it should have\". A field the form failed to draw reports `''`, which is a visible failure\n * rather than a confident answer about a control that is not there.\n */\nwindow.__tn3270FieldKind = (id: string): string => {\n const el = fields.querySelector(`[data-field=\"${id}\"][data-role=\"value\"]`);\n if (el instanceof HTMLSelectElement) return 'cycle';\n if (el instanceof HTMLInputElement) return 'text';\n return '';\n};\n\n/**\n * Cycle a cycle field until it reads `to`, and report what it holds.\n *\n * ## WHY `__tn3270SetField` COULD NOT DO THIS, MEASURED\n *\n * It routes through `ui.type` -> `setFieldText`, which is a documented NO-OP on a cycle field\n * (`transferForm.ts`: `field.kind === 'cycle'` returns the state untouched). So\n * `direction=send,host=vm` echoed `direction=receive,host=tso` -- the seam reported the unchanged\n * value honestly, but a scenario that did not read the echo would have sent a RECEIVE to a TSO host\n * while believing it had set both. Found by running it; nothing in the type system objects.\n *\n * ## IT CLICKS THROUGH THE MODEL RATHER THAN ASSIGNING\n *\n * `ui.cycle(id, 1)` is what a real arrow key does, so this cannot set a combination the operator\n * could not reach -- and `clearInapplicable` runs on every step, which is the whole reason the\n * model owns cycling. Assigning the value directly would bypass the rule that makes VM drop\n * `Recfm=undefined`.\n *\n * BOUNDED BY THE NUMBER OF VALUES THE FIELD OFFERS, so an unreachable target stops rather than\n * spinning: the caller sees an echo that is not what it asked for, which is the same visible\n * failure a bad id gives. A fixed bound also covers the case where the target becomes\n * inapplicable mid-walk and the field stops changing.\n */\nwindow.__tn3270CycleField = (id: string, to: string): string => {\n const field = id as TransferFieldId;\n for (let i = 0; i < 12; i++) {\n if (ui.values()[field] === to) break;\n ui.cycle(field, 1);\n }\n return ui.values()[field] ?? '';\n};\n\nwindow.__tn3270Submit = async (): Promise<{ ok: boolean; status: string }> => {\n await ui.start();\n /**\n * `ui.running()` IS THE ANSWER, and the status line is only the explanation.\n *\n * `start()` sets `isRunning` true only after `deps.submit` came back `ok` (`transferUi.ts`), so\n * this is the same fact the form's own enablement is driven from rather than a reading of its\n * prose. The status text is read second, for the diagnosis.\n */\n const status = document.querySelector('#status')?.textContent ?? '';\n return { ok: ui.running(), status };\n};\n\n/**\n * Wait for a RUNNING transfer to end, and report the status line it ended on.\n *\n * ## WHY `__tn3270Submit` ALONE WAS NOT ENOUGH, MEASURED AGAINST A LIVE HOST\n *\n * It returns as soon as the submit was ACCEPTED -- `ok: true, status: \"transferring\"` -- and the\n * seam then quit the app, killing the transfer it had just started. The first live run against\n * VM/CMS reported exactly that and nothing more: a real transfer began and the harness tore the\n * process down mid-flight, so no run could ever observe a COMPLETION. Replay mode hid this\n * perfectly, because there a submit is always refused and there is never anything to wait for.\n *\n * ## IT POLLS `running()`, WHICH IS THE SAME FACT THE FORM'S ENABLEMENT USES\n *\n * `finished()` clears `isRunning` on every ending -- success, failure, cancel, a stale-generation\n * drop -- so this cannot miss one by watching the wrong signal. Reading the status line instead\n * would be reading prose, and the three endings are different strings.\n *\n * ## IT RETURNS `timedOut` RATHER THAN THROWING\n *\n * The caller is `executeJavaScript` across an IPC boundary, where a throw arrives as a rejected\n * promise with a stack from the renderer -- unreadable in a harness log. A flag the seam can print\n * keeps the diagnosis legible, and a transfer still running when the budget expires is a real\n * result worth reporting rather than an error.\n */\nwindow.__tn3270AwaitDone = async (budgetMs: number): Promise<{\n running: boolean; status: string; timedOut: boolean;\n}> => {\n const deadline = Date.now() + budgetMs;\n while (ui.running() && Date.now() < deadline) {\n await new Promise((r) => { setTimeout(r, 250); });\n }\n return {\n running: ui.running(),\n status: document.querySelector('#status')?.textContent ?? '',\n timedOut: ui.running(),\n };\n};\n\n/**\n * Sample the status line over a window and return the DISTINCT texts seen, in order.\n *\n * ## WHY A SEQUENCE AND NOT A READING\n *\n * The property worth observing is that a real byte count **CLIMBS**. One reading cannot show that,\n * and the runbook names the failure it would miss: *\"a progress report that silently stops is the\n * failure this window would hide best\"* -- a transfer that reports `1107 bytes` once and then goes\n * quiet for two seconds looks identical to one reporting it continuously, through a single sample.\n *\n * DISTINCT, not every poll, because the poll rate and the host's frame rate are unrelated: at 15 ms\n * a frame and a 100 ms sample most readings are repeats, and a hundred identical lines in a harness\n * log hide the three that differ.\n *\n * ## IT RETURNS EARLY WHEN THE TRANSFER ENDS\n *\n * Otherwise a 200 KB transfer that finishes in 3 s would still hold the scenario for the full\n * budget, and the `done:` line -- the last and most interesting sample -- would be followed by\n * nothing but waiting. The caller gets the ending in the sequence either way.\n */\nwindow.__tn3270SampleStatus = async (budgetMs: number, everyMs: number): Promise<string[]> => {\n const seen: string[] = [];\n const deadline = Date.now() + budgetMs;\n for (;;) {\n const text = document.querySelector('#status')?.textContent ?? '';\n if (seen[seen.length - 1] !== text) seen.push(text);\n if (!ui.running() || Date.now() >= deadline) return seen;\n await new Promise((r) => { setTimeout(r, everyMs); });\n }\n};\n\n/**\n * Click the REAL Cancel button, and report whether it was enabled and what the status said.\n *\n * ## WHY A CLICK RATHER THAN `ui.requestCancel()`\n *\n * Calling the method directly would pass while the button was disabled, mislabelled, or wired to\n * nothing -- and the button IS the operator's only route out of a running transfer, because the\n * window refuses to close. So this goes through the same listener a mouse does\n * (`cancelBtn.addEventListener('click', ...)`), and reports `enabled` separately: a disabled button\n * that is \"clicked\" proves nothing, and `click()` on one is a silent no-op in the DOM.\n *\n * `statusBefore` is captured BEFORE the click because that is the byte count the cancel interrupted,\n * and it is the number that says the transfer was genuinely mid-flight. `cancel-transfer.py` makes\n * the same recording for the same reason: *\"A canceled 200KB transfer that reports 204800 bytes was\n * not canceled.\"*\n */\nwindow.__tn3270ClickCancel = (): { enabled: boolean; statusBefore: string } => {\n const btn = document.querySelector<HTMLButtonElement>('#cancel');\n const statusBefore = document.querySelector('#status')?.textContent ?? '';\n if (btn === null) return { enabled: false, statusBefore };\n const enabled = !btn.disabled;\n btn.click();\n return { enabled, statusBefore };\n};\n"],
5
+ "mappings": "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",
6
+ "names": ["status"]
7
+ }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ // packages/gui/dist/transferPreload.cjs
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ var electron_1 = require("electron");
6
+ electron_1.contextBridge.exposeInMainWorld("tn3270transfer", {
7
+ /** Open a native file dialog. Resolves to a path, or undefined if the user canceled. */
8
+ browse: (direction) => electron_1.ipcRenderer.invoke("transfer:browse", direction),
9
+ /**
10
+ * Hand the form's keywords to main.
11
+ *
12
+ * `invoke`, not `send`, because a LOCAL refusal is the answer: `startTransfer` checks 3270
13
+ * mode, the keyboard lock, the field and the file BEFORE the host is told anything, and the
14
+ * form must show that without the round trip going through `onDone` -- which fires only for
15
+ * transfers that actually started.
16
+ */
17
+ submit: (keywords) => electron_1.ipcRenderer.invoke("transfer:submit", keywords),
18
+ /** Abort a running transfer. Fire-and-forget: the outcome arrives through `onDone`. */
19
+ cancel: () => {
20
+ electron_1.ipcRenderer.send("transfer:cancel");
21
+ },
22
+ onProgress: (fn) => {
23
+ electron_1.ipcRenderer.on("transfer:progress", (_e, text) => {
24
+ fn(text);
25
+ });
26
+ },
27
+ onDone: (fn) => {
28
+ electron_1.ipcRenderer.on("transfer:done", (_e, r) => {
29
+ fn(r);
30
+ });
31
+ }
32
+ });
33
+ //# sourceMappingURL=transferPreload.cjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/transferPreload.cts"],
4
+ "sourcesContent": ["import { contextBridge, ipcRenderer } from 'electron';\n\n/**\n * The transfer window's bridge. A `.cts` FILE ON PURPOSE -- an ESM preload fails with \"Cannot\n * use import statement outside a module\" and the bridge then silently never appears, leaving a\n * window that does nothing and no error anywhere. See `preload.cts` for the same note.\n *\n * ## FIVE FUNCTIONS HERE DOES NOT BREAK THE FOUR-FUNCTION RULE\n *\n * That rule (`packages/web/src/bridgecore.ts`) is about the CANVAS bridge, whose width is what\n * lets `renderer.ts` be reused UNMODIFIED by the web gateway. This is a different window with a\n * different surface. Widening the canvas bridge to carry transfers instead would force the\n * browser front end to stub functions it cannot implement -- there is no native file dialog in a\n * browser, and the gateway's filesystem is not the operator's.\n *\n * ## THE NAME IS DIFFERENT FROM THE CANVAS WINDOW'S, DELIBERATELY\n *\n * `exposeInMainWorld` defines a NON-WRITABLE property. `main.ts:161-164` records the measured\n * failure from a name collision (\"Cannot assign to read only property 'tn3270' of object\n * '#<Window>'\"), so this is `tn3270transfer` and not an addition to `tn3270`.\n *\n * `contextIsolation` stays on and `nodeIntegration` off. This window handles file paths and\n * sits in front of a logged-on host session, so it is the last place to grant Node access.\n */\ncontextBridge.exposeInMainWorld('tn3270transfer', {\n /** Open a native file dialog. Resolves to a path, or undefined if the user canceled. */\n browse: (direction: string): Promise<string | undefined> =>\n ipcRenderer.invoke('transfer:browse', direction) as Promise<string | undefined>,\n /**\n * Hand the form's keywords to main.\n *\n * `invoke`, not `send`, because a LOCAL refusal is the answer: `startTransfer` checks 3270\n * mode, the keyboard lock, the field and the file BEFORE the host is told anything, and the\n * form must show that without the round trip going through `onDone` -- which fires only for\n * transfers that actually started.\n */\n submit: (keywords: readonly string[]): Promise<{ ok: boolean; error?: string }> =>\n ipcRenderer.invoke('transfer:submit', keywords) as Promise<{ ok: boolean; error?: string }>,\n /** Abort a running transfer. Fire-and-forget: the outcome arrives through `onDone`. */\n cancel: (): void => { ipcRenderer.send('transfer:cancel'); },\n onProgress: (fn: (text: string) => void): void => {\n ipcRenderer.on('transfer:progress', (_e, text: string) => { fn(text); });\n },\n onDone: (fn: (r: { ok: boolean; error?: string; bytes?: number }) => void): void => {\n ipcRenderer.on('transfer:done', (_e, r) => { fn(r); });\n },\n});\n"],
5
+ "mappings": ";;;;AAAA,IAAA,aAAA,QAAA,UAAA;AAwBA,WAAA,cAAc,kBAAkB,kBAAkB;;EAEhD,QAAQ,CAAC,cACP,WAAA,YAAY,OAAO,mBAAmB,SAAS;;;;;;;;;EASjD,QAAQ,CAAC,aACP,WAAA,YAAY,OAAO,mBAAmB,QAAQ;;EAEhD,QAAQ,MAAW;AAAG,eAAA,YAAY,KAAK,iBAAiB;EAAG;EAC3D,YAAY,CAAC,OAAoC;AAC/C,eAAA,YAAY,GAAG,qBAAqB,CAAC,IAAI,SAAgB;AAAG,SAAG,IAAI;IAAG,CAAC;EACzE;EACA,QAAQ,CAAC,OAA0E;AACjF,eAAA,YAAY,GAAG,iBAAiB,CAAC,IAAI,MAAK;AAAG,SAAG,CAAC;IAAG,CAAC;EACvD;CACD;",
6
+ "names": []
7
+ }