diff --git a/UPSTREAM_HEAD b/UPSTREAM_HEAD
index 7ccba44..9a0297e 100644
--- a/UPSTREAM_HEAD
+++ b/UPSTREAM_HEAD
@@ -1 +1 @@
-8f1413b1873ed74df61610fa00159123fc8d1743
+0f49fdc4d60f5f4c116e8b4e7b1eb99b841f0631
diff --git a/run/input/UPSTREAM_COMMIT b/run/input/UPSTREAM_COMMIT
index 357d0da..9a0297e 100644
--- a/run/input/UPSTREAM_COMMIT
+++ b/run/input/UPSTREAM_COMMIT
@@ -1 +1 @@
-23deacb95561364c3fe598ef9e7362acd9cdd803
+0f49fdc4d60f5f4c116e8b4e7b1eb99b841f0631
diff --git a/run/input/core/app/z2ui5/webapp/Component.js b/run/input/core/app/z2ui5/webapp/Component.js
index 0704803..95ab9f7 100644
--- a/run/input/core/app/z2ui5/webapp/Component.js
+++ b/run/input/core/app/z2ui5/webapp/Component.js
@@ -4,7 +4,7 @@ sap.ui.define(
"z2ui5/model/models",
"z2ui5/core/Server",
"sap/ui/VersionInfo",
- "z2ui5/core/DebugTool",
+ "z2ui5/core/DeveloperTools",
"z2ui5/core/Lib",
"z2ui5/core/AppState",
"z2ui5/Util",
@@ -16,7 +16,7 @@ sap.ui.define(
Models,
Server,
VersionInfo,
- DebugTool,
+ DeveloperTools,
Lib,
AppState,
DateUtil,
@@ -60,11 +60,29 @@ sap.ui.define(
AppState.state.oDeviceModel = Models.createDeviceModel();
this.setModel(AppState.state.oDeviceModel, "device");
+ // Warm-load the messaging module so Lib.getMessaging's synchronous
+ // sap.ui.require resolves it before the first view is displayed.
+ // On UI5 2.x sap/ui/core/Messaging is the only messaging API (the
+ // sap.ui.getCore().getMessageManager() fallback is gone), and
+ // nothing else pulls the module into the graph - without this the
+ // message> model and validation collection would silently no-op.
+ // Only attempt it where the module exists (1.118+): on older releases
+ // (e.g. 1.71) the require would 404 and make the ui5loader retry
+ // loudly via synchronous XHR; there Lib.getMessaging falls back to
+ // sap.ui.getCore().getMessageManager() instead.
+ if (Lib.hasMessagingModule()) {
+ sap.ui.require(
+ ["sap/ui/core/Messaging"],
+ () => {},
+ () => {},
+ );
+ }
+
this._initLaunchpad();
this._initVersionInfo();
this._installUnloadListener();
- this._installDebugToolShortcut();
+ this._installDeveloperToolsShortcut();
this._installScrollListener();
// The stopped router removed with the manifest routing section used
@@ -96,13 +114,14 @@ sap.ui.define(
window.addEventListener(this._unloadEvent, this._boundUnload);
},
- _installDebugToolShortcut() {
- // Ctrl + F12 opens / closes the in-app debug tool.
+ _installDeveloperToolsShortcut() {
+ // Ctrl + F12 opens / closes the in-app developer tools.
this._boundKeydown = (event) => {
if (event.ctrlKey && event.key === "F12") {
const state = AppState.state;
- if (!state.debugTool) state.debugTool = new DebugTool();
- state.debugTool.toggle();
+ if (!state.developerTools)
+ state.developerTools = new DeveloperTools();
+ state.developerTools.toggle();
}
};
document.addEventListener("keydown", this._boundKeydown);
@@ -225,12 +244,12 @@ sap.ui.define(
capture: true,
});
- // The debug tool is created lazily by the Ctrl+F12 shortcut -
- // destroy it (which also closes its dialog) so a re-launch (FLP)
- // does not leak the control instance.
- if (AppState.state.debugTool) {
- AppState.state.debugTool.destroy();
- AppState.state.debugTool = null;
+ // The developer tools control is created lazily by the Ctrl+F12
+ // shortcut - destroy it (which also closes its dialog) so a re-launch
+ // (FLP) does not leak the control instance.
+ if (AppState.state.developerTools) {
+ AppState.state.developerTools.destroy();
+ AppState.state.developerTools = null;
}
Server.endSession();
diff --git a/run/input/core/app/z2ui5/webapp/cc/Dirty.js b/run/input/core/app/z2ui5/webapp/cc/Dirty.js
index 4ec6567..c4d0538 100644
--- a/run/input/core/app/z2ui5/webapp/cc/Dirty.js
+++ b/run/input/core/app/z2ui5/webapp/cc/Dirty.js
@@ -5,6 +5,23 @@ sap.ui.define(
["sap/ui/core/Control", "z2ui5/core/Lib", "z2ui5/core/AppState"],
(Control, Lib, AppState) => {
"use strict";
+
+ // Every live Dirty instance that is currently dirty. The FLP dirty flag
+ // and the browser's onbeforeunload are single global slots, so the guard
+ // must reflect whether ANY instance is dirty - one instance clearing its
+ // own flag (or being destroyed) must not wipe another instance's unsaved
+ // guard (e.g. a main-view form plus a form in a dialog).
+ const dirtyControls = new Set();
+
+ function syncUnloadPrompt(anyDirty) {
+ window.onbeforeunload = anyDirty
+ ? (e) => {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ : null;
+ }
+
return Control.extend("z2ui5.cc.Dirty", {
metadata: {
properties: {
@@ -16,38 +33,36 @@ sap.ui.define(
},
setIsDirty(val) {
this.setProperty("isDirty", val);
+ if (val) {
+ dirtyControls.add(this);
+ } else {
+ dirtyControls.delete(this);
+ }
+ this._applyDirtyState();
+ },
- // Fallback for non-launchpad scenarios: ask the browser to confirm
- // before leaving the page when something is unsaved.
- const fallback = () => {
- if (val) {
- window.onbeforeunload = (e) => {
- e.preventDefault();
- e.returnValue = "";
- };
- } else {
- window.onbeforeunload = null;
- }
- };
-
- // Use the FLP dirty flag when running inside the Launchpad (SAPUI5
- // only); otherwise fall back to the browser unload prompt.
+ // Apply the AGGREGATE dirty state (any instance dirty) to whichever
+ // mechanism is active: the FLP dirty flag inside the Launchpad (SAPUI5
+ // only), else the browser unload prompt.
+ _applyDirtyState() {
+ const anyDirty = dirtyControls.size > 0;
try {
const launchpad = AppState.state.oLaunchpad;
const hasFlpDirtyFlag =
launchpad?.Container?.setDirtyFlag && launchpad.ShellUIService;
if (hasFlpDirtyFlag) {
- launchpad.Container.setDirtyFlag(val);
+ launchpad.Container.setDirtyFlag(anyDirty);
} else {
- fallback();
+ syncUnloadPrompt(anyDirty);
}
} catch (e) {
Lib.logError("Dirty.setIsDirty: setDirtyFlag failed", e);
- fallback();
+ syncUnloadPrompt(anyDirty);
}
},
exit() {
- window.onbeforeunload = null;
+ dirtyControls.delete(this);
+ this._applyDirtyState();
},
renderer: { apiVersion: 2, render() {} },
});
diff --git a/run/input/core/app/z2ui5/webapp/cc/Focus.js b/run/input/core/app/z2ui5/webapp/cc/Focus.js
index f205a78..ead5115 100644
--- a/run/input/core/app/z2ui5/webapp/cc/Focus.js
+++ b/run/input/core/app/z2ui5/webapp/cc/Focus.js
@@ -28,7 +28,7 @@ sap.ui.define(
setFocusId(val) {
try {
this.setProperty("focusId", val);
- const oElement = ViewSlots.byId("MAIN", val);
+ const oElement = ViewSlots.byIdOfOwner(this, val);
if (oElement) oElement.applyFocusInfo(oElement.getFocusInfo());
} catch (e) {
Lib.logError("Focus.setFocusId failed", e);
@@ -37,7 +37,10 @@ sap.ui.define(
onAfterRendering() {
if (!this._pendingFocus) return;
this._pendingFocus = false;
- const oElement = ViewSlots.byId("MAIN", this.getProperty("focusId"));
+ const oElement = ViewSlots.byIdOfOwner(
+ this,
+ this.getProperty("focusId"),
+ );
if (!oElement) return;
try {
// Merge the additional selection info into the existing focus info,
diff --git a/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js b/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js
index 12f6eeb..deb3b37 100644
--- a/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js
+++ b/run/input/core/app/z2ui5/webapp/cc/MultiInputExt.js
@@ -55,7 +55,10 @@ sap.ui.define(
},
renderer: { apiVersion: 2, render() {} },
setControl() {
- const input = ViewSlots.byId("MAIN", this.getProperty("MultiInputId"));
+ const input = ViewSlots.byIdOfOwner(
+ this,
+ this.getProperty("MultiInputId"),
+ );
if (!input || this.getProperty("checkInit")) return;
this.setProperty("checkInit", true);
try {
diff --git a/run/input/core/app/z2ui5/webapp/cc/Scrolling.js b/run/input/core/app/z2ui5/webapp/cc/Scrolling.js
index 1051509..acf5a69 100644
--- a/run/input/core/app/z2ui5/webapp/cc/Scrolling.js
+++ b/run/input/core/app/z2ui5/webapp/cc/Scrolling.js
@@ -25,14 +25,14 @@ sap.ui.define(
},
_getDomInnerElement(id) {
- const control = ViewSlots.byId("MAIN", id);
+ const control = ViewSlots.byIdOfOwner(this, id);
if (!control) return null;
return document.getElementById(`${control.getId()}-inner`);
},
_getScrollTop(item) {
try {
- const control = ViewSlots.byId("MAIN", item.N);
+ const control = ViewSlots.byIdOfOwner(this, item.N);
// Some controls expose a scroll delegate; prefer it when available.
const delegate = control?.getScrollDelegate?.();
if (delegate) return delegate.getScrollTop();
@@ -78,7 +78,7 @@ sap.ui.define(
_restoreScrollPosition(item) {
try {
- const control = ViewSlots.byId("MAIN", item.N);
+ const control = ViewSlots.byIdOfOwner(this, item.N);
if (control?.scrollTo) {
control.scrollTo(item.V);
return;
@@ -99,7 +99,7 @@ sap.ui.define(
try {
for (const item of items) {
- const control = ViewSlots.byId("MAIN", item.N);
+ const control = ViewSlots.byIdOfOwner(this, item.N);
if (!control) continue;
// Restore immediately when rendered, otherwise once it is.
diff --git a/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js b/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js
index e710ffe..ec878a9 100644
--- a/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js
+++ b/run/input/core/app/z2ui5/webapp/cc/SmartMultiInputExt.js
@@ -55,6 +55,13 @@ sap.ui.define(
// Mirror each range entry with the visible token text + long key
// so the backend has enough info to re-render the input later.
+ // NOTE: this pairs the i-th range entry with the i-th token by
+ // position, which assumes getRangeData() and getTokens() are index
+ // aligned. That holds only while every token is a range token; a
+ // mix of plain value tokens and range tokens would misalign the
+ // captions. The wrapped control is the SAPUI5-only sap.ui.comp
+ // SmartMultiInput, so a token-identity pairing needs verification
+ // against that control before it can replace the index pairing.
const source = oEvent.getSource();
const tokens = source.getTokens();
const rangeData = source.getRangeData() || [];
@@ -104,7 +111,10 @@ sap.ui.define(
},
renderer: { apiVersion: 2, render() {} },
setControl() {
- const input = ViewSlots.byId("MAIN", this.getProperty("multiInputId"));
+ const input = ViewSlots.byIdOfOwner(
+ this,
+ this.getProperty("multiInputId"),
+ );
if (!input || this.getProperty("checkInit")) return;
this.setProperty("checkInit", true);
try {
diff --git a/run/input/core/app/z2ui5/webapp/cc/Tree.js b/run/input/core/app/z2ui5/webapp/cc/Tree.js
index 7d295be..f030c36 100644
--- a/run/input/core/app/z2ui5/webapp/cc/Tree.js
+++ b/run/input/core/app/z2ui5/webapp/cc/Tree.js
@@ -21,16 +21,31 @@ sap.ui.define(
},
_getTreeBinding() {
- const treeControl = ViewSlots.byId("MAIN", this.getProperty("tree_id"));
+ // Resolve the tree in the companion's OWN slot (main, popup,
+ // popover, nested): byIdOfOwner works next to a tree in a dialog,
+ // and unlike resolveById it never picks a same-id tree from another
+ // open slot.
+ const treeControl = ViewSlots.byIdOfOwner(
+ this,
+ this.getProperty("tree_id"),
+ );
return treeControl?.getBinding("items");
},
+ // Snapshots are keyed by tree_id so several trees on one page (e.g. a
+ // main-view tree plus a tree in a popup) keep independent state - a
+ // single shared slot would let one companion overwrite another's.
setBackend() {
try {
const binding = this._getTreeBinding();
- AppState.state.treeState = binding
- ? binding.getCurrentTreeState()
- : undefined;
+ const id = this.getProperty("tree_id");
+ if (!id) return;
+ // Only overwrite the snapshot when the binding is actually
+ // resolvable - a momentarily missing binding must not wipe a
+ // still-valid snapshot for this id.
+ if (binding) {
+ AppState.state.treeStates[id] = binding.getCurrentTreeState();
+ }
} catch (e) {
Lib.logError("Tree.setBackend: failed", e);
}
@@ -46,11 +61,34 @@ sap.ui.define(
},
onAfterRendering() {
- if (!this._pendingTreeState) return;
- this._pendingTreeState = false;
try {
+ const id = this.getProperty("tree_id");
+ const snapshot = id && AppState.state.treeStates[id];
+ if (!snapshot) return;
const binding = this._getTreeBinding();
- if (binding) binding.setTreeState(AppState.state.treeState);
+ if (!binding) return;
+ // Restore exactly once per (snapshot, binding) pair. setBackend
+ // creates a fresh snapshot object each roundtrip, so a new
+ // snapshot or a rebuilt binding is what triggers a restore -
+ // NOT an incidental re-render (theme/density/parent
+ // invalidation), which would otherwise force a spurious
+ // refresh(true) and collapse the user's expansions back to the
+ // last roundtrip snapshot.
+ if (
+ this._appliedSnapshot === snapshot &&
+ this._appliedBinding === binding
+ ) {
+ return;
+ }
+ this._appliedSnapshot = snapshot;
+ this._appliedBinding = binding;
+ // setTreeState only stores an INITIAL tree state - the binding
+ // adapter consumes it while creating its nodes, which already
+ // happened during this rendering cycle. Force a rebuild so the
+ // snapshot actually gets applied (headless-verified: without the
+ // refresh the tree stays collapsed).
+ binding.setTreeState(snapshot);
+ binding.refresh(true);
} catch (e) {
Lib.logError("Tree.onAfterRendering: setTreeState failed", e);
}
@@ -63,8 +101,6 @@ sap.ui.define(
oRm.style("display", "none");
oRm.openEnd();
oRm.close("span");
- if (!AppState.state.treeState) return;
- oControl._pendingTreeState = true;
},
},
});
diff --git a/run/input/core/app/z2ui5/webapp/cc/UITableExt.js b/run/input/core/app/z2ui5/webapp/cc/UITableExt.js
index 3023339..7b7c611 100644
--- a/run/input/core/app/z2ui5/webapp/cc/UITableExt.js
+++ b/run/input/core/app/z2ui5/webapp/cc/UITableExt.js
@@ -43,7 +43,7 @@ sap.ui.define(
},
_getTable() {
- return ViewSlots.byId("MAIN", this.getProperty("tableId"));
+ return ViewSlots.byIdOfOwner(this, this.getProperty("tableId"));
},
readFilter() {
diff --git a/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js b/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js
index 2ad4e6c..004e632 100644
--- a/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js
+++ b/run/input/core/app/z2ui5/webapp/cc/UploadSetExt.js
@@ -86,8 +86,8 @@ sap.ui.define(
renderer: { apiVersion: 2, render() {} },
setControl() {
- const uploadSet = ViewSlots.byId(
- "MAIN",
+ const uploadSet = ViewSlots.byIdOfOwner(
+ this,
this.getProperty("uploadSetId"),
);
if (!uploadSet || this.getProperty("checkInit")) return;
diff --git a/run/input/core/app/z2ui5/webapp/controller/View1.controller.js b/run/input/core/app/z2ui5/webapp/controller/View1.controller.js
index 070187c..6c47ee4 100644
--- a/run/input/core/app/z2ui5/webapp/controller/View1.controller.js
+++ b/run/input/core/app/z2ui5/webapp/controller/View1.controller.js
@@ -98,6 +98,11 @@ sap.ui.define(
if (!PARAMS) return;
await this._displayPendingViews(PARAMS);
+ // The app may have been torn down (reset / FLP re-launch) while the
+ // pending views loaded; don't mutate history or fire onAfterRendering
+ // hooks against a dead app (the custom-JS phase below guards the same
+ // way via isDestroyed).
+ if (Lib.isDestroyed(this)) return;
this._updateBrowserHistory(PARAMS, oResponse.ID);
if (PARAMS.SET_NAV_BACK) history.back();
@@ -205,9 +210,9 @@ sap.ui.define(
return;
}
oFragment.setModel(oModel);
- // Share the one device model (created once in Component.js, never
- // destroyed) so {device>...} bindings work in popups too.
- oFragment.setModel(AppState.state.oDeviceModel, "device");
+ // The shared device + message models are attached inside
+ // ViewSlots.setView (the single funnel), so error paths that
+ // destroy a view without reaching setView never register it.
ViewSlots.setView("POPUP", oFragment);
oFragment.open();
},
@@ -232,8 +237,6 @@ sap.ui.define(
return;
}
oFragment.setModel(oModel);
- // Shared device model (see displayFragment) - for popovers too.
- oFragment.setModel(AppState.state.oDeviceModel, "device");
// Find the control to attach the popover to: any open slot first,
// then the global UI5 control registry as a last resort.
@@ -265,8 +268,6 @@ sap.ui.define(
return;
}
oView.setModel(oModel);
- // Shared device model (see displayFragment) - for nested views too.
- oView.setModel(AppState.state.oDeviceModel, "device");
const nestParams = AppState.state.oResponse?.PARAMS?.[paramKey];
if (!nestParams) {
@@ -385,7 +386,7 @@ sap.ui.define(
// The request body is built locally and handed explicitly through
// Server.roundtrip/readHttp. It is mirrored to AppState.state.oBody right
- // away so onBeforeRoundtrip hooks and the debug tool see it.
+ // away so onBeforeRoundtrip hooks and the developer tools see it.
const oBody = { VIEWNAME: "MAIN" };
AppState.state.oBody = oBody;
@@ -459,16 +460,21 @@ sap.ui.define(
// model + setModel() destroys and recreates every binding - measured
// ~3x slower with all values changed and ~150x slower when little
// changed (see node/tests-examples/modelUpdate.bench.spec.js).
- const existing = oView.getModel();
- if (existing?._z2ui5Tracked) {
- applyStoredSizeLimit(slotKey, existing);
- existing.setData(AppState.state.oResponse?.OVIEWMODEL);
+ // The framework-owned JSON model is the DEFAULT model normally, but
+ // the NAMED "http" model when SWITCH_DEFAULT_MODEL_PATH placed an
+ // OData model in the default slot - update whichever one is ours and
+ // never overwrite the OData default with a fresh JSON model.
+ const isOurs = (m) => (m?._z2ui5Tracked ? m : undefined);
+ const tracked =
+ isOurs(oView.getModel()) ?? isOurs(oView.getModel("http"));
+ if (tracked) {
+ applyStoredSizeLimit(slotKey, tracked);
+ tracked.setData(AppState.state.oResponse?.OVIEWMODEL);
return;
}
- // The slot's default model is not framework-owned (e.g. an
- // ODataModel via SWITCH_DEFAULT_MODEL_PATH): keep the previous
- // behavior and bind a fresh JSON model.
+ // No framework-owned model on this slot at all: bind a fresh default
+ // JSON model (keeps the previous behavior for that edge case).
const oModel = this._createViewModel();
applyStoredSizeLimit(slotKey, oModel);
oView.setModel(oModel);
@@ -510,7 +516,6 @@ sap.ui.define(
}
ViewSlots.setView("MAIN", oView);
- oView.setModel(AppState.state.oDeviceModel, "device");
if (switchPath) oView.setModel(oViewModel, "http");
AppState.state.oApp.removeAllPages();
AppState.state.oApp.insertPage(oView);
diff --git a/run/input/core/app/z2ui5/webapp/core/AppState.js b/run/input/core/app/z2ui5/webapp/core/AppState.js
index ce14d1b..5a24ec1 100644
--- a/run/input/core/app/z2ui5/webapp/core/AppState.js
+++ b/run/input/core/app/z2ui5/webapp/core/AppState.js
@@ -52,13 +52,13 @@
// oBody mirror of the current request payload - the body
// itself travels as a parameter through
// Server.roundtrip/readHttp; this record exists for
-// onBeforeRoundtrip hooks and the debug tool
+// onBeforeRoundtrip hooks and the developer tools
// (View1.eB / Server)
// oResponse last processed response { ID, PARAMS, OVIEWMODEL }
// responseData raw parsed response JSON (Server.readHttp); kept
// besides oResponse because it carries fields the
// cooked record does not (e.g. S_FRONT.APP, used by
-// the debug tool)
+// the developer tools)
// contextId stateful session id, header transport (Server)
// isBusy roundtrip in flight (View1.eB / Server)
// xxChangedPaths Set of edited /XX/ model paths for the delta (View1)
@@ -72,8 +72,10 @@
// timers single pending backend timer (FrontendAction)
// lastScrolled last scrolled element per slot (Server.onScrollCapture)
// viewSizeLimits per-slot model size limits (FrontendAction)
-// treeState tree binding state across rebuilds (Tree control)
-// debugTool DebugTool instance (Component, Ctrl+F12)
+// treeStates tree binding state per tree_id across rebuilds (Tree control)
+// developerTools DeveloperTools instance (Component, Ctrl+F12)
+// lastError the last fatal error shown by ErrorView (title/text/
+// onRetry), so the DeveloperTools Error tab can re-show it
// onBeforeRoundtrip, onAfterRoundtrip, onAfterRendering,
// onBeforeEventFrontend callback arrays, see Lib.registerCallback
sap.ui.define([], () => {
@@ -116,8 +118,9 @@ sap.ui.define([], () => {
timers: {},
lastScrolled: {},
viewSizeLimits: {},
- treeState: null,
- debugTool: null,
+ treeStates: {},
+ developerTools: null,
+ lastError: null,
// Callback arrays (see Lib.registerCallback / Lib.runCallbacks)
onBeforeRoundtrip: [],
diff --git a/run/input/core/app/z2ui5/webapp/core/DebugTool.js b/run/input/core/app/z2ui5/webapp/core/DebugTool.js
deleted file mode 100644
index d334aca..0000000
--- a/run/input/core/app/z2ui5/webapp/core/DebugTool.js
+++ /dev/null
@@ -1,351 +0,0 @@
-sap.ui.define(
- [
- "sap/ui/core/Control",
- "sap/ui/core/Fragment",
- "sap/ui/model/json/JSONModel",
- "z2ui5/core/Lib",
- "z2ui5/core/ViewSlots",
- "z2ui5/core/AppState",
- ],
- (Control, Fragment, JSONModel, Lib, ViewSlots, AppState) => {
- "use strict";
-
- // Fragment id under which the debug dialog's controls are registered;
- // used to resolve controls by their id instead of by content position.
- const FRAGMENT_ID = "z2ui5DebugTool";
-
- // Pretty-print any value (object, array, primitive) as indented JSON.
- // `null` is used as a fallback so undefined values still produce output.
- // A replacer drops circular references (the z2ui5 global can hold them,
- // e.g. via ComponentData) so the output stays useful JSON instead of
- // throwing and degrading to a bare "[object Object]".
- function toJson(val) {
- const safe = val === undefined ? null : val;
- const seen = new WeakSet();
- try {
- return JSON.stringify(
- safe,
- (key, value) => {
- if (typeof value === "object" && value !== null) {
- if (seen.has(value)) return "[Circular]";
- seen.add(value);
- }
- return value;
- },
- 3,
- );
- } catch {
- // The debug tool must never crash the host app, so degrade to the
- // plain string form if serialization still fails.
- return String(safe);
- }
- }
-
- // XSL stylesheet used by prettifyXml to reindent any XML string.
- const PRETTIFY_XSL = `
-
-
-
-
-
-
-
-
-
-
- `;
-
- // The XSLT processor and (de)serializers are expensive to construct, so
- // we keep them as module-level singletons.
- const _xmlSerializer = new XMLSerializer();
- const _domParser = new DOMParser();
- let _xsltProcessor = null;
-
- function getXsltProcessor() {
- if (_xsltProcessor) return _xsltProcessor;
- const xsltDoc = _domParser.parseFromString(
- PRETTIFY_XSL,
- "application/xml",
- );
- _xsltProcessor = new XSLTProcessor();
- _xsltProcessor.importStylesheet(xsltDoc);
- return _xsltProcessor;
- }
-
- // Helpers to pull the various pieces of state shown in the dialog.
- function getModelJson(view) {
- const model = view?.getModel();
- return model?.getData();
- }
-
- function getViewContent(view) {
- // Private member access (debug tool only): XMLView keeps the raw XML
- // string as a pseudo property in mProperties, but does not declare it
- // in its metadata - getProperty("viewContent") therefore throws and
- // would abort the whole tab selection. Read the plain object instead.
- return view?.mProperties?.viewContent;
- }
-
- function getRenderedContent(view) {
- // Private member access (debug tool only): _xContent holds the view
- // XML after XML templating ran; there is no public equivalent.
- return view?._xContent?.outerHTML;
- }
-
- // Minimal text dump of the shared error log (the entries Lib.logError
- // pushes to AppState.state.errors): one " " line per entry,
- // oldest first. Empty log yields a short placeholder.
- function formatErrorLog() {
- const errors = AppState.state.errors || [];
- if (!errors.length) return "(log is empty)";
- return errors.map((e) => `${e.ts} ${e.message}`).join("\n");
- }
-
- function getResponseXml(key) {
- const params = AppState.state.oResponse?.PARAMS;
- const slot = params?.[key];
- return slot?.XML;
- }
-
- // Preload the sap.ui.codeeditor modules used by the fragment. On older
- // UI5 releases (e.g. 1.71) Fragment.load still processes the XML with
- // the sync strategy, so an unloaded CodeEditor would be fetched via
- // synchronous XHR and executed with eval - which a Content-Security-
- // Policy without 'unsafe-eval' blocks. Requiring the modules
- // asynchronously up front makes the sync lookup a cache hit.
- function preloadCodeEditor() {
- return new Promise((resolve) => {
- sap.ui.require(
- ["sap/ui/codeeditor/library", "sap/ui/codeeditor/CodeEditor"],
- () => resolve(),
- // On failure continue anyway and let Fragment.load surface the
- // real error.
- () => resolve(),
- );
- });
- }
-
- // What each dropdown entry shows: either a JSON source or an XML source
- // (the latter optionally with the rendered DOM for the templating
- // toggle). The "SOURCE" entry is handled separately in onItemSelect.
- const jsonSources = {
- // The whole public z2ui5 global facade (oConfig, url, checkLocal,
- // Util, app-registered members, ...). Read directly here on purpose:
- // this is the debug inspector, whose job is to surface the live global
- // as-is - functions drop out under JSON.stringify, which is fine.
- // ui5lint-disable-next-line no-project-globals -- see reason above
- SYSTEM: () => window.z2ui5,
- MODEL: () => getModelJson(ViewSlots.getView("MAIN")),
- PLAIN: () => AppState.state.responseData,
- REQUEST: () => AppState.state.oBody,
- POPUP_MODEL: () => getModelJson(ViewSlots.getView("POPUP")),
- POPOVER_MODEL: () => getModelJson(ViewSlots.getView("POPOVER")),
- NEST1_MODEL: () => getModelJson(ViewSlots.getView("NEST")),
- NEST2_MODEL: () => getModelJson(ViewSlots.getView("NEST2")),
- };
-
- const xmlSources = {
- // Prefer the actual viewContent string; fall back to the XML that
- // arrived in the last server response.
- VIEW: () => ({
- xml:
- getViewContent(ViewSlots.getView("MAIN")) || getResponseXml("S_VIEW"),
- rendered: getRenderedContent(ViewSlots.getView("MAIN")),
- }),
- POPUP: () => ({ xml: getResponseXml("S_POPUP") }),
- POPOVER: () => ({ xml: getResponseXml("S_POPOVER") }),
- NEST1: () => ({
- xml: getViewContent(ViewSlots.getView("NEST")),
- rendered: getRenderedContent(ViewSlots.getView("NEST")),
- }),
- NEST2: () => ({
- xml: getViewContent(ViewSlots.getView("NEST2")),
- rendered: getRenderedContent(ViewSlots.getView("NEST2")),
- }),
- };
-
- return Control.extend("z2ui5.core.DebugTool", {
- // Reformat an XML string with indentation. If anything goes wrong the
- // original input is returned unchanged - the debug tool must never
- // crash the host app.
- prettifyXml(sourceXml) {
- if (!sourceXml) return "";
- try {
- const xmlDoc = _domParser.parseFromString(
- sourceXml,
- "application/xml",
- );
- const resultDoc = getXsltProcessor().transformToDocument(xmlDoc);
- if (!resultDoc) return sourceXml;
- const resultXml = _xmlSerializer.serializeToString(resultDoc);
- // The serializer escapes < and > inside text nodes; undo this so
- // the output is browseable XML again.
- return resultXml.replace(/>|</g, (match) =>
- match === ">" ? ">" : "<",
- );
- } catch {
- return sourceXml;
- }
- },
-
- // Called when the user picks an entry in the dropdown of the debug
- // dialog. The content shown per entry is defined declaratively in
- // jsonSources / xmlSources above.
- onItemSelect(oEvent) {
- const selItem = oEvent.getSource().getSelectedKey();
-
- if (jsonSources[selItem]) {
- this.displayEditor(oEvent, toJson(jsonSources[selItem]()), "json");
- return;
- }
-
- if (xmlSources[selItem]) {
- const { xml, rendered } = xmlSources[selItem]();
- this.displayEditor(
- oEvent,
- this.prettifyXml(xml),
- "xml",
- this.prettifyXml(rendered),
- );
- return;
- }
-
- if (selItem === "LOG") {
- this.displayEditor(oEvent, formatErrorLog(), "text");
- return;
- }
-
- if (selItem === "SOURCE") this.showAbapSource(oEvent);
- },
-
- // Show the ABAP source of the running app inside an iframe.
- showAbapSource(oEvent) {
- const contentControl = Fragment.byId(FRAGMENT_ID, "sourceHtml");
- if (!contentControl) return;
-
- const sFront = AppState.state.responseData?.S_FRONT;
- const appName = sFront?.APP || "";
- const appId = encodeURIComponent(appName);
- const url = `${window.location.origin}/sap/bc/adt/oo/classes/${appId}/source/main`;
- contentControl.setProperty(
- "content",
- ``,
- );
-
- const oModel = oEvent.getSource().getModel();
- if (!oModel) return;
- const modelData = oModel.getData();
- modelData.editor_visible = false;
- modelData.source_visible = true;
- oModel.refresh();
- },
-
- // Populates the dialog model so the right editor / source area is shown
- // with the given content. `xcontent` is the rendered DOM variant that
- // can be toggled in via the "Templating" button.
- displayEditor(oEvent, content, type, xcontent = "") {
- const oModel = oEvent.getSource().getModel();
- const modelData = oModel.getData();
- modelData.editor_visible = true;
- modelData.source_visible = false;
- modelData.isTemplating = Boolean(content?.includes("xmlns:template"));
- modelData.value = content;
- modelData.previousValue = content;
- modelData.xContent = xcontent;
- modelData.type = type;
- oModel.refresh();
- },
-
- onTemplatingPress(oEvent) {
- const oSource = oEvent.getSource();
- const oModel = oSource.getModel();
- const modelData = oModel.getData();
- // Toggle between the original (previousValue) and the rendered DOM
- // (xContent) representation.
- modelData.value = oSource.getPressed()
- ? modelData.xContent
- : modelData.previousValue;
- oModel.refresh();
- },
-
- onClose() {
- this.close();
- },
-
- async show() {
- // Guard against double-clicks while the fragment is still loading.
- if (this._showPending) return;
- this._showPending = true;
- try {
- if (!this.oDialog) {
- await preloadCodeEditor();
- this.oDialog = await Fragment.load({
- name: "z2ui5.core.DebugTool",
- controller: this,
- id: FRAGMENT_ID,
- });
- }
- // If the user closed the app while the fragment was loading we
- // must throw the freshly created dialog away.
- if (Lib.isDestroyed(this)) {
- if (this.oDialog) this.oDialog.destroy();
- this.oDialog = null;
- return;
- }
-
- const value = toJson(AppState.state.responseData);
- const oData = {
- type: "json",
- source_visible: false,
- editor_visible: true,
- value: value,
- xContent: "",
- previousValue: value,
- isTemplating: false,
- templatingSource: false,
- activeNest1: Boolean(getViewContent(ViewSlots.getView("NEST"))),
- activeNest2: Boolean(getViewContent(ViewSlots.getView("NEST2"))),
- activePopup: Boolean(getResponseXml("S_POPUP")),
- activePopover: Boolean(getResponseXml("S_POPOVER")),
- };
-
- const oModel = new JSONModel(oData);
- const oDialog = this.oDialog;
- oDialog.addStyleClass("dbg-ltr");
- oDialog.setModel(oModel);
- oDialog.open();
- } catch (e) {
- Lib.logError("DebugTool.show failed", e);
- } finally {
- this._showPending = false;
- }
- },
-
- close() {
- if (!this.oDialog) return;
- const oDialog = this.oDialog;
- this.oDialog = null;
- oDialog.close();
- oDialog.destroy();
- },
-
- // The dialog is not an aggregation of this control, so destroy() alone
- // would leave it (and its fragment controls) alive - clean it up when
- // the control is destroyed (Component.exit).
- exit() {
- this.close();
- },
-
- toggle() {
- if (this.oDialog) {
- this.close();
- } else {
- this.show();
- }
- },
-
- // The control itself renders nothing - it just provides the dialog API.
- renderer: { apiVersion: 2, render() {} },
- });
- },
-);
diff --git a/run/input/core/app/z2ui5/webapp/core/DebugTool.fragment.xml b/run/input/core/app/z2ui5/webapp/core/DeveloperTools.fragment.xml
similarity index 64%
rename from run/input/core/app/z2ui5/webapp/core/DebugTool.fragment.xml
rename to run/input/core/app/z2ui5/webapp/core/DeveloperTools.fragment.xml
index 01e53ef..26bf216 100644
--- a/run/input/core/app/z2ui5/webapp/core/DebugTool.fragment.xml
+++ b/run/input/core/app/z2ui5/webapp/core/DeveloperTools.fragment.xml
@@ -4,14 +4,20 @@
xmlns:ce="sap.ui.codeeditor"
>
diff --git a/run/input/core/app/z2ui5/webapp/core/DeveloperTools.js b/run/input/core/app/z2ui5/webapp/core/DeveloperTools.js
new file mode 100644
index 0000000..7de19ac
--- /dev/null
+++ b/run/input/core/app/z2ui5/webapp/core/DeveloperTools.js
@@ -0,0 +1,690 @@
+sap.ui.define(
+ [
+ "sap/ui/core/Control",
+ "sap/ui/core/Fragment",
+ "sap/ui/model/json/JSONModel",
+ "z2ui5/core/Lib",
+ "z2ui5/core/ViewSlots",
+ "z2ui5/core/AppState",
+ "z2ui5/core/ErrorView",
+ ],
+ (Control, Fragment, JSONModel, Lib, ViewSlots, AppState, ErrorView) => {
+ "use strict";
+
+ // Fragment id under which the developer tools dialog's controls are registered;
+ // used to resolve controls by their id instead of by content position.
+ const FRAGMENT_ID = "z2ui5DeveloperTools";
+
+ // toJson() pretty-prints with this many spaces per nesting level, so a
+ // line's leading-space count divided by it gives that line's JSON depth.
+ const INDENT_UNIT = 3;
+
+ // The System tab shows the whole (deeply nested) z2ui5 global; open only
+ // the first two levels so it is readable - the rest can be unfolded by
+ // hand in the editor.
+ const SYSTEM_OPEN_LEVELS = 2;
+
+ // JSON nesting depth of a pretty-printed line, read from its indentation.
+ function indentLevel(line, unit) {
+ return Math.floor(/^ */.exec(line)[0].length / unit);
+ }
+
+ // Fold every foldable block in the ACE edit session that sits at or below
+ // `keepLevels` nesting levels, leaving the outer levels open. Uses only the
+ // public EditSession folding API (unfold / getFoldWidget /
+ // getFoldWidgetRange / addFold), so it works with any CodeEditor build; a
+ // block's depth is read from its line indentation. Skipping to the folded
+ // block's end row keeps us from descending into (already hidden) children.
+ function foldSessionToLevel(session, keepLevels, unit) {
+ session.unfold();
+ const rowCount = session.getLength();
+ for (let row = 0; row < rowCount; row++) {
+ if (session.getFoldWidget(row) !== "start") continue;
+ if (indentLevel(session.getLine(row) || "", unit) < keepLevels)
+ continue;
+ const range = session.getFoldWidgetRange(row);
+ if (range && range.isMultiLine()) {
+ session.addFold("...", range);
+ row = range.end.row;
+ }
+ }
+ }
+
+ // Pretty-print any value (object, array, primitive) as indented JSON.
+ // `null` is used as a fallback so undefined values still produce output.
+ // A replacer drops circular references (the z2ui5 global can hold them,
+ // e.g. via ComponentData) so the output stays useful JSON instead of
+ // throwing and degrading to a bare "[object Object]".
+ function toJson(val) {
+ const safe = val === undefined ? null : val;
+ const seen = new WeakSet();
+ try {
+ return JSON.stringify(
+ safe,
+ (key, value) => {
+ if (typeof value === "object" && value !== null) {
+ if (seen.has(value)) return "[Circular]";
+ seen.add(value);
+ }
+ return value;
+ },
+ 3,
+ );
+ } catch {
+ // The developer tools must never crash the host app, so degrade to the
+ // plain string form if serialization still fails.
+ return String(safe);
+ }
+ }
+
+ // XSL stylesheet used by prettifyXml to reindent any XML string.
+ const PRETTIFY_XSL = `
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ // The XSLT processor and (de)serializers are expensive to construct, so
+ // we keep them as module-level singletons.
+ const _xmlSerializer = new XMLSerializer();
+ const _domParser = new DOMParser();
+ let _xsltProcessor = null;
+
+ function getXsltProcessor() {
+ if (_xsltProcessor) return _xsltProcessor;
+ const xsltDoc = _domParser.parseFromString(
+ PRETTIFY_XSL,
+ "application/xml",
+ );
+ _xsltProcessor = new XSLTProcessor();
+ _xsltProcessor.importStylesheet(xsltDoc);
+ return _xsltProcessor;
+ }
+
+ // Helpers to pull the various pieces of state shown in the dialog.
+ function getModelJson(view) {
+ const model = view?.getModel();
+ return model?.getData();
+ }
+
+ function getViewContent(view) {
+ // Private member access (developer tools only): XMLView keeps the raw XML
+ // string as a pseudo property in mProperties, but does not declare it
+ // in its metadata - getProperty("viewContent") therefore throws and
+ // would abort the whole tab selection. Read the plain object instead.
+ return view?.mProperties?.viewContent;
+ }
+
+ function getRenderedContent(view) {
+ // Private member access (developer tools only): _xContent holds the view
+ // XML after XML templating ran; there is no public equivalent.
+ return view?._xContent?.outerHTML;
+ }
+
+ // Minimal text dump of the shared error log (the entries Lib.logError
+ // pushes to AppState.state.errors): one " " line per entry,
+ // oldest first. Empty log yields a short placeholder.
+ function formatErrorLog() {
+ const errors = AppState.state.errors || [];
+ if (!errors.length) return "(log is empty)";
+ return errors.map((e) => `${e.ts} ${e.message}`).join("\n");
+ }
+
+ // The last fatal error the ErrorView overlay showed (title + full text),
+ // so the Error tab reproduces the overlay's content. Empty when the app
+ // has not hit a fatal error this session.
+ function formatLastError() {
+ const err = AppState.state.lastError;
+ if (!err) return "(no fatal error captured this session)";
+ return err.title ? `${err.title}\n\n${err.text}` : err.text;
+ }
+
+ function getResponseXml(key) {
+ const params = AppState.state.oResponse?.PARAMS;
+ const slot = params?.[key];
+ return slot?.XML;
+ }
+
+ // Preload the sap.ui.codeeditor modules used by the fragment. On older
+ // UI5 releases (e.g. 1.71) Fragment.load still processes the XML with
+ // the sync strategy, so an unloaded CodeEditor would be fetched via
+ // synchronous XHR and executed with eval - which a Content-Security-
+ // Policy without 'unsafe-eval' blocks. Requiring the modules
+ // asynchronously up front makes the sync lookup a cache hit.
+ function preloadCodeEditor() {
+ return new Promise((resolve) => {
+ sap.ui.require(
+ ["sap/ui/codeeditor/library", "sap/ui/codeeditor/CodeEditor"],
+ () => resolve(),
+ // On failure continue anyway and let Fragment.load surface the
+ // real error.
+ () => resolve(),
+ );
+ });
+ }
+
+ // What each dropdown entry shows: either a JSON source or an XML source
+ // (the latter optionally with the rendered DOM for the templating
+ // toggle). The "SOURCE" entry is handled separately in onItemSelect.
+ const jsonSources = {
+ // The whole public z2ui5 global facade (oConfig, url, checkLocal,
+ // Util, app-registered members, ...). Read directly here on purpose:
+ // this is the developer tools inspector, whose job is to surface the live global
+ // as-is - functions drop out under JSON.stringify, which is fine.
+ // ui5lint-disable-next-line no-project-globals -- see reason above
+ SYSTEM: () => window.z2ui5,
+ MODEL: () => getModelJson(ViewSlots.getView("MAIN")),
+ PLAIN: () => AppState.state.responseData,
+ REQUEST: () => AppState.state.oBody,
+ POPUP_MODEL: () => getModelJson(ViewSlots.getView("POPUP")),
+ POPOVER_MODEL: () => getModelJson(ViewSlots.getView("POPOVER")),
+ NEST1_MODEL: () => getModelJson(ViewSlots.getView("NEST")),
+ NEST2_MODEL: () => getModelJson(ViewSlots.getView("NEST2")),
+ };
+
+ const xmlSources = {
+ // Prefer the actual viewContent string; fall back to the XML that
+ // arrived in the last server response.
+ VIEW: () => ({
+ xml:
+ getViewContent(ViewSlots.getView("MAIN")) || getResponseXml("S_VIEW"),
+ rendered: getRenderedContent(ViewSlots.getView("MAIN")),
+ }),
+ POPUP: () => ({ xml: getResponseXml("S_POPUP") }),
+ POPOVER: () => ({ xml: getResponseXml("S_POPOVER") }),
+ NEST1: () => ({
+ xml: getViewContent(ViewSlots.getView("NEST")),
+ rendered: getRenderedContent(ViewSlots.getView("NEST")),
+ }),
+ NEST2: () => ({
+ xml: getViewContent(ViewSlots.getView("NEST2")),
+ rendered: getRenderedContent(ViewSlots.getView("NEST2")),
+ }),
+ };
+
+ return Control.extend("z2ui5.core.DeveloperTools", {
+ // Reformat an XML string with indentation. If anything goes wrong the
+ // original input is returned unchanged - the developer tools must never
+ // crash the host app.
+ prettifyXml(sourceXml) {
+ if (!sourceXml) return "";
+ try {
+ const xmlDoc = _domParser.parseFromString(
+ sourceXml,
+ "application/xml",
+ );
+ const resultDoc = getXsltProcessor().transformToDocument(xmlDoc);
+ if (!resultDoc) return sourceXml;
+ const resultXml = _xmlSerializer.serializeToString(resultDoc);
+ // The serializer escapes < and > inside text nodes; undo this so
+ // the output is browseable XML again.
+ return resultXml.replace(/>|</g, (match) =>
+ match === ">" ? ">" : "<",
+ );
+ } catch {
+ return sourceXml;
+ }
+ },
+
+ // Called when the user picks an entry in the dropdown of the developer
+ // tools dialog - resolve the model + key and render that tab.
+ onItemSelect(oEvent) {
+ this.renderTab(
+ oEvent.getSource().getSelectedKey(),
+ oEvent.getSource().getModel(),
+ );
+ },
+
+ // Render one tab's content into the dialog model. Shared by the user's
+ // tab selection (onItemSelect) and show(initialTab), which opens the
+ // dialog directly on a given tab (e.g. the error popup's Details jumps
+ // to "ERROR"). The content per entry is defined declaratively in
+ // jsonSources / xmlSources above.
+ renderTab(selItem, oModel) {
+ if (jsonSources[selItem]) {
+ this.displayEditor(oModel, toJson(jsonSources[selItem]()), "json");
+ if (selItem === "SYSTEM") this.foldSystemTab();
+ return;
+ }
+
+ if (xmlSources[selItem]) {
+ const { xml, rendered } = xmlSources[selItem]();
+ this.displayEditor(
+ oModel,
+ this.prettifyXml(xml),
+ "xml",
+ this.prettifyXml(rendered),
+ );
+ return;
+ }
+
+ if (selItem === "LOG") {
+ this.displayEditor(oModel, formatErrorLog(), "text");
+ return;
+ }
+
+ if (selItem === "ERROR") {
+ this.showError(oModel);
+ return;
+ }
+
+ if (selItem === "SOURCE") this.showAbapSource(oModel);
+ },
+
+ // Show the last fatal error (the ErrorView overlay's content). The
+ // Retry/Restart/Logout actions live in the dialog footer (always
+ // present); refresh hasRetry so the footer's Retry button shows only
+ // when this error carried a retry action.
+ showError(oModel) {
+ this.displayEditor(oModel, formatLastError(), "text");
+ const modelData = oModel.getData();
+ modelData.hasRetry =
+ typeof AppState.state.lastError?.onRetry === "function";
+ oModel.refresh();
+ },
+
+ // The Error tab's buttons mirror the ErrorView overlay: re-run the
+ // captured request, hard-reload, or log out (reusing ErrorView's own
+ // logout so the launchpad/fallback logic stays in one place).
+ onErrorRetry() {
+ const onRetry = AppState.state.lastError?.onRetry;
+ // Retrying re-runs the request, so don't bounce back to the error popup.
+ this.reopenErrorOnClose = false;
+ this.close();
+ if (typeof onRetry === "function") onRetry();
+ },
+ onErrorRestart() {
+ window.location.reload();
+ },
+ onErrorLogout() {
+ ErrorView.handleLogout();
+ },
+
+ // Collect the content of every developer-tools tab into one plain-text
+ // blob so it can be copied elsewhere in one go. XML tabs are
+ // pretty-printed, JSON tabs serialized; empty / inactive sections are
+ // skipped. Every source is guarded (a throwing one can never blank the
+ // whole export) and each section is capped, because the SYSTEM global can
+ // serialize to several MB - a value that large blanks a sap.m.TextArea.
+ buildExport() {
+ // Max characters per section; long ones (mainly SYSTEM) are truncated
+ // so the popup's TextArea still renders.
+ const MAX_SECTION = 100000;
+ const sections = [];
+ const push = (title, content) => {
+ if (!content) return;
+ let body = String(content);
+ if (body.length > MAX_SECTION) {
+ body = `${body.slice(0, MAX_SECTION)}\n\n... [truncated ${body.length - MAX_SECTION} more characters - open the ${title} tab for the full content]`;
+ }
+ sections.push(`===== ${title} =====\n${body}`);
+ };
+ const json = (fn) => {
+ try {
+ const v = fn();
+ return v === undefined || v === null ? "" : toJson(v);
+ } catch {
+ return "";
+ }
+ };
+ const xml = (fn) => {
+ try {
+ return this.prettifyXml(fn());
+ } catch {
+ return "";
+ }
+ };
+ const text = (fn) => {
+ try {
+ return fn() || "";
+ } catch {
+ return "";
+ }
+ };
+
+ if (AppState.state.lastError) push("ERROR", text(formatLastError));
+ push("LOG", text(formatErrorLog));
+ push(
+ "RESPONSE",
+ json(() => jsonSources.PLAIN()),
+ );
+ push(
+ "PREVIOUS REQUEST",
+ json(() => jsonSources.REQUEST()),
+ );
+ push(
+ "VIEW",
+ xml(() => xmlSources.VIEW().xml),
+ );
+ push(
+ "VIEW MODEL",
+ json(() => jsonSources.MODEL()),
+ );
+ if (getResponseXml("S_POPUP")) {
+ push(
+ "POPUP",
+ xml(() => xmlSources.POPUP().xml),
+ );
+ push(
+ "POPUP MODEL",
+ json(() => jsonSources.POPUP_MODEL()),
+ );
+ }
+ if (getResponseXml("S_POPOVER")) {
+ push(
+ "POPOVER",
+ xml(() => xmlSources.POPOVER().xml),
+ );
+ push(
+ "POPOVER MODEL",
+ json(() => jsonSources.POPOVER_MODEL()),
+ );
+ }
+ if (getViewContent(ViewSlots.getView("NEST"))) {
+ push(
+ "NEST1",
+ xml(() => xmlSources.NEST1().xml),
+ );
+ push(
+ "NEST1 MODEL",
+ json(() => jsonSources.NEST1_MODEL()),
+ );
+ }
+ if (getViewContent(ViewSlots.getView("NEST2"))) {
+ push(
+ "NEST2",
+ xml(() => xmlSources.NEST2().xml),
+ );
+ push(
+ "NEST2 MODEL",
+ json(() => jsonSources.NEST2_MODEL()),
+ );
+ }
+ // SYSTEM (the whole z2ui5 global) is the largest by far - keep it last
+ // so the useful sections come first even after truncation.
+ push(
+ "SYSTEM",
+ json(() => jsonSources.SYSTEM()),
+ );
+
+ return sections.join("\n\n") || "(nothing to export)";
+ },
+
+ // Show the whole export in a stretched popup with a read-through TextArea
+ // (selectable for manual copy) and a one-click "Copy to Clipboard".
+ onExport() {
+ let text;
+ try {
+ text = this.buildExport();
+ } catch (e) {
+ text = `(export failed: ${e?.message || e})`;
+ }
+ sap.ui.require(
+ ["sap/m/Dialog", "sap/m/TextArea", "sap/m/Button"],
+ (Dialog, TextArea, Button) => {
+ const area = new TextArea({
+ editable: true,
+ width: "100%",
+ rows: 25,
+ growing: false,
+ });
+ // Set the value explicitly (not only via the constructor) so a
+ // large payload is applied reliably after the control exists.
+ area.setValue(text);
+ const dialog = new Dialog({
+ title: "abap2UI5 - Developer Tools Export",
+ stretch: true,
+ content: [area],
+ beginButton: new Button({
+ text: "Copy to Clipboard",
+ type: "Emphasized",
+ press: () => {
+ // navigator.clipboard needs a secure (HTTPS) context, which
+ // an on-premise ABAP system often is not. Select the
+ // TextArea and use the classic execCommand("copy") first
+ // (works over plain HTTP), then fall back to the async API.
+ const ta = area.getFocusDomRef();
+ let copied = false;
+ if (ta) {
+ ta.focus();
+ ta.select();
+ ta.setSelectionRange(0, (ta.value || "").length);
+ try {
+ copied = document.execCommand("copy");
+ } catch {
+ copied = false;
+ }
+ }
+ if (!copied && navigator.clipboard?.writeText) {
+ navigator.clipboard.writeText(text).catch(() => {});
+ }
+ },
+ }),
+ endButton: new Button({
+ text: "Close",
+ press: () => dialog.close(),
+ }),
+ afterClose: () => dialog.destroy(),
+ });
+ dialog.addStyleClass("dbg-ltr");
+ dialog.open();
+ },
+ );
+ },
+
+ // The CodeEditor's underlying ACE editor, or null if it does not exist
+ // yet (created on the CodeEditor's first render) or the build exposes no
+ // internal instance.
+ getEditorInstance() {
+ const ce = Fragment.byId(FRAGMENT_ID, "developerToolsEditor");
+ return ce && typeof ce.getInternalEditorInstance === "function"
+ ? ce.getInternalEditorInstance()
+ : null;
+ },
+
+ // Fold the System tab's JSON down to the first SYSTEM_OPEN_LEVELS levels.
+ // The ACE editor is created lazily on the CodeEditor's first render, so
+ // on the very first open we retry briefly until it exists. Best-effort:
+ // any failure leaves the tab fully expanded rather than breaking the
+ // developer tools.
+ foldSystemTab(triesLeft = 10) {
+ let editor = null;
+ try {
+ editor = this.getEditorInstance();
+ } catch (e) {
+ Lib.logError("DeveloperTools System fold failed", e);
+ return;
+ }
+ if (editor) {
+ try {
+ const session = editor.getSession && editor.getSession();
+ if (session && typeof session.getFoldWidget === "function") {
+ foldSessionToLevel(session, SYSTEM_OPEN_LEVELS, INDENT_UNIT);
+ }
+ } catch (e) {
+ Lib.logError("DeveloperTools System fold failed", e);
+ }
+ return;
+ }
+ if (triesLeft > 0) {
+ setTimeout(() => this.foldSystemTab(triesLeft - 1), 30);
+ }
+ },
+
+ // Show the ABAP source of the running app inside an iframe.
+ showAbapSource(oModel) {
+ const contentControl = Fragment.byId(FRAGMENT_ID, "sourceHtml");
+ if (!contentControl) return;
+
+ const sFront = AppState.state.responseData?.S_FRONT;
+ const appName = sFront?.APP || "";
+ const appId = encodeURIComponent(appName);
+ const url = `${window.location.origin}/sap/bc/adt/oo/classes/${appId}/source/main`;
+ // setContent (not a bare setProperty) so an already rendered iframe
+ // is replaced in the live DOM; a plain property set never reached
+ // the DOM once the control had rendered, leaving a stale class
+ // on screen after navigating to another app.
+ contentControl.setContent(
+ ``,
+ );
+
+ if (!oModel) return;
+ const modelData = oModel.getData();
+ modelData.editor_visible = false;
+ modelData.source_visible = true;
+ oModel.refresh();
+ },
+
+ // Populates the dialog model so the right editor / source area is shown
+ // with the given content. `xcontent` is the rendered DOM variant that
+ // can be toggled in via the "Templating" button.
+ displayEditor(oModel, content, type, xcontent = "") {
+ const modelData = oModel.getData();
+ modelData.editor_visible = true;
+ modelData.source_visible = false;
+ modelData.isTemplating = Boolean(content?.includes("xmlns:template"));
+ modelData.value = content;
+ modelData.previousValue = content;
+ modelData.xContent = xcontent;
+ modelData.type = type;
+ oModel.refresh();
+ },
+
+ onTemplatingPress(oEvent) {
+ const oSource = oEvent.getSource();
+ const oModel = oSource.getModel();
+ const modelData = oModel.getData();
+ // Toggle between the original (previousValue) and the rendered DOM
+ // (xContent) representation.
+ modelData.value = oSource.getPressed()
+ ? modelData.xContent
+ : modelData.previousValue;
+ oModel.refresh();
+ },
+
+ onClose() {
+ this.close();
+ },
+
+ // sap.m.Dialog closes on Escape without routing through onClose; handle
+ // it ourselves (reject the default, run our close) so Escape behaves
+ // exactly like the Close button - including re-showing the error popup.
+ onEscape(oPromise) {
+ oPromise.reject();
+ this.close();
+ },
+
+ // Open the developer tools dialog. `initialTab` (a tab key, e.g. "ERROR") opens
+ // it directly on that tab - used by the error popup's Details action;
+ // defaults to the response tab.
+ async show(initialTab) {
+ // Guard against double-clicks while the fragment is still loading.
+ if (this._showPending) return;
+ this._showPending = true;
+ try {
+ if (!this.oDialog) {
+ await preloadCodeEditor();
+ this.oDialog = await Fragment.load({
+ name: "z2ui5.core.DeveloperTools",
+ controller: this,
+ id: FRAGMENT_ID,
+ });
+ }
+ // If the user closed the app while the fragment was loading we
+ // must throw the freshly created dialog away.
+ if (Lib.isDestroyed(this)) {
+ if (this.oDialog) this.oDialog.destroy();
+ this.oDialog = null;
+ return;
+ }
+
+ const selectedTab =
+ typeof initialTab === "string" ? initialTab : "PLAIN";
+ const value = toJson(AppState.state.responseData);
+ const oData = {
+ selectedTab: selectedTab,
+ type: "json",
+ source_visible: false,
+ editor_visible: true,
+ hasError: Boolean(AppState.state.lastError),
+ hasRetry: typeof AppState.state.lastError?.onRetry === "function",
+ value: value,
+ xContent: "",
+ previousValue: value,
+ isTemplating: false,
+ templatingSource: false,
+ activeNest1: Boolean(getViewContent(ViewSlots.getView("NEST"))),
+ activeNest2: Boolean(getViewContent(ViewSlots.getView("NEST2"))),
+ activePopup: Boolean(getResponseXml("S_POPUP")),
+ activePopover: Boolean(getResponseXml("S_POPOVER")),
+ };
+
+ const oModel = new JSONModel(oData);
+ const oDialog = this.oDialog;
+ oDialog.addStyleClass("dbg-ltr");
+ oDialog.setModel(oModel);
+ // Render the requested tab's content (the default "PLAIN" already
+ // matches the JSON response seeded above, so only re-render when a
+ // specific tab was asked for).
+ if (initialTab && selectedTab !== "PLAIN") {
+ this.renderTab(selectedTab, oModel);
+ }
+ oDialog.open();
+ } catch (e) {
+ Lib.logError("DeveloperTools.show failed", e);
+ } finally {
+ this._showPending = false;
+ }
+ },
+
+ close() {
+ if (!this.oDialog || !this.oDialog.isOpen()) return;
+ // When the dialog was opened from the error popup's Details action,
+ // closing it (Close or Escape) re-shows that popup so the user never
+ // ends up on the dismissed, broken app.
+ const reopenError = this.reopenErrorOnClose;
+ this.reopenErrorOnClose = false;
+ // Keep the dialog (and its fragment controls, e.g. the CodeEditor)
+ // and reuse it on the next show(). Destroying and re-loading the
+ // fragment each time raced the close animation on older UI5 (1.71):
+ // the CodeEditor's fragment-scoped id survived long enough that the
+ // reload threw "adding element with duplicate id
+ // 'z2ui5DeveloperTools--developerToolsEditor'". The instance is
+ // destroyed once in exit() when the control itself goes away.
+ this.oDialog.close();
+ if (reopenError) ErrorView.reopenErrorDialog();
+ },
+
+ // The dialog is not an aggregation of this control, so destroy() alone
+ // would leave it (and its fragment controls) alive - clean it up when
+ // the control is destroyed (Component.exit). Never re-show the error
+ // popup while the app itself is being torn down.
+ exit() {
+ this.reopenErrorOnClose = false;
+ if (this.oDialog) {
+ this.oDialog.close();
+ this.oDialog.destroy();
+ this.oDialog = null;
+ }
+ },
+
+ toggle() {
+ if (this.oDialog && this.oDialog.isOpen()) {
+ this.close();
+ } else {
+ this.show();
+ }
+ },
+
+ // The control itself renders nothing - it just provides the dialog API.
+ renderer: { apiVersion: 2, render() {} },
+ });
+ },
+);
diff --git a/run/input/core/app/z2ui5/webapp/core/ErrorView.js b/run/input/core/app/z2ui5/webapp/core/ErrorView.js
index 09def7f..549b101 100644
--- a/run/input/core/app/z2ui5/webapp/core/ErrorView.js
+++ b/run/input/core/app/z2ui5/webapp/core/ErrorView.js
@@ -11,6 +11,80 @@ sap.ui.define(["z2ui5/core/AppState"], (AppState) => {
// so a stack trace from the backend cannot blow up the error overlay.
const ERROR_MAX_LENGTH = 50000;
+ // The friendly dialog shows only a short preview of the error text (the
+ // full text stays on the Developer Tools Error tab); longer previews are cut.
+ const PREVIEW_MAX_LENGTH = 500;
+
+ // Remember the last dialog's inputs so the DeveloperTools can re-show the popup
+ // after the user closes the developer tools they opened via its Details action.
+ let lastDialogTitle = "";
+ let lastDialogDetails = "";
+
+ // The currently open friendly error dialog, so a second fatal error (or a
+ // reopen from the Developer Tools) never stacks two of them.
+ let friendlyDialog = null;
+
+ // Decode the HTML entities that turn up in backend error pages. Non-ASCII
+ // replacements go through fromCharCode so this source file stays 7-bit ASCII
+ // (it is embedded verbatim into an ABAP class). & is decoded last so an
+ // already-encoded entity is not decoded twice.
+ function decodeEntities(s) {
+ return s
+ .replace(/ /gi, " ")
+ .replace(/</gi, "<")
+ .replace(/>/gi, ">")
+ .replace(/"/gi, '"')
+ .replace(/'|*39;/gi, "'")
+ .replace(/©/gi, String.fromCharCode(169))
+ .replace(/(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
+ .replace(/([0-9a-f]+);/gi, (_, n) =>
+ String.fromCharCode(parseInt(n, 16)),
+ )
+ .replace(/&/gi, "&");
+ }
+
+ const cleanText = (s) =>
+ decodeEntities(s.replace(/<[^>]+>/g, " "))
+ .replace(/\s+/g, " ")
+ .trim();
+
+ // Pull just the meaningful message out of a SAP application-server error
+ // page: the error header (e.g. "500 Internal Server Error") and the message
+ // paragraphs (e.g. "Division by zero"). Everything else - page title, footer,
+ // copyright, the client-side clock