@inteli.city/node-red-plugin-project-files 1.0.0
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/CHANGELOG.md +64 -0
- package/LICENSE +201 -0
- package/README.md +300 -0
- package/editor/project-files.html +7 -0
- package/package.json +40 -0
- package/resources/project-files/admin-ajax.js +12 -0
- package/resources/project-files/dialogs.js +107 -0
- package/resources/project-files/plugin.js +1675 -0
- package/resources/project-files/prefs.js +11 -0
- package/resources/project-files/rules.js +47 -0
- package/resources/project-files/style.js +492 -0
- package/runtime/admin.js +469 -0
|
@@ -0,0 +1,1675 @@
|
|
|
1
|
+
// plugin.js — Main file for the Project Files sidebar plugin.
|
|
2
|
+
//
|
|
3
|
+
// What lives here: plugin bootstrap, DOM, state, layout, editor, tree, event wiring.
|
|
4
|
+
// What lives in rules.js: isProtected / isProtectedDir / isReadOnly / langFromName
|
|
5
|
+
// What lives in dialogs.js: dsffModal / dsffPrompt / dsffConfirm
|
|
6
|
+
//
|
|
7
|
+
// Where future changes go:
|
|
8
|
+
// new protected/read-only rule → rules.js
|
|
9
|
+
// new language extension → rules.js → EXT_LANG
|
|
10
|
+
// new modal variant → dialogs.js
|
|
11
|
+
// new sidebar layout tweak → Section 5 (layout)
|
|
12
|
+
// Monaco options / editor quirk → Section 6 (editor)
|
|
13
|
+
// new context menu item → Section 7 (tree) + Section 8 (event wiring)
|
|
14
|
+
// new backend route → runtime/admin.js
|
|
15
|
+
|
|
16
|
+
RED.plugins.registerPlugin("project-files", {
|
|
17
|
+
onadd: function () {
|
|
18
|
+
|
|
19
|
+
// ── 1. Constants ─────────────────────────────────────────────────────────
|
|
20
|
+
var COMPACT_BREAKPOINT = 480; // < this px → compact single-panel layout
|
|
21
|
+
var TOOLBAR_WIDE_THRESHOLD = 480; // >= this px → width-gated toolbar buttons visible
|
|
22
|
+
var VS_PREFIX = "dsff-vs::";
|
|
23
|
+
|
|
24
|
+
// ── 2. Plugin state ──────────────────────────────────────────────────────
|
|
25
|
+
// View-state restore
|
|
26
|
+
var restoreGuardUntil = 0;
|
|
27
|
+
var posDebounceTimer = null;
|
|
28
|
+
|
|
29
|
+
// Hidden-file visibility — session-only, always OFF on page load.
|
|
30
|
+
var showHidden = false;
|
|
31
|
+
|
|
32
|
+
// Navigation
|
|
33
|
+
var baseDir = "";
|
|
34
|
+
var projectRoot = null; // locked navigation root (active project dir)
|
|
35
|
+
var currentDir = ".";
|
|
36
|
+
|
|
37
|
+
// Operating mode — "project" (active Node-RED project) or "user" (no active
|
|
38
|
+
// project; scope is the Node-RED user directory). Reported by the backend.
|
|
39
|
+
var mode = "project";
|
|
40
|
+
var scopeDir = ""; // authoritative boundary path for the current mode
|
|
41
|
+
var currentFile = null;
|
|
42
|
+
var selectedPath = null;
|
|
43
|
+
|
|
44
|
+
// Tree display
|
|
45
|
+
var sortField = "name";
|
|
46
|
+
var sortDir = "asc";
|
|
47
|
+
var lastList = null;
|
|
48
|
+
|
|
49
|
+
// Layout
|
|
50
|
+
var isCompact = false;
|
|
51
|
+
var isWide = false; // width-based toolbar visibility flag
|
|
52
|
+
var compactView = "browser";
|
|
53
|
+
var savedLeftWidth = "38%";
|
|
54
|
+
// Host-sidebar expand/collapse state lives in the `host` adapter (defined
|
|
55
|
+
// below). The plugin no longer keeps a shared global or reaches into
|
|
56
|
+
// Node-RED's layout directly.
|
|
57
|
+
|
|
58
|
+
// Context menu current target
|
|
59
|
+
var ctxTarget = null; // { path, type, name }
|
|
60
|
+
|
|
61
|
+
// Editor
|
|
62
|
+
var dirty = false;
|
|
63
|
+
var fileReadOnly = false; // true while a read-only file is open
|
|
64
|
+
var savedText = ""; // content at last open/save — dirty-check baseline
|
|
65
|
+
var editorKind = "none";
|
|
66
|
+
var monacoEditor = null;
|
|
67
|
+
var editorModel = null;
|
|
68
|
+
var editorModelUri = null;
|
|
69
|
+
var suppressDirty = false;
|
|
70
|
+
var currentTextCache = "";
|
|
71
|
+
var $textarea = null;
|
|
72
|
+
var hotkeyInstalled = false;
|
|
73
|
+
|
|
74
|
+
// Disk-change polling
|
|
75
|
+
var lastDisk = { mtime: null, size: null };
|
|
76
|
+
var statTimer = null;
|
|
77
|
+
var onDiskChanged = false;
|
|
78
|
+
|
|
79
|
+
// Internal drag (file row → folder row)
|
|
80
|
+
var isDraggingSplit = false;
|
|
81
|
+
var splitDrag = {};
|
|
82
|
+
var draggedFile = null; // { path, name } while an internal drag is active
|
|
83
|
+
|
|
84
|
+
// Python venv state — refreshed on project load / change.
|
|
85
|
+
var hasVenv = false;
|
|
86
|
+
|
|
87
|
+
// ── 3. DOM construction ──────────────────────────────────────────────────
|
|
88
|
+
var $root = $("<div>").css({
|
|
89
|
+
position: "relative", height: "100%",
|
|
90
|
+
display: "flex", gap: "0",
|
|
91
|
+
}).addClass("dsff-expanded");
|
|
92
|
+
|
|
93
|
+
// Left panel — directory tree
|
|
94
|
+
var $left = $("<div>").css({
|
|
95
|
+
width: "38%", minWidth: "220px", height: "100%",
|
|
96
|
+
display: "flex", flexDirection: "column",
|
|
97
|
+
borderRight: "1px solid var(--red-ui-secondary-background)",
|
|
98
|
+
flexShrink: 0,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
var $leftHdr = $("<div>").css({
|
|
102
|
+
padding: "6px", display: "flex", gap: "6px",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
borderBottom: "1px solid var(--red-ui-secondary-background)",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
var $baseLabel = $("<span>").addClass("dsff-base-label").css({
|
|
108
|
+
fontSize: "0.78em", opacity: 0.65,
|
|
109
|
+
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
110
|
+
flex: "1 1 auto", minWidth: 0,
|
|
111
|
+
});
|
|
112
|
+
var $compactFolderLabel = $('<span class="dsff-compact-folder">');
|
|
113
|
+
var $btnRefresh = $('<button class="red-ui-button" title="Refresh"><i class="fa fa-refresh"></i></button>');
|
|
114
|
+
var $btnHidden = $('<button class="red-ui-button dsff-btn-hidden"><i class="fa"></i></button>');
|
|
115
|
+
var $btnNewCompact = $('<button class="red-ui-button dsff-btn-new-compact" title="New…"><i class="fa fa-plus"></i></button>');
|
|
116
|
+
var $btnCollapse = $('<button class="red-ui-button dsff-btn-collapse" title="Collapse sidebar"><i class="fa fa-compress"></i></button>');
|
|
117
|
+
var $btnExpand = $('<button class="red-ui-button dsff-btn-expand" title="Expand sidebar"><i class="fa fa-expand"></i></button>');
|
|
118
|
+
$leftHdr.append($baseLabel, $compactFolderLabel, $btnRefresh, $btnHidden, $btnNewCompact, $btnCollapse, $btnExpand);
|
|
119
|
+
|
|
120
|
+
// Operating-mode bar — always visible once loaded. Answers, at a glance:
|
|
121
|
+
// which mode is active, what directory scope is managed, and (in User
|
|
122
|
+
// Directory Mode) which project-only features are unavailable.
|
|
123
|
+
var $modeBar = $('<div class="dsff-mode-bar">').hide();
|
|
124
|
+
var $modeIcon = $('<i class="fa">');
|
|
125
|
+
var $modeText = $('<span class="dsff-mode-text">');
|
|
126
|
+
$modeBar.append($modeIcon, $modeText);
|
|
127
|
+
|
|
128
|
+
// Python venv bar — one row under the header, shown only when a project is active.
|
|
129
|
+
var $venvBar = $('<div class="dsff-venv-bar">').hide();
|
|
130
|
+
var $venvIcon = $('<i class="fa fa-cube">');
|
|
131
|
+
var $venvStatus = $('<span class="dsff-venv-status">');
|
|
132
|
+
var $venvAction = $('<a href="#" class="dsff-venv-action">').hide();
|
|
133
|
+
$venvBar.append($venvIcon, $venvStatus, $venvAction);
|
|
134
|
+
|
|
135
|
+
var $treeHeader = $("<div>").addClass("dsff-tree-header");
|
|
136
|
+
var $hdrName = $('<span class="dsff-tree-header-name">Name</span>');
|
|
137
|
+
var $hdrMod = $('<span class="dsff-tree-header-mod">Modified</span>');
|
|
138
|
+
$treeHeader.append($hdrName, $hdrMod);
|
|
139
|
+
|
|
140
|
+
var $treeWrap = $("<div>").css({ flex: "1 1 auto", overflow: "auto", position: "relative" });
|
|
141
|
+
var $tree = $("<div>").css({ padding: "4px" });
|
|
142
|
+
var $treeEmpty = $('<div class="dsff-tree-empty">No files here</div>');
|
|
143
|
+
var $dropOverlay = $('<div class="dsff-drop-overlay"><i class="fa fa-cloud-upload"></i><span>Drop files to upload</span></div>');
|
|
144
|
+
$treeWrap.append($tree, $treeEmpty, $dropOverlay);
|
|
145
|
+
$left.append($leftHdr, $modeBar, $venvBar, $treeHeader, $treeWrap);
|
|
146
|
+
|
|
147
|
+
var $split = $("<div>").addClass("dsff-split-handle");
|
|
148
|
+
|
|
149
|
+
// Right panel — editor
|
|
150
|
+
var $right = $("<div>").css({
|
|
151
|
+
flex: "1 1 auto", height: "100%",
|
|
152
|
+
display: "flex", flexDirection: "column", minWidth: 0,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
var $toolbar = $("<div>").css({
|
|
156
|
+
padding: "6px", display: "flex", gap: "6px",
|
|
157
|
+
alignItems: "center", flexWrap: "wrap",
|
|
158
|
+
borderBottom: "1px solid var(--red-ui-secondary-background)",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
var $btnBack = $('<button class="red-ui-button dsff-btn-back" title="Back to files"><i class="fa fa-arrow-left"></i></button>');
|
|
162
|
+
var $compactFileName = $('<span class="dsff-compact-filename">');
|
|
163
|
+
var $btnNew = $('<button class="red-ui-button dsff-btn-new" title="New file or folder"><i class="fa fa-plus"></i> New <i class="fa fa-caret-down" style="font-size:0.8em;opacity:0.7"></i></button>');
|
|
164
|
+
var $btnSave = $('<button class="red-ui-button dsff-btn-save" disabled><i class="fa fa-save"></i> Save</button>');
|
|
165
|
+
var $btnWrap = $('<button class="red-ui-button dsff-btn-wrap" title="Toggle word wrap"><i class="fa fa-align-left"></i> Wrap</button>');
|
|
166
|
+
var $crumb = $("<div>").addClass("dsff-crumb").css({
|
|
167
|
+
fontSize: "0.85em", opacity: 0.75,
|
|
168
|
+
flex: "1 1 auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
|
|
169
|
+
});
|
|
170
|
+
$toolbar.append($btnBack, $compactFileName, $btnNew, $btnSave, $crumb, $btnWrap);
|
|
171
|
+
|
|
172
|
+
var $readOnlyBanner = $('<div class="dsff-readonly-banner"><i class="fa fa-lock"></i><span>This file is managed by Node-RED and cannot be edited here.</span></div>');
|
|
173
|
+
var $editorHost = $("<div>").css({ flex: "1 1 auto", position: "relative", minHeight: "200px" });
|
|
174
|
+
var $editorEmpty = $('<div class="dsff-editor-empty"><i class="fa fa-file-text-o"></i><span>Select a file to edit</span></div>');
|
|
175
|
+
$editorHost.append($editorEmpty);
|
|
176
|
+
|
|
177
|
+
var $statusBar = $("<div>").css({
|
|
178
|
+
padding: "3px 8px",
|
|
179
|
+
borderTop: "1px solid var(--red-ui-secondary-background)",
|
|
180
|
+
fontSize: "0.82em", opacity: 0.75, whiteSpace: "nowrap",
|
|
181
|
+
overflow: "hidden", textOverflow: "ellipsis",
|
|
182
|
+
});
|
|
183
|
+
$right.append($toolbar, $readOnlyBanner, $editorHost, $statusBar);
|
|
184
|
+
$root.append($left, $split, $right);
|
|
185
|
+
|
|
186
|
+
// Context menus (appended to body so they escape overflow:hidden containers)
|
|
187
|
+
var $ctxMenu = $('<div class="dsff-ctx-menu">').hide().appendTo(document.body);
|
|
188
|
+
var $ctxLabel = $('<div class="dsff-ctx-menu-label">');
|
|
189
|
+
var $ctxCopyPath = $('<div class="dsff-ctx-menu-item"><i class="fa fa-clipboard"></i> Copy path</div>');
|
|
190
|
+
var $ctxDownload = $('<div class="dsff-ctx-menu-item"><i class="fa fa-download"></i> Download</div>');
|
|
191
|
+
var $ctxInstallPy = $('<div class="dsff-ctx-menu-item"><i class="fa fa-cube"></i> Install Python libraries</div>');
|
|
192
|
+
var $ctxInstallNode = $('<div class="dsff-ctx-menu-item"><i class="fa fa-cubes"></i> Install Node libraries</div>');
|
|
193
|
+
var $ctxRename = $('<div class="dsff-ctx-menu-item"><i class="fa fa-pencil"></i> Rename</div>');
|
|
194
|
+
var $ctxSep = $('<div class="dsff-ctx-menu-sep">');
|
|
195
|
+
var $ctxDelete = $('<div class="dsff-ctx-menu-item dsff-ctx-danger"><i class="fa fa-trash-o"></i> Delete</div>');
|
|
196
|
+
$ctxMenu.append($ctxLabel, $ctxCopyPath, $ctxDownload, $ctxInstallPy, $ctxInstallNode, $ctxRename, $ctxSep, $ctxDelete);
|
|
197
|
+
|
|
198
|
+
var $bgCtxMenu = $('<div class="dsff-ctx-menu">').hide().appendTo(document.body);
|
|
199
|
+
var $bgCtxNewFile = $('<div class="dsff-ctx-menu-item"><i class="fa fa-file-o"></i> New file</div>');
|
|
200
|
+
var $bgCtxNewDir = $('<div class="dsff-ctx-menu-item"><i class="fa fa-folder-o"></i> New folder</div>');
|
|
201
|
+
$bgCtxMenu.append($bgCtxNewFile, $bgCtxNewDir);
|
|
202
|
+
|
|
203
|
+
var $compactMenu = $('<div class="dsff-compact-menu">').hide().appendTo(document.body);
|
|
204
|
+
var $menuNewFile = $('<div class="dsff-compact-menu-item"><i class="fa fa-file-o"></i> New file</div>');
|
|
205
|
+
var $menuNewDir = $('<div class="dsff-compact-menu-item"><i class="fa fa-folder-o"></i> New folder</div>');
|
|
206
|
+
$compactMenu.append($menuNewFile, $menuNewDir);
|
|
207
|
+
|
|
208
|
+
// ── 4. Small utilities ───────────────────────────────────────────────────
|
|
209
|
+
function toast(msg, type) {
|
|
210
|
+
try { RED.notify(msg, { type: type || "success", timeout: 1800 }); }
|
|
211
|
+
catch (e) { RED.notify(msg, type || "success"); }
|
|
212
|
+
}
|
|
213
|
+
function notifyErr(msg) {
|
|
214
|
+
try { RED.notify(msg, { type: "error", timeout: 2500 }); }
|
|
215
|
+
catch (e) { RED.notify(msg, "error"); }
|
|
216
|
+
}
|
|
217
|
+
function setStatus(t) { $statusBar.text(t || ""); }
|
|
218
|
+
function layoutEditorSoon() {
|
|
219
|
+
if (editorKind === "monaco" && monacoEditor) {
|
|
220
|
+
requestAnimationFrame(function () { try { monacoEditor.layout(); } catch (e) {} });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── 5. Layout behavior ───────────────────────────────────────────────────
|
|
225
|
+
function applyMode(force) {
|
|
226
|
+
var width = $root.width() || 0;
|
|
227
|
+
var nowCompact = width > 0 && width < COMPACT_BREAKPOINT;
|
|
228
|
+
var nowWide = width > 0 && width >= TOOLBAR_WIDE_THRESHOLD;
|
|
229
|
+
if (!force && nowCompact === isCompact && nowWide === isWide) return;
|
|
230
|
+
isCompact = nowCompact;
|
|
231
|
+
isWide = nowWide;
|
|
232
|
+
$root.toggleClass("dsff-compact", isCompact)
|
|
233
|
+
.toggleClass("dsff-expanded", !isCompact)
|
|
234
|
+
.toggleClass("dsff-wide", isWide);
|
|
235
|
+
if (isCompact) {
|
|
236
|
+
applyCompactLayout();
|
|
237
|
+
} else {
|
|
238
|
+
$left.css({ display: "flex", width: savedLeftWidth, minWidth: "220px" });
|
|
239
|
+
$split.css("display", "");
|
|
240
|
+
$right.css({ display: "flex", width: "" });
|
|
241
|
+
$root.removeClass("dsff-browser-view dsff-editor-view");
|
|
242
|
+
}
|
|
243
|
+
layoutEditorSoon();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function applyCompactLayout() {
|
|
247
|
+
if (compactView === "editor" && currentFile) showEditorPanel();
|
|
248
|
+
else showBrowserPanel();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function showBrowserPanel() {
|
|
252
|
+
compactView = "browser";
|
|
253
|
+
$left.css({ display: "flex", width: "100%", minWidth: "0" });
|
|
254
|
+
$split.css("display", "none");
|
|
255
|
+
$right.css("display", "none");
|
|
256
|
+
$root.addClass("dsff-browser-view").removeClass("dsff-editor-view");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function showEditorPanel() {
|
|
260
|
+
compactView = "editor";
|
|
261
|
+
$left.css("display", "none");
|
|
262
|
+
$split.css("display", "none");
|
|
263
|
+
$right.css({ display: "flex", width: "100%" });
|
|
264
|
+
$root.addClass("dsff-editor-view").removeClass("dsff-browser-view");
|
|
265
|
+
updateCompactFileName();
|
|
266
|
+
layoutEditorSoon();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function updateCompactFolderLabel() {
|
|
270
|
+
var parts = (currentDir === "." ? [] : currentDir.split("/").filter(Boolean));
|
|
271
|
+
$compactFolderLabel.text(
|
|
272
|
+
parts.length ? parts[parts.length - 1] :
|
|
273
|
+
(baseDir.split("/").filter(Boolean).pop() || "Project Files")
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function updateCompactFileName() {
|
|
278
|
+
if (!currentFile) { $compactFileName.text("").removeClass("dsff-name-dirty"); return; }
|
|
279
|
+
var parts = currentFile.split("/");
|
|
280
|
+
$compactFileName.text(parts[parts.length - 1] || currentFile)
|
|
281
|
+
.toggleClass("dsff-name-dirty", dirty);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function showCompactMenu(ev) {
|
|
285
|
+
hideCompactMenu();
|
|
286
|
+
var btn = $(ev.currentTarget);
|
|
287
|
+
var off = btn.offset();
|
|
288
|
+
$compactMenu.css({ top: 0, left: 0 }).show();
|
|
289
|
+
var menuW = $compactMenu.outerWidth();
|
|
290
|
+
var menuH = $compactMenu.outerHeight();
|
|
291
|
+
var x = off.left;
|
|
292
|
+
var y = off.top + btn.outerHeight() + 2;
|
|
293
|
+
if (x + menuW > window.innerWidth) x = window.innerWidth - menuW - 4;
|
|
294
|
+
if (y + menuH > window.innerHeight) y = off.top - menuH - 2;
|
|
295
|
+
$compactMenu.css({ top: y + "px", left: x + "px" }).show();
|
|
296
|
+
setTimeout(function () { $(document).one("click.dsff-menu", hideCompactMenu); }, 0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hideCompactMenu() {
|
|
300
|
+
$compactMenu.hide();
|
|
301
|
+
$(document).off("click.dsff-menu");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Host compatibility adapter ────────────────────────────────────────────
|
|
305
|
+
// The SINGLE place that reads or writes Node-RED host layout. Capabilities
|
|
306
|
+
// are detected from the live DOM (never version-sniffed) and the sidebar
|
|
307
|
+
// side (left/right) is derived from geometry (never assumed). When anything
|
|
308
|
+
// is uncertain we report "can't resize" and the buttons degrade safely — we
|
|
309
|
+
// never throw and never freeze the editor. No fragile right-only offsets in
|
|
310
|
+
// normal view logic; the one pre-v5 offset write is isolated + guarded here.
|
|
311
|
+
var host = {
|
|
312
|
+
// STATELESS BY DESIGN: this adapter keeps NO expand/collapse state in JS.
|
|
313
|
+
// "Expanded?" is derived from the live sidebar width, and expand/collapse
|
|
314
|
+
// just set deterministic target widths computed from the viewport. The
|
|
315
|
+
// shared source of truth is the DOM (the actual sidebar width), so the
|
|
316
|
+
// sibling telemetry plugin — which controls the SAME host sidebar — stays
|
|
317
|
+
// consistent with this one, with NO shared globals, events, or any
|
|
318
|
+
// communication between the plugins. Each reads the same width and
|
|
319
|
+
// computes the same answer independently.
|
|
320
|
+
|
|
321
|
+
// The sidebar container that actually owns our panel (correct even with
|
|
322
|
+
// multiple sidebars); falls back to the well-known id. null when absent.
|
|
323
|
+
_findSidebarContainer: function () {
|
|
324
|
+
var start = ($root && $root[0]) || null;
|
|
325
|
+
for (var n = start; n; n = n.parentElement) {
|
|
326
|
+
if (n.id === "red-ui-sidebar") return n;
|
|
327
|
+
if (n.classList && n.classList.contains("red-ui-sidebar")) return n;
|
|
328
|
+
}
|
|
329
|
+
return document.getElementById("red-ui-sidebar");
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
getSidebarContext: function () {
|
|
333
|
+
var container = this._findSidebarContainer();
|
|
334
|
+
if (!container || typeof container.getBoundingClientRect !== "function") return null;
|
|
335
|
+
var rect = container.getBoundingClientRect();
|
|
336
|
+
if (!rect || !rect.width) return null; // not laid out / not measurable yet
|
|
337
|
+
var parent = container.parentElement;
|
|
338
|
+
var pr = (parent && typeof parent.getBoundingClientRect === "function")
|
|
339
|
+
? parent.getBoundingClientRect() : null;
|
|
340
|
+
var viewportW = window.innerWidth || (pr && pr.width) || rect.width;
|
|
341
|
+
var parentLeft = pr ? pr.left : 0;
|
|
342
|
+
var parentRight = pr ? pr.right : viewportW;
|
|
343
|
+
var parentWidth = pr ? pr.width : viewportW;
|
|
344
|
+
// Side = whichever parent edge the container hugs. NEVER assume right.
|
|
345
|
+
var side = Math.abs(rect.left - parentLeft) <= Math.abs(parentRight - rect.right)
|
|
346
|
+
? "left" : "right";
|
|
347
|
+
return {
|
|
348
|
+
container: container, side: side,
|
|
349
|
+
width: Math.round(rect.width),
|
|
350
|
+
parentWidth: Math.round(parentWidth),
|
|
351
|
+
viewportWidth: Math.round(viewportW),
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
canResizeSidebar: function () {
|
|
356
|
+
var ctx = this.getSidebarContext();
|
|
357
|
+
return !!(ctx && ctx.viewportWidth > 0 && ctx.container && ctx.container.style);
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
// Deterministic narrow/wide target widths + the threshold that separates
|
|
361
|
+
// them, all computed purely from the viewport. Identical inputs (same DOM,
|
|
362
|
+
// same viewport) yield identical values in every plugin, so "expanded" is
|
|
363
|
+
// shared without any of them talking to each other.
|
|
364
|
+
_widths: function (ctx) {
|
|
365
|
+
var vw = ctx.viewportWidth;
|
|
366
|
+
var narrowW = Math.min(Math.max(Math.round(vw * 0.25), 180), 380);
|
|
367
|
+
var wideW = Math.min(Math.max(Math.round(vw * 0.5), narrowW + 120), 720);
|
|
368
|
+
return { narrowW: narrowW, wideW: wideW, threshold: Math.round((narrowW + wideW) / 2) };
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
// Expanded iff the LIVE sidebar width is past the midpoint threshold — read
|
|
372
|
+
// from the DOM, never from a remembered flag. This is what keeps two
|
|
373
|
+
// independent plugins agreeing on the same sidebar.
|
|
374
|
+
isSidebarExpanded: function () {
|
|
375
|
+
var ctx = this.getSidebarContext();
|
|
376
|
+
return !!(ctx && ctx.width >= this._widths(ctx).threshold);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
expandSidebar: function () {
|
|
380
|
+
var ctx = this.getSidebarContext();
|
|
381
|
+
if (!ctx) return false;
|
|
382
|
+
// Grow to the wide target (never shrink if already wider). No state
|
|
383
|
+
// stored — the new width IS the state.
|
|
384
|
+
this._applyWidth(ctx.container, Math.max(ctx.width, this._widths(ctx).wideW), ctx.side);
|
|
385
|
+
return true;
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
collapseSidebar: function () {
|
|
389
|
+
var ctx = this.getSidebarContext();
|
|
390
|
+
if (!ctx) return false;
|
|
391
|
+
// Shrink to the narrow target (never widen one that's already narrower).
|
|
392
|
+
// Works regardless of which plugin expanded it.
|
|
393
|
+
this._applyWidth(ctx.container, Math.min(ctx.width, this._widths(ctx).narrowW), ctx.side);
|
|
394
|
+
return true;
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
_separatorWidth: function () {
|
|
398
|
+
var sep = document.getElementById("red-ui-sidebar-separator");
|
|
399
|
+
return sep ? (sep.offsetWidth || 7) : 7;
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
// The ONLY function that writes host layout. Guarded; never throws out.
|
|
403
|
+
_applyWidth: function (container, width, side) {
|
|
404
|
+
try {
|
|
405
|
+
// Model-agnostic: size the sidebar container itself. Set width AND
|
|
406
|
+
// flex-basis so it works whether the sidebar is a block (pre-v5,
|
|
407
|
+
// absolutely positioned) or a flex child (Node-RED 5 panel layout).
|
|
408
|
+
container.style.width = width + "px";
|
|
409
|
+
container.style.flexBasis = width + "px";
|
|
410
|
+
|
|
411
|
+
// pre-v5 ONLY (capability-gated): the workspace + editor stack are
|
|
412
|
+
// absolutely positioned and offset from the sidebar's side; they do
|
|
413
|
+
// NOT reflow when the sidebar width changes, so the offset on the SAME
|
|
414
|
+
// side the sidebar occupies must follow it. Node-RED 5's flex layout
|
|
415
|
+
// reflows on its own, so we detect position:absolute and skip it
|
|
416
|
+
// there. Side-aware (left → left offset, right → right offset).
|
|
417
|
+
var sepW = this._separatorWidth();
|
|
418
|
+
["red-ui-workspace", "red-ui-editor-stack"].forEach(function (id) {
|
|
419
|
+
var elm = document.getElementById(id);
|
|
420
|
+
if (!elm) return;
|
|
421
|
+
var cs = window.getComputedStyle ? window.getComputedStyle(elm) : null;
|
|
422
|
+
if (!cs || cs.position !== "absolute") return; // flex model → leave alone
|
|
423
|
+
if (side === "left") elm.style.left = (width + sepW) + "px";
|
|
424
|
+
else elm.style.right = (width + sepW) + "px";
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Let Node-RED re-layout (tray.js handleWindowResize) if it listens.
|
|
428
|
+
if (window.RED && RED.events && typeof RED.events.emit === "function") {
|
|
429
|
+
RED.events.emit("sidebar:resize");
|
|
430
|
+
}
|
|
431
|
+
} catch (e) { /* never throw out of the adapter */ }
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Reflect host expand state onto the two toolbar buttons. `.dsff-sidebar-
|
|
436
|
+
// expanded` (CSS) decides which of expand/collapse is visible; both are
|
|
437
|
+
// disabled when the host can't be resized, so the feature degrades to a
|
|
438
|
+
// safe disabled state instead of a confusing broken no-op.
|
|
439
|
+
function syncSidebarBtn() {
|
|
440
|
+
if (!host.canResizeSidebar()) {
|
|
441
|
+
$btnExpand.prop("disabled", true)
|
|
442
|
+
.attr("title", "Sidebar resize isn't available in this Node-RED layout");
|
|
443
|
+
$btnCollapse.prop("disabled", true);
|
|
444
|
+
$root.removeClass("dsff-sidebar-expanded");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
$btnExpand.prop("disabled", false).attr("title", "Expand sidebar");
|
|
448
|
+
$btnCollapse.prop("disabled", false).attr("title", "Collapse sidebar");
|
|
449
|
+
$root.toggleClass("dsff-sidebar-expanded", host.isSidebarExpanded());
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Expand/collapse the host sidebar via the adapter, then let our own
|
|
453
|
+
// internal layout (applyMode) and the Monaco editor re-flow for the new
|
|
454
|
+
// width. No direct host-layout writes live here. Names kept (tryExpandSidebar
|
|
455
|
+
// / collapseSidebar) so existing call sites are unchanged.
|
|
456
|
+
function tryExpandSidebar() {
|
|
457
|
+
if (!host.canResizeSidebar()) return;
|
|
458
|
+
host.expandSidebar();
|
|
459
|
+
syncSidebarBtn();
|
|
460
|
+
applyMode(false);
|
|
461
|
+
layoutEditorSoon();
|
|
462
|
+
}
|
|
463
|
+
function collapseSidebar() {
|
|
464
|
+
if (!host.canResizeSidebar()) return;
|
|
465
|
+
host.collapseSidebar();
|
|
466
|
+
syncSidebarBtn();
|
|
467
|
+
applyMode(false);
|
|
468
|
+
layoutEditorSoon();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── 6. Editor behavior ───────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
// View-state persistence (scroll position + cursor per file)
|
|
474
|
+
function vsKey(rel) { return VS_PREFIX + (rel || ""); }
|
|
475
|
+
function vsGet(k) { try { var s = localStorage.getItem(k); return s ? JSON.parse(s) : null; } catch (e) { return null; } }
|
|
476
|
+
function vsSet(k,v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
|
|
477
|
+
|
|
478
|
+
function ignoreChanges() { return Date.now() < restoreGuardUntil; }
|
|
479
|
+
function startGuard(ms) { restoreGuardUntil = Date.now() + (ms || 800); }
|
|
480
|
+
|
|
481
|
+
function saveViewState() {
|
|
482
|
+
if (!currentFile || ignoreChanges()) return;
|
|
483
|
+
if (editorKind === "monaco" && monacoEditor) {
|
|
484
|
+
try {
|
|
485
|
+
var top = monacoEditor.getScrollTop();
|
|
486
|
+
if (typeof top !== "number" || top < 0) return;
|
|
487
|
+
vsSet(vsKey(currentFile), {
|
|
488
|
+
kind: "monaco",
|
|
489
|
+
viewState: monacoEditor.saveViewState() || null,
|
|
490
|
+
scrollTop: top,
|
|
491
|
+
scrollLeft: monacoEditor.getScrollLeft(),
|
|
492
|
+
});
|
|
493
|
+
} catch (e) {}
|
|
494
|
+
} else if (editorKind === "textarea" && $textarea) {
|
|
495
|
+
var el = $textarea[0];
|
|
496
|
+
if (!el) return;
|
|
497
|
+
vsSet(vsKey(currentFile), {
|
|
498
|
+
kind: "textarea",
|
|
499
|
+
scrollTop: el.scrollTop || 0,
|
|
500
|
+
selectionStart: el.selectionStart || 0,
|
|
501
|
+
selectionEnd: el.selectionEnd || 0,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function scheduleRemember() {
|
|
507
|
+
if (posDebounceTimer) clearTimeout(posDebounceTimer);
|
|
508
|
+
posDebounceTimer = setTimeout(saveViewState, 160);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function restorePositionFor(relPath, opts) {
|
|
512
|
+
if (!relPath) return;
|
|
513
|
+
var st = vsGet(vsKey(relPath));
|
|
514
|
+
if (!st) return;
|
|
515
|
+
var retries = (opts && opts.retries) || 12;
|
|
516
|
+
var delay = (opts && opts.delay) || 60;
|
|
517
|
+
var attempt = 0;
|
|
518
|
+
startGuard((opts && opts.guardMs) || 800);
|
|
519
|
+
(function tryApply() {
|
|
520
|
+
attempt++;
|
|
521
|
+
var ok = false;
|
|
522
|
+
try {
|
|
523
|
+
if (st.kind === "monaco" && editorKind === "monaco" && monacoEditor) {
|
|
524
|
+
if (st.viewState) {
|
|
525
|
+
monacoEditor.restoreViewState(st.viewState);
|
|
526
|
+
monacoEditor.focus();
|
|
527
|
+
} else {
|
|
528
|
+
if (typeof st.scrollTop === "number" && st.scrollTop >= 0) monacoEditor.setScrollTop(st.scrollTop);
|
|
529
|
+
if (typeof st.scrollLeft === "number" && st.scrollLeft >= 0) monacoEditor.setScrollLeft(st.scrollLeft);
|
|
530
|
+
}
|
|
531
|
+
ok = true;
|
|
532
|
+
} else if (st.kind === "textarea" && editorKind === "textarea" && $textarea) {
|
|
533
|
+
var el = $textarea[0];
|
|
534
|
+
if (el) {
|
|
535
|
+
el.scrollTop = st.scrollTop || 0;
|
|
536
|
+
if (typeof st.selectionStart === "number") {
|
|
537
|
+
el.selectionStart = st.selectionStart;
|
|
538
|
+
el.selectionEnd = st.selectionEnd || st.selectionStart;
|
|
539
|
+
}
|
|
540
|
+
ok = true;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} catch (e) {}
|
|
544
|
+
if (!ok && attempt < retries) setTimeout(tryApply, delay);
|
|
545
|
+
})();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Monaco loader
|
|
549
|
+
function monacoReady() {
|
|
550
|
+
return new Promise(function (resolve, reject) {
|
|
551
|
+
if (window.monaco && window.monaco.editor) { resolve(window.monaco); return; }
|
|
552
|
+
if (!window.require) { reject(new Error("AMD loader not available")); return; }
|
|
553
|
+
try {
|
|
554
|
+
window.require(["vs/editor/editor.main"], function () {
|
|
555
|
+
window.monaco && window.monaco.editor
|
|
556
|
+
? resolve(window.monaco)
|
|
557
|
+
: reject(new Error("Monaco did not initialise"));
|
|
558
|
+
});
|
|
559
|
+
} catch (e) { reject(e); }
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function installHotkey() {
|
|
564
|
+
if (hotkeyInstalled) return;
|
|
565
|
+
hotkeyInstalled = true;
|
|
566
|
+
$editorHost[0].addEventListener("keydown", function (ev) {
|
|
567
|
+
var isS = ev.key === "s" || ev.key === "S" || ev.keyCode === 83;
|
|
568
|
+
if ((ev.ctrlKey || ev.metaKey) && isS) {
|
|
569
|
+
if (!currentFile) return;
|
|
570
|
+
ev.preventDefault();
|
|
571
|
+
ev.stopPropagation();
|
|
572
|
+
doSave();
|
|
573
|
+
}
|
|
574
|
+
}, true);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async function ensureEditorReady() {
|
|
578
|
+
if (editorKind === "monaco" && monacoEditor) return "monaco";
|
|
579
|
+
if (editorKind === "textarea" && $textarea) return "textarea";
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
await monacoReady();
|
|
583
|
+
monacoEditor = window.monaco.editor.create($editorHost[0], {
|
|
584
|
+
value: "", language: "plaintext",
|
|
585
|
+
automaticLayout: true,
|
|
586
|
+
minimap: { enabled: false },
|
|
587
|
+
wordWrap: dsffWrapEnabled() ? "on" : "off",
|
|
588
|
+
});
|
|
589
|
+
editorModelUri = window.monaco.Uri.parse("inmemory://dsff/current");
|
|
590
|
+
editorModel = window.monaco.editor.createModel("", "plaintext", editorModelUri);
|
|
591
|
+
monacoEditor.setModel(editorModel);
|
|
592
|
+
installHotkey();
|
|
593
|
+
|
|
594
|
+
monacoEditor.onDidChangeModelContent(function () {
|
|
595
|
+
if (suppressDirty) { try { currentTextCache = monacoEditor.getValue(); } catch (e) {} return; }
|
|
596
|
+
try { currentTextCache = monacoEditor.getValue(); } catch (e) {}
|
|
597
|
+
syncDirty();
|
|
598
|
+
});
|
|
599
|
+
if (typeof monacoEditor.onDidScrollChange === "function") {
|
|
600
|
+
monacoEditor.onDidScrollChange(function () { if (!ignoreChanges()) scheduleRemember(); });
|
|
601
|
+
}
|
|
602
|
+
if (typeof monacoEditor.onDidChangeCursorPosition === "function") {
|
|
603
|
+
monacoEditor.onDidChangeCursorPosition(function () { if (!ignoreChanges()) scheduleRemember(); });
|
|
604
|
+
}
|
|
605
|
+
editorKind = "monaco";
|
|
606
|
+
layoutEditorSoon();
|
|
607
|
+
return "monaco";
|
|
608
|
+
} catch (_) {
|
|
609
|
+
$textarea = $("<textarea>").css({
|
|
610
|
+
position: "absolute", inset: "0", width: "100%", height: "100%",
|
|
611
|
+
fontFamily: "monospace", fontSize: "12px",
|
|
612
|
+
padding: "8px", boxSizing: "border-box", resize: "none",
|
|
613
|
+
wrap: dsffWrapEnabled() ? "soft" : "off",
|
|
614
|
+
});
|
|
615
|
+
$editorHost.css("position", "relative").empty().append($textarea);
|
|
616
|
+
installHotkey();
|
|
617
|
+
$textarea.on("input", function () { syncDirty(); if (!ignoreChanges()) scheduleRemember(); });
|
|
618
|
+
$textarea.on("scroll", function () { if (!ignoreChanges()) scheduleRemember(); });
|
|
619
|
+
editorKind = "textarea";
|
|
620
|
+
return "textarea";
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function setEditorContent(text, filename) {
|
|
625
|
+
if (editorKind === "monaco" && monacoEditor && window.monaco) {
|
|
626
|
+
var lang = langFromName(filename || "");
|
|
627
|
+
if (!editorModel || editorModel.isDisposed()) {
|
|
628
|
+
editorModel = window.monaco.editor.createModel(text || "", lang, editorModelUri);
|
|
629
|
+
monacoEditor.setModel(editorModel);
|
|
630
|
+
} else {
|
|
631
|
+
window.monaco.editor.setModelLanguage(editorModel, lang);
|
|
632
|
+
}
|
|
633
|
+
suppressDirty = true;
|
|
634
|
+
try { editorModel.setValue(String(text || "")); } finally { suppressDirty = false; }
|
|
635
|
+
currentTextCache = String(text || "");
|
|
636
|
+
layoutEditorSoon();
|
|
637
|
+
restorePositionFor(currentFile || filename, { retries: 14, delay: 60, guardMs: 900 });
|
|
638
|
+
} else if (editorKind === "textarea" && $textarea) {
|
|
639
|
+
$textarea.val(text || "");
|
|
640
|
+
restorePositionFor(currentFile || filename, { retries: 10, delay: 60, guardMs: 700 });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function getEditorContent() {
|
|
645
|
+
if (editorKind === "monaco" && monacoEditor) return monacoEditor.getValue();
|
|
646
|
+
if (editorKind === "textarea" && $textarea) return $textarea.val();
|
|
647
|
+
return "";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function applyWrap() {
|
|
651
|
+
var on = dsffWrapEnabled();
|
|
652
|
+
if (editorKind === "monaco" && monacoEditor) {
|
|
653
|
+
try { monacoEditor.updateOptions({ wordWrap: on ? "on" : "off" }); } catch (e) {}
|
|
654
|
+
layoutEditorSoon();
|
|
655
|
+
} else if (editorKind === "textarea" && $textarea) {
|
|
656
|
+
$textarea.attr("wrap", on ? "soft" : "off")
|
|
657
|
+
.css("whiteSpace", on ? "pre-wrap" : "pre");
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function applyReadOnly() {
|
|
662
|
+
if (editorKind === "monaco" && monacoEditor) {
|
|
663
|
+
monacoEditor.updateOptions({ readOnly: fileReadOnly });
|
|
664
|
+
} else if (editorKind === "textarea" && $textarea) {
|
|
665
|
+
$textarea.prop("readonly", fileReadOnly);
|
|
666
|
+
}
|
|
667
|
+
if (fileReadOnly) {
|
|
668
|
+
dirty = false;
|
|
669
|
+
$btnSave.prop("disabled", true).removeClass("dsff-dirty");
|
|
670
|
+
updateCompactFileName();
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function markDirty(val) {
|
|
675
|
+
if (val && fileReadOnly) return;
|
|
676
|
+
dirty = !!val;
|
|
677
|
+
$btnSave.prop("disabled", !dirty).toggleClass("dsff-dirty", dirty);
|
|
678
|
+
updateCompactFileName();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// isDirty() is the AUTHORITATIVE check — compares editor content against
|
|
682
|
+
// savedText. The dirty flag is only for UI; never trust it alone for guards.
|
|
683
|
+
function isDirty() {
|
|
684
|
+
if (!currentFile) return false;
|
|
685
|
+
return getEditorContent() !== savedText;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function syncDirty() {
|
|
689
|
+
var d = isDirty();
|
|
690
|
+
if (d !== dirty) markDirty(d);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Call withCleanState before ANY action that abandons the current file.
|
|
694
|
+
function withCleanState(verb, target, action) {
|
|
695
|
+
syncDirty();
|
|
696
|
+
if (!dirty) { action(); return; }
|
|
697
|
+
showDirtyDialog(verb, target, action);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function showDirtyDialog(verb, target, action) {
|
|
701
|
+
var fname = (currentFile || "").split("/").pop() || currentFile || "file";
|
|
702
|
+
var safeFile = $("<span>").text(fname).html();
|
|
703
|
+
var safeTarget = $("<span>").text(target || "").html();
|
|
704
|
+
|
|
705
|
+
dsffModal({
|
|
706
|
+
title: "Unsaved changes",
|
|
707
|
+
body: "You have unsaved changes in <strong>" + safeFile + "</strong>. " +
|
|
708
|
+
"What would you like to do before " + (verb || "leaving") +
|
|
709
|
+
(safeTarget ? " <strong>" + safeTarget + "</strong>" : "") + "?",
|
|
710
|
+
buttons: [
|
|
711
|
+
{ label: "Cancel",
|
|
712
|
+
key: "Escape",
|
|
713
|
+
action: function () { selectedPath = currentFile; refreshSelectedRow(); }
|
|
714
|
+
},
|
|
715
|
+
{ label: "Discard changes",
|
|
716
|
+
cls: "dsff-modal-btn-danger",
|
|
717
|
+
action: function () { markDirty(false); action(); }
|
|
718
|
+
},
|
|
719
|
+
{ label: "Save",
|
|
720
|
+
cls: "dsff-modal-btn-primary",
|
|
721
|
+
key: "Enter",
|
|
722
|
+
action: function () { doSave(action); }
|
|
723
|
+
}
|
|
724
|
+
]
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function stopStatTimer() { if (statTimer) { clearInterval(statTimer); statTimer = null; } }
|
|
729
|
+
function startStatTimer() {
|
|
730
|
+
stopStatTimer();
|
|
731
|
+
if (!currentFile) return;
|
|
732
|
+
statTimer = setInterval(function () {
|
|
733
|
+
if (!currentFile) return;
|
|
734
|
+
dsffAjax("GET", "project-files/stat?path=" + encodeURIComponent(currentFile))
|
|
735
|
+
.done(function (r) {
|
|
736
|
+
var changed = lastDisk.mtime !== null &&
|
|
737
|
+
(r.mtime !== lastDisk.mtime || r.size !== lastDisk.size);
|
|
738
|
+
onDiskChanged = !!changed;
|
|
739
|
+
setStatus("Opened: " + currentFile + (onDiskChanged ? " (changed on disk)" : ""));
|
|
740
|
+
})
|
|
741
|
+
.fail(function () {});
|
|
742
|
+
}, 5000);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// openFile is a pure loader — no dirty guard. Callers must use withCleanState.
|
|
746
|
+
function openFile(relPath) {
|
|
747
|
+
selectedPath = relPath;
|
|
748
|
+
refreshSelectedRow();
|
|
749
|
+
saveViewState();
|
|
750
|
+
|
|
751
|
+
dsffAjax("GET", "project-files/open?path=" + encodeURIComponent(relPath))
|
|
752
|
+
.done(function (res) {
|
|
753
|
+
ensureEditorReady().then(function () {
|
|
754
|
+
currentFile = relPath;
|
|
755
|
+
savedText = res.text || "";
|
|
756
|
+
lastDisk = { mtime: res.mtime || null, size: res.size || null };
|
|
757
|
+
onDiskChanged = false;
|
|
758
|
+
fileReadOnly = isReadOnly(relPath);
|
|
759
|
+
setEditorContent(res.text || "", relPath);
|
|
760
|
+
applyReadOnly();
|
|
761
|
+
markDirty(false);
|
|
762
|
+
setStatus("Opened: " + relPath);
|
|
763
|
+
startStatTimer();
|
|
764
|
+
layoutEditorSoon();
|
|
765
|
+
$root.addClass("dsff-file-open").toggleClass("dsff-readonly-open", fileReadOnly);
|
|
766
|
+
updateCompactFileName();
|
|
767
|
+
if (isCompact) tryExpandSidebar();
|
|
768
|
+
else layoutEditorSoon();
|
|
769
|
+
});
|
|
770
|
+
})
|
|
771
|
+
.fail(function (xhr) {
|
|
772
|
+
notifyErr("Open error: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText || xhr.status));
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function doSave(callback) {
|
|
777
|
+
if (!currentFile || fileReadOnly) return;
|
|
778
|
+
var text = getEditorContent();
|
|
779
|
+
dsffAjax("POST", "project-files/save", { path: currentFile, text: text })
|
|
780
|
+
.done(function (r) {
|
|
781
|
+
savedText = text;
|
|
782
|
+
lastDisk = { mtime: r.mtime || Date.now(), size: r.size || (text || "").length };
|
|
783
|
+
onDiskChanged = false;
|
|
784
|
+
markDirty(false);
|
|
785
|
+
setStatus("Saved: " + currentFile);
|
|
786
|
+
toast("Saved", "success");
|
|
787
|
+
saveViewState();
|
|
788
|
+
if (typeof callback === "function") callback();
|
|
789
|
+
})
|
|
790
|
+
.fail(function (xhr) {
|
|
791
|
+
notifyErr("Save error: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText || xhr.status));
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function resetEditorState() {
|
|
796
|
+
stopStatTimer();
|
|
797
|
+
currentFile = null;
|
|
798
|
+
selectedPath = null;
|
|
799
|
+
currentDir = ".";
|
|
800
|
+
savedText = "";
|
|
801
|
+
currentTextCache = "";
|
|
802
|
+
lastDisk = { mtime: null, size: null };
|
|
803
|
+
onDiskChanged = false;
|
|
804
|
+
fileReadOnly = false;
|
|
805
|
+
markDirty(false);
|
|
806
|
+
$root.removeClass("dsff-file-open dsff-readonly-open");
|
|
807
|
+
updateCompactFileName();
|
|
808
|
+
setStatus("");
|
|
809
|
+
if (editorKind === "monaco" && monacoEditor) {
|
|
810
|
+
monacoEditor.updateOptions({ readOnly: false });
|
|
811
|
+
if (editorModel && !editorModel.isDisposed()) {
|
|
812
|
+
suppressDirty = true;
|
|
813
|
+
try { editorModel.setValue(""); } finally { suppressDirty = false; }
|
|
814
|
+
}
|
|
815
|
+
} else if (editorKind === "textarea" && $textarea) {
|
|
816
|
+
$textarea.prop("readonly", false).val("");
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function loadConfigThenList() {
|
|
821
|
+
dsffAjax("GET", "project-files/config")
|
|
822
|
+
.done(function (res) {
|
|
823
|
+
var newProject = res.activeProject || null;
|
|
824
|
+
if (projectRoot !== null && newProject !== projectRoot) resetEditorState();
|
|
825
|
+
baseDir = res.baseDir || "";
|
|
826
|
+
mode = res.mode || (newProject ? "project" : "user");
|
|
827
|
+
scopeDir = res.scopeDir || "";
|
|
828
|
+
projectRoot = newProject;
|
|
829
|
+
$baseLabel.text(baseDir).attr("title", baseDir);
|
|
830
|
+
updateModeBar();
|
|
831
|
+
loadList(projectRoot || ".");
|
|
832
|
+
refreshVenvState();
|
|
833
|
+
})
|
|
834
|
+
.fail(function () { loadList("."); });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Operating-mode indicator.
|
|
838
|
+
// Project Mode is the normal/default state, so it shows NO banner — the
|
|
839
|
+
// active project name stays visible compactly in the header path label, and
|
|
840
|
+
// no vertical file-list space is consumed. A warning bar is shown ONLY in
|
|
841
|
+
// the fallback (User Directory) Mode, where behavior differs from the normal
|
|
842
|
+
// project-based workflow.
|
|
843
|
+
function updateModeBar() {
|
|
844
|
+
if (mode === "project") {
|
|
845
|
+
$modeBar.hide();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
$modeBar.addClass("dsff-mode-user").show();
|
|
849
|
+
$modeIcon.attr("class", "fa fa-exclamation-triangle");
|
|
850
|
+
$modeText.empty().append(
|
|
851
|
+
$("<strong>").text("User Directory Mode"),
|
|
852
|
+
document.createTextNode(
|
|
853
|
+
" — Node-RED Projects is disabled or no active project is available. " +
|
|
854
|
+
"Now browsing the Node-RED user directory; some project-specific " +
|
|
855
|
+
"features (Python & Node tools) are unavailable."
|
|
856
|
+
)
|
|
857
|
+
);
|
|
858
|
+
$modeBar.attr("title", "User-directory scope: " + (scopeDir || baseDir || ""));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Python venv — detect, create, install. Always scoped to projectRoot.
|
|
862
|
+
function updateVenvBar() {
|
|
863
|
+
if (!projectRoot) { $venvBar.hide(); return; }
|
|
864
|
+
$venvBar.show().toggleClass("dsff-venv-ready", hasVenv);
|
|
865
|
+
if (hasVenv) {
|
|
866
|
+
$venvStatus.text("Python environment ready");
|
|
867
|
+
$venvAction.hide();
|
|
868
|
+
} else {
|
|
869
|
+
$venvStatus.text("No Python environment");
|
|
870
|
+
$venvAction.text("Create").show();
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function refreshVenvState() {
|
|
874
|
+
if (!projectRoot) { hasVenv = false; updateVenvBar(); return; }
|
|
875
|
+
dsffAjax("GET", "project-files/python-env?project=" + encodeURIComponent(projectRoot))
|
|
876
|
+
.done(function (r) { hasVenv = !!(r && r.present); updateVenvBar(); })
|
|
877
|
+
.fail(function () { hasVenv = false; updateVenvBar(); });
|
|
878
|
+
}
|
|
879
|
+
function doCreateVenv() {
|
|
880
|
+
if (!projectRoot) return;
|
|
881
|
+
dsffConfirm({
|
|
882
|
+
title: "Create Python environment",
|
|
883
|
+
body: 'Create a virtual environment at <strong>' + $("<span>").text(projectRoot + "/.venv").html() + '</strong>? This may take a moment.',
|
|
884
|
+
confirmLabel: "Create",
|
|
885
|
+
onConfirm: function () {
|
|
886
|
+
setStatus("Creating Python environment…");
|
|
887
|
+
dsffAjax("POST", "project-files/python-env/create", { project: projectRoot })
|
|
888
|
+
.done(function () {
|
|
889
|
+
toast("Python environment created", "success");
|
|
890
|
+
setStatus("");
|
|
891
|
+
refreshVenvState();
|
|
892
|
+
loadList(currentDir, { silent: true });
|
|
893
|
+
})
|
|
894
|
+
.fail(function (xhr) {
|
|
895
|
+
setStatus("");
|
|
896
|
+
notifyErr("Create failed: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
function doInstallRequirements(relPath) {
|
|
902
|
+
if (!projectRoot || !hasVenv) return;
|
|
903
|
+
setStatus("Installing Python libraries…");
|
|
904
|
+
dsffAjax("POST", "project-files/python-env/install", { project: projectRoot, requirements: relPath })
|
|
905
|
+
.done(function () {
|
|
906
|
+
toast("Python libraries installed", "success");
|
|
907
|
+
setStatus("");
|
|
908
|
+
})
|
|
909
|
+
.fail(function (xhr) {
|
|
910
|
+
setStatus("");
|
|
911
|
+
notifyErr("Install failed: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function doInstallNodePackages() {
|
|
916
|
+
if (!projectRoot) return;
|
|
917
|
+
setStatus("Installing Node libraries…");
|
|
918
|
+
dsffAjax("POST", "project-files/node-packages/install", { project: projectRoot })
|
|
919
|
+
.done(function () {
|
|
920
|
+
toast("Node libraries installed", "success");
|
|
921
|
+
setStatus("");
|
|
922
|
+
loadList(currentDir, { silent: true });
|
|
923
|
+
})
|
|
924
|
+
.fail(function (xhr) {
|
|
925
|
+
setStatus("");
|
|
926
|
+
notifyErr("Install failed: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ── 7. File-tree behavior ────────────────────────────────────────────────
|
|
931
|
+
|
|
932
|
+
// Sort
|
|
933
|
+
function updateSortHeader() {
|
|
934
|
+
var na = sortField === "name" ? (sortDir === "asc" ? " ▲" : " ▼") : "";
|
|
935
|
+
var ma = sortField === "mtime" ? (sortDir === "asc" ? " ▲" : " ▼") : "";
|
|
936
|
+
$hdrName.text("Name" + na);
|
|
937
|
+
$hdrMod.text("Modified" + ma);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function sortItems(items) {
|
|
941
|
+
var copy = items.slice();
|
|
942
|
+
copy.sort(function (a, b) {
|
|
943
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
944
|
+
var cmp = sortField === "mtime"
|
|
945
|
+
? (a.mtime || 0) - (b.mtime || 0)
|
|
946
|
+
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
|
947
|
+
if (cmp === 0) cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
|
948
|
+
return sortDir === "asc" ? cmp : -cmp;
|
|
949
|
+
});
|
|
950
|
+
return copy;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Tree rendering
|
|
954
|
+
function formatMtime(ms) {
|
|
955
|
+
if (!ms) return "";
|
|
956
|
+
var d = new Date(ms);
|
|
957
|
+
var p = function (n) { return String(n).padStart(2, "0"); };
|
|
958
|
+
return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) +
|
|
959
|
+
" " + p(d.getHours()) + ":" + p(d.getMinutes());
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function refreshSelectedRow() {
|
|
963
|
+
$tree.find(".dsff-row").removeClass("dsff-row-selected");
|
|
964
|
+
if (!selectedPath) return;
|
|
965
|
+
$tree.find(".dsff-row").each(function () {
|
|
966
|
+
if ($(this).data("dsff-path") === selectedPath) $(this).addClass("dsff-row-selected");
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function setCrumb(parts) {
|
|
971
|
+
var segs = [];
|
|
972
|
+
var acc = [];
|
|
973
|
+
segs.push($('<a href="#">.</a>').on("click", function (e) { e.preventDefault(); loadList("."); }));
|
|
974
|
+
(parts || []).forEach(function (p) {
|
|
975
|
+
acc.push(p);
|
|
976
|
+
var snap = acc.slice();
|
|
977
|
+
segs.push($("<span>/</span>"));
|
|
978
|
+
segs.push($("<a href='#'></a>").text(p).on("click", function (e) {
|
|
979
|
+
e.preventDefault(); loadList(snap.join("/"));
|
|
980
|
+
}));
|
|
981
|
+
});
|
|
982
|
+
$crumb.empty().append(segs);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function renderTree(list) {
|
|
986
|
+
lastList = list;
|
|
987
|
+
baseDir = list.baseDir || baseDir;
|
|
988
|
+
if (list.mode) mode = list.mode;
|
|
989
|
+
if (list.scopeDir) scopeDir = list.scopeDir;
|
|
990
|
+
updateModeBar();
|
|
991
|
+
var _fullPath = (list.baseDir || "") + (list.cwd && list.cwd !== "." ? "/" + list.cwd : "");
|
|
992
|
+
$baseLabel.text(_fullPath).attr("title", _fullPath);
|
|
993
|
+
currentDir = list.cwd;
|
|
994
|
+
setCrumb(list.breadcrumb);
|
|
995
|
+
$tree.empty();
|
|
996
|
+
updateSortHeader();
|
|
997
|
+
updateCompactFolderLabel();
|
|
998
|
+
|
|
999
|
+
var rows = [];
|
|
1000
|
+
var atRoot = list.cwd === "." || list.cwd === projectRoot;
|
|
1001
|
+
|
|
1002
|
+
if (!atRoot) {
|
|
1003
|
+
var parentDir = (function () {
|
|
1004
|
+
var parts = currentDir.split("/").filter(Boolean);
|
|
1005
|
+
var p = parts.slice(0, -1).join("/") || ".";
|
|
1006
|
+
if (projectRoot && p !== "." && !p.startsWith(projectRoot)) p = projectRoot;
|
|
1007
|
+
return p;
|
|
1008
|
+
})();
|
|
1009
|
+
var $up = $('<div class="dsff-row">').data("dsff-path", null);
|
|
1010
|
+
$up.append($('<i class="fa fa-level-up">').css({ width: "16px", marginRight: "6px" }));
|
|
1011
|
+
$up.append($('<span class="dsff-row-name">..'));
|
|
1012
|
+
$up.on("click", function () { loadList(parentDir); });
|
|
1013
|
+
$up.on("dragover", function (ev) {
|
|
1014
|
+
if (!draggedFile) return;
|
|
1015
|
+
ev.preventDefault();
|
|
1016
|
+
ev.originalEvent.dataTransfer.dropEffect = "move";
|
|
1017
|
+
});
|
|
1018
|
+
$up.on("dragenter", function (ev) {
|
|
1019
|
+
if (!draggedFile) return;
|
|
1020
|
+
ev.preventDefault();
|
|
1021
|
+
if (!$(ev.relatedTarget).closest(this).length) $(this).addClass("dsff-row-drag-over");
|
|
1022
|
+
});
|
|
1023
|
+
$up.on("dragleave", function (ev) {
|
|
1024
|
+
if (!$(ev.relatedTarget).closest(this).length) $(this).removeClass("dsff-row-drag-over");
|
|
1025
|
+
});
|
|
1026
|
+
$up.on("drop", function (ev) {
|
|
1027
|
+
// External (OS) drops are handled by the capture listener on $root[0].
|
|
1028
|
+
// Bail without touching the event so nothing interferes with that flow.
|
|
1029
|
+
if (!draggedFile) return;
|
|
1030
|
+
ev.preventDefault();
|
|
1031
|
+
$(this).removeClass("dsff-row-drag-over");
|
|
1032
|
+
var src = draggedFile;
|
|
1033
|
+
draggedFile = null;
|
|
1034
|
+
doMoveFile(src.path, parentDir);
|
|
1035
|
+
});
|
|
1036
|
+
rows.push($up);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
sortItems(list.items.filter(function (it) {
|
|
1040
|
+
return showHidden || it.name[0] !== ".";
|
|
1041
|
+
})).forEach(function (it) {
|
|
1042
|
+
var hidden = it.name[0] === ".";
|
|
1043
|
+
var $r = $('<div class="dsff-row">').data("dsff-path", it.path);
|
|
1044
|
+
var iconClass = it.type === "dir" ? "fa-folder" : "fa-file-text-o";
|
|
1045
|
+
if (hidden) $r.addClass("dsff-row-hidden");
|
|
1046
|
+
$r.append($('<i class="fa ' + iconClass + '">').css({ width: "16px", marginRight: "6px" }));
|
|
1047
|
+
$r.append($('<span class="dsff-row-name">').text(it.name));
|
|
1048
|
+
if (isProtected(it)) $r.append($('<i class="fa fa-lock dsff-protected-icon" title="Protected">'));
|
|
1049
|
+
$r.append($('<span class="dsff-mtime">').text(formatMtime(it.mtime)));
|
|
1050
|
+
|
|
1051
|
+
if (it.type === "dir") {
|
|
1052
|
+
if (isProtectedDir(it)) {
|
|
1053
|
+
$r.addClass("dsff-row-protected-dir");
|
|
1054
|
+
$r.on("click", function () {
|
|
1055
|
+
toast("This folder is managed by Git and cannot be accessed here.", "warning");
|
|
1056
|
+
});
|
|
1057
|
+
} else {
|
|
1058
|
+
$r.on("click", function () { loadList(it.path); });
|
|
1059
|
+
$r.on("dragover", function (ev) {
|
|
1060
|
+
if (!draggedFile) return;
|
|
1061
|
+
ev.preventDefault();
|
|
1062
|
+
ev.originalEvent.dataTransfer.dropEffect = "move";
|
|
1063
|
+
});
|
|
1064
|
+
$r.on("dragenter", function (ev) {
|
|
1065
|
+
if (!draggedFile) return;
|
|
1066
|
+
ev.preventDefault();
|
|
1067
|
+
if (!$(ev.relatedTarget).closest(this).length) $(this).addClass("dsff-row-drag-over");
|
|
1068
|
+
});
|
|
1069
|
+
$r.on("dragleave", function (ev) {
|
|
1070
|
+
if (!$(ev.relatedTarget).closest(this).length) $(this).removeClass("dsff-row-drag-over");
|
|
1071
|
+
});
|
|
1072
|
+
$r.on("drop", function (ev) {
|
|
1073
|
+
if (!draggedFile) return; // external drop: $root[0] capture owns it
|
|
1074
|
+
ev.preventDefault();
|
|
1075
|
+
$(this).removeClass("dsff-row-drag-over");
|
|
1076
|
+
var src = draggedFile;
|
|
1077
|
+
draggedFile = null;
|
|
1078
|
+
doMoveFile(src.path, it.path);
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
} else {
|
|
1082
|
+
$r.on("click", (function (path, name) {
|
|
1083
|
+
return function () {
|
|
1084
|
+
withCleanState("opening", name, function () { openFile(path); });
|
|
1085
|
+
};
|
|
1086
|
+
})(it.path, it.name));
|
|
1087
|
+
if (canMove(it)) {
|
|
1088
|
+
$r.attr("draggable", "true");
|
|
1089
|
+
$r.on("dragstart", function (ev) {
|
|
1090
|
+
draggedFile = { path: it.path, name: it.name };
|
|
1091
|
+
ev.originalEvent.dataTransfer.effectAllowed = "move";
|
|
1092
|
+
ev.originalEvent.dataTransfer.setData("text/plain", it.path);
|
|
1093
|
+
var self = this;
|
|
1094
|
+
setTimeout(function () { $(self).addClass("dsff-row-dragging"); }, 0);
|
|
1095
|
+
});
|
|
1096
|
+
$r.on("dragend", function () {
|
|
1097
|
+
$(this).removeClass("dsff-row-dragging");
|
|
1098
|
+
draggedFile = null;
|
|
1099
|
+
$tree.find(".dsff-row-drag-over").removeClass("dsff-row-drag-over");
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
$r.on("contextmenu", function (ev) {
|
|
1105
|
+
showCtxMenu(ev, { path: it.path, type: it.type, name: it.name });
|
|
1106
|
+
});
|
|
1107
|
+
rows.push($r);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
rows.forEach(function (r) { $tree.append(r); });
|
|
1111
|
+
$treeEmpty.toggle(rows.length === 0);
|
|
1112
|
+
refreshSelectedRow();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// File list API
|
|
1116
|
+
function loadList(dir, opts) {
|
|
1117
|
+
opts = opts || {};
|
|
1118
|
+
return dsffAjax("GET", "project-files/list?path=" + encodeURIComponent(dir))
|
|
1119
|
+
.done(function (res) { renderTree(res); if (!opts.silent) setStatus("Listed: " + res.cwd); })
|
|
1120
|
+
.fail(function (xhr) { notifyErr("List error: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText || xhr.status)); });
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Context menus
|
|
1124
|
+
function hideCtxMenu() {
|
|
1125
|
+
$ctxMenu.hide();
|
|
1126
|
+
$(document).off("mousedown.dsff-ctx");
|
|
1127
|
+
ctxTarget = null;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function showCtxMenu(ev, item) {
|
|
1131
|
+
ev.preventDefault();
|
|
1132
|
+
hideCtxMenu();
|
|
1133
|
+
ctxTarget = item;
|
|
1134
|
+
$ctxLabel.text(item.name);
|
|
1135
|
+
$ctxDownload.toggle(item.type === "file");
|
|
1136
|
+
var prot = isProtected(item);
|
|
1137
|
+
$ctxRename.toggleClass("dsff-ctx-disabled", prot)
|
|
1138
|
+
.attr("title", prot ? "This item is protected and cannot be renamed or moved" : null);
|
|
1139
|
+
$ctxDelete.toggleClass("dsff-ctx-disabled", prot)
|
|
1140
|
+
.attr("title", prot ? "This file is protected and cannot be deleted" : null);
|
|
1141
|
+
|
|
1142
|
+
var isReqFile = item.type === "file" && item.name === "requirements.txt";
|
|
1143
|
+
$ctxInstallPy.toggle(isReqFile);
|
|
1144
|
+
if (isReqFile) {
|
|
1145
|
+
$ctxInstallPy.toggleClass("dsff-ctx-disabled", !hasVenv)
|
|
1146
|
+
.attr("title", hasVenv ? null : "Create a Python environment first");
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Root-level package.json only. Nested package.json files are ignored.
|
|
1150
|
+
var isRootPkg = item.type === "file" && item.name === "package.json" &&
|
|
1151
|
+
!!projectRoot && item.path === projectRoot + "/package.json";
|
|
1152
|
+
$ctxInstallNode.toggle(isRootPkg);
|
|
1153
|
+
|
|
1154
|
+
var x = ev.clientX, y = ev.clientY;
|
|
1155
|
+
$ctxMenu.css({ top: 0, left: 0 }).show();
|
|
1156
|
+
var mw = $ctxMenu.outerWidth(), mh = $ctxMenu.outerHeight();
|
|
1157
|
+
if (x + mw > window.innerWidth) x = window.innerWidth - mw - 4;
|
|
1158
|
+
if (y + mh > window.innerHeight) y = window.innerHeight - mh - 4;
|
|
1159
|
+
$ctxMenu.css({ top: y + "px", left: x + "px" });
|
|
1160
|
+
|
|
1161
|
+
setTimeout(function () {
|
|
1162
|
+
$(document).on("mousedown.dsff-ctx", function (e) {
|
|
1163
|
+
if (!$(e.target).closest($ctxMenu).length) hideCtxMenu();
|
|
1164
|
+
});
|
|
1165
|
+
}, 0);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function showBgCtxMenu(ev) {
|
|
1169
|
+
ev.preventDefault();
|
|
1170
|
+
$bgCtxMenu.css({ top: 0, left: 0 }).show();
|
|
1171
|
+
var x = ev.clientX, y = ev.clientY;
|
|
1172
|
+
var mw = $bgCtxMenu.outerWidth(), mh = $bgCtxMenu.outerHeight();
|
|
1173
|
+
if (x + mw > window.innerWidth) x = window.innerWidth - mw - 4;
|
|
1174
|
+
if (y + mh > window.innerHeight) y = window.innerHeight - mh - 4;
|
|
1175
|
+
$bgCtxMenu.css({ top: y + "px", left: x + "px" });
|
|
1176
|
+
setTimeout(function () {
|
|
1177
|
+
$(document).one("mousedown.dsff-bg-ctx", function (e) {
|
|
1178
|
+
if (!$(e.target).closest($bgCtxMenu).length) hideBgCtxMenu();
|
|
1179
|
+
});
|
|
1180
|
+
}, 0);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function hideBgCtxMenu() {
|
|
1184
|
+
$bgCtxMenu.hide();
|
|
1185
|
+
$(document).off("mousedown.dsff-bg-ctx");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// File operations
|
|
1189
|
+
function doMoveFile(srcPath, destDir, overwrite) {
|
|
1190
|
+
var srcName = srcPath.split("/").pop();
|
|
1191
|
+
var destName = destDir.split("/").pop();
|
|
1192
|
+
dsffAjax("POST", "project-files/move", { path: srcPath, dir: destDir, overwrite: overwrite || false })
|
|
1193
|
+
.done(function (res) {
|
|
1194
|
+
toast('Moved "' + srcName + '" into "' + destName + '"', "success");
|
|
1195
|
+
if (currentFile === srcPath) {
|
|
1196
|
+
currentFile = res.path;
|
|
1197
|
+
updateCompactFileName();
|
|
1198
|
+
}
|
|
1199
|
+
loadList(currentDir, { silent: true });
|
|
1200
|
+
})
|
|
1201
|
+
.fail(function (xhr) {
|
|
1202
|
+
if (xhr.status === 409) {
|
|
1203
|
+
var errMsg = (xhr.responseJSON && xhr.responseJSON.error) ||
|
|
1204
|
+
'"' + srcName + '" already exists in "' + destName + '"';
|
|
1205
|
+
var safeMsg = $("<span>").text(errMsg).html();
|
|
1206
|
+
dsffConfirm({
|
|
1207
|
+
title: "File already exists",
|
|
1208
|
+
body: safeMsg + " Do you want to overwrite it?",
|
|
1209
|
+
confirmLabel: "Overwrite",
|
|
1210
|
+
danger: true,
|
|
1211
|
+
onConfirm: function () { doMoveFile(srcPath, destDir, true); }
|
|
1212
|
+
});
|
|
1213
|
+
} else {
|
|
1214
|
+
notifyErr("Move failed: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function doNewFile() {
|
|
1220
|
+
hideCompactMenu();
|
|
1221
|
+
dsffPrompt({
|
|
1222
|
+
title: "New file",
|
|
1223
|
+
placeholder: "filename.txt",
|
|
1224
|
+
confirmLabel: "Create",
|
|
1225
|
+
onConfirm: function (name) {
|
|
1226
|
+
dsffAjax("POST", "project-files/new-file", { dir: currentDir, name: name })
|
|
1227
|
+
.done(function (res) {
|
|
1228
|
+
toast("File created", "success");
|
|
1229
|
+
loadList(currentDir, { silent: true });
|
|
1230
|
+
withCleanState("creating", name, function () { openFile(res.path); });
|
|
1231
|
+
})
|
|
1232
|
+
.fail(function (xhr) {
|
|
1233
|
+
notifyErr("Error: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function doNewFolder() {
|
|
1240
|
+
hideCompactMenu();
|
|
1241
|
+
dsffPrompt({
|
|
1242
|
+
title: "New folder",
|
|
1243
|
+
placeholder: "folder-name",
|
|
1244
|
+
confirmLabel: "Create",
|
|
1245
|
+
onConfirm: function (name) {
|
|
1246
|
+
dsffAjax("POST", "project-files/new-folder", { dir: currentDir, name: name })
|
|
1247
|
+
.done(function (res) {
|
|
1248
|
+
toast("Folder created", "success");
|
|
1249
|
+
currentDir = res.path;
|
|
1250
|
+
loadList(currentDir);
|
|
1251
|
+
})
|
|
1252
|
+
.fail(function (xhr) {
|
|
1253
|
+
notifyErr("Error: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function uploadFiles(files) {
|
|
1260
|
+
var total = files.length;
|
|
1261
|
+
var pending = total;
|
|
1262
|
+
var failed = 0;
|
|
1263
|
+
function onDone(ok) {
|
|
1264
|
+
if (!ok) failed++;
|
|
1265
|
+
pending--;
|
|
1266
|
+
if (pending === 0) {
|
|
1267
|
+
loadList(currentDir, { silent: true });
|
|
1268
|
+
if (failed === 0) toast("Uploaded " + total + " file" + (total > 1 ? "s" : ""), "success");
|
|
1269
|
+
else notifyErr(failed + " upload(s) failed");
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
files.forEach(function (file) {
|
|
1273
|
+
var reader = new FileReader();
|
|
1274
|
+
reader.onload = function (e) {
|
|
1275
|
+
var base64 = e.target.result.split(",")[1];
|
|
1276
|
+
dsffAjax("POST", "project-files/upload", { dir: currentDir, name: file.name, data: base64 })
|
|
1277
|
+
.done(function () { onDone(true); })
|
|
1278
|
+
.fail(function (xhr) {
|
|
1279
|
+
notifyErr("Upload failed: " + file.name + " — " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
1280
|
+
onDone(false);
|
|
1281
|
+
});
|
|
1282
|
+
};
|
|
1283
|
+
reader.onerror = function () { notifyErr("Could not read: " + file.name); onDone(false); };
|
|
1284
|
+
reader.readAsDataURL(file);
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// True iff this drag originates from the OS (files from the user's computer),
|
|
1289
|
+
// not an internal row drag. Robust across browsers: checks `files` (populated
|
|
1290
|
+
// on drop), the `types` list (populated during drag), and the Mozilla-specific
|
|
1291
|
+
// "application/x-moz-file" marker.
|
|
1292
|
+
function isOsDrag(ev) {
|
|
1293
|
+
var dt = ev.dataTransfer;
|
|
1294
|
+
if (!dt) return false;
|
|
1295
|
+
if (dt.files && dt.files.length) return true;
|
|
1296
|
+
var types = dt.types;
|
|
1297
|
+
if (!types) return false;
|
|
1298
|
+
for (var i = 0; i < types.length; i++) {
|
|
1299
|
+
var t = String(types[i]);
|
|
1300
|
+
if (t === "Files" || t === "application/x-moz-file") return true;
|
|
1301
|
+
}
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ── 8. Event wiring ──────────────────────────────────────────────────────
|
|
1306
|
+
|
|
1307
|
+
// Context menu actions
|
|
1308
|
+
$ctxCopyPath.on("click", function () {
|
|
1309
|
+
if (!ctxTarget) return;
|
|
1310
|
+
var fullPath = baseDir + "/" + ctxTarget.path;
|
|
1311
|
+
hideCtxMenu();
|
|
1312
|
+
navigator.clipboard.writeText(fullPath)
|
|
1313
|
+
.then(function () { toast("Path copied", "success"); })
|
|
1314
|
+
.catch(function () { notifyErr("Copy failed"); });
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
$ctxDownload.on("click", function () {
|
|
1318
|
+
if (!ctxTarget || ctxTarget.type !== "file") return;
|
|
1319
|
+
var item = ctxTarget;
|
|
1320
|
+
hideCtxMenu();
|
|
1321
|
+
dsffAjax("GET", "project-files/open?path=" + encodeURIComponent(item.path))
|
|
1322
|
+
.done(function (res) {
|
|
1323
|
+
var blob = new Blob([res.text || ""], { type: "application/octet-stream" });
|
|
1324
|
+
var url = URL.createObjectURL(blob);
|
|
1325
|
+
var a = document.createElement("a");
|
|
1326
|
+
a.href = url; a.download = item.name;
|
|
1327
|
+
a.style.cssText = "position:fixed;opacity:0";
|
|
1328
|
+
document.body.appendChild(a);
|
|
1329
|
+
a.click();
|
|
1330
|
+
document.body.removeChild(a);
|
|
1331
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
|
1332
|
+
})
|
|
1333
|
+
.fail(function (xhr) {
|
|
1334
|
+
notifyErr("Download failed: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
1335
|
+
});
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
$ctxRename.on("click", function () {
|
|
1339
|
+
if (!ctxTarget) return;
|
|
1340
|
+
if (isProtected(ctxTarget)) {
|
|
1341
|
+
hideCtxMenu();
|
|
1342
|
+
notifyErr("This item is protected and cannot be renamed or moved.");
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
var item = ctxTarget;
|
|
1346
|
+
hideCtxMenu();
|
|
1347
|
+
dsffPrompt({
|
|
1348
|
+
title: 'Rename "' + item.name + '"',
|
|
1349
|
+
placeholder: item.name,
|
|
1350
|
+
value: item.name,
|
|
1351
|
+
confirmLabel: "Rename",
|
|
1352
|
+
onConfirm: function (newName) {
|
|
1353
|
+
if (newName === item.name) return;
|
|
1354
|
+
dsffAjax("POST", "project-files/rename", { path: item.path, name: newName })
|
|
1355
|
+
.done(function (res) {
|
|
1356
|
+
toast('Renamed to "' + newName + '"', "success");
|
|
1357
|
+
if (currentFile === item.path) {
|
|
1358
|
+
currentFile = res.path;
|
|
1359
|
+
$root.addClass("dsff-file-open");
|
|
1360
|
+
updateCompactFileName();
|
|
1361
|
+
}
|
|
1362
|
+
loadList(currentDir, { silent: true });
|
|
1363
|
+
})
|
|
1364
|
+
.fail(function (xhr) {
|
|
1365
|
+
notifyErr("Rename failed: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
$ctxDelete.on("click", function () {
|
|
1372
|
+
if (!ctxTarget || isProtected(ctxTarget)) return;
|
|
1373
|
+
var item = ctxTarget;
|
|
1374
|
+
hideCtxMenu();
|
|
1375
|
+
var safeName = $("<span>").text(item.name).html();
|
|
1376
|
+
dsffConfirm({
|
|
1377
|
+
title: item.type === "dir" ? "Delete folder" : "Delete file",
|
|
1378
|
+
body: item.type === "dir"
|
|
1379
|
+
? "Delete folder <strong>" + safeName + "</strong> and all its contents? This cannot be undone."
|
|
1380
|
+
: "Delete <strong>" + safeName + "</strong>? This cannot be undone.",
|
|
1381
|
+
confirmLabel: "Delete",
|
|
1382
|
+
danger: true,
|
|
1383
|
+
onConfirm: function () {
|
|
1384
|
+
dsffAjax("POST", "project-files/delete", { path: item.path })
|
|
1385
|
+
.done(function () {
|
|
1386
|
+
toast('Deleted "' + item.name + '"', "success");
|
|
1387
|
+
if (currentFile === item.path) {
|
|
1388
|
+
currentFile = null;
|
|
1389
|
+
selectedPath = null;
|
|
1390
|
+
$root.removeClass("dsff-file-open");
|
|
1391
|
+
setStatus("");
|
|
1392
|
+
stopStatTimer();
|
|
1393
|
+
if (editorKind === "monaco" && monacoEditor) {
|
|
1394
|
+
suppressDirty = true;
|
|
1395
|
+
try { editorModel && editorModel.setValue(""); } finally { suppressDirty = false; }
|
|
1396
|
+
} else if (editorKind === "textarea" && $textarea) {
|
|
1397
|
+
$textarea.val("");
|
|
1398
|
+
}
|
|
1399
|
+
markDirty(false);
|
|
1400
|
+
}
|
|
1401
|
+
loadList(currentDir, { silent: true });
|
|
1402
|
+
})
|
|
1403
|
+
.fail(function (xhr) {
|
|
1404
|
+
notifyErr("Delete failed: " + (xhr.responseJSON && xhr.responseJSON.error || xhr.statusText));
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
$ctxInstallPy.on("click", function () {
|
|
1411
|
+
if (!ctxTarget) return;
|
|
1412
|
+
if (!hasVenv) {
|
|
1413
|
+
hideCtxMenu();
|
|
1414
|
+
notifyErr("Create a Python environment first.");
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
var item = ctxTarget;
|
|
1418
|
+
hideCtxMenu();
|
|
1419
|
+
doInstallRequirements(item.path);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
$ctxInstallNode.on("click", function () {
|
|
1423
|
+
if (!ctxTarget) return;
|
|
1424
|
+
hideCtxMenu();
|
|
1425
|
+
doInstallNodePackages();
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
// Python venv create link in the venv bar
|
|
1429
|
+
$venvAction.on("click", function (ev) { ev.preventDefault(); doCreateVenv(); });
|
|
1430
|
+
|
|
1431
|
+
// Background context menu (right-click on empty tree area)
|
|
1432
|
+
$bgCtxNewFile.on("click", function () { hideBgCtxMenu(); doNewFile(); });
|
|
1433
|
+
$bgCtxNewDir.on("click", function () { hideBgCtxMenu(); doNewFolder(); });
|
|
1434
|
+
|
|
1435
|
+
$treeWrap.on("contextmenu", function (ev) {
|
|
1436
|
+
if ($(ev.target).closest(".dsff-row").length) return;
|
|
1437
|
+
showBgCtxMenu(ev);
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
$treeWrap.on("dragstart", function () { hideBgCtxMenu(); hideCtxMenu(); });
|
|
1441
|
+
|
|
1442
|
+
// Sort header clicks
|
|
1443
|
+
$hdrName.on("click", function () {
|
|
1444
|
+
if (sortField === "name") sortDir = sortDir === "asc" ? "desc" : "asc";
|
|
1445
|
+
else { sortField = "name"; sortDir = "asc"; }
|
|
1446
|
+
if (lastList) renderTree(lastList);
|
|
1447
|
+
});
|
|
1448
|
+
$hdrMod.on("click", function () {
|
|
1449
|
+
if (sortField === "mtime") sortDir = sortDir === "asc" ? "desc" : "asc";
|
|
1450
|
+
else { sortField = "mtime"; sortDir = "asc"; }
|
|
1451
|
+
if (lastList) renderTree(lastList);
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
// Toolbar buttons
|
|
1455
|
+
function updateHiddenBtn() {
|
|
1456
|
+
$btnHidden
|
|
1457
|
+
.toggleClass("dsff-btn-active", showHidden)
|
|
1458
|
+
.attr("title", showHidden ? "Hide hidden files" : "Show hidden files")
|
|
1459
|
+
.find("i").attr("class", showHidden ? "fa fa-eye" : "fa fa-eye-slash");
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
$btnHidden.on("click", function () {
|
|
1463
|
+
showHidden = !showHidden;
|
|
1464
|
+
updateHiddenBtn();
|
|
1465
|
+
loadList(currentDir, { silent: true });
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
$btnRefresh.on("click", function () { loadList(currentDir); refreshVenvState(); });
|
|
1469
|
+
$btnSave.on("click", doSave);
|
|
1470
|
+
$btnNew.on("click", function (ev) { ev.stopPropagation(); showCompactMenu(ev); });
|
|
1471
|
+
$menuNewFile.on("click", doNewFile);
|
|
1472
|
+
$menuNewDir.on("click", doNewFolder);
|
|
1473
|
+
$btnBack.on("click", function () { if (isCompact) showBrowserPanel(); });
|
|
1474
|
+
$btnCollapse.on("click", collapseSidebar);
|
|
1475
|
+
$btnExpand.on("click", tryExpandSidebar);
|
|
1476
|
+
$btnNewCompact.on("click", function (ev) { ev.stopPropagation(); showCompactMenu(ev); });
|
|
1477
|
+
$btnWrap.on("click", function () {
|
|
1478
|
+
var on = !dsffWrapEnabled();
|
|
1479
|
+
dsffSetWrap(on);
|
|
1480
|
+
applyWrap();
|
|
1481
|
+
$btnWrap.attr("title", "Toggle word wrap (currently " + (on ? "ON" : "OFF") + ")");
|
|
1482
|
+
toast("Wrap " + (on ? "ON" : "OFF"), "compact");
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// OS drag-and-drop upload.
|
|
1486
|
+
// Registered on $root[0] with capture so these handlers fire before any
|
|
1487
|
+
// inner row/Monaco handler can swallow the event, and before Node-RED's
|
|
1488
|
+
// canvas-level drop handlers. Scoped to $treeWrap via contains() so drops
|
|
1489
|
+
// on the editor pane / toolbar are left alone.
|
|
1490
|
+
//
|
|
1491
|
+
// Two drag modes coexist here, distinguished explicitly:
|
|
1492
|
+
// • external (OS file) → isOsDrag(ev) === true, draggedFile === null
|
|
1493
|
+
// • internal (row move) → isOsDrag(ev) === false, draggedFile is set
|
|
1494
|
+
// The external path only runs when isOsDrag is true, so the internal path
|
|
1495
|
+
// on the rows is never triggered by OS drags.
|
|
1496
|
+
function isInTree(ev) { return $treeWrap[0].contains(ev.target); }
|
|
1497
|
+
|
|
1498
|
+
$root[0].addEventListener("dragenter", function (ev) {
|
|
1499
|
+
if (!isOsDrag(ev) || !isInTree(ev)) return;
|
|
1500
|
+
ev.preventDefault();
|
|
1501
|
+
ev.stopPropagation();
|
|
1502
|
+
$treeWrap.addClass("dsff-drop-active");
|
|
1503
|
+
}, true);
|
|
1504
|
+
$root[0].addEventListener("dragover", function (ev) {
|
|
1505
|
+
if (!isOsDrag(ev) || !isInTree(ev)) return;
|
|
1506
|
+
ev.preventDefault();
|
|
1507
|
+
ev.stopPropagation();
|
|
1508
|
+
ev.dataTransfer.dropEffect = "copy";
|
|
1509
|
+
}, true);
|
|
1510
|
+
$root[0].addEventListener("dragleave", function (ev) {
|
|
1511
|
+
if (!$treeWrap.hasClass("dsff-drop-active")) return;
|
|
1512
|
+
var to = ev.relatedTarget;
|
|
1513
|
+
if (to && $treeWrap[0].contains(to)) return;
|
|
1514
|
+
$treeWrap.removeClass("dsff-drop-active");
|
|
1515
|
+
}, true);
|
|
1516
|
+
$root[0].addEventListener("drop", function (ev) {
|
|
1517
|
+
if (!isOsDrag(ev) || !isInTree(ev)) return;
|
|
1518
|
+
ev.preventDefault();
|
|
1519
|
+
ev.stopPropagation();
|
|
1520
|
+
$treeWrap.removeClass("dsff-drop-active");
|
|
1521
|
+
var files = ev.dataTransfer && ev.dataTransfer.files;
|
|
1522
|
+
if (!files || !files.length) return;
|
|
1523
|
+
uploadFiles(Array.from(files));
|
|
1524
|
+
}, true);
|
|
1525
|
+
|
|
1526
|
+
// Splitter drag
|
|
1527
|
+
$split.on("mousedown", function (ev) {
|
|
1528
|
+
if (ev.button !== 0) return;
|
|
1529
|
+
ev.preventDefault();
|
|
1530
|
+
isDraggingSplit = true;
|
|
1531
|
+
splitDrag.startX = ev.clientX;
|
|
1532
|
+
splitDrag.startLeft = $left.width();
|
|
1533
|
+
splitDrag.min = 200;
|
|
1534
|
+
splitDrag.max = Math.max(splitDrag.min + 200, ($root.width() || 600) - 260);
|
|
1535
|
+
$root.addClass("dsff-root-splitting");
|
|
1536
|
+
|
|
1537
|
+
$(document)
|
|
1538
|
+
.on("mousemove.dsff-split", function (e) {
|
|
1539
|
+
if (!isDraggingSplit) return;
|
|
1540
|
+
var dx = e.clientX - splitDrag.startX;
|
|
1541
|
+
var w = Math.min(Math.max(splitDrag.startLeft + dx, splitDrag.min), splitDrag.max);
|
|
1542
|
+
$left.css("width", w + "px");
|
|
1543
|
+
savedLeftWidth = w + "px";
|
|
1544
|
+
layoutEditorSoon();
|
|
1545
|
+
})
|
|
1546
|
+
.on("mouseup.dsff-split", function () {
|
|
1547
|
+
isDraggingSplit = false;
|
|
1548
|
+
$(document).off("mousemove.dsff-split mouseup.dsff-split");
|
|
1549
|
+
$root.removeClass("dsff-root-splitting");
|
|
1550
|
+
});
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// ResizeObserver for compact/expanded mode switching. Also refreshes the
|
|
1554
|
+
// sidebar button so a manual divider drag keeps the expand/collapse control
|
|
1555
|
+
// accurate — without resizing the host ourselves (we don't fight the user).
|
|
1556
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1557
|
+
new ResizeObserver(function () { applyMode(false); syncSidebarBtn(); }).observe($root[0]);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Node-RED editor events
|
|
1561
|
+
RED.events.on("editor:close", function () {
|
|
1562
|
+
saveViewState();
|
|
1563
|
+
// Defer Monaco model revival so NR's own close handler finishes first.
|
|
1564
|
+
Promise.resolve().then(function () {
|
|
1565
|
+
if (editorKind === "monaco" && window.monaco) {
|
|
1566
|
+
var lang = langFromName(currentFile || "");
|
|
1567
|
+
suppressDirty = true;
|
|
1568
|
+
try {
|
|
1569
|
+
if (!editorModel || editorModel.isDisposed()) {
|
|
1570
|
+
editorModel = window.monaco.editor.createModel(currentTextCache || "", lang, editorModelUri);
|
|
1571
|
+
monacoEditor.setModel(editorModel);
|
|
1572
|
+
} else {
|
|
1573
|
+
window.monaco.editor.setModelLanguage(editorModel, lang);
|
|
1574
|
+
if (monacoEditor.getValue() !== currentTextCache) {
|
|
1575
|
+
editorModel.setValue(currentTextCache || "");
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
} finally {
|
|
1579
|
+
suppressDirty = false;
|
|
1580
|
+
}
|
|
1581
|
+
applyReadOnly();
|
|
1582
|
+
layoutEditorSoon();
|
|
1583
|
+
restorePositionFor(currentFile, { retries: 14, delay: 60, guardMs: 900 });
|
|
1584
|
+
} else if (editorKind === "textarea") {
|
|
1585
|
+
restorePositionFor(currentFile, { retries: 10, delay: 60, guardMs: 700 });
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
RED.events.on("editor:open", function () {
|
|
1591
|
+
layoutEditorSoon();
|
|
1592
|
+
Promise.resolve().then(function () { restorePositionFor(currentFile, { guardMs: 700 }); });
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
RED.events.on("deploy", function () {
|
|
1596
|
+
layoutEditorSoon();
|
|
1597
|
+
Promise.resolve().then(function () { restorePositionFor(currentFile, { guardMs: 700 }); });
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
if (RED.events.on) {
|
|
1601
|
+
// workspace:resize / sidebar:resize come from NR's own layout system (and
|
|
1602
|
+
// from our adapter's expand/collapse). React by re-flowing our internal
|
|
1603
|
+
// layout + editor; never write host layout back here (no feedback loops).
|
|
1604
|
+
RED.events.on("workspace:resize", function () {
|
|
1605
|
+
layoutEditorSoon();
|
|
1606
|
+
restorePositionFor(currentFile, { guardMs: 600 });
|
|
1607
|
+
});
|
|
1608
|
+
RED.events.on("sidebar:resize", function () {
|
|
1609
|
+
applyMode(false);
|
|
1610
|
+
syncSidebarBtn();
|
|
1611
|
+
layoutEditorSoon();
|
|
1612
|
+
restorePositionFor(currentFile, { guardMs: 600 });
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
window.addEventListener("resize", function () { layoutEditorSoon(); restorePositionFor(currentFile, { guardMs: 600 }); });
|
|
1617
|
+
window.addEventListener("beforeunload", saveViewState);
|
|
1618
|
+
|
|
1619
|
+
// ── 9. Sidebar registration / startup ────────────────────────────────────
|
|
1620
|
+
|
|
1621
|
+
updateHiddenBtn(); // set initial toggle state
|
|
1622
|
+
|
|
1623
|
+
RED.actions.add("project-files:show", function () { RED.sidebar.show("project-files"); });
|
|
1624
|
+
|
|
1625
|
+
RED.sidebar.addTab({
|
|
1626
|
+
id: "project-files",
|
|
1627
|
+
name: "Project Files",
|
|
1628
|
+
label: "Project Files",
|
|
1629
|
+
iconClass: "fa fa-files-o",
|
|
1630
|
+
content: $root,
|
|
1631
|
+
action: "project-files:show",
|
|
1632
|
+
enableOnEdit: true,
|
|
1633
|
+
toolbar: null,
|
|
1634
|
+
onshow: function () {
|
|
1635
|
+
if (!$tree.children().length) loadConfigThenList();
|
|
1636
|
+
applyMode(true);
|
|
1637
|
+
layoutEditorSoon();
|
|
1638
|
+
if (currentFile) restorePositionFor(currentFile, { guardMs: 700 });
|
|
1639
|
+
// Sync the expand/collapse button to the host's current capability +
|
|
1640
|
+
// state. The $root ResizeObserver and the sidebar:resize listener keep
|
|
1641
|
+
// it accurate afterwards (incl. manual divider drags).
|
|
1642
|
+
syncSidebarBtn();
|
|
1643
|
+
},
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
// nodes:loaded fires after NR restores its sidebar state — safely activates
|
|
1647
|
+
// Files as the default tab without racing against NR's own init.
|
|
1648
|
+
var _defaultTabShown = false;
|
|
1649
|
+
RED.events.on("nodes:loaded", function () {
|
|
1650
|
+
if (!_defaultTabShown) {
|
|
1651
|
+
_defaultTabShown = true;
|
|
1652
|
+
RED.sidebar.show("project-files");
|
|
1653
|
+
}
|
|
1654
|
+
loadConfigThenList();
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
// project:change fires (with {name}) before nodes:loaded, without a page reload.
|
|
1658
|
+
RED.events.on("project:change", function (data) {
|
|
1659
|
+
var name = (data && data.name) || null;
|
|
1660
|
+
if (name === projectRoot) return;
|
|
1661
|
+
resetEditorState();
|
|
1662
|
+
projectRoot = name;
|
|
1663
|
+
mode = name ? "project" : "user";
|
|
1664
|
+
hasVenv = false;
|
|
1665
|
+
updateVenvBar();
|
|
1666
|
+
updateModeBar();
|
|
1667
|
+
loadList(projectRoot || "."); // list response refreshes scopeDir + mode
|
|
1668
|
+
refreshVenvState();
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
setTimeout(function () {
|
|
1672
|
+
if (!$tree.children().length) loadConfigThenList();
|
|
1673
|
+
}, 0);
|
|
1674
|
+
},
|
|
1675
|
+
});
|