flexdesk 0.1.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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * State Machine Base (js_new)
3
+ *
4
+ * Purpose
5
+ * -------
6
+ * Shared finite-state-machine utility that workspace, simulation, and other controllers extend.
7
+ *
8
+ * Responsibilities
9
+ * - Provide declarative state/transition definitions with guard + action hooks.
10
+ * - Emit lifecycle events via the injected EventBus for observability.
11
+ * - Supply debugging helpers (transition tracing) without referencing window globals.
12
+ *
13
+ * Source Material
14
+ * - html/js/state_machine.js (base implementation semantics).
15
+ * - html/js/workspace_state_machine.js (practical usage patterns to support).
16
+ *
17
+ */
18
+
19
+ const BUS_EVENTS = Object.freeze({
20
+ ATTEMPT: 'fsm:transition:attempt',
21
+ BLOCKED: 'fsm:transition:blocked',
22
+ COMPLETE: 'fsm:transition:complete',
23
+ ERROR: 'fsm:transition:error',
24
+ });
25
+
26
+ const isPromiseLike = (value) => Boolean(value) && typeof value.then === 'function';
27
+
28
+ export class StateMachine {
29
+ constructor({
30
+ name = 'state-machine',
31
+ initialState,
32
+ states = {},
33
+ eventBus = null,
34
+ logger = null,
35
+ trace = false,
36
+ } = {}) {
37
+ if (!initialState || typeof initialState !== 'string') {
38
+ throw new Error('StateMachine requires a non-empty initialState name');
39
+ }
40
+ if (!states[initialState]) {
41
+ throw new Error(`StateMachine initial state "${initialState}" is not defined in states map`);
42
+ }
43
+
44
+ this.name = name;
45
+ this.states = this._normalizeStates(states);
46
+ this.initialState = initialState;
47
+ this.currentState = null;
48
+ this.previousState = null;
49
+ this.eventBus = eventBus;
50
+ this.logger = logger;
51
+ this.trace = trace;
52
+ this._isTransitioning = false;
53
+ this._ready = Promise.resolve().then(() => this._enterState(initialState, {
54
+ type: 'init',
55
+ payload: undefined,
56
+ fromState: null,
57
+ }));
58
+
59
+ this._ready.catch((err) => {
60
+ this.logger?.error?.('state-machine', 'Failed to enter initial state', {
61
+ machine: this.name,
62
+ error: err,
63
+ });
64
+ });
65
+ }
66
+
67
+ describeStates() {
68
+ return Object.keys(this.states);
69
+ }
70
+
71
+ getState() {
72
+ return this.currentState;
73
+ }
74
+
75
+ canTransition(eventName) {
76
+ const current = this.states[this.currentState];
77
+ return Boolean(current?.transitions?.[eventName]);
78
+ }
79
+
80
+ async transition(eventName, payload = undefined) {
81
+ await this._ready;
82
+
83
+ if (this._isTransitioning) {
84
+ throw new Error(`StateMachine "${this.name}" is already processing a transition`);
85
+ }
86
+ const currentDef = this.states[this.currentState];
87
+ const transitionDef = currentDef?.transitions?.[eventName];
88
+
89
+ if (!transitionDef) {
90
+ this._emitBus(BUS_EVENTS.BLOCKED, { reason: 'missing-transition', event: eventName });
91
+ this.logger?.warn?.('state-machine', `No transition for event "${eventName}" in state "${this.currentState}"`, {
92
+ machine: this.name,
93
+ state: this.currentState,
94
+ event: eventName,
95
+ });
96
+ return { transitioned: false, reason: 'missing-transition' };
97
+ }
98
+
99
+ const targetState = transitionDef.target;
100
+ if (!targetState || !this.states[targetState]) {
101
+ throw new Error(`Transition from "${this.currentState}" via "${eventName}" references undefined target state "${targetState}"`);
102
+ }
103
+
104
+ const context = {
105
+ machine: this,
106
+ event: eventName,
107
+ fromState: this.currentState,
108
+ toState: targetState,
109
+ payload,
110
+ };
111
+
112
+ const guardAllowed = await this._evaluateGuard(transitionDef.guard, context);
113
+ if (!guardAllowed) {
114
+ this._emitBus(BUS_EVENTS.BLOCKED, {
115
+ reason: 'guard-blocked',
116
+ event: eventName,
117
+ fromState: this.currentState,
118
+ toState: targetState,
119
+ });
120
+ return { transitioned: false, reason: 'guard-blocked' };
121
+ }
122
+
123
+ this._emitBus(BUS_EVENTS.ATTEMPT, context);
124
+ this._isTransitioning = true;
125
+
126
+ try {
127
+ await this._runHook(this.states[this.currentState]?.onExit, context, 'onExit');
128
+ await this._runHook(transitionDef.action, context, 'transition-action');
129
+ await this._enterState(targetState, { type: eventName, payload, fromState: context.fromState });
130
+ this._emitBus(BUS_EVENTS.COMPLETE, context);
131
+ return { transitioned: true };
132
+ } catch (err) {
133
+ this._emitBus(BUS_EVENTS.ERROR, { ...context, error: err });
134
+ this.logger?.error?.('state-machine', 'Transition failed', {
135
+ machine: this.name,
136
+ event: eventName,
137
+ fromState: context.fromState,
138
+ toState: context.toState,
139
+ error: err,
140
+ });
141
+ throw err;
142
+ } finally {
143
+ this._isTransitioning = false;
144
+ }
145
+ }
146
+
147
+ async reset(stateName = this.initialState) {
148
+ await this._ready;
149
+ if (!this.states[stateName]) {
150
+ throw new Error(`Cannot reset state machine to undefined state "${stateName}"`);
151
+ }
152
+ this.previousState = this.currentState;
153
+ await this._enterState(stateName, {
154
+ type: 'reset',
155
+ payload: undefined,
156
+ fromState: this.previousState,
157
+ });
158
+ this._emitBus(BUS_EVENTS.COMPLETE, {
159
+ machine: this,
160
+ event: 'reset',
161
+ fromState: this.previousState,
162
+ toState: stateName,
163
+ payload: undefined,
164
+ });
165
+ }
166
+
167
+ updateStates(partialStates = {}) {
168
+ this.states = this._normalizeStates({ ...this.states, ...partialStates });
169
+ if (!this.states[this.currentState]) {
170
+ throw new Error('Current state was removed during updateStates');
171
+ }
172
+ }
173
+
174
+ _normalizeStates(definitions) {
175
+ const normalized = {};
176
+ for (const [stateName, config] of Object.entries(definitions)) {
177
+ if (!config || typeof config !== 'object') {
178
+ throw new Error(`Invalid state definition for "${stateName}"`);
179
+ }
180
+ normalized[stateName] = {
181
+ onEnter: config.onEnter ?? null,
182
+ onExit: config.onExit ?? null,
183
+ transitions: { ...(config.transitions ?? {}) },
184
+ };
185
+ }
186
+ return normalized;
187
+ }
188
+
189
+ async _enterState(stateName, meta) {
190
+ const prev = this.currentState;
191
+ this.previousState = prev;
192
+ this.currentState = stateName;
193
+
194
+ const context = {
195
+ machine: this,
196
+ event: meta.type,
197
+ fromState: meta.fromState,
198
+ toState: stateName,
199
+ payload: meta.payload,
200
+ };
201
+
202
+ await this._runHook(this.states[stateName]?.onEnter, context, 'onEnter');
203
+ if (this.trace) {
204
+ this.logger?.debug?.('state-machine', `State machine "${this.name}" entered state "${stateName}"`, {
205
+ machine: this.name,
206
+ fromState: prev,
207
+ toState: stateName,
208
+ });
209
+ }
210
+ }
211
+
212
+ async _evaluateGuard(guardFn, context) {
213
+ if (!guardFn) {
214
+ return true;
215
+ }
216
+ const result = guardFn(context);
217
+ if (isPromiseLike(result)) {
218
+ return result;
219
+ }
220
+ return result !== false;
221
+ }
222
+
223
+ async _runHook(hook, context, hookName) {
224
+ if (typeof hook !== 'function') {
225
+ return;
226
+ }
227
+ const result = hook(context);
228
+ if (isPromiseLike(result)) {
229
+ await result;
230
+ }
231
+ if (this.trace) {
232
+ this.logger?.debug?.('state-machine', `${hookName} executed`, {
233
+ machine: this.name,
234
+ hook: hookName,
235
+ state: context.toState,
236
+ });
237
+ }
238
+ }
239
+
240
+ _emitBus(eventName, detail) {
241
+ if (!this.eventBus || !eventName) {
242
+ return;
243
+ }
244
+ try {
245
+ this.eventBus.emit(eventName, {
246
+ machine: this.name,
247
+ state: this.currentState,
248
+ detail,
249
+ });
250
+ } catch (err) {
251
+ this.logger?.warn?.('state-machine', 'Failed to emit state machine event', {
252
+ machine: this.name,
253
+ eventName,
254
+ error: err,
255
+ });
256
+ }
257
+ }
258
+ }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * EditorPane — one half (or whole) of the split editor.
3
+ *
4
+ * Each pane owns:
5
+ * - A NotebookTabBar instance
6
+ * - An editor container where the active file's editor is mounted
7
+ * - A list of file paths assigned to this pane
8
+ * - The active file path within this pane
9
+ *
10
+ * The pane does NOT mount editors itself — it delegates to the parent
11
+ * (NotebookPage) via callbacks, since editor mounting requires access to
12
+ * ProjectModel, symbol index, plot controller, etc.
13
+ *
14
+ * Layout:
15
+ * ┌──────────────────────────────┐
16
+ * │ [Tab1] [Tab2] │ ← NotebookTabBar
17
+ * ├──────────────────────────────┤
18
+ * │ │
19
+ * │ Editor content │ ← editorContainer
20
+ * │ │
21
+ * └──────────────────────────────┘
22
+ */
23
+
24
+ import { NotebookTabBar } from './notebook_tab_bar.js';
25
+
26
+ export class EditorPane {
27
+ /** @type {string} */
28
+ #id;
29
+
30
+ /** @type {HTMLElement} root element */
31
+ #el = null;
32
+
33
+ /** @type {NotebookTabBar} */
34
+ #tabBar = null;
35
+
36
+ /** @type {HTMLElement} */
37
+ #editorContainer = null;
38
+
39
+ /** @type {string[]} file paths in tab order */
40
+ #filePaths = [];
41
+
42
+ /** @type {string|null} */
43
+ #activeFilePath = null;
44
+
45
+ /** @type {string|null} file path of the preview (transient) tab, if any */
46
+ #previewFilePath = null;
47
+
48
+ /** @type {object} callbacks from parent */
49
+ #callbacks = {};
50
+
51
+ /** @type {boolean} whether this pane is the focused/active pane */
52
+ #focused = false;
53
+
54
+ /**
55
+ * @param {string} id — unique pane identifier ('left' or 'right')
56
+ * @param {object} callbacks
57
+ * @param {Function} callbacks.onActivate — (paneId, filePath) => void
58
+ * @param {Function} callbacks.onClose — (paneId, filePath) => void
59
+ * @param {Function} callbacks.onCloseOthers — (paneId, filePath) => void
60
+ * @param {Function} callbacks.onCloseToRight — (paneId, filePath) => void
61
+ * @param {Function} callbacks.onCloseAll — (paneId) => void
62
+ * @param {Function} callbacks.onCloseSaved — (paneId) => void
63
+ * @param {Function} callbacks.onRename — (oldPath, newPath) => void
64
+ * @param {Function} callbacks.onDuplicate — (filePath) => void
65
+ * @param {Function} callbacks.onReorder — (paneId, orderedPaths) => void
66
+ * @param {Function} callbacks.onRevealInExplorer — (filePath) => void
67
+ * @param {Function} callbacks.onFocus — (paneId) => void
68
+ * @param {Function} callbacks.onSplitRight — (paneId, filePath) => void
69
+ * @param {Function} callbacks.onMoveToOtherPane — (paneId, filePath) => void
70
+ */
71
+ constructor(id, callbacks = {}) {
72
+ this.#id = id;
73
+ this.#callbacks = callbacks;
74
+ }
75
+
76
+ get id() { return this.#id; }
77
+ get activeFilePath() { return this.#activeFilePath; }
78
+ get filePaths() { return [...this.#filePaths]; }
79
+ get editorContainer() { return this.#editorContainer; }
80
+ get element() { return this.#el; }
81
+ get isEmpty() { return this.#filePaths.length === 0; }
82
+
83
+ /**
84
+ * Build DOM and mount the tab bar.
85
+ * @returns {HTMLElement} the root element (to be placed in SplitPaneContainer)
86
+ */
87
+ mount() {
88
+ this.#el = document.createElement('div');
89
+ this.#el.className = 'editor-pane';
90
+ this.#el.dataset.paneId = this.#id;
91
+
92
+ // Focus tracking — clicking anywhere in the pane focuses it
93
+ this.#el.addEventListener('mousedown', () => {
94
+ this.#callbacks.onFocus?.(this.#id);
95
+ }, true);
96
+
97
+ // Tab bar
98
+ const tabBarEl = document.createElement('div');
99
+ this.#el.appendChild(tabBarEl);
100
+
101
+ this.#tabBar = new NotebookTabBar();
102
+ this.#tabBar.mount(tabBarEl, {
103
+ onActivate: (path) => this.#callbacks.onActivate?.(this.#id, path),
104
+ onClose: (path) => this.#callbacks.onClose?.(this.#id, path),
105
+ onCloseOthers: (path) => this.#callbacks.onCloseOthers?.(this.#id, path),
106
+ onCloseToRight: (path) => this.#callbacks.onCloseToRight?.(this.#id, path),
107
+ onCloseAll: () => this.#callbacks.onCloseAll?.(this.#id),
108
+ onCloseSaved: () => this.#callbacks.onCloseSaved?.(this.#id),
109
+ onRename: (o, n) => this.#callbacks.onRename?.(o, n),
110
+ onDuplicate: (path) => this.#callbacks.onDuplicate?.(path),
111
+ onReorder: (paths) => this.#callbacks.onReorder?.(this.#id, paths),
112
+ onRevealInExplorer:(path) => this.#callbacks.onRevealInExplorer?.(path),
113
+ onSplitRight: (path) => this.#callbacks.onSplitRight?.(this.#id, path),
114
+ onMoveToOtherPane: (path) => this.#callbacks.onMoveToOtherPane?.(this.#id, path),
115
+ onDropFromOtherPane: (path) => this.#callbacks.onDropFromOtherPane?.(this.#id, path),
116
+ });
117
+
118
+ // Editor body wrapper — positioned container that fills remaining
119
+ // flex space. The actual editor container is absolutely positioned
120
+ // inside, so its height is ALWAYS the wrapper's height, regardless
121
+ // of content. This makes the scroll chain bulletproof — no flex
122
+ // ancestor chain needed for the scrollbar to work.
123
+ const body = document.createElement('div');
124
+ body.className = 'editor-pane__body';
125
+ this.#el.appendChild(body);
126
+
127
+ // Editor container (absolutely positioned inside wrapper)
128
+ this.#editorContainer = document.createElement('div');
129
+ this.#editorContainer.className = 'notebook-editor-container';
130
+ body.appendChild(this.#editorContainer);
131
+
132
+ // Accept cross-pane tab drops on the editor area too
133
+ this.#editorContainer.addEventListener('dragover', (e) => {
134
+ if (this.#isExternalTabDrag(e)) {
135
+ e.preventDefault();
136
+ try { e.dataTransfer.dropEffect = 'move'; } catch {}
137
+ this.#editorContainer.classList.add('notebook-editor-container--drop-target');
138
+ }
139
+ });
140
+ this.#editorContainer.addEventListener('drop', (e) => {
141
+ if (this.#isExternalTabDrag(e)) {
142
+ e.preventDefault();
143
+ this.#editorContainer.classList.remove('notebook-editor-container--drop-target');
144
+ const filePath = e.dataTransfer.getData('application/x-ecosim-tab')
145
+ || e.dataTransfer.getData('text/plain');
146
+ if (filePath) {
147
+ this.#callbacks.onDropFromOtherPane?.(this.#id, filePath);
148
+ }
149
+ }
150
+ });
151
+ this.#editorContainer.addEventListener('dragleave', (e) => {
152
+ if (!this.#editorContainer.contains(e.relatedTarget)) {
153
+ this.#editorContainer.classList.remove('notebook-editor-container--drop-target');
154
+ }
155
+ });
156
+
157
+ return this.#el;
158
+ }
159
+
160
+ /**
161
+ * Update the file list and active tab.
162
+ * @param {Array<{filePath: string, fileType: string, isDirty: boolean}>} tabs
163
+ * @param {string|null} activeFilePath
164
+ */
165
+ syncTabBar(tabs, activeFilePath) {
166
+ this.#tabBar?.update(tabs, activeFilePath);
167
+ }
168
+
169
+ /**
170
+ * Set the files assigned to this pane.
171
+ * @param {string[]} filePaths
172
+ */
173
+ setFilePaths(filePaths) {
174
+ this.#filePaths = [...filePaths];
175
+ }
176
+
177
+ /**
178
+ * Add a file to this pane's tabs (at end).
179
+ * @param {string} filePath
180
+ */
181
+ addFile(filePath) {
182
+ if (!this.#filePaths.includes(filePath)) {
183
+ this.#filePaths.push(filePath);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Remove a file from this pane's tabs.
189
+ * @param {string} filePath
190
+ */
191
+ removeFile(filePath) {
192
+ this.#filePaths = this.#filePaths.filter(p => p !== filePath);
193
+ if (this.#previewFilePath === filePath) this.#previewFilePath = null;
194
+ if (this.#activeFilePath === filePath) {
195
+ this.#activeFilePath = this.#filePaths.length > 0
196
+ ? this.#filePaths[this.#filePaths.length - 1]
197
+ : null;
198
+ }
199
+ }
200
+
201
+ hasFile(filePath) {
202
+ return this.#filePaths.includes(filePath);
203
+ }
204
+
205
+ setActiveFile(filePath) {
206
+ this.#activeFilePath = filePath;
207
+ }
208
+
209
+ setFocused(focused) {
210
+ this.#focused = focused;
211
+ this.#el?.classList.toggle('editor-pane--focused', focused);
212
+ }
213
+
214
+ markDirty(filePath, isDirty) {
215
+ this.#tabBar?.markDirty(filePath, isDirty);
216
+ }
217
+
218
+ reorderFiles(orderedPaths) {
219
+ // Keep only paths that belong to this pane
220
+ const valid = orderedPaths.filter(p => this.#filePaths.includes(p));
221
+ // Append any missing (shouldn't happen)
222
+ for (const p of this.#filePaths) {
223
+ if (!valid.includes(p)) valid.push(p);
224
+ }
225
+ this.#filePaths = valid;
226
+ }
227
+
228
+ renameFile(oldPath, newPath) {
229
+ const idx = this.#filePaths.indexOf(oldPath);
230
+ if (idx !== -1) this.#filePaths[idx] = newPath;
231
+ if (this.#activeFilePath === oldPath) this.#activeFilePath = newPath;
232
+ if (this.#previewFilePath === oldPath) this.#previewFilePath = newPath;
233
+ }
234
+
235
+ // ─── Preview (transient) tab ──────────────────────────────────────────────
236
+
237
+ /** @returns {string|null} the preview file path, or null if no preview tab */
238
+ get previewFilePath() { return this.#previewFilePath; }
239
+
240
+ /** Mark a file as the preview tab. Only one preview tab per pane. */
241
+ setPreview(filePath) { this.#previewFilePath = filePath; }
242
+
243
+ /** Whether a file is the preview (transient) tab. */
244
+ isPreview(filePath) { return this.#previewFilePath === filePath; }
245
+
246
+ /** Pin the preview tab (promote to permanent). */
247
+ pinPreview() { this.#previewFilePath = null; }
248
+
249
+ /** Check if a drag event is carrying a tab from another pane. */
250
+ #isExternalTabDrag(e) {
251
+ try { return e.dataTransfer.types.includes('application/x-ecosim-tab'); } catch { return false; }
252
+ }
253
+
254
+ dispose() {
255
+ this.#tabBar?.dispose();
256
+ this.#tabBar = null;
257
+ this.#el?.remove();
258
+ this.#el = null;
259
+ this.#editorContainer = null;
260
+ this.#filePaths = [];
261
+ this.#activeFilePath = null;
262
+ this.#previewFilePath = null;
263
+ }
264
+ }