@yuneta/gobj-ui 7.25.4 → 7.25.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.25.4",
3
+ "version": "7.25.6",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -26,8 +26,9 @@
26
26
  * the owner's design of M36 (2026-09-21 review) every write here
27
27
  * raised both versions, so an edit half made was already the
28
28
  * schema of the next start. The topic list marks what this session
29
- * wrote and has not saved; the host's EV_REFRESH after a save is
30
- * what forgets it.
29
+ * wrote and has not saved (the host's EV_REFRESH after a save is
30
+ * what forgets it) and what the host says is not saved (EV_DRAFTS,
31
+ * replaced whole each time it is sent).
31
32
  *
32
33
  * WHAT ELSE IS HERE, and why each is here and not somewhere else:
33
34
  *
@@ -69,6 +70,12 @@
69
70
  * `update-node` / `delete-node` on the treedb service, and it may
70
71
  * be a routing adapter two hops away (the agent console's
71
72
  * C_AGENT_TREEDB_LINK) — this view cannot tell, and must not.
73
+ * A DROP of it (the host's EV_TRANSPORT_STATE, or a failure that
74
+ * arrives while the transport is out of session) ends the load or
75
+ * the write it cut, and the model is asked again when the session
76
+ * is back. Every request carries its round (`round` for a load,
77
+ * `write` for a write) in `__md_command__`, so an answer of what
78
+ * was cut is ignored instead of counted in the next one.
72
79
  *
73
80
  * Copyright (c) 2026, ArtGins.
74
81
  * All Rights Reserved.
@@ -107,13 +114,14 @@ import {
107
114
  clean_name,
108
115
  msg_iev_get_stack,
109
116
  kw_get_str,
117
+ kw_get_int,
110
118
  kw_get_dict,
111
119
  is_object,
112
120
  is_array,
113
121
  empty_string,
114
122
  } from "@yuneta/gobj-js";
115
123
  import {yui_tint} from "./bulma_tint.js";
116
- import {mark_host_drafts} from "./host_drafts.js";
124
+ import {host_draft_ids} from "./host_drafts.js";
117
125
 
118
126
  import "./c_yui_schema_editor.css";
119
127
 
@@ -197,6 +205,7 @@ let PRIVATE_DATA = {
197
205
  model: null, /* build_schema_model() of the above */
198
206
  pending: 0, /* `nodes` requests still in flight */
199
207
  load_error: "",
208
+ load_round: 0, /* which load an answer belongs to: echoed as `round` */
200
209
 
201
210
  /* Where the operator is. The url carries it; these mirror it. */
202
211
  treedb_id: "",
@@ -207,6 +216,8 @@ let PRIVATE_DATA = {
207
216
  * version still has to move. */
208
217
  baseline: null, /* {topic id: topic_version} at load time */
209
218
  written: null, /* {topic id: true} */
219
+ host_drafts: null, /* {treedb: [topic names]}, the host's last EV_DRAFTS */
220
+ host_ids: null, /* host_drafts resolved on the model, {topic id: true} */
210
221
  order_undo: null, /* {topic id: [{id, order}]} before the first drag */
211
222
 
212
223
  /* Children and overlays */
@@ -217,7 +228,9 @@ let PRIVATE_DATA = {
217
228
  save_queue: null,
218
229
  save_return: "",
219
230
  save_wrote: false,
231
+ write_tag: 0, /* which write an answer belongs to: echoed as `write` */
220
232
  connected: false,
233
+ reload_on_open: false, /* a drop cut a load or a write: ask the model again */
221
234
 
222
235
  /* A position that arrived while a write was in flight */
223
236
  pending_seg: null,
@@ -250,6 +263,7 @@ function mt_create(gobj)
250
263
  priv.baseline = {};
251
264
  priv.written = {};
252
265
  priv.host_drafts = {}; /* what the host says is a draft, {treedb: [topics]} (EV_DRAFTS) */
266
+ priv.host_ids = {}; /* the same, as {topic id: true} of the model */
253
267
  priv.save_queue = [];
254
268
 
255
269
  build_ui(gobj);
@@ -454,6 +468,7 @@ function request_model(gobj)
454
468
  priv.records = {treedbs: [], topics: [], cols: []};
455
469
  priv.load_error = "";
456
470
  priv.pending = 0;
471
+ priv.load_round++;
457
472
  gobj_change_state(gobj, "ST_LOADING");
458
473
  show_notice(gobj, "loading the schemas");
459
474
 
@@ -461,7 +476,7 @@ function request_model(gobj)
461
476
  if(remote_command(gobj, "nodes", {
462
477
  topic_name: topic_name,
463
478
  options: {list_dict: true}
464
- }) === 0) {
479
+ }, {round: priv.load_round}) === 0) {
465
480
  priv.pending++;
466
481
  }
467
482
  }
@@ -483,7 +498,7 @@ function request_model(gobj)
483
498
  * answer says which topic it is about, since the command is
484
499
  * `nodes` for all three.
485
500
  ***************************************************************/
486
- function remote_command(gobj, command, kw)
501
+ function remote_command(gobj, command, kw, tag)
487
502
  {
488
503
  let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
489
504
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
@@ -508,11 +523,11 @@ function remote_command(gobj, command, kw)
508
523
  * whichever one is under this view — and `record_id` is here and
509
524
  * NOT at the top level because a delete answers with nothing, and a
510
525
  * parameter the treedb does not know has no business travelling. */
511
- full_kw.__md_command__ = {
526
+ full_kw.__md_command__ = Object.assign({
512
527
  purpose: PURPOSE,
513
528
  topic_name: (kw && kw.topic_name) || "",
514
529
  record_id: (kw && kw.record_id) || ""
515
- };
530
+ }, tag || {});
516
531
  delete full_kw.record_id;
517
532
 
518
533
  let ret = gobj_command(remote, command, full_kw, gobj);
@@ -564,6 +579,7 @@ function start_measuring(gobj)
564
579
 
565
580
  priv.baseline = {};
566
581
  priv.written = {};
582
+ priv.host_ids = {};
567
583
 
568
584
  if(!priv.model) {
569
585
  return;
@@ -573,29 +589,39 @@ function start_measuring(gobj)
573
589
  priv.baseline[topic.id] = topic.topic_version;
574
590
  }
575
591
  }
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);
592
+ /* A draft is a draft after a reload too: what the host said last
593
+ * is resolved against the new model (host_drafts.js), apart from
594
+ * what this session writes, until the host says otherwise. */
595
+ priv.host_ids = host_draft_ids(priv.model, priv.host_drafts);
579
596
  }
580
597
 
581
598
  /***************************************************************
582
- * "This topic was written in this session and is not saved."
599
+ * "This topic holds an edit that is not saved."
583
600
  *
584
601
  * An edit is a DRAFT: it moves no version and reaches no treedb
585
602
  * until the host saves it (C_TREEDB's save-schema, which raises the
586
- * versions of what changed) -- and the save makes the host send
587
- * EV_REFRESH, whose reload is what forgets the drafts.
603
+ * versions of what changed). Two sources say a topic is one, and
604
+ * they are kept APART because they are forgotten differently:
605
+ * - `written`: what THIS session wrote since the model was loaded
606
+ * (a reload, the host's EV_REFRESH, forgets it -- the store says
607
+ * what is left);
608
+ * - `host_ids`: what the host said last (EV_DRAFTS), replaced WHOLE
609
+ * by the next EV_DRAFTS. Folded into `written`, a topic the host
610
+ * named once stayed a draft after the Save that published it.
588
611
  *
589
612
  * ONE predicate, because it is asked in three places -- the topic
590
613
  * list draws a chip with it, the column view a banner, the export
591
614
  * a warning -- and copies of a rule are how they drift.
592
615
  ***************************************************************/
593
- function topic_is_draft(written, topic)
616
+ function topic_is_draft(priv, topic)
594
617
  {
595
- if(!topic || !written) {
618
+ if(!topic || !priv) {
596
619
  return false;
597
620
  }
598
- return written[topic.id]? true: false;
621
+ if(priv.written && priv.written[topic.id]) {
622
+ return true;
623
+ }
624
+ return (priv.host_ids && priv.host_ids[topic.id])? true: false;
599
625
  }
600
626
 
601
627
  /***************************************************************
@@ -1050,7 +1076,7 @@ function render_topics(gobj, $body)
1050
1076
  let twice = repeated_names(treedb.topics);
1051
1077
  let $rows = [];
1052
1078
  for(let topic of treedb.topics) {
1053
- let pending = topic_is_draft(priv.written, topic);
1079
+ let pending = topic_is_draft(priv, topic);
1054
1080
  /* Two topics of one name: the id is what tells them apart, and
1055
1081
  * it is what the url has to carry — addressing by name would
1056
1082
  * open the first one whichever row was clicked. */
@@ -1256,7 +1282,7 @@ function draft_banner(gobj, topic)
1256
1282
  {
1257
1283
  let priv = gobj.priv;
1258
1284
 
1259
- if(!topic_is_draft(priv.written, topic)) {
1285
+ if(!topic_is_draft(priv, topic)) {
1260
1286
  return null;
1261
1287
  }
1262
1288
  if(is_readonly(gobj)) {
@@ -1997,7 +2023,7 @@ function open_report(gobj, findings, treedb_id)
1997
2023
  function open_export(gobj, treedb)
1998
2024
  {
1999
2025
  let priv = gobj.priv;
2000
- let drafts = treedb.topics.some((topic) => topic_is_draft(priv.written, topic));
2026
+ let drafts = treedb.topics.some((topic) => topic_is_draft(priv, topic));
2001
2027
  let c_text = schema_to_c(treedb);
2002
2028
  let json_text = JSON.stringify(schema_to_json(treedb), null, 4);
2003
2029
 
@@ -2347,17 +2373,80 @@ function run_next_write(gobj)
2347
2373
  let command = write_command(write.op);
2348
2374
  let options = write_options(write.op);
2349
2375
 
2376
+ priv.write_tag++;
2350
2377
  if(remote_command(gobj, command, {
2351
2378
  topic_name: write.topic_name,
2352
2379
  record: write.record,
2353
2380
  options: options,
2354
2381
  record_id: write.record.id || ""
2355
- }) < 0) {
2382
+ }, {write: priv.write_tag}) < 0) {
2356
2383
  return end_writes(gobj, t("cannot reach the treedb"));
2357
2384
  }
2358
2385
  return 0;
2359
2386
  }
2360
2387
 
2388
+ /***************************************************************
2389
+ * Does the transport say it is in session? The state the library
2390
+ * reads off every transport (C_IEVENT_CLI and the routing adapters
2391
+ * name it the same).
2392
+ ***************************************************************/
2393
+ function transport_in_session(gobj)
2394
+ {
2395
+ let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
2396
+
2397
+ return !!remote && gobj_current_state(remote) === "ST_SESSION";
2398
+ }
2399
+
2400
+ /***************************************************************
2401
+ * Is this answer of the load in flight, or of the write in
2402
+ * flight? Each request carries its round in `__md_command__`, and
2403
+ * one the drop abandoned (or a deadline settled) can still come
2404
+ * back: counted, it ended the next load with the old data or
2405
+ * moved the next write's queue.
2406
+ ***************************************************************/
2407
+ function is_current_load(gobj, md)
2408
+ {
2409
+ return gobj_current_state(gobj) === "ST_LOADING" &&
2410
+ kw_get_int(gobj, md, "round", 0, 0) === gobj.priv.load_round;
2411
+ }
2412
+
2413
+ function is_current_write(gobj, md)
2414
+ {
2415
+ return gobj_current_state(gobj) === "ST_SAVING" &&
2416
+ kw_get_int(gobj, md, "write", 0, 0) === gobj.priv.write_tag;
2417
+ }
2418
+
2419
+ /***************************************************************
2420
+ * The transport dropped under a load or a write: its answers
2421
+ * will never come. The load goes back to the screen it replaced
2422
+ * (or to "not connected" when there was none), the write ends
2423
+ * with the drop said -- whether it landed is unknown -- and both
2424
+ * mark the model to be asked again when the session is back.
2425
+ * Bumping the round makes an answer of what was cut stale.
2426
+ ***************************************************************/
2427
+ function transport_dropped(gobj)
2428
+ {
2429
+ let priv = gobj.priv;
2430
+ let state = gobj_current_state(gobj);
2431
+
2432
+ priv.reload_on_open = true;
2433
+ if(state === "ST_SAVING") {
2434
+ priv.write_tag++;
2435
+ return end_writes(gobj, "the connection dropped during the write");
2436
+ }
2437
+ if(state === "ST_LOADING") {
2438
+ priv.load_round++;
2439
+ priv.pending = 0;
2440
+ priv.load_error = "";
2441
+ if(priv.model) {
2442
+ return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2443
+ }
2444
+ gobj_change_state(gobj, "ST_IDLE");
2445
+ render(gobj);
2446
+ }
2447
+ return 0;
2448
+ }
2449
+
2361
2450
  /***************************************************************
2362
2451
  * The queue is done, or gave up. Either way the model is what
2363
2452
  * the answers made it, so the screen is redrawn from it and the
@@ -2612,8 +2701,28 @@ function ac_mt_command_answer(gobj, event, kw, src)
2612
2701
  return 0; /* another panel's answer on a shared transport */
2613
2702
  }
2614
2703
 
2704
+ /* A failure while the transport is not in session is the DROP, not a
2705
+ * refusal: a routing adapter settles what it had in flight when its
2706
+ * session closes, and that can arrive before the host's
2707
+ * EV_TRANSPORT_STATE. Read as a refusal, the write it ended looked
2708
+ * refused, and the reconnect did not reload a model it may have
2709
+ * changed. */
2710
+ if(result < 0 && !transport_in_session(gobj) &&
2711
+ (gobj_current_state(gobj) === "ST_LOADING" ||
2712
+ gobj_current_state(gobj) === "ST_SAVING")) {
2713
+ if((command === "nodes" && is_current_load(gobj, md)) ||
2714
+ (command !== "nodes" && is_current_write(gobj, md))) {
2715
+ return transport_dropped(gobj);
2716
+ }
2717
+ }
2718
+
2615
2719
  if(command === "nodes") {
2616
2720
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2721
+ if(!is_current_load(gobj, md)) {
2722
+ log_warning(`${gobj_short_name(gobj)}: 'nodes' of '${topic_name}' answered ` +
2723
+ `for a load that is over: ignored`);
2724
+ return 0;
2725
+ }
2617
2726
  priv.pending--;
2618
2727
  if(result < 0) {
2619
2728
  priv.load_error = comment || t("cannot load the schemas");
@@ -2647,6 +2756,11 @@ function ac_mt_command_answer(gobj, event, kw, src)
2647
2756
 
2648
2757
  if(command === "update-node" || command === "delete-node") {
2649
2758
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2759
+ if(!is_current_write(gobj, md)) {
2760
+ log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' answered ` +
2761
+ `for a write that is over (result ${result}): ignored`);
2762
+ return 0;
2763
+ }
2650
2764
  if(result < 0) {
2651
2765
  return end_writes(gobj, comment || t("the treedb refused the write"));
2652
2766
  }
@@ -2728,16 +2842,31 @@ function ac_hide(gobj, event, kw, src)
2728
2842
  }
2729
2843
 
2730
2844
  /***************************************************************
2731
- * The session came up or went down. The model stays either way
2732
- * — a schema does not change under you — but a first load may
2733
- * have had no transport to run on.
2845
+ * The session came up or went down. The model stays -- a schema
2846
+ * does not change under you -- unless the drop cut a load or a
2847
+ * write (transport_dropped()): then it is asked again on the way
2848
+ * back, as it is when a first load had no transport to run on.
2849
+ *
2850
+ * Nothing else ends a load or a write the drop cut: their answers
2851
+ * will never come, and waiting in ST_LOADING skipped the reload
2852
+ * itself (M-1 of the independent review of 7.25.4).
2734
2853
  ***************************************************************/
2735
2854
  function ac_transport_state(gobj, event, kw, src)
2736
2855
  {
2737
2856
  let priv = gobj.priv;
2857
+ let state = gobj_current_state(gobj);
2738
2858
 
2739
2859
  priv.connected = !!(kw && kw.connected);
2740
- if(priv.connected && !priv.model && gobj_current_state(gobj) !== "ST_LOADING") {
2860
+ if(!priv.connected) {
2861
+ if(state === "ST_LOADING" || state === "ST_SAVING") {
2862
+ return transport_dropped(gobj);
2863
+ }
2864
+ render_toolbar(gobj);
2865
+ return 0;
2866
+ }
2867
+ if((!priv.model || priv.reload_on_open) &&
2868
+ state !== "ST_LOADING" && state !== "ST_SAVING") {
2869
+ priv.reload_on_open = false;
2741
2870
  return request_model(gobj);
2742
2871
  }
2743
2872
  render_toolbar(gobj);
@@ -2762,20 +2891,24 @@ function ac_refresh(gobj, event, kw, src)
2762
2891
 
2763
2892
  /***************************************************************
2764
2893
  * {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.
2894
+ * topics hold a draft in __system__ that is not saved (C_TREEDB's
2895
+ * saved-schema, `draft_changed`). The mark of a write lived in this
2896
+ * session's memory only, so a reload of the page, a reconnect or a
2897
+ * refresh showed no draft while __system__ still differed (N13 of
2898
+ * the 2026-09-22 review).
2899
+ *
2900
+ * REPLACES what the host said before -- the host is the truth after
2901
+ * a reload, and a Save is exactly the host saying "none any more".
2902
+ * It only ever added, and a topic stayed a draft after the Save that
2903
+ * published it (M1 of the 2026-09-23 review). What this session wrote
2904
+ * stays marked: it is kept apart (see topic_is_draft()).
2772
2905
  ***************************************************************/
2773
2906
  function ac_drafts(gobj, event, kw, src)
2774
2907
  {
2775
2908
  let priv = gobj.priv;
2776
2909
 
2777
2910
  priv.host_drafts = (kw && kw.drafts && typeof kw.drafts === "object")? kw.drafts: {};
2778
- mark_host_drafts(priv.written, priv.model, priv.host_drafts);
2911
+ priv.host_ids = host_draft_ids(priv.model, priv.host_drafts);
2779
2912
  if(priv.model) {
2780
2913
  render(gobj);
2781
2914
  }
@@ -43,6 +43,8 @@ import {
43
43
  createElement2, empty_string, is_object, is_array, is_string,
44
44
  refresh_language,
45
45
  is_gobj,
46
+ gobj_has_event,
47
+ gobj_is_destroying,
46
48
  } from "@yuneta/gobj-js";
47
49
 
48
50
  import {
@@ -174,6 +176,10 @@ let PRIVATE_DATA = {
174
176
  * backend-connected state. State is host/event-driven (unlike
175
177
  * the avatar provider it is a setter, not a pull callback). */
176
178
  conn_nodes: [],
179
+ /* The last state yui_shell_set_connection_state() was told, null
180
+ * while nobody told it anything: EV_CONNECTION_STATE goes out on an
181
+ * EDGE, not on every call. */
182
+ connected: null,
177
183
  /* Currently open toolbar dropdown panel, if any. Tracked here
178
184
  * so a second click on any trigger (or programmatic close) can
179
185
  * tear down the previous one through the same code path. */
@@ -3033,11 +3039,34 @@ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
3033
3039
  /*---------------------------------------------*
3034
3040
  * Global methods table
3035
3041
  *---------------------------------------------*/
3042
+ /***************************************************************
3043
+ * EV_CONNECTION_STATE is an OPTIONAL fact: a view that waits for a
3044
+ * backend answer subscribes to it by name. An app that subscribed
3045
+ * to EVERY event of its shell with one attr (`subscriber`) did not
3046
+ * ask for it and does not declare it -- published to it anyway, the
3047
+ * event would be an FSM error in the app on every connection edge,
3048
+ * and adding it to the shell a breaking change for every such app.
3049
+ * So it reaches only the subscribers that declare it. Every other
3050
+ * event of the shell goes out as before.
3051
+ ***************************************************************/
3052
+ function mt_publication_pre_filter(gobj, subs, event, kw)
3053
+ {
3054
+ if(event !== "EV_CONNECTION_STATE") {
3055
+ return 1;
3056
+ }
3057
+ let subscriber = subs && subs.subscriber;
3058
+ if(!subscriber || !gobj_has_event(subscriber, event, 0)) {
3059
+ return 0;
3060
+ }
3061
+ return 1;
3062
+ }
3063
+
3036
3064
  const gmt = {
3037
- mt_create: mt_create,
3038
- mt_start: mt_start,
3039
- mt_stop: mt_stop,
3040
- mt_destroy: mt_destroy
3065
+ mt_create: mt_create,
3066
+ mt_start: mt_start,
3067
+ mt_stop: mt_stop,
3068
+ mt_destroy: mt_destroy,
3069
+ mt_publication_pre_filter: mt_publication_pre_filter
3041
3070
  };
3042
3071
 
3043
3072
  function create_gclass(gclass_name)
@@ -3088,6 +3117,13 @@ function create_gclass(gclass_name)
3088
3117
  * re-renders its own translated parts. */
3089
3118
  ["EV_LANGUAGE_CHANGED", event_flag_t.EVF_OUTPUT_EVENT
3090
3119
  |event_flag_t.EVF_PUBLIC_EVENT
3120
+ |event_flag_t.EVF_NO_WARN_SUBS],
3121
+
3122
+ /* The app's backend connection went up or down
3123
+ * (yui_shell_set_connection_state), {connected}. Delivered only
3124
+ * to the subscribers that declare it: see
3125
+ * mt_publication_pre_filter(). */
3126
+ ["EV_CONNECTION_STATE", event_flag_t.EVF_OUTPUT_EVENT
3091
3127
  |event_flag_t.EVF_NO_WARN_SUBS]
3092
3128
  ];
3093
3129
 
@@ -3552,14 +3588,31 @@ function yui_shell_language_changed(shell_gobj)
3552
3588
  ************************************************************/
3553
3589
  function yui_shell_set_connection_state(shell_gobj, connected)
3554
3590
  {
3591
+ if(!shell_gobj || !is_gobj(shell_gobj) || gobj_is_destroying(shell_gobj)) {
3592
+ return;
3593
+ }
3555
3594
  let priv = shell_gobj.priv;
3556
- if(!priv || !is_array(priv.conn_nodes)) {
3595
+ if(!priv) {
3557
3596
  return;
3558
3597
  }
3559
3598
  let on = !!connected;
3560
- for(let $n of priv.conn_nodes) {
3561
- $n.classList.toggle("is-connected", on);
3562
- $n.classList.toggle("is-disconnected", !on);
3599
+ let changed = (priv.connected !== on);
3600
+ priv.connected = on;
3601
+ if(is_array(priv.conn_nodes)) {
3602
+ for(let $n of priv.conn_nodes) {
3603
+ $n.classList.toggle("is-connected", on);
3604
+ $n.classList.toggle("is-disconnected", !on);
3605
+ }
3606
+ }
3607
+ /* The fact goes out as well, for the views: a write a view sent
3608
+ * before the backend dropped is never answered, and the view that
3609
+ * waits for it (a topic form's Save) learns the drop from HERE --
3610
+ * it holds no subscription of its own to the transport, because
3611
+ * subscribing to a C_IEVENT_CLI forwards the subscription to the
3612
+ * backend. Only an EDGE is published: the app calls this on every
3613
+ * reconnect attempt too. */
3614
+ if(changed) {
3615
+ gobj_publish_event(shell_gobj, "EV_CONNECTION_STATE", {connected: on});
3563
3616
  }
3564
3617
  }
3565
3618
 
@@ -99,7 +99,8 @@ import {t} from "i18next";
99
99
  import {yui_toolbar_icon} from "./yui_toolbar.js";
100
100
 
101
101
  import {
102
- plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb
102
+ plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb,
103
+ instance_keys_of
103
104
  } from "./treedb_write_plan.js";
104
105
  import {
105
106
  yui_file_read, yui_files_manifest, yui_file_id_label, yui_file_size_label
@@ -2395,6 +2396,10 @@ function open_form_dialog(gobj, mode, record)
2395
2396
  style: 'display:flex; flex-direction:column; height:100%;'}, []]
2396
2397
  );
2397
2398
 
2399
+ /* The instance this form writes to, kept as the record HAD it: the
2400
+ * widget is not trusted with an address (instance_keys_of()). */
2401
+ priv.form_instance = (form_mode === "update")? instance_keys_of(record, desc) : null;
2402
+
2398
2403
  let form_plan = write_plan(gobj);
2399
2404
  let form = gobj_create_pure_child(
2400
2405
  "form_" + clean_name(gobj_name(gobj)),
@@ -2508,6 +2513,7 @@ function teardown_form_child(gobj)
2508
2513
  priv.reading_files = 0; // a read in flight lands on nobody
2509
2514
  priv.awaiting_write = 0; // and so does the answer of a write
2510
2515
  priv.form_busy = {busy: false}; // its toolbar goes with it
2516
+ priv.form_instance = null;
2511
2517
  if(priv.form) {
2512
2518
  if(gobj_is_running(priv.form)) {
2513
2519
  gobj_stop(priv.form);
@@ -4313,6 +4319,11 @@ function publish_treedb_write(gobj, mode, record, jn_files, form_write)
4313
4319
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
4314
4320
  record: strip_read_only_cols(gobj, record)
4315
4321
  };
4322
+ if(mode !== "create" && gobj.priv.form_instance) {
4323
+ /* The pkey2 names the instance: the value the record had, not
4324
+ * the one that came back through a datetime-local. */
4325
+ Object.assign(kw_write.record, gobj.priv.form_instance);
4326
+ }
4316
4327
  if(form_write) {
4317
4328
  /* The serial the host echoes back with the answer */
4318
4329
  kw_write.form_write = form_write;
@@ -156,10 +156,11 @@ let __gclass__ = null;
156
156
  ***************************************************************/
157
157
  function mt_create(gobj)
158
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. */
159
+ /* The writes of topic forms sent and not yet answered, by topic
160
+ * and serial (form_writes_in_flight): answered refused when the
161
+ * transport closes under them. */
162
162
  gobj.priv.form_writes = {};
163
+ gobj.priv.conn_shell = null; /* the shell whose EV_CONNECTION_STATE we hear */
163
164
 
164
165
  build_ui(gobj);
165
166
 
@@ -178,6 +179,17 @@ function mt_create(gobj)
178
179
  ***************************************************************/
179
180
  function mt_start(gobj)
180
181
  {
182
+ /* The app's backend connection, as the app tells its shell
183
+ * (yui_shell_set_connection_state). A write in flight when it drops
184
+ * is never answered, and its form waited for ever in every host
185
+ * that did not forward EV_TRANSPORT_STATE -- wattyzer and yunovatios
186
+ * among them (M8 of the 2026-09-23 review). The transport itself is
187
+ * not subscribed to: see ac_transport_state(). */
188
+ let shell = yui_shell_of(gobj);
189
+ if(shell) {
190
+ gobj_subscribe_event(shell, "EV_CONNECTION_STATE", {}, gobj);
191
+ gobj.priv.conn_shell = shell;
192
+ }
181
193
  request_treedb_descs(gobj);
182
194
  }
183
195
 
@@ -186,6 +198,14 @@ function mt_start(gobj)
186
198
  ***************************************************************/
187
199
  function mt_stop(gobj)
188
200
  {
201
+ let priv = gobj.priv;
202
+ if(priv.conn_shell) {
203
+ if(!gobj_is_destroying(priv.conn_shell)) {
204
+ gobj_unsubscribe_event(priv.conn_shell, "EV_CONNECTION_STATE", {}, gobj);
205
+ }
206
+ priv.conn_shell = null;
207
+ }
208
+
189
209
  /* Retire our site-map sub-routes so a torn-down view leaves no
190
210
  * stale children in the map (ROUTING.md contributor). */
191
211
  let shell = yui_shell_of(gobj);
@@ -2077,7 +2097,7 @@ function ac_mt_command_answer(gobj, event, kw, src)
2077
2097
  {topic_name: failed_topic}, gobj);
2078
2098
  }
2079
2099
  settle_form_write(gobj.priv.form_writes,
2080
- kw_get_int(gobj, kw_command, "form_write", 0, 0));
2100
+ kw_get_int(gobj, kw_command, "form_write", 0, 0), failed_topic);
2081
2101
  answer_form_write(
2082
2102
  gobj,
2083
2103
  get_gobj_formtable(gobj, failed_topic),
@@ -2177,7 +2197,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
2177
2197
  command: command
2178
2198
  });
2179
2199
  settle_form_write(gobj.priv.form_writes,
2180
- kw_get_int(gobj, kw_command, "form_write", 0, 0));
2200
+ kw_get_int(gobj, kw_command, "form_write", 0, 0),
2201
+ kw_get_str(gobj, kw_command, "topic_name", "", 0));
2181
2202
  answer_form_write(
2182
2203
  gobj,
2183
2204
  get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)),
@@ -2413,11 +2434,19 @@ function ac_back_to_topics(gobj, event, kw, src)
2413
2434
  }
2414
2435
 
2415
2436
  /************************************************************
2416
- * The host (C_TREEDB_VIEW) forwards the backend transport edges here so the
2417
- * toolbar can disable the JSON viewers the moment the session drops (and
2418
- * re-enable them on reconnect) — the library view must not subscribe to the
2419
- * C_IEVENT_CLI itself (that forwards the subscription upstream and breaks
2420
- * the session).
2437
+ * A backend transport edge, from either of two places:
2438
+ * - EV_TRANSPORT_STATE, forwarded by a host that keeps one
2439
+ * transport per view (gui_treedb's C_TREEDB_VIEW, gui_agent's
2440
+ * C_AGENT_TREEDB_VIEW);
2441
+ * - EV_CONNECTION_STATE, published by the SHELL when the app tells
2442
+ * it its backend went up or down (yui_shell_set_connection_state),
2443
+ * which is what every single-backend app already does for the
2444
+ * toolbar dot. No host has to remember anything for this one.
2445
+ * Both can arrive for one edge; what is done here is idempotent.
2446
+ * The toolbar disables the JSON viewers the moment the session drops
2447
+ * (and re-enables them on reconnect). The library view must not
2448
+ * subscribe to the C_IEVENT_CLI itself: that forwards the subscription
2449
+ * upstream and breaks the session.
2421
2450
  ************************************************************/
2422
2451
  function ac_transport_state(gobj, event, kw, src)
2423
2452
  {
@@ -3042,6 +3071,7 @@ function create_gclass(gclass_name)
3042
3071
  ["EV_HIDE", ac_hide, null],
3043
3072
  ["EV_BACK_TO_TOPICS", ac_back_to_topics, null],
3044
3073
  ["EV_TRANSPORT_STATE", ac_transport_state, null],
3074
+ ["EV_CONNECTION_STATE", ac_transport_state, null],
3045
3075
  ]]
3046
3076
  ];
3047
3077
 
@@ -3077,6 +3107,7 @@ function create_gclass(gclass_name)
3077
3107
  ["EV_HIDE", 0],
3078
3108
  ["EV_BACK_TO_TOPICS", 0],
3079
3109
  ["EV_TRANSPORT_STATE", 0],
3110
+ ["EV_CONNECTION_STATE", 0],
3080
3111
  ["EV_TOPIC_SELECTED",
3081
3112
  event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS]
3082
3113
  ];