@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.
@@ -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
+ });