structile 6.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ // anywidget front-end: mounts structile.html inside an iframe and
2
+ // drives it purely through its documented embedded-mode postMessage
3
+ // protocol (see the "EMBEDDED MODE" section of structile.html) —
4
+ // this file has no knowledge of the viewer's internals beyond that
5
+ // contract, so it stays correct across viewer changes as long as the
6
+ // protocol does. Shared by both StructileWidget ("value" mode, single
7
+ // document, read-write) and StructileDiffWidget ("diff" mode, two-sided,
8
+ // read-only graph) — see widget.py's own `mode` trait on each class.
9
+ function render({ model, el }) {
10
+ const iframe = document.createElement("iframe");
11
+ iframe.style.width = "100%";
12
+ iframe.style.height = model.get("height") + "px";
13
+ iframe.style.border = "0px";
14
+ iframe.style.borderRadius = "0px";
15
+ iframe.style.display = "block";
16
+ // srcdoc (not src) — the viewer HTML is delivered inline over the widget
17
+ // model, not fetched from a URL, so this works the same whether the
18
+ // notebook is local or hosted.
19
+ iframe.srcdoc = model.get("viewer_html");
20
+ el.appendChild(iframe);
21
+ model.on("change:height", () => {
22
+ iframe.style.height = model.get("height") + "px";
23
+ });
24
+
25
+ if (model.get("mode") === "diff") return renderDiff(model, iframe);
26
+ return renderValue(model, iframe);
27
+ }
28
+
29
+ // A model trait holding a single interpreter source, or a list of candidates
30
+ // the viewer tries itself (tryInterpreterCandidates in structile.html) —
31
+ // shared truthiness check for the three traits shaped this way
32
+ // (interpreter_source, left_interpreter_source, right_interpreter_source).
33
+ function hasInterpreterSource(source) {
34
+ return typeof source === "string" ? !!source : Array.isArray(source) && source.length > 0;
35
+ }
36
+
37
+ // Forwards a viewer -> host structile-save-requested message on to Python's on_msg
38
+ // (see widget.py's _on_custom_msg) — shared by renderValue/renderDiff, which
39
+ // differ only in whether a `side` field is also present (diff mode only).
40
+ function forwardSaveRequested(model, msg, extra) {
41
+ model.send({
42
+ type: "structile-save-requested",
43
+ format: msg.format,
44
+ ext: msg.ext,
45
+ fileName: msg.fileName,
46
+ isSaveAs: !!msg.isSaveAs,
47
+ content: msg.content,
48
+ ...extra,
49
+ });
50
+ }
51
+
52
+ // Forwards a viewer -> host structile-settings-changed message on to Python's
53
+ // on_msg (see widget.py's _handle_settings_changed) — a one-off event, same
54
+ // custom-message channel as structile-save-requested (see the comment on
55
+ // forwardSaveRequested/onViewerMessage below for why this isn't a trait).
56
+ function forwardSettingsChanged(model, msg) {
57
+ model.send({
58
+ type: "structile-settings-changed",
59
+ settings: msg.settings,
60
+ renderMap: msg.renderMap,
61
+ links: msg.links,
62
+ theme: msg.theme,
63
+ });
64
+ }
65
+
66
+ function renderValue(model, iframe) {
67
+ function sendInit() {
68
+ if (!iframe.contentWindow) return;
69
+ // raw_format non-empty, OR a non-empty interpreter_candidates_by_format
70
+ // (format genuinely unknown, still TBD by the viewer itself — see
71
+ // tryInterpreterCandidatesAnyFormat), selects the raw_text+interpreter
72
+ // path (XML, HTML included, via a host-supplied interpreter script) over the
73
+ // normal already-parsed-value path (see StructileWidget in widget.py).
74
+ const rawFormat = model.get("raw_format");
75
+ const candidatesByFormat = model.get("interpreter_candidates_by_format");
76
+ const hasCandidatesByFormat = candidatesByFormat && Object.keys(candidatesByFormat).length > 0;
77
+ const msg = {
78
+ type: "structile-embed-init",
79
+ name: model.get("data_name"),
80
+ config: JSON.parse(model.get("config_json")),
81
+ // Shows the viewer's settings panel (normally hidden in every embed
82
+ // host — see enableSettingsPanel in structile.html's EMBEDDED
83
+ // MODE section) and its "Set as default" button, which posts
84
+ // structile-settings-changed back — see onViewerMessage below.
85
+ enableSettingsPanel: true,
86
+ };
87
+ if (rawFormat || hasCandidatesByFormat) {
88
+ msg.text = model.get("raw_text");
89
+ msg.format = rawFormat;
90
+ // interpreter_source is a single string or a list of candidates —
91
+ // the viewer tries each itself (tryInterpreterCandidates), so this
92
+ // widget never has to disambiguate more than one candidate.
93
+ const interpreterSource = model.get("interpreter_source");
94
+ if (hasInterpreterSource(interpreterSource)) msg.interpreterSource = interpreterSource;
95
+ if (hasCandidatesByFormat) msg.interpreterCandidatesByFormat = candidatesByFormat;
96
+ } else {
97
+ msg.text = model.get("value_json");
98
+ msg.format = "json";
99
+ }
100
+ iframe.contentWindow.postMessage(msg, "*");
101
+ }
102
+
103
+ // The viewer's own init() attaches its "message" listener synchronously
104
+ // during initial script execution, which finishes well before the
105
+ // iframe's "load" event (DOMContentLoaded, then load) — so it's always
106
+ // listening by the time this fires.
107
+ iframe.addEventListener("load", sendInit, { once: true });
108
+ model.on("change:value_json", sendInit);
109
+ model.on("change:raw_text", sendInit);
110
+ model.on("change:raw_format", sendInit);
111
+ model.on("change:interpreter_source", sendInit);
112
+ model.on("change:interpreter_candidates_by_format", sendInit);
113
+ model.on("change:data_name", sendInit);
114
+ model.on("change:config_json", sendInit);
115
+
116
+ // Viewer -> host: dirty-state is plain synced state (a trait, pushed
117
+ // whenever it changes); save-requested is a one-off event, so it goes
118
+ // over the custom-message channel instead (model.send <-> Python's
119
+ // on_msg) rather than a trait, since "a save just happened" isn't state
120
+ // that makes sense to read back later.
121
+ function onViewerMessage(e) {
122
+ if (e.source !== iframe.contentWindow || !e.data) return;
123
+ const msg = e.data;
124
+ if (msg.type === "structile-dirty-state-changed") {
125
+ model.set("dirty", !!msg.dirty);
126
+ model.set("dirty_count", msg.totalCount || 0);
127
+ model.save_changes();
128
+ } else if (msg.type === "structile-save-requested") {
129
+ forwardSaveRequested(model, msg);
130
+ } else if (msg.type === "structile-settings-changed") {
131
+ forwardSettingsChanged(model, msg);
132
+ }
133
+ }
134
+ window.addEventListener("message", onViewerMessage);
135
+ return () => window.removeEventListener("message", onViewerMessage);
136
+ }
137
+
138
+ function renderDiff(model, iframe) {
139
+ function sendInit() {
140
+ if (!iframe.contentWindow) return;
141
+ const msg = {
142
+ type: "structile-embed-diff-init",
143
+ left: { text: model.get("left_text"), format: model.get("left_format"), name: model.get("left_name") },
144
+ right: { text: model.get("right_text"), format: model.get("right_format"), name: model.get("right_name") },
145
+ config: JSON.parse(model.get("config_json")),
146
+ diff: { view: model.get("diff_view") },
147
+ };
148
+ const keyColumns = model.get("key_columns");
149
+ if (keyColumns && keyColumns.length) msg.keyColumns = keyColumns;
150
+ const leftInterp = model.get("left_interpreter_source");
151
+ if (hasInterpreterSource(leftInterp)) msg.leftInterpreterSource = leftInterp;
152
+ const rightInterp = model.get("right_interpreter_source");
153
+ if (hasInterpreterSource(rightInterp)) msg.rightInterpreterSource = rightInterp;
154
+ iframe.contentWindow.postMessage(msg, "*");
155
+ }
156
+
157
+ iframe.addEventListener("load", sendInit, { once: true });
158
+ model.on("change:left_text", sendInit);
159
+ model.on("change:left_format", sendInit);
160
+ model.on("change:left_name", sendInit);
161
+ model.on("change:right_text", sendInit);
162
+ model.on("change:right_format", sendInit);
163
+ model.on("change:right_name", sendInit);
164
+ model.on("change:left_interpreter_source", sendInit);
165
+ model.on("change:right_interpreter_source", sendInit);
166
+ model.on("change:diff_view", sendInit);
167
+ model.on("change:key_columns", sendInit);
168
+ model.on("change:config_json", sendInit);
169
+
170
+ // No dirty-state-changed for diff mode (the viewer never emits one — see
171
+ // structile.html's emitDirtyStateChanged, single-value-only) —
172
+ // only a per-side structile-save-requested (with `side`), forwarded the same
173
+ // way the value widget forwards its own.
174
+ function onViewerMessage(e) {
175
+ if (e.source !== iframe.contentWindow || !e.data) return;
176
+ const msg = e.data;
177
+ if (msg.type === "structile-save-requested") {
178
+ forwardSaveRequested(model, msg, { side: msg.side });
179
+ }
180
+ }
181
+ window.addEventListener("message", onViewerMessage);
182
+ return () => window.removeEventListener("message", onViewerMessage);
183
+ }
184
+
185
+ export default { render };
structile/widget.py ADDED
@@ -0,0 +1,406 @@
1
+ """anywidget front-end wrapper around structile.html (embedded mode)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import pathlib
6
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
7
+
8
+ import anywidget
9
+ import traitlets
10
+
11
+ from ._log import logger
12
+ from ._paths import find_viewer_html as _find_viewer_html
13
+ from .config import options_from_dict, replace_options
14
+ from .serialize import normalize
15
+
16
+ if TYPE_CHECKING:
17
+ from .render import Payload
18
+
19
+ _PACKAGE_DIR = pathlib.Path(__file__).resolve().parent
20
+
21
+
22
+ class StructileWidget(anywidget.AnyWidget):
23
+ """Renders structile.html, in embedded mode, inside an iframe.
24
+
25
+ The value is handed to the viewer over its existing host<->viewer
26
+ postMessage protocol (see the "EMBEDDED MODE" section of
27
+ structile.html) — this class never reaches into the viewer's
28
+ internals, it only speaks that protocol from the JS side (see
29
+ static/widget.js).
30
+
31
+ Two data paths, matching `structile-embed-init`'s own `value` vs `text`+
32
+ `format` split:
33
+ - `value_json` (the default): an already-normalized Python value,
34
+ shown/edited/saved as JSON.
35
+ - `raw_text` + `raw_format` ("xml"/"html", or any other format like
36
+ "ini") + `interpreter_source`: raw source text run through a
37
+ host-supplied interpreter script, for data `normalize()` has no
38
+ business touching. `interpreter_source` is a single source or a
39
+ list of candidates — the viewer tries each itself
40
+ (structile.html's `tryInterpreterCandidates`), so
41
+ disambiguating more than one never needs Node.js here either. When
42
+ `raw_format` is left `""` (format genuinely unknown), the viewer
43
+ instead tries every entry of `interpreter_candidates_by_format`
44
+ (`{"xml": [...], "ini": [...]}` — any mix of formats) to determine
45
+ the format itself.
46
+
47
+ Edits made in the viewer come back on save (Ctrl+S / Save),
48
+ via `structile-save-requested` — never live per-keystroke, matching the
49
+ viewer's own "nothing but Save commits an edit" contract. `self.value`
50
+ is updated in place; if a target path is known (`source_path` or an
51
+ explicit `save_path`), that file is written too — Python, not the
52
+ browser, owns the filesystem in embedded mode.
53
+
54
+ The viewer's Save As… button (embed-only — see `[data-embed-only]` in
55
+ structile.html) sends the same message with a new `fileName`,
56
+ which `_resolve_save_path` honors as a rename: the new file is written
57
+ *alongside* `source_path` (never overwriting it) and adopted as
58
+ `self._source_path` for every save after that, so there's no lingering
59
+ "which file is this, really" ambiguity — what's on screen and what the
60
+ next Save writes to always agree. A rename that would silently clobber
61
+ an unrelated, already-existing file is refused instead (see
62
+ `_handle_save`), since there's no channel back to the viewer to ask
63
+ "overwrite?" first.
64
+
65
+ `self.source_text` tracks the literal text of the most recent save (or
66
+ the initial load) even when it doesn't parse — the viewer's source pane
67
+ (source_code_view_claude.md) can save a mid-edit/invalid buffer on
68
+ purpose (its own "Save source (graph is stale)" case), and that literal
69
+ text is still written to disk. `self.value` never reflects that broken
70
+ intermediate state — it only ever updates on a SUCCESSFUL parse, so
71
+ anything reading `.value` between saves never sees a broken one.
72
+ """
73
+
74
+ _esm = _PACKAGE_DIR / "static" / "widget.js"
75
+
76
+ # Shared with StructileDiffWidget's own "diff" — static/widget.js's
77
+ # render() branches on this to decide which embed-init message (single-
78
+ # value vs two-sided) to send, since both classes point at the same ESM
79
+ # file.
80
+ mode = traitlets.Unicode("value").tag(sync=True)
81
+ value_json = traitlets.Unicode("null").tag(sync=True)
82
+ raw_text = traitlets.Unicode("").tag(sync=True)
83
+ raw_format = traitlets.Unicode("").tag(sync=True)
84
+ # A single source, or a list of candidates the viewer tries itself —
85
+ # see the class docstring. traitlets.Union tries each trait in order,
86
+ # same as the plain-string case has always worked.
87
+ interpreter_source = traitlets.Union(
88
+ [traitlets.Unicode(), traitlets.List(traitlets.Unicode())], default_value=""
89
+ ).tag(sync=True)
90
+ # {"xml": [...], "ini": [...]} — any mix of formats — only used when raw_format is "".
91
+ interpreter_candidates_by_format = traitlets.Dict().tag(sync=True)
92
+ data_name = traitlets.Unicode("data").tag(sync=True)
93
+ viewer_html = traitlets.Unicode("").tag(sync=True)
94
+ height = traitlets.Int(600).tag(sync=True)
95
+ # JSON-encoded embedded-mode `config` object (settings/theme), or the
96
+ # literal string "null" for none — see `structile-embed-init` in
97
+ # structile.html's EMBEDDED MODE section.
98
+ config_json = traitlets.Unicode("null").tag(sync=True)
99
+ # Pushed from the viewer on every `structile-dirty-state-changed`.
100
+ dirty = traitlets.Bool(False).tag(sync=True)
101
+ dirty_count = traitlets.Int(0).tag(sync=True)
102
+
103
+ def __init__(
104
+ self,
105
+ *,
106
+ value_json: str = "null",
107
+ raw_text: str = "",
108
+ raw_format: str = "",
109
+ interpreter_source: Union[str, List[str]] = "",
110
+ interpreter_candidates_by_format: Optional[Dict[str, List[str]]] = None,
111
+ data_name: str = "data",
112
+ height: int = 600,
113
+ config_json: str = "null",
114
+ value: Any = None,
115
+ source_path: Optional[pathlib.Path] = None,
116
+ save_path: Optional[pathlib.Path] = None,
117
+ **kwargs: Any,
118
+ ) -> None:
119
+ # Read fresh on every construction (not once at import time) so
120
+ # local edits to structile.html show up in the next
121
+ # open(...) call without reinstalling the package.
122
+ viewer_html = _find_viewer_html().read_text(encoding="utf-8")
123
+ super().__init__(
124
+ value_json=value_json,
125
+ raw_text=raw_text,
126
+ raw_format=raw_format,
127
+ interpreter_source=interpreter_source,
128
+ interpreter_candidates_by_format=interpreter_candidates_by_format or {},
129
+ data_name=data_name,
130
+ height=height,
131
+ config_json=config_json,
132
+ viewer_html=viewer_html,
133
+ **kwargs,
134
+ )
135
+ # Plain instance state, not a synced trait — Python-only, mirrors
136
+ # whatever was last shown/saved rather than something the JS side
137
+ # reads or drives.
138
+ self.value = value
139
+ # The literal text of the most recent save (or the initial load),
140
+ # regardless of whether it parsed — see _handle_save. Separate from
141
+ # .value, which only ever reflects the last SUCCESSFULLY parsed
142
+ # value (source_code_view_claude.md Phase 6's ".value"/raw-text
143
+ # distinction for invalid-buffer saves).
144
+ self.source_text = raw_text or value_json
145
+ self._source_path = source_path
146
+ self._save_path = save_path
147
+ self.on_msg(self._on_custom_msg)
148
+
149
+ def set_value(self, obj: Any, *, name: Optional[str] = None, default: Optional[Callable[[Any], Any]] = None) -> None:
150
+ """Replace the displayed value in place (re-renders the viewer).
151
+
152
+ `default`: same `normalize()` escape hatch as `open()`'s own
153
+ `default=` — called on any value `normalize()` doesn't otherwise
154
+ support, in place of raising `TypeError`.
155
+ """
156
+ normalized = normalize(obj, default)
157
+ self.raw_format = ""
158
+ self.value_json = json.dumps(normalized, indent=2)
159
+ self.value = normalized
160
+ self.source_text = self.value_json
161
+ if name is not None:
162
+ self.data_name = name
163
+
164
+ # NOT named _handle_msg — ipywidgets.Widget already defines a method by
165
+ # that exact name (the real comm-message router, wired to the comm via
166
+ # on_msg during open(): handles state "update" — the sync a plain
167
+ # model.set()+save_changes() in JS produces, e.g. dirty/dirty_count —
168
+ # AND dispatches "custom" messages, e.g. our own model.send(...), to
169
+ # on_msg-registered callbacks like this one). Reusing that name here
170
+ # would silently override it instead of adding to it, breaking both.
171
+ def _on_custom_msg(self, widget: "StructileWidget", content: Any, buffers: Any) -> None:
172
+ if not isinstance(content, dict):
173
+ return
174
+ if content.get("type") == "structile-save-requested":
175
+ logger.debug("structile: widget received structile-save-requested")
176
+ self._handle_save(content)
177
+ elif content.get("type") == "structile-settings-changed":
178
+ logger.debug("structile: widget received structile-settings-changed")
179
+ self._handle_settings_changed(content)
180
+
181
+ def _handle_save(self, content: dict) -> None:
182
+ text = content.get("content")
183
+ if not isinstance(text, str):
184
+ logger.warning("structile: structile-save-requested had no usable content, ignoring (%r)", content.get("error"))
185
+ return
186
+ self.source_text = text # the literal buffer, saved as-is below regardless of validity
187
+ if self.raw_format:
188
+ self.value = text # xml/html/etc — already serialized text, nothing to parse
189
+ else:
190
+ try:
191
+ self.value = json.loads(text)
192
+ except ValueError:
193
+ # Mid-edit/invalid JSON — source_code_view_claude.md Phase
194
+ # 3b's "Save source (graph is stale)" case, saveable on
195
+ # purpose. .value keeps reflecting the last successfully
196
+ # parsed value (so anything reading it between saves never
197
+ # sees a broken parse) rather than becoming the raw,
198
+ # unparseable text itself; the literal buffer is still
199
+ # captured above (self.source_text) and still written to
200
+ # disk below.
201
+ logger.debug("structile: structile-save-requested content isn't valid JSON, .value left unchanged")
202
+ target = self._resolve_save_path(content)
203
+ if target is None:
204
+ logger.debug("structile: save updated self.value only (no source_path/save_path known)")
205
+ return
206
+ if target != self._source_path and target.exists():
207
+ # Save As colliding with an existing, unrelated file — refuse
208
+ # rather than silently clobbering it. There's no ack/error
209
+ # channel back to the viewer for this message (see
210
+ # structile-save-requested in the "EMBEDDED MODE" protocol doc), so the
211
+ # only signal is this log line; self.value/self.source_text above
212
+ # already reflect the saved buffer either way, and a plain Save
213
+ # right after this still targets the ORIGINAL file, unaffected by
214
+ # the rejected rename (self._source_path is left untouched).
215
+ logger.warning(
216
+ "structile: save-as target %s already exists, refusing to overwrite it — data kept in-memory only",
217
+ target,
218
+ )
219
+ return
220
+ target.write_text(text, encoding="utf-8")
221
+ self._source_path = target
222
+ logger.info("structile: saved %s", target)
223
+
224
+ def _resolve_save_path(self, content: dict) -> Optional[pathlib.Path]:
225
+ """Where to write on save: a caller-fixed `save_path` always wins;
226
+ otherwise the file this data was loaded from. `fileName` normally just
227
+ equals that file's stem (an ordinary Save/Ctrl+S), but a differing one
228
+ — from the viewer's own embed-only Save As… popover (performSaveAs in
229
+ structile.html), or some other future host mechanism — is
230
+ honored as a rename: written alongside the original (which is never
231
+ itself touched) and adopted as `self._source_path` for every save
232
+ after this one (see _handle_save). `None` (no known path — an
233
+ in-memory object with no `path=` override) means: update `self.value`
234
+ only and write nothing — deliberately true for Save As too, since
235
+ without an existing source_path there's no directory to anchor a new
236
+ file's location to."""
237
+ if self._save_path is not None:
238
+ return self._save_path
239
+ if self._source_path is None:
240
+ return None
241
+ file_name = content.get("fileName")
242
+ ext = content.get("ext") or self._source_path.suffix.lstrip(".")
243
+ if file_name and file_name != self._source_path.stem:
244
+ try:
245
+ return self._source_path.with_name(file_name + "." + ext)
246
+ except ValueError:
247
+ # An invalid name (e.g. containing a path separator) reached
248
+ # here despite the viewer's own inline validation
249
+ # (sanitizeSaveAsName in structile.html) — some other
250
+ # protocol producer, or a stale cached build. Fall back to the
251
+ # current path rather than raising out of a comm message
252
+ # handler.
253
+ logger.warning("structile: ignoring invalid save-as file name %r", file_name)
254
+ return self._source_path
255
+ return self._source_path
256
+
257
+ def _handle_settings_changed(self, content: dict) -> None:
258
+ """The viewer's embed-only "Set as default" button (see
259
+ enableSettingsPanel/structile-settings-changed in structile.html's
260
+ EMBEDDED MODE section) — `content` is the FULL current settings-panel
261
+ state (settings/renderMap/links/theme), not a diff, same as Export
262
+ Settings would produce. Promotes it into `structile.options`
263
+ (matplotlib.rcParams-style — see config.replace_options), so it
264
+ becomes the new baseline for every `open()` call for the rest
265
+ of this process. This widget instance itself is unaffected — it
266
+ already shows these values."""
267
+ payload = dict(content.get("settings") or {})
268
+ payload.update(content.get("renderMap") or {})
269
+ # `links` carries the viewer's own {open, close} field names (see
270
+ # structile.html's linkSettings) — renamed to the
271
+ # Python-facing linkOpen/linkClose option names (see
272
+ # config._LINK_KEYS_TO_FIELD for the reverse direction).
273
+ links = content.get("links") or {}
274
+ if "open" in links:
275
+ payload["linkOpen"] = links["open"]
276
+ if "close" in links:
277
+ payload["linkClose"] = links["close"]
278
+ if "theme" in content:
279
+ payload["theme"] = content["theme"]
280
+ try:
281
+ opts = options_from_dict(payload)
282
+ except (AttributeError, TypeError, ValueError) as e:
283
+ logger.warning("structile: structile-settings-changed had an invalid payload, ignoring (%s)", e)
284
+ return
285
+ replace_options(opts)
286
+ logger.info("structile: settings panel state promoted to structile.options (module default)")
287
+
288
+
289
+ class StructileDiffWidget(anywidget.AnyWidget):
290
+ """Renders structile.html in embedded two-sided diff mode
291
+ (`structile-embed-diff-init` — see the "EMBEDDED MODE"/"DIFF MODE" sections of
292
+ structile.html), for `structile.diff(renderer="widget")`.
293
+
294
+ Shares `static/widget.js` with `StructileWidget` (its `mode` trait is
295
+ what tells the front end which embed-init message to build/send) rather
296
+ than duplicating the iframe-mounting boilerplate for a second file.
297
+
298
+ The diff graph itself is always read-only (same as every other diff
299
+ renderer), but each side's own source pane is independently editable and
300
+ saveable — see structile.html's `performDiffSideSave`, which
301
+ posts `structile-save-requested` with a `side: "left" | "right"` field. Saving
302
+ a side updates `self.left_value`/`self.right_value` in place; there is
303
+ no `left_path`/`right_path` to write back to yet (`structile.diff()`
304
+ doesn't take one), so — mirroring `StructileWidget`'s own "no
305
+ source_path/save_path known" case — a save only ever updates the
306
+ in-memory value, never a file.
307
+ """
308
+
309
+ _esm = _PACKAGE_DIR / "static" / "widget.js"
310
+
311
+ mode = traitlets.Unicode("diff").tag(sync=True)
312
+ left_text = traitlets.Unicode("").tag(sync=True)
313
+ left_format = traitlets.Unicode("").tag(sync=True)
314
+ left_name = traitlets.Unicode("left").tag(sync=True)
315
+ right_text = traitlets.Unicode("").tag(sync=True)
316
+ right_format = traitlets.Unicode("").tag(sync=True)
317
+ right_name = traitlets.Unicode("right").tag(sync=True)
318
+ # Same Union-of-str-or-list shape as StructileWidget.interpreter_source,
319
+ # once per side (a diff's two sides can be different, mutually
320
+ # incompatible markup schemas — see structile.diff()'s own
321
+ # interpreter=/format= docs).
322
+ left_interpreter_source = traitlets.Union(
323
+ [traitlets.Unicode(), traitlets.List(traitlets.Unicode())], default_value=""
324
+ ).tag(sync=True)
325
+ right_interpreter_source = traitlets.Union(
326
+ [traitlets.Unicode(), traitlets.List(traitlets.Unicode())], default_value=""
327
+ ).tag(sync=True)
328
+ diff_view = traitlets.Unicode("unified").tag(sync=True)
329
+ key_columns = traitlets.List(traitlets.Unicode()).tag(sync=True)
330
+ viewer_html = traitlets.Unicode("").tag(sync=True)
331
+ height = traitlets.Int(600).tag(sync=True)
332
+ config_json = traitlets.Unicode("null").tag(sync=True)
333
+
334
+ def __init__(
335
+ self,
336
+ *,
337
+ left: "Payload",
338
+ right: "Payload",
339
+ view: str = "unified",
340
+ key_columns: Optional[list] = None,
341
+ height: int = 600,
342
+ **kwargs: Any,
343
+ ) -> None:
344
+ viewer_html = _find_viewer_html().read_text(encoding="utf-8")
345
+ config_json = json.dumps(left.config) if left.config is not None else "null"
346
+ super().__init__(
347
+ left_text=left.text,
348
+ left_format=left.format,
349
+ left_name=left.name,
350
+ right_text=right.text,
351
+ right_format=right.format,
352
+ right_name=right.name,
353
+ left_interpreter_source=left.interpreter_source or "",
354
+ right_interpreter_source=right.interpreter_source or "",
355
+ diff_view=view if view == "split" else "unified",
356
+ key_columns=list(key_columns or []),
357
+ viewer_html=viewer_html,
358
+ height=height,
359
+ config_json=config_json,
360
+ **kwargs,
361
+ )
362
+ # Plain instance state (not synced traits) — mirrors StructileWidget's
363
+ # own .value: Python-only, updated on each side's own Save. Parsed
364
+ # up front for a "json" side (matching open()'s own
365
+ # already-normalized initial .value) — every other format (no
366
+ # Python-side parser here — see _handle_save) keeps the raw text.
367
+ self.left_value: Any = json.loads(left.text) if left.format == "json" else left.text
368
+ self.right_value: Any = json.loads(right.text) if right.format == "json" else right.text
369
+ self.on_msg(self._on_custom_msg)
370
+
371
+ def _on_custom_msg(self, widget: "StructileDiffWidget", content: Any, buffers: Any) -> None:
372
+ if isinstance(content, dict) and content.get("type") == "structile-save-requested":
373
+ logger.debug("structile.diff: widget received structile-save-requested")
374
+ self._handle_save(content)
375
+
376
+ def _handle_save(self, content: dict) -> None:
377
+ text = content.get("content")
378
+ side = content.get("side")
379
+ if not isinstance(text, str) or side not in ("left", "right"):
380
+ logger.warning(
381
+ "structile.diff: widget structile-save-requested had no usable content/side, ignoring (%r)",
382
+ content.get("error"),
383
+ )
384
+ return
385
+ # Unlike StructileWidget.raw_format (only truthy for markup/other
386
+ # non-JSON formats — "" means "plain JSON value"), a diff side's own
387
+ # `format` is always a real string, "json" included (see
388
+ # _resolve_diff_side) — so the "already-serialized, nothing to
389
+ # parse" check has to name "json" explicitly rather than testing
390
+ # for emptiness. "python" text has no Python-side repr parser here
391
+ # (same gap `_describe_payload_for_text` already has), so it's kept
392
+ # as raw text too, same as xml/html/any other format.
393
+ fmt = self.left_format if side == "left" else self.right_format
394
+ if fmt == "json":
395
+ try:
396
+ value: Any = json.loads(text)
397
+ except ValueError:
398
+ logger.debug("structile.diff: %s side saved non-JSON text, value left unchanged", side)
399
+ return
400
+ else:
401
+ value = text
402
+ if side == "left":
403
+ self.left_value = value
404
+ else:
405
+ self.right_value = value
406
+ logger.info("structile.diff: %s side saved (in-memory only — diff() has no source path to write back to)", side)