@yuneta/gobj-ui 7.25.6 → 7.25.7

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/README.md CHANGED
@@ -2161,7 +2161,28 @@ else.
2161
2161
  `the connection dropped during the write` (an i18n key the consumer's
2162
2162
  locales carry). Each request carries its round in `__md_command__`
2163
2163
  (`round` / `write`), so an answer of what the drop cut is ignored with a
2164
- warning.
2164
+ warning. The drop itself is logged ONCE; the failures a routing adapter
2165
+ settles for the same drop are that one event and are not logged again.
2166
+ - **Nothing moves the editor out of `ST_LOADING` or `ST_SAVING` but the end of
2167
+ what is in flight** (7.25.7). An `EV_SHOW` that arrives meanwhile waits, the
2168
+ last one wins, and it is applied when the load lands, fails or is cut, or
2169
+ when the write ends. 7.25.6 let it move a RELOAD out of `ST_LOADING`: every
2170
+ answer of that load was then "for a load that is over", and the next write
2171
+ patched the records the reload had emptied into a model with no treedb.
2172
+ With no model and no load, `EV_SHOW` keeps the position and stays in
2173
+ `ST_IDLE`; the next load goes there.
2174
+
2175
+ ```js
2176
+ gobj_send_event(editor, "EV_REFRESH", {}, host); // ST_LOADING
2177
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host); // waits
2178
+ // ... the three `nodes` answers land -> ST_TOPICS of `db`
2179
+ ```
2180
+ - A write this view gave up on (a drop, a deadline) whose answer still says it
2181
+ was DONE makes the editor read the model again (at once, after the write in
2182
+ flight, or on the reconnect). Through a routing adapter that settled the
2183
+ write on its OWN deadline the late answer does not reach the view -- the
2184
+ adapter echoes a node event instead, which this view does not hear -- and
2185
+ Refresh is what reads the store.
2165
2186
 
2166
2187
  ```js
2167
2188
  // the host forwards its session edges; nothing else is needed
@@ -3025,6 +3025,14 @@ function maybe_apply_translator($node, opts) {
3025
3025
  * Non-blocking, auto-dismiss after `opts.timeout` ms (default
3026
3026
  * 5000). `opts.timeout = 0` disables auto-dismiss.
3027
3027
  * Returns `{ close() }` so callers can dismiss programmatically.
3028
+ *
3029
+ * ONE toast per message on screen: a string message of the same
3030
+ * kind as a toast still showing is not stacked under it -- that
3031
+ * toast is kept, its time starts again, and its handle is
3032
+ * returned. One close of a transport settles every request it
3033
+ * cut, each view shows its failure, and the operator got a
3034
+ * column of identical "the connection dropped". A message built
3035
+ * of nodes is never merged: its text is not a key.
3028
3036
  ***************************************************************/
3029
3037
  function show_notification(shell, kind, message, opts) {
3030
3038
  let $layer = notification_layer(shell);
@@ -3032,6 +3040,13 @@ function show_notification(shell, kind, message, opts) {
3032
3040
  (0, _yuneta_gobj_js.log_warning)("yui_shell_show_*: shell has no notification layer");
3033
3041
  return { close: () => {} };
3034
3042
  }
3043
+ let key = typeof message === "string" ? `${kind}\u0000${message}` : null;
3044
+ if (key !== null) {
3045
+ for (let $shown of Array.from($layer.children)) if ($shown.__toast_key__ === key && $shown.__toast__) {
3046
+ $shown.__toast__.restart();
3047
+ return $shown.__toast__.handle;
3048
+ }
3049
+ }
3035
3050
  let p_attrs = typeof message === "string" ? { i18n: message } : {};
3036
3051
  let $note = (0, _yuneta_gobj_js.createElement2)([
3037
3052
  "div",
@@ -3068,8 +3083,21 @@ function show_notification(shell, kind, message, opts) {
3068
3083
  let $del = $note.querySelector(".delete");
3069
3084
  if ($del) $del.addEventListener("click", close);
3070
3085
  let timeout = opts && opts.timeout != null ? opts.timeout : 5e3;
3071
- if (timeout > 0) timeout_id = setTimeout(close, timeout);
3072
- return { close };
3086
+ let restart = function() {
3087
+ if (timeout_id) {
3088
+ clearTimeout(timeout_id);
3089
+ timeout_id = null;
3090
+ }
3091
+ if (timeout > 0) timeout_id = setTimeout(close, timeout);
3092
+ };
3093
+ restart();
3094
+ let handle = { close };
3095
+ $note.__toast_key__ = key;
3096
+ $note.__toast__ = {
3097
+ handle,
3098
+ restart
3099
+ };
3100
+ return handle;
3073
3101
  }
3074
3102
  function yui_shell_show_info(shell, message, opts) {
3075
3103
  return show_notification(shell, "info", message, opts);
@@ -72156,6 +72184,9 @@ var PRIVATE_DATA$1 = {
72156
72184
  write_tag: 0,
72157
72185
  connected: false,
72158
72186
  reload_on_open: false,
72187
+ dropped_round: 0,
72188
+ dropped_write: 0,
72189
+ reload_after_write: false,
72159
72190
  pending_seg: null,
72160
72191
  import_plan: null,
72161
72192
  import_pane: null,
@@ -74624,6 +74655,15 @@ function transport_in_session(gobj) {
74624
74655
  * one the drop abandoned (or a deadline settled) can still come
74625
74656
  * back: counted, it ended the next load with the old data or
74626
74657
  * moved the next write's queue.
74658
+ *
74659
+ * The state is part of the question, and that is only true
74660
+ * because NOTHING moves the editor out of ST_LOADING or ST_SAVING
74661
+ * but the end of what is in flight: a position the host sends
74662
+ * meanwhile waits in `pending_seg` (ac_show()). 7.25.6 let EV_SHOW
74663
+ * move a RELOAD out of ST_LOADING, and every answer of it was then
74664
+ * "for a load that is over": the records it had emptied stayed
74665
+ * empty, and the next write patched them into a model with no
74666
+ * treedb at all.
74627
74667
  ***************************************************************/
74628
74668
  function is_current_load(gobj, md) {
74629
74669
  return (0, _yuneta_gobj_js.gobj_current_state)(gobj) === "ST_LOADING" && (0, _yuneta_gobj_js.kw_get_int)(gobj, md, "round", 0, 0) === gobj.priv.load_round;
@@ -74644,17 +74684,39 @@ function transport_dropped(gobj) {
74644
74684
  let state = (0, _yuneta_gobj_js.gobj_current_state)(gobj);
74645
74685
  priv.reload_on_open = true;
74646
74686
  if (state === "ST_SAVING") {
74687
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: the transport dropped during a write: the model is read again on the reconnect`);
74688
+ priv.dropped_write = priv.write_tag;
74647
74689
  priv.write_tag++;
74648
74690
  return end_writes(gobj, "the connection dropped during the write");
74649
74691
  }
74650
74692
  if (state === "ST_LOADING") {
74693
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: the transport dropped during the load: the model is read again on the reconnect`);
74694
+ priv.dropped_round = priv.load_round;
74651
74695
  priv.load_round++;
74652
74696
  priv.pending = 0;
74653
74697
  priv.load_error = "";
74654
- if (priv.model) return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74655
- (0, _yuneta_gobj_js.gobj_change_state)(gobj, "ST_IDLE");
74656
- render(gobj);
74698
+ return end_load(gobj);
74699
+ }
74700
+ return 0;
74701
+ }
74702
+ /***************************************************************
74703
+ * A load is over -- landed, failed or cut -- and the editor goes
74704
+ * where the operator is: the position the host sent while it was
74705
+ * in flight (ac_show() kept it), or the one it had. Without a
74706
+ * model there is no screen to go to: the position is kept for the
74707
+ * next load, and the editor says why it is empty.
74708
+ ***************************************************************/
74709
+ function end_load(gobj) {
74710
+ let priv = gobj.priv;
74711
+ let seg = priv.pending_seg;
74712
+ priv.pending_seg = null;
74713
+ if (priv.model) {
74714
+ if (seg !== null) return apply_seg(gobj, seg);
74715
+ return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74657
74716
  }
74717
+ if (seg !== null) adopt_seg(gobj, seg);
74718
+ (0, _yuneta_gobj_js.gobj_change_state)(gobj, "ST_IDLE");
74719
+ render(gobj);
74658
74720
  return 0;
74659
74721
  }
74660
74722
  /***************************************************************
@@ -74677,7 +74739,7 @@ function end_writes(gobj, error) {
74677
74739
  apply_seg(gobj, seg);
74678
74740
  if (priv.save_wrote) (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_RECORD_WRITTEN", { treedb_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name") });
74679
74741
  priv.save_wrote = false;
74680
- return 0;
74742
+ return reload_if_owed(gobj);
74681
74743
  }
74682
74744
  }
74683
74745
  if (error) yui_shell_show_error(yui_shell_of(gobj), error, { t: i18next.t });
@@ -74685,9 +74747,25 @@ function end_writes(gobj, error) {
74685
74747
  priv.save_wrote = false;
74686
74748
  render(gobj);
74687
74749
  publish_check(gobj);
74750
+ reload_if_owed(gobj);
74688
74751
  return error ? -1 : 0;
74689
74752
  }
74690
74753
  /***************************************************************
74754
+ * A write given up earlier turned out DONE while another one was
74755
+ * in flight (see ac_mt_command_answer()): the model is read again
74756
+ * now that nothing is.
74757
+ ***************************************************************/
74758
+ function reload_if_owed(gobj) {
74759
+ let priv = gobj.priv;
74760
+ if (!priv.reload_after_write) return 0;
74761
+ priv.reload_after_write = false;
74762
+ if (!transport_in_session(gobj)) {
74763
+ priv.reload_on_open = true;
74764
+ return 0;
74765
+ }
74766
+ return request_model(gobj);
74767
+ }
74768
+ /***************************************************************
74691
74769
  * The record a column form produces.
74692
74770
  *
74693
74771
  * Everything the store needs and the operator did not type is
@@ -74755,7 +74833,7 @@ function go(gobj, treedb_id, topic_name, diagram, mirror) {
74755
74833
  priv.topic_name = topic_name || "";
74756
74834
  priv.diagram = !!diagram;
74757
74835
  let state = "ST_TREEDBS";
74758
- if (!priv.model) state = "ST_LOADING";
74836
+ if (!priv.model) state = "ST_IDLE";
74759
74837
  else if (priv.model.treedbs.length === 0) state = "ST_EMPTY";
74760
74838
  else if (priv.treedb_id && priv.diagram) state = "ST_DIAGRAM";
74761
74839
  else if (priv.treedb_id && priv.topic_name) state = "ST_COLUMNS";
@@ -74782,12 +74860,39 @@ function position_seg(gobj) {
74782
74860
  * empty screen: a shared link outlives the schema it points at.
74783
74861
  ***************************************************************/
74784
74862
  function apply_seg(gobj, seg) {
74863
+ let pos = seg_position(seg);
74864
+ return go(gobj, pos.treedb_id, pos.topic_name, pos.diagram, false);
74865
+ }
74866
+ function seg_position(seg) {
74785
74867
  let parts = String(seg || "").split("/").filter((x) => x.length > 0);
74786
74868
  let treedb_id = parts[0] || "";
74787
74869
  let tail = parts[1] || "";
74788
- if (!treedb_id) return go(gobj, "", "", false, false);
74789
- if (tail === DIAGRAM_SEG) return go(gobj, treedb_id, "", true, false);
74790
- return go(gobj, treedb_id, tail, false, false);
74870
+ if (!treedb_id) return {
74871
+ treedb_id: "",
74872
+ topic_name: "",
74873
+ diagram: false
74874
+ };
74875
+ if (tail === DIAGRAM_SEG) return {
74876
+ treedb_id,
74877
+ topic_name: "",
74878
+ diagram: true
74879
+ };
74880
+ return {
74881
+ treedb_id,
74882
+ topic_name: tail,
74883
+ diagram: false
74884
+ };
74885
+ }
74886
+ /***************************************************************
74887
+ * Where the operator is, taken WITHOUT a screen: there is no model
74888
+ * to draw it on yet, and the load that brings one goes there.
74889
+ ***************************************************************/
74890
+ function adopt_seg(gobj, seg) {
74891
+ let priv = gobj.priv;
74892
+ let pos = seg_position(seg);
74893
+ priv.treedb_id = pos.treedb_id;
74894
+ priv.topic_name = pos.topic_name;
74895
+ priv.diagram = pos.diagram;
74791
74896
  }
74792
74897
  /***************************
74793
74898
  * Actions
@@ -74815,6 +74920,7 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74815
74920
  if (command === "nodes") {
74816
74921
  let topic_name = (0, _yuneta_gobj_js.kw_get_str)(gobj, md, "topic_name", "", 0);
74817
74922
  if (!is_current_load(gobj, md)) {
74923
+ if (result < 0 && (0, _yuneta_gobj_js.kw_get_int)(gobj, md, "round", 0, 0) === priv.dropped_round) return 0;
74818
74924
  (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: 'nodes' of '${topic_name}' answered for a load that is over: ignored`);
74819
74925
  return 0;
74820
74926
  }
@@ -74824,20 +74930,22 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74824
74930
  if (priv.pending > 0) return 0;
74825
74931
  if (priv.load_error) {
74826
74932
  priv.model = null;
74827
- (0, _yuneta_gobj_js.gobj_change_state)(gobj, "ST_IDLE");
74828
- render(gobj);
74829
- return 0;
74933
+ return end_load(gobj);
74830
74934
  }
74831
74935
  priv.order_undo = {};
74832
74936
  build_model(gobj);
74833
74937
  start_measuring(gobj);
74834
- return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74938
+ return end_load(gobj);
74835
74939
  }
74836
74940
  if (command === "update-node" || command === "delete-node") {
74837
74941
  let topic_name = (0, _yuneta_gobj_js.kw_get_str)(gobj, md, "topic_name", "", 0);
74838
74942
  if (!is_current_write(gobj, md)) {
74839
- (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: '${command}' of '${topic_name}' answered for a write that is over (result ${result}): ignored`);
74840
- return 0;
74943
+ if (result < 0 && (0, _yuneta_gobj_js.kw_get_int)(gobj, md, "write", 0, 0) === priv.dropped_write) return 0;
74944
+ if (result < 0) {
74945
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: '${command}' of '${topic_name}' answered for a write that is over (result ${result}): ignored`);
74946
+ return 0;
74947
+ }
74948
+ return late_write_done(gobj, command, topic_name);
74841
74949
  }
74842
74950
  if (result < 0) return end_writes(gobj, comment || (0, i18next.t)("the treedb refused the write"));
74843
74951
  priv.save_wrote = true;
@@ -74863,6 +74971,35 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74863
74971
  return 0;
74864
74972
  }
74865
74973
  /***************************************************************
74974
+ * A write this view gave up on (a drop, a deadline) answers that
74975
+ * it was DONE: the store holds a change the model does not show.
74976
+ * The model is read again -- now, or when what is in flight ends,
74977
+ * or on the reconnect. A load in flight needs nothing: it was
74978
+ * asked after the write, on the same transport, and reads it.
74979
+ *
74980
+ * Only when the answer reaches this view. A routing adapter that
74981
+ * settled the write on its own deadline (gui_agent's
74982
+ * C_AGENT_TREEDB_LINK) does not deliver its late answer again --
74983
+ * the view was answered once, as failed -- and echoes the node
74984
+ * event instead, which this view does not hear: there, Refresh is
74985
+ * what reads the store.
74986
+ ***************************************************************/
74987
+ function late_write_done(gobj, command, topic_name) {
74988
+ let priv = gobj.priv;
74989
+ let state = (0, _yuneta_gobj_js.gobj_current_state)(gobj);
74990
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: '${command}' of '${topic_name}' was given up, and it was DONE: the model is read again`);
74991
+ if (state === "ST_LOADING") return 0;
74992
+ if (state === "ST_SAVING") {
74993
+ priv.reload_after_write = true;
74994
+ return 0;
74995
+ }
74996
+ if (!transport_in_session(gobj)) {
74997
+ priv.reload_on_open = true;
74998
+ return 0;
74999
+ }
75000
+ return request_model(gobj);
75001
+ }
75002
+ /***************************************************************
74866
75003
  * Remember which topic this write belongs to, which is what
74867
75004
  * "its version still has to move" is asked about.
74868
75005
  ***************************************************************/
@@ -74882,7 +75019,8 @@ function mark_written(gobj, topic_name, record) {
74882
75019
  function ac_show(gobj, event, kw, src) {
74883
75020
  let priv = gobj.priv;
74884
75021
  let seg = kw && kw.subpath || "";
74885
- if ((0, _yuneta_gobj_js.gobj_current_state)(gobj) === "ST_SAVING") {
75022
+ let state = (0, _yuneta_gobj_js.gobj_current_state)(gobj);
75023
+ if (state === "ST_SAVING" || state === "ST_LOADING") {
74886
75024
  priv.pending_seg = seg;
74887
75025
  return 0;
74888
75026
  }
@@ -3020,6 +3020,14 @@ function maybe_apply_translator($node, opts) {
3020
3020
  * Non-blocking, auto-dismiss after `opts.timeout` ms (default
3021
3021
  * 5000). `opts.timeout = 0` disables auto-dismiss.
3022
3022
  * Returns `{ close() }` so callers can dismiss programmatically.
3023
+ *
3024
+ * ONE toast per message on screen: a string message of the same
3025
+ * kind as a toast still showing is not stacked under it -- that
3026
+ * toast is kept, its time starts again, and its handle is
3027
+ * returned. One close of a transport settles every request it
3028
+ * cut, each view shows its failure, and the operator got a
3029
+ * column of identical "the connection dropped". A message built
3030
+ * of nodes is never merged: its text is not a key.
3023
3031
  ***************************************************************/
3024
3032
  function show_notification(shell, kind, message, opts) {
3025
3033
  let $layer = notification_layer(shell);
@@ -3027,6 +3035,13 @@ function show_notification(shell, kind, message, opts) {
3027
3035
  log_warning("yui_shell_show_*: shell has no notification layer");
3028
3036
  return { close: () => {} };
3029
3037
  }
3038
+ let key = typeof message === "string" ? `${kind}\u0000${message}` : null;
3039
+ if (key !== null) {
3040
+ for (let $shown of Array.from($layer.children)) if ($shown.__toast_key__ === key && $shown.__toast__) {
3041
+ $shown.__toast__.restart();
3042
+ return $shown.__toast__.handle;
3043
+ }
3044
+ }
3030
3045
  let p_attrs = typeof message === "string" ? { i18n: message } : {};
3031
3046
  let $note = createElement2([
3032
3047
  "div",
@@ -3063,8 +3078,21 @@ function show_notification(shell, kind, message, opts) {
3063
3078
  let $del = $note.querySelector(".delete");
3064
3079
  if ($del) $del.addEventListener("click", close);
3065
3080
  let timeout = opts && opts.timeout != null ? opts.timeout : 5e3;
3066
- if (timeout > 0) timeout_id = setTimeout(close, timeout);
3067
- return { close };
3081
+ let restart = function() {
3082
+ if (timeout_id) {
3083
+ clearTimeout(timeout_id);
3084
+ timeout_id = null;
3085
+ }
3086
+ if (timeout > 0) timeout_id = setTimeout(close, timeout);
3087
+ };
3088
+ restart();
3089
+ let handle = { close };
3090
+ $note.__toast_key__ = key;
3091
+ $note.__toast__ = {
3092
+ handle,
3093
+ restart
3094
+ };
3095
+ return handle;
3068
3096
  }
3069
3097
  function yui_shell_show_info(shell, message, opts) {
3070
3098
  return show_notification(shell, "info", message, opts);
@@ -72151,6 +72179,9 @@ var PRIVATE_DATA$1 = {
72151
72179
  write_tag: 0,
72152
72180
  connected: false,
72153
72181
  reload_on_open: false,
72182
+ dropped_round: 0,
72183
+ dropped_write: 0,
72184
+ reload_after_write: false,
72154
72185
  pending_seg: null,
72155
72186
  import_plan: null,
72156
72187
  import_pane: null,
@@ -74619,6 +74650,15 @@ function transport_in_session(gobj) {
74619
74650
  * one the drop abandoned (or a deadline settled) can still come
74620
74651
  * back: counted, it ended the next load with the old data or
74621
74652
  * moved the next write's queue.
74653
+ *
74654
+ * The state is part of the question, and that is only true
74655
+ * because NOTHING moves the editor out of ST_LOADING or ST_SAVING
74656
+ * but the end of what is in flight: a position the host sends
74657
+ * meanwhile waits in `pending_seg` (ac_show()). 7.25.6 let EV_SHOW
74658
+ * move a RELOAD out of ST_LOADING, and every answer of it was then
74659
+ * "for a load that is over": the records it had emptied stayed
74660
+ * empty, and the next write patched them into a model with no
74661
+ * treedb at all.
74622
74662
  ***************************************************************/
74623
74663
  function is_current_load(gobj, md) {
74624
74664
  return gobj_current_state(gobj) === "ST_LOADING" && kw_get_int(gobj, md, "round", 0, 0) === gobj.priv.load_round;
@@ -74639,17 +74679,39 @@ function transport_dropped(gobj) {
74639
74679
  let state = gobj_current_state(gobj);
74640
74680
  priv.reload_on_open = true;
74641
74681
  if (state === "ST_SAVING") {
74682
+ log_warning(`${gobj_short_name(gobj)}: the transport dropped during a write: the model is read again on the reconnect`);
74683
+ priv.dropped_write = priv.write_tag;
74642
74684
  priv.write_tag++;
74643
74685
  return end_writes(gobj, "the connection dropped during the write");
74644
74686
  }
74645
74687
  if (state === "ST_LOADING") {
74688
+ log_warning(`${gobj_short_name(gobj)}: the transport dropped during the load: the model is read again on the reconnect`);
74689
+ priv.dropped_round = priv.load_round;
74646
74690
  priv.load_round++;
74647
74691
  priv.pending = 0;
74648
74692
  priv.load_error = "";
74649
- if (priv.model) return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74650
- gobj_change_state(gobj, "ST_IDLE");
74651
- render(gobj);
74693
+ return end_load(gobj);
74694
+ }
74695
+ return 0;
74696
+ }
74697
+ /***************************************************************
74698
+ * A load is over -- landed, failed or cut -- and the editor goes
74699
+ * where the operator is: the position the host sent while it was
74700
+ * in flight (ac_show() kept it), or the one it had. Without a
74701
+ * model there is no screen to go to: the position is kept for the
74702
+ * next load, and the editor says why it is empty.
74703
+ ***************************************************************/
74704
+ function end_load(gobj) {
74705
+ let priv = gobj.priv;
74706
+ let seg = priv.pending_seg;
74707
+ priv.pending_seg = null;
74708
+ if (priv.model) {
74709
+ if (seg !== null) return apply_seg(gobj, seg);
74710
+ return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74652
74711
  }
74712
+ if (seg !== null) adopt_seg(gobj, seg);
74713
+ gobj_change_state(gobj, "ST_IDLE");
74714
+ render(gobj);
74653
74715
  return 0;
74654
74716
  }
74655
74717
  /***************************************************************
@@ -74672,7 +74734,7 @@ function end_writes(gobj, error) {
74672
74734
  apply_seg(gobj, seg);
74673
74735
  if (priv.save_wrote) gobj_publish_event(gobj, "EV_RECORD_WRITTEN", { treedb_name: gobj_read_str_attr(gobj, "treedb_name") });
74674
74736
  priv.save_wrote = false;
74675
- return 0;
74737
+ return reload_if_owed(gobj);
74676
74738
  }
74677
74739
  }
74678
74740
  if (error) yui_shell_show_error(yui_shell_of(gobj), error, { t });
@@ -74680,9 +74742,25 @@ function end_writes(gobj, error) {
74680
74742
  priv.save_wrote = false;
74681
74743
  render(gobj);
74682
74744
  publish_check(gobj);
74745
+ reload_if_owed(gobj);
74683
74746
  return error ? -1 : 0;
74684
74747
  }
74685
74748
  /***************************************************************
74749
+ * A write given up earlier turned out DONE while another one was
74750
+ * in flight (see ac_mt_command_answer()): the model is read again
74751
+ * now that nothing is.
74752
+ ***************************************************************/
74753
+ function reload_if_owed(gobj) {
74754
+ let priv = gobj.priv;
74755
+ if (!priv.reload_after_write) return 0;
74756
+ priv.reload_after_write = false;
74757
+ if (!transport_in_session(gobj)) {
74758
+ priv.reload_on_open = true;
74759
+ return 0;
74760
+ }
74761
+ return request_model(gobj);
74762
+ }
74763
+ /***************************************************************
74686
74764
  * The record a column form produces.
74687
74765
  *
74688
74766
  * Everything the store needs and the operator did not type is
@@ -74750,7 +74828,7 @@ function go(gobj, treedb_id, topic_name, diagram, mirror) {
74750
74828
  priv.topic_name = topic_name || "";
74751
74829
  priv.diagram = !!diagram;
74752
74830
  let state = "ST_TREEDBS";
74753
- if (!priv.model) state = "ST_LOADING";
74831
+ if (!priv.model) state = "ST_IDLE";
74754
74832
  else if (priv.model.treedbs.length === 0) state = "ST_EMPTY";
74755
74833
  else if (priv.treedb_id && priv.diagram) state = "ST_DIAGRAM";
74756
74834
  else if (priv.treedb_id && priv.topic_name) state = "ST_COLUMNS";
@@ -74777,12 +74855,39 @@ function position_seg(gobj) {
74777
74855
  * empty screen: a shared link outlives the schema it points at.
74778
74856
  ***************************************************************/
74779
74857
  function apply_seg(gobj, seg) {
74858
+ let pos = seg_position(seg);
74859
+ return go(gobj, pos.treedb_id, pos.topic_name, pos.diagram, false);
74860
+ }
74861
+ function seg_position(seg) {
74780
74862
  let parts = String(seg || "").split("/").filter((x) => x.length > 0);
74781
74863
  let treedb_id = parts[0] || "";
74782
74864
  let tail = parts[1] || "";
74783
- if (!treedb_id) return go(gobj, "", "", false, false);
74784
- if (tail === DIAGRAM_SEG) return go(gobj, treedb_id, "", true, false);
74785
- return go(gobj, treedb_id, tail, false, false);
74865
+ if (!treedb_id) return {
74866
+ treedb_id: "",
74867
+ topic_name: "",
74868
+ diagram: false
74869
+ };
74870
+ if (tail === DIAGRAM_SEG) return {
74871
+ treedb_id,
74872
+ topic_name: "",
74873
+ diagram: true
74874
+ };
74875
+ return {
74876
+ treedb_id,
74877
+ topic_name: tail,
74878
+ diagram: false
74879
+ };
74880
+ }
74881
+ /***************************************************************
74882
+ * Where the operator is, taken WITHOUT a screen: there is no model
74883
+ * to draw it on yet, and the load that brings one goes there.
74884
+ ***************************************************************/
74885
+ function adopt_seg(gobj, seg) {
74886
+ let priv = gobj.priv;
74887
+ let pos = seg_position(seg);
74888
+ priv.treedb_id = pos.treedb_id;
74889
+ priv.topic_name = pos.topic_name;
74890
+ priv.diagram = pos.diagram;
74786
74891
  }
74787
74892
  /***************************
74788
74893
  * Actions
@@ -74810,6 +74915,7 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74810
74915
  if (command === "nodes") {
74811
74916
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
74812
74917
  if (!is_current_load(gobj, md)) {
74918
+ if (result < 0 && kw_get_int(gobj, md, "round", 0, 0) === priv.dropped_round) return 0;
74813
74919
  log_warning(`${gobj_short_name(gobj)}: 'nodes' of '${topic_name}' answered for a load that is over: ignored`);
74814
74920
  return 0;
74815
74921
  }
@@ -74819,20 +74925,22 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74819
74925
  if (priv.pending > 0) return 0;
74820
74926
  if (priv.load_error) {
74821
74927
  priv.model = null;
74822
- gobj_change_state(gobj, "ST_IDLE");
74823
- render(gobj);
74824
- return 0;
74928
+ return end_load(gobj);
74825
74929
  }
74826
74930
  priv.order_undo = {};
74827
74931
  build_model(gobj);
74828
74932
  start_measuring(gobj);
74829
- return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
74933
+ return end_load(gobj);
74830
74934
  }
74831
74935
  if (command === "update-node" || command === "delete-node") {
74832
74936
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
74833
74937
  if (!is_current_write(gobj, md)) {
74834
- log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' answered for a write that is over (result ${result}): ignored`);
74835
- return 0;
74938
+ if (result < 0 && kw_get_int(gobj, md, "write", 0, 0) === priv.dropped_write) return 0;
74939
+ if (result < 0) {
74940
+ log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' answered for a write that is over (result ${result}): ignored`);
74941
+ return 0;
74942
+ }
74943
+ return late_write_done(gobj, command, topic_name);
74836
74944
  }
74837
74945
  if (result < 0) return end_writes(gobj, comment || t("the treedb refused the write"));
74838
74946
  priv.save_wrote = true;
@@ -74858,6 +74966,35 @@ function ac_mt_command_answer(gobj, event, kw, src) {
74858
74966
  return 0;
74859
74967
  }
74860
74968
  /***************************************************************
74969
+ * A write this view gave up on (a drop, a deadline) answers that
74970
+ * it was DONE: the store holds a change the model does not show.
74971
+ * The model is read again -- now, or when what is in flight ends,
74972
+ * or on the reconnect. A load in flight needs nothing: it was
74973
+ * asked after the write, on the same transport, and reads it.
74974
+ *
74975
+ * Only when the answer reaches this view. A routing adapter that
74976
+ * settled the write on its own deadline (gui_agent's
74977
+ * C_AGENT_TREEDB_LINK) does not deliver its late answer again --
74978
+ * the view was answered once, as failed -- and echoes the node
74979
+ * event instead, which this view does not hear: there, Refresh is
74980
+ * what reads the store.
74981
+ ***************************************************************/
74982
+ function late_write_done(gobj, command, topic_name) {
74983
+ let priv = gobj.priv;
74984
+ let state = gobj_current_state(gobj);
74985
+ log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' was given up, and it was DONE: the model is read again`);
74986
+ if (state === "ST_LOADING") return 0;
74987
+ if (state === "ST_SAVING") {
74988
+ priv.reload_after_write = true;
74989
+ return 0;
74990
+ }
74991
+ if (!transport_in_session(gobj)) {
74992
+ priv.reload_on_open = true;
74993
+ return 0;
74994
+ }
74995
+ return request_model(gobj);
74996
+ }
74997
+ /***************************************************************
74861
74998
  * Remember which topic this write belongs to, which is what
74862
74999
  * "its version still has to move" is asked about.
74863
75000
  ***************************************************************/
@@ -74877,7 +75014,8 @@ function mark_written(gobj, topic_name, record) {
74877
75014
  function ac_show(gobj, event, kw, src) {
74878
75015
  let priv = gobj.priv;
74879
75016
  let seg = kw && kw.subpath || "";
74880
- if (gobj_current_state(gobj) === "ST_SAVING") {
75017
+ let state = gobj_current_state(gobj);
75018
+ if (state === "ST_SAVING" || state === "ST_LOADING") {
74881
75019
  priv.pending_seg = seg;
74882
75020
  return 0;
74883
75021
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.25.6",
3
+ "version": "7.25.7",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -231,8 +231,11 @@ let PRIVATE_DATA = {
231
231
  write_tag: 0, /* which write an answer belongs to: echoed as `write` */
232
232
  connected: false,
233
233
  reload_on_open: false, /* a drop cut a load or a write: ask the model again */
234
+ dropped_round: 0, /* the load a drop ended: its failures are the drop, said once */
235
+ dropped_write: 0, /* the same for the write a drop ended */
236
+ reload_after_write: false, /* a write given up turned out DONE while another was in flight */
234
237
 
235
- /* A position that arrived while a write was in flight */
238
+ /* A position that arrived while a load or a write was in flight */
236
239
  pending_seg: null,
237
240
 
238
241
  /* The import dialog's plan and the two nodes it draws into */
@@ -2403,6 +2406,15 @@ function transport_in_session(gobj)
2403
2406
  * one the drop abandoned (or a deadline settled) can still come
2404
2407
  * back: counted, it ended the next load with the old data or
2405
2408
  * moved the next write's queue.
2409
+ *
2410
+ * The state is part of the question, and that is only true
2411
+ * because NOTHING moves the editor out of ST_LOADING or ST_SAVING
2412
+ * but the end of what is in flight: a position the host sends
2413
+ * meanwhile waits in `pending_seg` (ac_show()). 7.25.6 let EV_SHOW
2414
+ * move a RELOAD out of ST_LOADING, and every answer of it was then
2415
+ * "for a load that is over": the records it had emptied stayed
2416
+ * empty, and the next write patched them into a model with no
2417
+ * treedb at all.
2406
2418
  ***************************************************************/
2407
2419
  function is_current_load(gobj, md)
2408
2420
  {
@@ -2431,19 +2443,51 @@ function transport_dropped(gobj)
2431
2443
 
2432
2444
  priv.reload_on_open = true;
2433
2445
  if(state === "ST_SAVING") {
2446
+ log_warning(`${gobj_short_name(gobj)}: the transport dropped during a write: ` +
2447
+ `the model is read again on the reconnect`);
2448
+ priv.dropped_write = priv.write_tag;
2434
2449
  priv.write_tag++;
2435
2450
  return end_writes(gobj, "the connection dropped during the write");
2436
2451
  }
2437
2452
  if(state === "ST_LOADING") {
2453
+ /* Said HERE, once: the transport settles every request the drop
2454
+ * cut, and those failures are this one event (see
2455
+ * ac_mt_command_answer()). */
2456
+ log_warning(`${gobj_short_name(gobj)}: the transport dropped during the load: ` +
2457
+ `the model is read again on the reconnect`);
2458
+ priv.dropped_round = priv.load_round;
2438
2459
  priv.load_round++;
2439
2460
  priv.pending = 0;
2440
2461
  priv.load_error = "";
2441
- if(priv.model) {
2442
- return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2462
+ return end_load(gobj);
2463
+ }
2464
+ return 0;
2465
+ }
2466
+
2467
+ /***************************************************************
2468
+ * A load is over -- landed, failed or cut -- and the editor goes
2469
+ * where the operator is: the position the host sent while it was
2470
+ * in flight (ac_show() kept it), or the one it had. Without a
2471
+ * model there is no screen to go to: the position is kept for the
2472
+ * next load, and the editor says why it is empty.
2473
+ ***************************************************************/
2474
+ function end_load(gobj)
2475
+ {
2476
+ let priv = gobj.priv;
2477
+ let seg = priv.pending_seg;
2478
+
2479
+ priv.pending_seg = null;
2480
+ if(priv.model) {
2481
+ if(seg !== null) {
2482
+ return apply_seg(gobj, seg);
2443
2483
  }
2444
- gobj_change_state(gobj, "ST_IDLE");
2445
- render(gobj);
2484
+ return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2485
+ }
2486
+ if(seg !== null) {
2487
+ adopt_seg(gobj, seg);
2446
2488
  }
2489
+ gobj_change_state(gobj, "ST_IDLE");
2490
+ render(gobj);
2447
2491
  return 0;
2448
2492
  }
2449
2493
 
@@ -2476,7 +2520,7 @@ function end_writes(gobj, error)
2476
2520
  });
2477
2521
  }
2478
2522
  priv.save_wrote = false;
2479
- return 0;
2523
+ return reload_if_owed(gobj);
2480
2524
  }
2481
2525
  }
2482
2526
 
@@ -2491,9 +2535,30 @@ function end_writes(gobj, error)
2491
2535
  priv.save_wrote = false;
2492
2536
  render(gobj);
2493
2537
  publish_check(gobj);
2538
+ reload_if_owed(gobj);
2494
2539
  return error ? -1 : 0;
2495
2540
  }
2496
2541
 
2542
+ /***************************************************************
2543
+ * A write given up earlier turned out DONE while another one was
2544
+ * in flight (see ac_mt_command_answer()): the model is read again
2545
+ * now that nothing is.
2546
+ ***************************************************************/
2547
+ function reload_if_owed(gobj)
2548
+ {
2549
+ let priv = gobj.priv;
2550
+
2551
+ if(!priv.reload_after_write) {
2552
+ return 0;
2553
+ }
2554
+ priv.reload_after_write = false;
2555
+ if(!transport_in_session(gobj)) {
2556
+ priv.reload_on_open = true;
2557
+ return 0;
2558
+ }
2559
+ return request_model(gobj);
2560
+ }
2561
+
2497
2562
  /***************************************************************
2498
2563
  * The record a column form produces.
2499
2564
  *
@@ -2599,9 +2664,13 @@ function go(gobj, treedb_id, topic_name, diagram, mirror)
2599
2664
  priv.topic_name = topic_name || "";
2600
2665
  priv.diagram = !!diagram;
2601
2666
 
2667
+ /* No model and no load (a load in flight never gets here: ac_show()
2668
+ * keeps the position for it): nothing to draw, and ST_LOADING would
2669
+ * claim a load that nobody asked for -- the reconnect skipped the one
2670
+ * it owed BECAUSE the state said a load was running. */
2602
2671
  let state = "ST_TREEDBS";
2603
2672
  if(!priv.model) {
2604
- state = "ST_LOADING";
2673
+ state = "ST_IDLE";
2605
2674
  } else if(priv.model.treedbs.length === 0) {
2606
2675
  state = "ST_EMPTY";
2607
2676
  } else if(priv.treedb_id && priv.diagram) {
@@ -2647,18 +2716,39 @@ function position_seg(gobj)
2647
2716
  * empty screen: a shared link outlives the schema it points at.
2648
2717
  ***************************************************************/
2649
2718
  function apply_seg(gobj, seg)
2719
+ {
2720
+ let pos = seg_position(seg);
2721
+
2722
+ return go(gobj, pos.treedb_id, pos.topic_name, pos.diagram, false);
2723
+ }
2724
+
2725
+ function seg_position(seg)
2650
2726
  {
2651
2727
  let parts = String(seg || "").split("/").filter(x => x.length > 0);
2652
2728
  let treedb_id = parts[0] || "";
2653
2729
  let tail = parts[1] || "";
2654
2730
 
2655
2731
  if(!treedb_id) {
2656
- return go(gobj, "", "", false, false);
2732
+ return {treedb_id: "", topic_name: "", diagram: false};
2657
2733
  }
2658
2734
  if(tail === DIAGRAM_SEG) {
2659
- return go(gobj, treedb_id, "", true, false);
2735
+ return {treedb_id: treedb_id, topic_name: "", diagram: true};
2660
2736
  }
2661
- return go(gobj, treedb_id, tail, false, false);
2737
+ return {treedb_id: treedb_id, topic_name: tail, diagram: false};
2738
+ }
2739
+
2740
+ /***************************************************************
2741
+ * Where the operator is, taken WITHOUT a screen: there is no model
2742
+ * to draw it on yet, and the load that brings one goes there.
2743
+ ***************************************************************/
2744
+ function adopt_seg(gobj, seg)
2745
+ {
2746
+ let priv = gobj.priv;
2747
+ let pos = seg_position(seg);
2748
+
2749
+ priv.treedb_id = pos.treedb_id;
2750
+ priv.topic_name = pos.topic_name;
2751
+ priv.diagram = pos.diagram;
2662
2752
  }
2663
2753
 
2664
2754
 
@@ -2719,6 +2809,9 @@ function ac_mt_command_answer(gobj, event, kw, src)
2719
2809
  if(command === "nodes") {
2720
2810
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2721
2811
  if(!is_current_load(gobj, md)) {
2812
+ if(result < 0 && kw_get_int(gobj, md, "round", 0, 0) === priv.dropped_round) {
2813
+ return 0; /* the drop that ended this load failed it: said once, in transport_dropped() */
2814
+ }
2722
2815
  log_warning(`${gobj_short_name(gobj)}: 'nodes' of '${topic_name}' answered ` +
2723
2816
  `for a load that is over: ignored`);
2724
2817
  return 0;
@@ -2734,9 +2827,7 @@ function ac_mt_command_answer(gobj, event, kw, src)
2734
2827
  }
2735
2828
  if(priv.load_error) {
2736
2829
  priv.model = null;
2737
- gobj_change_state(gobj, "ST_IDLE");
2738
- render(gobj);
2739
- return 0;
2830
+ return end_load(gobj);
2740
2831
  }
2741
2832
  /* Records straight from the store: what an undo would name is the
2742
2833
  * order that was ON SCREEN, and this is the stored one arriving.
@@ -2749,17 +2840,24 @@ function ac_mt_command_answer(gobj, event, kw, src)
2749
2840
  * against. Taken on the write patches instead, it measured every
2750
2841
  * version against itself. */
2751
2842
  start_measuring(gobj);
2752
- /* Back to where the operator was: the load may be a refresh, or
2753
- * the answer to a deep link that arrived before the model. */
2754
- return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2843
+ /* Back to where the operator was, or went while it loaded: the
2844
+ * load may be a refresh, or the answer to a deep link that
2845
+ * arrived before the model. */
2846
+ return end_load(gobj);
2755
2847
  }
2756
2848
 
2757
2849
  if(command === "update-node" || command === "delete-node") {
2758
2850
  let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2759
2851
  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;
2852
+ if(result < 0 && kw_get_int(gobj, md, "write", 0, 0) === priv.dropped_write) {
2853
+ return 0; /* the drop that ended this write failed it: said once, in transport_dropped() */
2854
+ }
2855
+ if(result < 0) {
2856
+ log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' answered ` +
2857
+ `for a write that is over (result ${result}): ignored`);
2858
+ return 0;
2859
+ }
2860
+ return late_write_done(gobj, command, topic_name);
2763
2861
  }
2764
2862
  if(result < 0) {
2765
2863
  return end_writes(gobj, comment || t("the treedb refused the write"));
@@ -2796,6 +2894,41 @@ function ac_mt_command_answer(gobj, event, kw, src)
2796
2894
  return 0;
2797
2895
  }
2798
2896
 
2897
+ /***************************************************************
2898
+ * A write this view gave up on (a drop, a deadline) answers that
2899
+ * it was DONE: the store holds a change the model does not show.
2900
+ * The model is read again -- now, or when what is in flight ends,
2901
+ * or on the reconnect. A load in flight needs nothing: it was
2902
+ * asked after the write, on the same transport, and reads it.
2903
+ *
2904
+ * Only when the answer reaches this view. A routing adapter that
2905
+ * settled the write on its own deadline (gui_agent's
2906
+ * C_AGENT_TREEDB_LINK) does not deliver its late answer again --
2907
+ * the view was answered once, as failed -- and echoes the node
2908
+ * event instead, which this view does not hear: there, Refresh is
2909
+ * what reads the store.
2910
+ ***************************************************************/
2911
+ function late_write_done(gobj, command, topic_name)
2912
+ {
2913
+ let priv = gobj.priv;
2914
+ let state = gobj_current_state(gobj);
2915
+
2916
+ log_warning(`${gobj_short_name(gobj)}: '${command}' of '${topic_name}' was given up, ` +
2917
+ `and it was DONE: the model is read again`);
2918
+ if(state === "ST_LOADING") {
2919
+ return 0;
2920
+ }
2921
+ if(state === "ST_SAVING") {
2922
+ priv.reload_after_write = true;
2923
+ return 0;
2924
+ }
2925
+ if(!transport_in_session(gobj)) {
2926
+ priv.reload_on_open = true;
2927
+ return 0;
2928
+ }
2929
+ return request_model(gobj);
2930
+ }
2931
+
2799
2932
  /***************************************************************
2800
2933
  * Remember which topic this write belongs to, which is what
2801
2934
  * "its version still has to move" is asked about.
@@ -2822,10 +2955,14 @@ function ac_show(gobj, event, kw, src)
2822
2955
  {
2823
2956
  let priv = gobj.priv;
2824
2957
  let seg = (kw && kw.subpath) || "";
2958
+ let state = gobj_current_state(gobj);
2825
2959
 
2826
- if(gobj_current_state(gobj) === "ST_SAVING") {
2960
+ /* Nothing but the end of what is in flight moves the editor out of
2961
+ * these two (see is_current_load()): the position waits, and the
2962
+ * last one sent wins. */
2963
+ if(state === "ST_SAVING" || state === "ST_LOADING") {
2827
2964
  priv.pending_seg = seg;
2828
- return 0; /* applied when the write lands (end_writes) */
2965
+ return 0; /* applied when the write lands (end_writes) or the load (end_load) */
2829
2966
  }
2830
2967
  return apply_seg(gobj, seg);
2831
2968
  }
@@ -56,8 +56,13 @@ const RECORDS = {
56
56
  ],
57
57
  };
58
58
 
59
+ /* A transport out of session refuses in the RETURN, as C_IEVENT_CLI and
60
+ * the routing adapters do: a request that did not leave is not waited for. */
59
61
  function remote_command_parser(gobj, command, kw, src)
60
62
  {
63
+ if(gobj_current_state(gobj) !== "ST_SESSION") {
64
+ return `${command}: not in session`;
65
+ }
61
66
  commands.push({command, kw, src});
62
67
  return null;
63
68
  }
@@ -123,7 +128,10 @@ function answer_the_load(editor, remote)
123
128
  {
124
129
  const asked = take("nodes");
125
130
  for(const c of asked) {
126
- answer(editor, remote, c, 0, RECORDS[c.kw.topic_name]);
131
+ /* A copy, as a transport hands it: the editor patches what it is
132
+ * given, and a shared fixture would carry one test's write into
133
+ * the next. */
134
+ answer(editor, remote, c, 0, JSON.parse(JSON.stringify(RECORDS[c.kw.topic_name])));
127
135
  }
128
136
  return asked.length;
129
137
  }
@@ -183,6 +191,11 @@ function errors()
183
191
  return logged.filter((l) => l.level === "error").map((l) => l.msg);
184
192
  }
185
193
 
194
+ function warnings()
195
+ {
196
+ return logged.filter((l) => l.level === "warning").map((l) => l.msg);
197
+ }
198
+
186
199
  describe("a drop while LOADING", () => {
187
200
 
188
201
  test("the editor leaves ST_LOADING, and the reconnect loads again", () => {
@@ -276,9 +289,169 @@ describe("a drop while SAVING", () => {
276
289
  topics: ["topics^db.users^cols"]});
277
290
  expect(gobj_current_state(editor)).toBe("ST_SAVING");
278
291
 
292
+ /* ...and it says the first one was DONE, so once the second ends
293
+ * the model is read again (late_write_done()). */
279
294
  answer(editor, remote, second, 0, {id: "db.users.name", value: "name", order: 2,
280
295
  type: "string", topics: ["topics^db.users^cols"]});
296
+ expect(gobj_current_state(editor)).toBe("ST_LOADING");
297
+ expect(answer_the_load(editor, remote)).toBe(3);
298
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
299
+ expect(errors()).toEqual([]);
300
+ });
301
+ });
302
+
303
+ /*
304
+ * The regression of 7.25.6: a navigation DURING a reload. The load was
305
+ * matched by its round AND by the state being ST_LOADING, and EV_SHOW
306
+ * moved the editor out of ST_LOADING while its three `nodes` were in
307
+ * flight. Every answer was then "for a load that is over", the reload
308
+ * never landed, and request_model() had already emptied the records: the
309
+ * next write patched an empty store and the model lost every treedb.
310
+ */
311
+ describe("a navigation DURING a reload", () => {
312
+
313
+ test("refresh, move, write: the model keeps every treedb", () => {
314
+ const {editor, remote, host} = build("n1", true);
315
+ gobj_send_event(editor, "EV_REFRESH", {}, host);
316
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
317
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db/users"}, host);
318
+ expect(answer_the_load(editor, remote)).toBe(3);
319
+
320
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
321
+ expect(editor.priv.pending).toBe(0);
322
+
323
+ const write = start_a_write(editor, host);
324
+ answer(editor, remote, write, 0, {id: "db.users.name", value: "name", order: 2,
325
+ type: "string", topics: ["topics^db.users^cols"]});
326
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
327
+ expect(editor.priv.model.treedbs.length).toBe(1);
328
+ expect(editor.priv.model.treedbs[0].topics[0].cols.length).toBe(2);
329
+ expect(warnings()).toEqual([]);
330
+ expect(errors()).toEqual([]);
331
+ });
332
+
333
+ test("the editor stays in ST_LOADING, and lands where the host said last", () => {
334
+ const {editor, remote, host} = build("n2", true);
335
+ gobj_send_event(editor, "EV_REFRESH", {}, host);
336
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
337
+ expect(gobj_current_state(editor)).toBe("ST_LOADING");
338
+
339
+ expect(answer_the_load(editor, remote)).toBe(3);
340
+ expect(gobj_current_state(editor)).toBe("ST_TOPICS");
341
+ expect(editor.priv.treedb_id).toBe("db");
342
+ expect(editor.priv.topic_name).toBe("");
343
+ expect(warnings()).toEqual([]);
344
+ });
345
+
346
+ test("the reload of a reconnect, with a move in the middle", () => {
347
+ const {editor, remote, host} = build("n3", true);
348
+ start_a_write(editor, host);
349
+ drop(editor, remote, host); /* the reconnect will reload */
350
+ shown.length = 0;
351
+ reconnect(editor, remote, host);
352
+ expect(gobj_current_state(editor)).toBe("ST_LOADING");
353
+
354
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
355
+ expect(answer_the_load(editor, remote)).toBe(3);
356
+ expect(gobj_current_state(editor)).toBe("ST_TOPICS");
357
+ expect(editor.priv.pending).toBe(0);
358
+ expect(editor.priv.records.cols.length).toBe(1);
359
+ expect(editor.priv.model.treedbs.length).toBe(1);
360
+ expect(errors()).toEqual([]);
361
+ });
362
+
363
+ test("a drop after the move: the move is applied on the model kept, and the reconnect reloads", () => {
364
+ const {editor, remote, host} = build("n4", true);
365
+ gobj_send_event(editor, "EV_REFRESH", {}, host);
366
+ take("nodes"); /* never answered */
367
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
368
+
369
+ drop(editor, remote, host);
370
+ expect(gobj_current_state(editor)).toBe("ST_TOPICS");
371
+
372
+ reconnect(editor, remote, host);
373
+ expect(answer_the_load(editor, remote)).toBe(3);
374
+ expect(gobj_current_state(editor)).toBe("ST_TOPICS");
375
+ expect(editor.priv.model.treedbs.length).toBe(1);
376
+ expect(errors()).toEqual([]);
377
+ });
378
+ });
379
+
380
+ describe("a position with no model and no load", () => {
381
+
382
+ test("does not claim a load, and the session loads it", () => {
383
+ const remote = gobj_create_service("p1_remote", "C_TEST_REMOTE", {}, yuno);
384
+ gobj_change_state(remote, "ST_DISCONNECTED");
385
+ const host = gobj_create("p1_host", "C_TEST_EDITOR_HOST", {}, yuno);
386
+ const editor = gobj_create("p1_editor", "C_YUI_SCHEMA_EDITOR", {
387
+ gobj_remote_yuno: remote,
388
+ treedb_name: "treedb_system_schema",
389
+ base_route: "/schemas"
390
+ }, host);
391
+ gobj_start(editor); /* its load cannot leave */
392
+ logged.length = 0;
393
+ expect(gobj_current_state(editor)).toBe("ST_IDLE");
394
+
395
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db/users"}, host);
396
+ expect(gobj_current_state(editor)).toBe("ST_IDLE");
397
+
398
+ reconnect(editor, remote, host);
399
+ expect(answer_the_load(editor, remote)).toBe(3);
281
400
  expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
282
401
  expect(errors()).toEqual([]);
283
402
  });
284
403
  });
404
+
405
+ describe("the drop is settled ONCE", () => {
406
+
407
+ test("a load cut by the drop: the transport's three failures are one event", () => {
408
+ const {editor, remote, host} = build("o1", true);
409
+ gobj_send_event(editor, "EV_REFRESH", {}, host);
410
+ const cut = take("nodes");
411
+ expect(cut.length).toBe(3);
412
+
413
+ /* A routing adapter settles all three when its session closes,
414
+ * before (or after) the host's edge. */
415
+ gobj_change_state(remote, "ST_DISCONNECTED");
416
+ for(const c of cut) {
417
+ answer(editor, remote, c, -1, null, "the connection dropped");
418
+ }
419
+ gobj_send_event(editor, "EV_TRANSPORT_STATE", {connected: false}, host);
420
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
421
+ expect(warnings().length).toBe(1);
422
+ expect(warnings()[0]).toMatch(/dropped during the load/);
423
+ expect(errors()).toEqual([]);
424
+ });
425
+
426
+ test("the host's edge first: the failures that follow are not said again", () => {
427
+ const {editor, remote, host} = build("o2", true);
428
+ gobj_send_event(editor, "EV_REFRESH", {}, host);
429
+ const cut = take("nodes");
430
+
431
+ drop(editor, remote, host);
432
+ for(const c of cut) {
433
+ answer(editor, remote, c, -1, null, "the connection dropped");
434
+ }
435
+ expect(warnings().length).toBe(1);
436
+ expect(errors()).toEqual([]);
437
+ });
438
+ });
439
+
440
+ describe("a write answered after it was given up", () => {
441
+
442
+ test("it was DONE: the model is read again", () => {
443
+ const {editor, remote, host} = build("w1", true);
444
+ const write = start_a_write(editor, host);
445
+ drop(editor, remote, host);
446
+ reconnect(editor, remote, host);
447
+ answer_the_load(editor, remote);
448
+ expect(gobj_current_state(editor)).toBe("ST_COLUMNS");
449
+
450
+ /* The treedb says it wrote it, after all. */
451
+ answer(editor, remote, write, 0, {id: "db.users.name", value: "name",
452
+ topics: ["topics^db.users^cols"]});
453
+ expect(gobj_current_state(editor)).toBe("ST_LOADING");
454
+ expect(take("nodes").length).toBe(3);
455
+ expect(errors()).toEqual([]);
456
+ });
457
+ });
@@ -111,6 +111,14 @@ function maybe_apply_translator($node, opts)
111
111
  * Non-blocking, auto-dismiss after `opts.timeout` ms (default
112
112
  * 5000). `opts.timeout = 0` disables auto-dismiss.
113
113
  * Returns `{ close() }` so callers can dismiss programmatically.
114
+ *
115
+ * ONE toast per message on screen: a string message of the same
116
+ * kind as a toast still showing is not stacked under it -- that
117
+ * toast is kept, its time starts again, and its handle is
118
+ * returned. One close of a transport settles every request it
119
+ * cut, each view shows its failure, and the operator got a
120
+ * column of identical "the connection dropped". A message built
121
+ * of nodes is never merged: its text is not a key.
114
122
  ***************************************************************/
115
123
  function show_notification(shell, kind, message, opts)
116
124
  {
@@ -120,6 +128,16 @@ function show_notification(shell, kind, message, opts)
120
128
  return { close: () => {} };
121
129
  }
122
130
 
131
+ let key = (typeof message === "string") ? `${kind}\u0000${message}` : null;
132
+ if(key !== null) {
133
+ for(let $shown of Array.from($layer.children)) {
134
+ if($shown.__toast_key__ === key && $shown.__toast__) {
135
+ $shown.__toast__.restart();
136
+ return $shown.__toast__.handle;
137
+ }
138
+ }
139
+ }
140
+
123
141
  let p_attrs = (typeof message === "string")
124
142
  ? {i18n: message}
125
143
  : {};
@@ -158,11 +176,21 @@ function show_notification(shell, kind, message, opts)
158
176
  }
159
177
 
160
178
  let timeout = (opts && opts.timeout != null) ? opts.timeout : 5000;
161
- if(timeout > 0) {
162
- timeout_id = setTimeout(close, timeout);
163
- }
179
+ let restart = function() {
180
+ if(timeout_id) {
181
+ clearTimeout(timeout_id);
182
+ timeout_id = null;
183
+ }
184
+ if(timeout > 0) {
185
+ timeout_id = setTimeout(close, timeout);
186
+ }
187
+ };
188
+ restart();
164
189
 
165
- return { close };
190
+ let handle = { close };
191
+ $note.__toast_key__ = key;
192
+ $note.__toast__ = {handle: handle, restart: restart};
193
+ return handle;
166
194
  }
167
195
 
168
196
 
@@ -0,0 +1,64 @@
1
+ /***********************************************************************
2
+ * shell_toast_once.test.js
3
+ *
4
+ * A toast that says what one already on screen says is not
5
+ * stacked under it: one close of the transport settles every
6
+ * request it cut, each view that asked shows its failure, and
7
+ * the operator got a column of identical "the connection
8
+ * dropped" (low of the independent review of the 2nd round).
9
+ *
10
+ * Copyright (c) 2026, ArtGins.
11
+ * All Rights Reserved.
12
+ ***********************************************************************/
13
+ import {describe, test, expect, beforeEach} from "vitest";
14
+ import {install_dom_double} from "../test/dom_double.js";
15
+
16
+ install_dom_double();
17
+
18
+ const {
19
+ yui_shell_show_error,
20
+ yui_shell_show_info,
21
+ } = await import("./shell_modals.js");
22
+
23
+ let shell = null;
24
+
25
+ function toasts()
26
+ {
27
+ return shell.priv.layers.notification.children;
28
+ }
29
+
30
+ beforeEach(() => {
31
+ shell = {priv: {layers: {notification: document.createElement("div")}}};
32
+ });
33
+
34
+ describe("one toast per message on screen", () => {
35
+
36
+ test("the same error twice is ONE toast", () => {
37
+ yui_shell_show_error(shell, "the connection dropped");
38
+ yui_shell_show_error(shell, "the connection dropped");
39
+ yui_shell_show_error(shell, "the connection dropped");
40
+ expect(toasts().length).toBe(1);
41
+ });
42
+
43
+ test("different messages, or kinds, still stack", () => {
44
+ yui_shell_show_error(shell, "the connection dropped");
45
+ yui_shell_show_error(shell, "the connection dropped during the write");
46
+ yui_shell_show_info(shell, "the connection dropped");
47
+ expect(toasts().length).toBe(3);
48
+ });
49
+
50
+ test("closing the one on screen: the next one is shown", () => {
51
+ const first = yui_shell_show_error(shell, "the connection dropped");
52
+ first.close();
53
+ expect(toasts().length).toBe(0);
54
+ yui_shell_show_error(shell, "the connection dropped");
55
+ expect(toasts().length).toBe(1);
56
+ });
57
+
58
+ test("the handle of the repeat closes the toast on screen", () => {
59
+ yui_shell_show_error(shell, "the connection dropped");
60
+ const again = yui_shell_show_error(shell, "the connection dropped");
61
+ again.close();
62
+ expect(toasts().length).toBe(0);
63
+ });
64
+ });