@yuneta/gobj-ui 7.25.1 → 7.25.3

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.
@@ -23016,6 +23016,53 @@ function yui_shell_show_route_map(shell, opts) {
23016
23016
  return modal_ref.modal;
23017
23017
  }
23018
23018
  //#endregion
23019
+ //#region src/form_writes_in_flight.js
23020
+ /***********************************************************************
23021
+ * form_writes_in_flight.js
23022
+ *
23023
+ * The writes of topic forms a C_YUI_TREEDB_TOPICS view has sent
23024
+ * and not yet answered, by their `form_write` serial. A form that
23025
+ * waits for its answer (form_waits_for_answer) stays open and
23026
+ * busy until the host answers, so the host has to answer EVERY
23027
+ * way a write ends -- and the transport closing under a write in
23028
+ * flight, or a command that never left because there was no
23029
+ * session, are two of them that nobody answered: the form stayed
23030
+ * busy for ever (N8 of the 2026-09-22 review).
23031
+ *
23032
+ * Copyright (c) 2026, ArtGins.
23033
+ * All Rights Reserved.
23034
+ ***********************************************************************/
23035
+ /************************************************************
23036
+ * A write left: remember whose it is
23037
+ ************************************************************/
23038
+ function track_form_write(in_flight, form_write, topic_name) {
23039
+ if (!in_flight || !form_write) return;
23040
+ in_flight[form_write] = topic_name || "";
23041
+ }
23042
+ /************************************************************
23043
+ * A write was answered, one way or the other
23044
+ ************************************************************/
23045
+ function settle_form_write(in_flight, form_write) {
23046
+ if (!in_flight || !form_write) return;
23047
+ delete in_flight[form_write];
23048
+ }
23049
+ /************************************************************
23050
+ * Nothing in flight can be answered any more: take them all,
23051
+ * as [{form_write, topic_name}], and leave the map empty.
23052
+ ************************************************************/
23053
+ function abandon_form_writes(in_flight) {
23054
+ const abandoned = [];
23055
+ if (!in_flight) return abandoned;
23056
+ Object.keys(in_flight).forEach((serial) => {
23057
+ abandoned.push({
23058
+ form_write: Number(serial),
23059
+ topic_name: in_flight[serial]
23060
+ });
23061
+ delete in_flight[serial];
23062
+ });
23063
+ return abandoned;
23064
+ }
23065
+ //#endregion
23019
23066
  //#region src/nodes_answer.js
23020
23067
  /***********************************************************************
23021
23068
  * nodes_answer.js
@@ -23582,6 +23629,7 @@ var __gclass__$6 = null;
23582
23629
  * Framework Method: Create
23583
23630
  ***************************************************************/
23584
23631
  function mt_create$6(gobj) {
23632
+ gobj.priv.form_writes = {};
23585
23633
  build_ui$6(gobj);
23586
23634
  let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
23587
23635
  if (!subscriber) subscriber = gobj_parent(gobj);
@@ -24738,6 +24786,7 @@ function treedb_update_node$1(gobj, treedb_name, topic_name, record, options, fo
24738
24786
  log_error(ret);
24739
24787
  return -1;
24740
24788
  }
24789
+ track_form_write(gobj.priv.form_writes, form_write, topic_name);
24741
24790
  return 0;
24742
24791
  }
24743
24792
  /************************************************************
@@ -25001,6 +25050,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
25001
25050
  if (command === "update-node" || command === "create-node") {
25002
25051
  let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
25003
25052
  if (failed_topic) gobj_send_event(gobj, "EV_REFRESH_TOPIC", { topic_name: failed_topic }, gobj);
25053
+ settle_form_write(gobj.priv.form_writes, kw_get_int(gobj, kw_command, "form_write", 0, 0));
25004
25054
  answer_form_write(gobj, get_gobj_formtable(gobj, failed_topic), kw_get_int(gobj, kw_command, "form_write", 0, 0), false);
25005
25055
  }
25006
25056
  }
@@ -25038,6 +25088,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
25038
25088
  created: command === "create-node",
25039
25089
  command
25040
25090
  });
25091
+ settle_form_write(gobj.priv.form_writes, kw_get_int(gobj, kw_command, "form_write", 0, 0));
25041
25092
  answer_form_write(gobj, get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)), kw_get_int(gobj, kw_command, "form_write", 0, 0), true);
25042
25093
  break;
25043
25094
  case "delete-node": break;
@@ -25188,7 +25239,12 @@ function ac_back_to_topics(gobj, event, kw, src) {
25188
25239
  * the session).
25189
25240
  ************************************************************/
25190
25241
  function ac_transport_state$2(gobj, event, kw, src) {
25191
- refresh_toolbar_buttons(gobj, !!(kw && kw.connected));
25242
+ let connected = !!(kw && kw.connected);
25243
+ refresh_toolbar_buttons(gobj, connected);
25244
+ if (!connected) abandon_form_writes(gobj.priv.form_writes).forEach((w) => {
25245
+ log_warning(`${gobj_short_name(gobj)}: transport closed, the write ${w.form_write} of '${w.topic_name}' is lost`);
25246
+ answer_form_write(gobj, get_gobj_formtable(gobj, w.topic_name), w.form_write, false);
25247
+ });
25192
25248
  return 0;
25193
25249
  }
25194
25250
  /************************************************************
@@ -25262,6 +25318,10 @@ function ac_create_record(gobj, event, kw, src) {
25262
25318
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
25263
25319
  let topic_name = gobj_read_attr(src, "topic_name");
25264
25320
  let record = kw.record;
25321
+ if (refuse_without_session(gobj, topic_name)) {
25322
+ answer_form_write(gobj, src, form_write, false);
25323
+ return -1;
25324
+ }
25265
25325
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25266
25326
  list_dict: true,
25267
25327
  create: true,
@@ -25429,6 +25489,10 @@ function ac_update_record(gobj, event, kw, src) {
25429
25489
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
25430
25490
  let topic_name = gobj_read_attr(src, "topic_name");
25431
25491
  let record = kw.record;
25492
+ if (refuse_without_session(gobj, topic_name)) {
25493
+ answer_form_write(gobj, src, form_write, false);
25494
+ return -1;
25495
+ }
25432
25496
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25433
25497
  list_dict: true,
25434
25498
  autolink: true
@@ -25439,6 +25503,18 @@ function ac_update_record(gobj, event, kw, src) {
25439
25503
  return 0;
25440
25504
  }
25441
25505
  /************************************************************
25506
+ * A write sent with no session never leaves: C_IEVENT_CLI logs
25507
+ * "Not in session" and answers null, as it does when the command
25508
+ * went -- so a form waiting for the answer waited for ever (N8 of
25509
+ * the 2026-09-22 review). Refused before it is sent, aloud.
25510
+ ************************************************************/
25511
+ function refuse_without_session(gobj, topic_name) {
25512
+ if (is_connected$1(gobj)) return false;
25513
+ log_error(`${gobj_short_name(gobj)}: no session, the write of '${topic_name}' was not sent`);
25514
+ yui_shell_show_error(yui_shell_of(gobj), "no session", { t });
25515
+ return true;
25516
+ }
25517
+ /************************************************************
25442
25518
  * Tell a topic form how its write ended: EV_WRITE_DONE closes
25443
25519
  * it, EV_WRITE_REFUSED leaves it open on what was typed. Every
25444
25520
  * way a form's write can end passes here -- refused before it
@@ -26506,6 +26582,49 @@ function delete_impact(desc, records) {
26506
26582
  return out;
26507
26583
  }
26508
26584
  //#endregion
26585
+ //#region src/form_busy.js
26586
+ /***********************************************************************
26587
+ * form_busy.js
26588
+ *
26589
+ * The toolbar of a topic form while a write is in flight: every
26590
+ * button disabled, the save button spinning. Bulma's `is-loading`
26591
+ * is the spinner; `disabled` is what stops the second click.
26592
+ *
26593
+ * A transition, not a setting: each button remembers whether it
26594
+ * was disabled BEFORE the toolbar went busy, and busy(false) puts
26595
+ * that back. So a second busy(true) while already busy must not
26596
+ * run at all -- it read the buttons it had just disabled as
26597
+ * "disabled on their own", and the busy(false) of a refused write
26598
+ * left Save and Cancel dead (N9 of the 2026-09-22 review: a record
26599
+ * with a picked file went busy for the read, then busy again for
26600
+ * the write). The caller owns `state` ({busy}) for the form's life.
26601
+ *
26602
+ * Copyright (c) 2026, ArtGins.
26603
+ * All Rights Reserved.
26604
+ ***********************************************************************/
26605
+ /************************************************************
26606
+ * Move the toolbar to `busy`. Returns true when it moved, false when
26607
+ * it was already there (nothing touched).
26608
+ ************************************************************/
26609
+ function set_toolbar_busy($container, state, busy) {
26610
+ busy = !!busy;
26611
+ if (!$container || !state) return false;
26612
+ if (!!state.busy === busy) return false;
26613
+ state.busy = busy;
26614
+ $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
26615
+ if (busy) {
26616
+ $b.dataset.was_disabled = $b.disabled ? "1" : "0";
26617
+ $b.disabled = true;
26618
+ } else if ($b.dataset.was_disabled !== void 0) {
26619
+ $b.disabled = $b.dataset.was_disabled === "1";
26620
+ delete $b.dataset.was_disabled;
26621
+ }
26622
+ });
26623
+ const $save = $container.querySelector(".yui-toolbar-form .button-save");
26624
+ if ($save) $save.classList.toggle("is-loading", busy);
26625
+ return true;
26626
+ }
26627
+ //#endregion
26509
26628
  //#region src/table_cell_text.js
26510
26629
  /****************************************************************************
26511
26630
  * table_cell_text.js
@@ -26790,6 +26909,7 @@ function mt_create$5(gobj) {
26790
26909
  gobj.priv.read_serial = 0;
26791
26910
  gobj.priv.awaiting_write = 0;
26792
26911
  gobj.priv.write_serial = 0;
26912
+ gobj.priv.form_busy = { busy: false };
26793
26913
  let name = clean_name(gobj_name(gobj));
26794
26914
  gobj_write_attr(gobj, "table_id", "table" + name);
26795
26915
  gobj_write_attr(gobj, "toolbar_id", "toolbar" + name);
@@ -28193,6 +28313,7 @@ function teardown_form_child(gobj) {
28193
28313
  let priv = gobj.priv;
28194
28314
  priv.reading_files = 0;
28195
28315
  priv.awaiting_write = 0;
28316
+ priv.form_busy = { busy: false };
28196
28317
  if (priv.form) {
28197
28318
  if (gobj_is_running(priv.form)) gobj_stop(priv.form);
28198
28319
  gobj_destroy(priv.form);
@@ -29417,26 +29538,16 @@ function ac_form_save_record(gobj, event, kw, src) {
29417
29538
  return 0;
29418
29539
  }
29419
29540
  /************************************************************
29420
- * The form's toolbar while a read is in flight: every button
29421
- * disabled, the save button spinning. Bulma's `is-loading` is the
29422
- * spinner; `disabled` is what stops the second click.
29541
+ * The form's toolbar while a read or a write is in flight: every
29542
+ * button disabled, the save button spinning (set_toolbar_busy). A
29543
+ * transition: busy(true) while busy touches nothing, or a record
29544
+ * with a picked file -- busy for the read, busy again for the
29545
+ * write -- came back from a refusal with Save and Cancel dead.
29423
29546
  ************************************************************/
29424
29547
  function set_form_busy(gobj, busy) {
29425
29548
  let priv = gobj.priv;
29426
29549
  if (!priv.form) return;
29427
- const $container = gobj_read_attr(priv.form, "$container");
29428
- if (!$container) return;
29429
- $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
29430
- if (busy) {
29431
- $b.dataset.was_disabled = $b.disabled ? "1" : "0";
29432
- $b.disabled = true;
29433
- } else if ($b.dataset.was_disabled !== void 0) {
29434
- $b.disabled = $b.dataset.was_disabled === "1";
29435
- delete $b.dataset.was_disabled;
29436
- }
29437
- });
29438
- const $save = $container.querySelector(".yui-toolbar-form .button-save");
29439
- if ($save) $save.classList.toggle("is-loading", !!busy);
29550
+ set_toolbar_busy(gobj_read_attr(priv.form, "$container"), priv.form_busy, busy);
29440
29551
  }
29441
29552
  /************************************************************
29442
29553
  * Is this the read the view is waiting for? A read whose form was
@@ -70139,6 +70250,44 @@ function register_c_yui_treedb_schema() {
70139
70250
  return create_gclass$2(GCLASS_NAME$2);
70140
70251
  }
70141
70252
  //#endregion
70253
+ //#region src/host_drafts.js
70254
+ /***********************************************************************
70255
+ * host_drafts.js
70256
+ *
70257
+ * The topics a schema editor shows as DRAFTS (edited in
70258
+ * __system__, not yet saved), told by the host from data. The
70259
+ * editor marks a topic when a write of this session lands on
70260
+ * it, and that mark lived in the memory of the session only:
70261
+ * a reload of the page, a reconnect or a refresh of the model
70262
+ * lost the chip, the banner and the export warning while
70263
+ * __system__ still differed from the file in use (N13 of the
70264
+ * 2026-09-22 review). The host reads that difference from
70265
+ * C_TREEDB's `saved-schema` (`draft_changed`, by treedb) and
70266
+ * hands it here; the editor applies it to its model.
70267
+ *
70268
+ * Copyright (c) 2026, ArtGins.
70269
+ * All Rights Reserved.
70270
+ ***********************************************************************/
70271
+ /************************************************************
70272
+ * Mark in `written` the topics of `model` the host names as
70273
+ * drafts: `drafts` is {treedb_name: [topic names]}. A treedb or a
70274
+ * topic the model does not hold is ignored. Returns how many were
70275
+ * marked.
70276
+ ************************************************************/
70277
+ function mark_host_drafts(written, model, drafts) {
70278
+ let marked = 0;
70279
+ if (!written || !model || !Array.isArray(model.treedbs) || !drafts) return marked;
70280
+ for (let treedb of model.treedbs) {
70281
+ const names = drafts[treedb.id];
70282
+ if (!Array.isArray(names) || names.length === 0) continue;
70283
+ for (let topic of treedb.topics || []) if (names.includes(topic.name)) {
70284
+ written[topic.id] = true;
70285
+ marked++;
70286
+ }
70287
+ }
70288
+ return marked;
70289
+ }
70290
+ //#endregion
70142
70291
  //#region src/schema_validate.js
70143
70292
  /***********************************************************************
70144
70293
  * schema_validate.js
@@ -71865,6 +72014,7 @@ function mt_create$1(gobj) {
71865
72014
  let priv = gobj.priv;
71866
72015
  priv.baseline = {};
71867
72016
  priv.written = {};
72017
+ priv.host_drafts = {};
71868
72018
  priv.save_queue = [];
71869
72019
  build_ui$1(gobj);
71870
72020
  let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
@@ -72106,6 +72256,7 @@ function start_measuring(gobj) {
72106
72256
  priv.written = {};
72107
72257
  if (!priv.model) return;
72108
72258
  for (let treedb of priv.model.treedbs) for (let topic of treedb.topics) priv.baseline[topic.id] = topic.topic_version;
72259
+ mark_host_drafts(priv.written, priv.model, priv.host_drafts);
72109
72260
  }
72110
72261
  /***************************************************************
72111
72262
  * "This topic was written in this session and is not saved."
@@ -74543,6 +74694,23 @@ function ac_refresh$1(gobj, event, kw, src) {
74543
74694
  return request_model(gobj);
74544
74695
  }
74545
74696
  /***************************************************************
74697
+ * {drafts: {treedb_name: [topic names]}} -- the host says which
74698
+ * topics hold a draft in __system__ that the file in use does not
74699
+ * have (C_TREEDB's saved-schema, `draft_changed`). The mark of a
74700
+ * write lived in this session's memory only, so a reload of the
74701
+ * page, a reconnect or a refresh showed no draft while __system__
74702
+ * still differed from the file (N13 of the 2026-09-22 review).
74703
+ * Replaces what the host said before; what this session wrote
74704
+ * stays marked.
74705
+ ***************************************************************/
74706
+ function ac_drafts(gobj, event, kw, src) {
74707
+ let priv = gobj.priv;
74708
+ priv.host_drafts = kw && kw.drafts && typeof kw.drafts === "object" ? kw.drafts : {};
74709
+ mark_host_drafts(priv.written, priv.model, priv.host_drafts);
74710
+ if (priv.model) render(gobj);
74711
+ return 0;
74712
+ }
74713
+ /***************************************************************
74546
74714
  * Moving about.
74547
74715
  ***************************************************************/
74548
74716
  function ac_select_treedb(gobj, event, kw, src) {
@@ -75016,6 +75184,11 @@ function create_gclass$1(gclass_name) {
75016
75184
  "EV_REFRESH",
75017
75185
  ac_refresh$1,
75018
75186
  null
75187
+ ],
75188
+ [
75189
+ "EV_DRAFTS",
75190
+ ac_drafts,
75191
+ null
75019
75192
  ]
75020
75193
  ];
75021
75194
  const TREEDB_WIDE = [
@@ -75164,6 +75337,7 @@ function create_gclass$1(gclass_name) {
75164
75337
  ["EV_TRANSPORT_STATE", 0],
75165
75338
  ["EV_LANGUAGE_CHANGED", 0],
75166
75339
  ["EV_REFRESH", 0],
75340
+ ["EV_DRAFTS", 0],
75167
75341
  ["EV_SELECT_TREEDB", 0],
75168
75342
  ["EV_SELECT_TOPIC", 0],
75169
75343
  ["EV_BACK", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.25.1",
3
+ "version": "7.25.3",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -113,6 +113,7 @@ import {
113
113
  empty_string,
114
114
  } from "@yuneta/gobj-js";
115
115
  import {yui_tint} from "./bulma_tint.js";
116
+ import {mark_host_drafts} from "./host_drafts.js";
116
117
 
117
118
  import "./c_yui_schema_editor.css";
118
119
 
@@ -248,6 +249,7 @@ function mt_create(gobj)
248
249
 
249
250
  priv.baseline = {};
250
251
  priv.written = {};
252
+ priv.host_drafts = {}; /* what the host says is a draft, {treedb: [topics]} (EV_DRAFTS) */
251
253
  priv.save_queue = [];
252
254
 
253
255
  build_ui(gobj);
@@ -571,6 +573,9 @@ function start_measuring(gobj)
571
573
  priv.baseline[topic.id] = topic.topic_version;
572
574
  }
573
575
  }
576
+ /* A draft is a draft after a reload too: what the host said stays
577
+ * marked (host_drafts.js) until the host says otherwise. */
578
+ mark_host_drafts(priv.written, priv.model, priv.host_drafts);
574
579
  }
575
580
 
576
581
  /***************************************************************
@@ -2755,6 +2760,28 @@ function ac_refresh(gobj, event, kw, src)
2755
2760
  return request_model(gobj);
2756
2761
  }
2757
2762
 
2763
+ /***************************************************************
2764
+ * {drafts: {treedb_name: [topic names]}} -- the host says which
2765
+ * topics hold a draft in __system__ that the file in use does not
2766
+ * have (C_TREEDB's saved-schema, `draft_changed`). The mark of a
2767
+ * write lived in this session's memory only, so a reload of the
2768
+ * page, a reconnect or a refresh showed no draft while __system__
2769
+ * still differed from the file (N13 of the 2026-09-22 review).
2770
+ * Replaces what the host said before; what this session wrote
2771
+ * stays marked.
2772
+ ***************************************************************/
2773
+ function ac_drafts(gobj, event, kw, src)
2774
+ {
2775
+ let priv = gobj.priv;
2776
+
2777
+ priv.host_drafts = (kw && kw.drafts && typeof kw.drafts === "object")? kw.drafts: {};
2778
+ mark_host_drafts(priv.written, priv.model, priv.host_drafts);
2779
+ if(priv.model) {
2780
+ render(gobj);
2781
+ }
2782
+ return 0;
2783
+ }
2784
+
2758
2785
  /***************************************************************
2759
2786
  * Moving about.
2760
2787
  ***************************************************************/
@@ -3328,7 +3355,8 @@ function create_gclass(gclass_name)
3328
3355
  ["EV_HIDE", ac_hide, null],
3329
3356
  ["EV_TRANSPORT_STATE", ac_transport_state, null],
3330
3357
  ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
3331
- ["EV_REFRESH", ac_refresh, null]
3358
+ ["EV_REFRESH", ac_refresh, null],
3359
+ ["EV_DRAFTS", ac_drafts, null]
3332
3360
  ];
3333
3361
 
3334
3362
  /* What a treedb is open for: reading it whole, and writing it
@@ -3411,6 +3439,7 @@ function create_gclass(gclass_name)
3411
3439
  ["EV_TRANSPORT_STATE", 0],
3412
3440
  ["EV_LANGUAGE_CHANGED", 0],
3413
3441
  ["EV_REFRESH", 0],
3442
+ ["EV_DRAFTS", 0],
3414
3443
 
3415
3444
  /* From this view's own screens. */
3416
3445
  ["EV_SELECT_TREEDB", 0],
@@ -110,6 +110,7 @@ import {
110
110
  import {delete_impact} from "./delete_impact.js";
111
111
 
112
112
  import "./c_yui_treedb_topic_with_form.css";
113
+ import {set_toolbar_busy} from "./form_busy.js";
113
114
  import "./tabulator.css";
114
115
  import {cell_text, hook_cell_spec} from "./table_cell_text.js";
115
116
 
@@ -432,6 +433,8 @@ function mt_create(gobj)
432
433
  * serial the host echoes in EV_WRITE_DONE / EV_WRITE_REFUSED. */
433
434
  gobj.priv.awaiting_write = 0;
434
435
  gobj.priv.write_serial = 0;
436
+ /* The busy transition of the form's toolbar (set_toolbar_busy) */
437
+ gobj.priv.form_busy = {busy: false};
435
438
 
436
439
  let name = clean_name(gobj_name(gobj));
437
440
  gobj_write_attr(gobj, "table_id", "table" + name);
@@ -2503,6 +2506,7 @@ function teardown_form_child(gobj)
2503
2506
  let priv = gobj.priv;
2504
2507
  priv.reading_files = 0; // a read in flight lands on nobody
2505
2508
  priv.awaiting_write = 0; // and so does the answer of a write
2509
+ priv.form_busy = {busy: false}; // its toolbar goes with it
2506
2510
  if(priv.form) {
2507
2511
  if(gobj_is_running(priv.form)) {
2508
2512
  gobj_stop(priv.form);
@@ -4261,9 +4265,11 @@ function ac_form_save_record(gobj, event, kw, src)
4261
4265
  }
4262
4266
 
4263
4267
  /************************************************************
4264
- * The form's toolbar while a read is in flight: every button
4265
- * disabled, the save button spinning. Bulma's `is-loading` is the
4266
- * spinner; `disabled` is what stops the second click.
4268
+ * The form's toolbar while a read or a write is in flight: every
4269
+ * button disabled, the save button spinning (set_toolbar_busy). A
4270
+ * transition: busy(true) while busy touches nothing, or a record
4271
+ * with a picked file -- busy for the read, busy again for the
4272
+ * write -- came back from a refusal with Save and Cancel dead.
4267
4273
  ************************************************************/
4268
4274
  function set_form_busy(gobj, busy)
4269
4275
  {
@@ -4271,23 +4277,7 @@ function set_form_busy(gobj, busy)
4271
4277
  if(!priv.form) {
4272
4278
  return;
4273
4279
  }
4274
- const $container = gobj_read_attr(priv.form, "$container");
4275
- if(!$container) {
4276
- return;
4277
- }
4278
- $container.querySelectorAll('.yui-toolbar-form button').forEach(($b) => {
4279
- if(busy) {
4280
- $b.dataset.was_disabled = $b.disabled? "1": "0";
4281
- $b.disabled = true;
4282
- } else if($b.dataset.was_disabled !== undefined) {
4283
- $b.disabled = ($b.dataset.was_disabled === "1");
4284
- delete $b.dataset.was_disabled;
4285
- }
4286
- });
4287
- const $save = $container.querySelector('.yui-toolbar-form .button-save');
4288
- if($save) {
4289
- $save.classList.toggle('is-loading', !!busy);
4290
- }
4280
+ set_toolbar_busy(gobj_read_attr(priv.form, "$container"), priv.form_busy, busy);
4291
4281
  }
4292
4282
 
4293
4283
  /************************************************************
@@ -57,6 +57,11 @@ import {
57
57
  } from "@yuneta/gobj-js";
58
58
 
59
59
  import "./c_yui_treedb_topics.css";
60
+ import {
61
+ track_form_write,
62
+ settle_form_write,
63
+ abandon_form_writes,
64
+ } from "./form_writes_in_flight.js";
60
65
 
61
66
  import {yui_shell_show_error, yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
62
67
  import {
@@ -151,6 +156,11 @@ let __gclass__ = null;
151
156
  ***************************************************************/
152
157
  function mt_create(gobj)
153
158
  {
159
+ /* The writes of topic forms sent and not yet answered, by serial
160
+ * (form_writes_in_flight): answered refused when the transport
161
+ * closes under them. */
162
+ gobj.priv.form_writes = {};
163
+
154
164
  build_ui(gobj);
155
165
 
156
166
  /*
@@ -1655,6 +1665,7 @@ function treedb_update_node(gobj, treedb_name, topic_name, record, options, form
1655
1665
  log_error(ret);
1656
1666
  return -1;
1657
1667
  }
1668
+ track_form_write(gobj.priv.form_writes, form_write, topic_name);
1658
1669
  return 0;
1659
1670
  }
1660
1671
 
@@ -2065,6 +2076,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
2065
2076
  gobj_send_event(gobj, "EV_REFRESH_TOPIC",
2066
2077
  {topic_name: failed_topic}, gobj);
2067
2078
  }
2079
+ settle_form_write(gobj.priv.form_writes,
2080
+ kw_get_int(gobj, kw_command, "form_write", 0, 0));
2068
2081
  answer_form_write(
2069
2082
  gobj,
2070
2083
  get_gobj_formtable(gobj, failed_topic),
@@ -2163,6 +2176,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
2163
2176
  created: (command === "create-node"),
2164
2177
  command: command
2165
2178
  });
2179
+ settle_form_write(gobj.priv.form_writes,
2180
+ kw_get_int(gobj, kw_command, "form_write", 0, 0));
2166
2181
  answer_form_write(
2167
2182
  gobj,
2168
2183
  get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)),
@@ -2406,7 +2421,26 @@ function ac_back_to_topics(gobj, event, kw, src)
2406
2421
  ************************************************************/
2407
2422
  function ac_transport_state(gobj, event, kw, src)
2408
2423
  {
2409
- refresh_toolbar_buttons(gobj, !!(kw && kw.connected));
2424
+ let connected = !!(kw && kw.connected);
2425
+ refresh_toolbar_buttons(gobj, connected);
2426
+
2427
+ /*
2428
+ * A write in flight when the transport closes is never answered by
2429
+ * the backend: its form stayed open and busy for ever (N8 of the
2430
+ * 2026-09-22 review). Answered refused: the form stays open on what
2431
+ * was typed, and the person saves again when the session is back.
2432
+ */
2433
+ if(!connected) {
2434
+ abandon_form_writes(gobj.priv.form_writes).forEach((w) => {
2435
+ log_warning(`${gobj_short_name(gobj)}: transport closed, the write ${w.form_write} of '${w.topic_name}' is lost`);
2436
+ answer_form_write(
2437
+ gobj,
2438
+ get_gobj_formtable(gobj, w.topic_name),
2439
+ w.form_write,
2440
+ false
2441
+ );
2442
+ });
2443
+ }
2410
2444
  return 0;
2411
2445
  }
2412
2446
 
@@ -2522,6 +2556,10 @@ function ac_create_record(gobj, event, kw, src)
2522
2556
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2523
2557
  let topic_name = gobj_read_attr(src, "topic_name");
2524
2558
  let record = kw.record;
2559
+ if(refuse_without_session(gobj, topic_name)) {
2560
+ answer_form_write(gobj, src, form_write, false);
2561
+ return -1; /* Error already logged */
2562
+ }
2525
2563
 
2526
2564
  /* `create_only`: +New makes a NEW record. With `create` alone a taken
2527
2565
  * id was an update -- the record overwritten and, through autolink
@@ -2751,6 +2789,10 @@ function ac_update_record(gobj, event, kw, src)
2751
2789
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2752
2790
  let topic_name = gobj_read_attr(src, "topic_name");
2753
2791
  let record = kw.record;
2792
+ if(refuse_without_session(gobj, topic_name)) {
2793
+ answer_form_write(gobj, src, form_write, false);
2794
+ return -1; /* Error already logged */
2795
+ }
2754
2796
 
2755
2797
  let options = {
2756
2798
  list_dict: true,
@@ -2765,6 +2807,22 @@ function ac_update_record(gobj, event, kw, src)
2765
2807
  return 0;
2766
2808
  }
2767
2809
 
2810
+ /************************************************************
2811
+ * A write sent with no session never leaves: C_IEVENT_CLI logs
2812
+ * "Not in session" and answers null, as it does when the command
2813
+ * went -- so a form waiting for the answer waited for ever (N8 of
2814
+ * the 2026-09-22 review). Refused before it is sent, aloud.
2815
+ ************************************************************/
2816
+ function refuse_without_session(gobj, topic_name)
2817
+ {
2818
+ if(is_connected(gobj)) {
2819
+ return false;
2820
+ }
2821
+ log_error(`${gobj_short_name(gobj)}: no session, the write of '${topic_name}' was not sent`);
2822
+ yui_shell_show_error(yui_shell_of(gobj), "no session", {t: t});
2823
+ return true;
2824
+ }
2825
+
2768
2826
  /************************************************************
2769
2827
  * Tell a topic form how its write ended: EV_WRITE_DONE closes
2770
2828
  * it, EV_WRITE_REFUSED leaves it open on what was typed. Every
@@ -0,0 +1,54 @@
1
+ /***********************************************************************
2
+ * form_busy.js
3
+ *
4
+ * The toolbar of a topic form while a write is in flight: every
5
+ * button disabled, the save button spinning. Bulma's `is-loading`
6
+ * is the spinner; `disabled` is what stops the second click.
7
+ *
8
+ * A transition, not a setting: each button remembers whether it
9
+ * was disabled BEFORE the toolbar went busy, and busy(false) puts
10
+ * that back. So a second busy(true) while already busy must not
11
+ * run at all -- it read the buttons it had just disabled as
12
+ * "disabled on their own", and the busy(false) of a refused write
13
+ * left Save and Cancel dead (N9 of the 2026-09-22 review: a record
14
+ * with a picked file went busy for the read, then busy again for
15
+ * the write). The caller owns `state` ({busy}) for the form's life.
16
+ *
17
+ * Copyright (c) 2026, ArtGins.
18
+ * All Rights Reserved.
19
+ ***********************************************************************/
20
+
21
+ /************************************************************
22
+ * Move the toolbar to `busy`. Returns true when it moved, false when
23
+ * it was already there (nothing touched).
24
+ ************************************************************/
25
+ function set_toolbar_busy($container, state, busy)
26
+ {
27
+ busy = !!busy;
28
+ if(!$container || !state) {
29
+ return false;
30
+ }
31
+ if(!!state.busy === busy) {
32
+ return false;
33
+ }
34
+ state.busy = busy;
35
+
36
+ $container.querySelectorAll('.yui-toolbar-form button').forEach(($b) => {
37
+ if(busy) {
38
+ $b.dataset.was_disabled = $b.disabled? "1": "0";
39
+ $b.disabled = true;
40
+ } else if($b.dataset.was_disabled !== undefined) {
41
+ $b.disabled = ($b.dataset.was_disabled === "1");
42
+ delete $b.dataset.was_disabled;
43
+ }
44
+ });
45
+ const $save = $container.querySelector('.yui-toolbar-form .button-save');
46
+ if($save) {
47
+ $save.classList.toggle('is-loading', busy);
48
+ }
49
+ return true;
50
+ }
51
+
52
+ export {
53
+ set_toolbar_busy,
54
+ };