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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- package/src/ui/utils/tooltip_service.js +406 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tile_tree.js — i3/sway-style binary tree for the tiling window manager.
|
|
3
|
+
*
|
|
4
|
+
* A workspace is one tree of Node objects:
|
|
5
|
+
*
|
|
6
|
+
* Node = { id, kind: 'split' | 'leaf', parentId, ... }
|
|
7
|
+
* Split: { dir: 'h' | 'v', children: [childId, ...], sizes: [number, ...] }
|
|
8
|
+
* Leaf: { tabs: [{kind, props, title}], activeTabIdx, content, title }
|
|
9
|
+
*
|
|
10
|
+
* **Tabs**. Each leaf carries an ordered list of tab specs and an active
|
|
11
|
+
* index. The active tab is mirrored onto the legacy `content` /
|
|
12
|
+
* `title` fields so the rest of the codebase (renderer, WM,
|
|
13
|
+
* breadcrumb, navigateBack, …) keeps reading `leaf.content.kind`
|
|
14
|
+
* unchanged. Single-tab leaves behave identically to today; multi-tab
|
|
15
|
+
* leaves expose a small TabBar at the bottom of the tile.
|
|
16
|
+
*
|
|
17
|
+
* The tree carries no DOM. A separate renderer maps it to flex containers.
|
|
18
|
+
* Mutations return new node-id arrays; consumers re-render after each call.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
let _idCounter = 1;
|
|
22
|
+
function _newId(prefix) { return `${prefix}-${_idCounter++}`; }
|
|
23
|
+
|
|
24
|
+
function _isPanel(leaf) {
|
|
25
|
+
return String(leaf?.content?.kind || '').startsWith('panel:');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Mirror the active tab back onto `leaf.content` / `leaf.title` so the
|
|
29
|
+
* rest of the system sees no diff. Called after every tab mutation. */
|
|
30
|
+
function _syncActiveTab(leaf) {
|
|
31
|
+
if (!leaf || leaf.kind !== 'leaf') return;
|
|
32
|
+
const tabs = leaf.tabs || [];
|
|
33
|
+
if (tabs.length === 0) {
|
|
34
|
+
leaf.content = null;
|
|
35
|
+
leaf.title = leaf.title || '';
|
|
36
|
+
leaf.activeTabIdx = 0;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (leaf.activeTabIdx == null
|
|
40
|
+
|| leaf.activeTabIdx < 0
|
|
41
|
+
|| leaf.activeTabIdx >= tabs.length) {
|
|
42
|
+
leaf.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
43
|
+
}
|
|
44
|
+
const t = tabs[leaf.activeTabIdx];
|
|
45
|
+
leaf.content = { kind: t.kind, props: t.props || {} };
|
|
46
|
+
leaf.title = t.title || t.kind || '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function makeLeaf({ content = null, title = '' } = {}) {
|
|
50
|
+
const leaf = {
|
|
51
|
+
id: _newId('leaf'),
|
|
52
|
+
kind: 'leaf',
|
|
53
|
+
parentId: null,
|
|
54
|
+
// Tabs are the source of truth; content/title mirror tabs[active].
|
|
55
|
+
tabs: content
|
|
56
|
+
? [{ kind: content.kind, props: content.props || {}, title: title || content.kind || '' }]
|
|
57
|
+
: [],
|
|
58
|
+
activeTabIdx: content ? 0 : 0,
|
|
59
|
+
content,
|
|
60
|
+
title,
|
|
61
|
+
// Per-top-nav-page tab archive. When a top-nav button switches
|
|
62
|
+
// this leaf to another page, the current `tabs` + `activeTabIdx`
|
|
63
|
+
// get stashed here under the LEAVING page's key, and the target
|
|
64
|
+
// page's archived list (if any) gets restored. This is what
|
|
65
|
+
// makes a tile remember its tab set when the user clicks away to
|
|
66
|
+
// another top-nav page and back. Keyed by top-nav kind — see the
|
|
67
|
+
// taxonomy's `topNavFor`.
|
|
68
|
+
pageTabs: {},
|
|
69
|
+
};
|
|
70
|
+
_syncActiveTab(leaf);
|
|
71
|
+
return leaf;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function makeSplit({ dir = 'h', children = [], sizes = null } = {}) {
|
|
75
|
+
return {
|
|
76
|
+
id: _newId('split'),
|
|
77
|
+
kind: 'split',
|
|
78
|
+
parentId: null,
|
|
79
|
+
dir,
|
|
80
|
+
children: children.slice(),
|
|
81
|
+
sizes: sizes ? sizes.slice() : children.map(() => 1),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* TileTree — holds the node table and root, exposes mutation operations.
|
|
87
|
+
* Every mutation operates by node id; the tree never hands out raw nodes
|
|
88
|
+
* to callers (defensive copies via .get()).
|
|
89
|
+
*/
|
|
90
|
+
export class TileTree {
|
|
91
|
+
constructor() {
|
|
92
|
+
this.nodes = new Map();
|
|
93
|
+
this.rootId = null;
|
|
94
|
+
this.focusedLeafId = null;
|
|
95
|
+
// Stack of recently-focused leaves. New content loads target the
|
|
96
|
+
// top of this stack — i.e. the last leaf the user focused.
|
|
97
|
+
this.focusStack = [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Read helpers ────────────────────────────────────────────────
|
|
101
|
+
get(id) { return this.nodes.get(id) || null; }
|
|
102
|
+
root() { return this.rootId ? this.get(this.rootId) : null; }
|
|
103
|
+
focused() { return this.focusedLeafId ? this.get(this.focusedLeafId) : null; }
|
|
104
|
+
|
|
105
|
+
leaves() {
|
|
106
|
+
const out = [];
|
|
107
|
+
const walk = (id) => {
|
|
108
|
+
const n = this.get(id);
|
|
109
|
+
if (!n) return;
|
|
110
|
+
if (n.kind === 'leaf') out.push(n);
|
|
111
|
+
else n.children.forEach(walk);
|
|
112
|
+
};
|
|
113
|
+
if (this.rootId) walk(this.rootId);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Construction ───────────────────────────────────────────────
|
|
118
|
+
setRoot(node) {
|
|
119
|
+
this._register(node);
|
|
120
|
+
this.rootId = node.id;
|
|
121
|
+
if (node.kind === 'leaf') {
|
|
122
|
+
this.focus(node.id);
|
|
123
|
+
}
|
|
124
|
+
return node.id;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_register(node) {
|
|
128
|
+
this.nodes.set(node.id, node);
|
|
129
|
+
if (node.kind === 'split') {
|
|
130
|
+
for (const cid of node.children) {
|
|
131
|
+
const c = this.get(cid);
|
|
132
|
+
if (c) c.parentId = node.id;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Focus ───────────────────────────────────────────────────────
|
|
138
|
+
focus(leafId) {
|
|
139
|
+
if (!this.nodes.has(leafId)) return;
|
|
140
|
+
const node = this.get(leafId);
|
|
141
|
+
if (node.kind !== 'leaf') return;
|
|
142
|
+
this.focusedLeafId = leafId;
|
|
143
|
+
this.focusStack = this.focusStack.filter((id) => id !== leafId);
|
|
144
|
+
this.focusStack.unshift(leafId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The leaf where "open content" should land — top of focus stack,
|
|
148
|
+
* skipping panel:* tiles which are chrome, not content. */
|
|
149
|
+
primaryLeafId() {
|
|
150
|
+
for (const id of this.focusStack) {
|
|
151
|
+
const n = this.nodes.get(id);
|
|
152
|
+
if (n && n.kind === 'leaf' && !_isPanel(n)) return id;
|
|
153
|
+
}
|
|
154
|
+
for (const leaf of this.leaves()) {
|
|
155
|
+
if (!_isPanel(leaf)) return leaf.id;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Mutations ───────────────────────────────────────────────────
|
|
161
|
+
/** Replace the leaf's tab list with a single tab carrying `content`.
|
|
162
|
+
* Closes any existing tabs in that leaf — this is the "open in
|
|
163
|
+
* place" / "replace tile" path that the WM has always used. */
|
|
164
|
+
setLeafContent(leafId, content, title = '') {
|
|
165
|
+
const n = this.get(leafId);
|
|
166
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
167
|
+
if (content) {
|
|
168
|
+
n.tabs = [{
|
|
169
|
+
kind: content.kind,
|
|
170
|
+
props: content.props || {},
|
|
171
|
+
title: title || content.kind || '',
|
|
172
|
+
}];
|
|
173
|
+
n.activeTabIdx = 0;
|
|
174
|
+
} else {
|
|
175
|
+
n.tabs = [];
|
|
176
|
+
n.activeTabIdx = 0;
|
|
177
|
+
}
|
|
178
|
+
_syncActiveTab(n);
|
|
179
|
+
if (title) n.title = title;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Page-aware swap: archive the leaf's current tab list under its
|
|
184
|
+
* current top-nav key, then restore the target top-nav's archived
|
|
185
|
+
* list (or initialize one with the requested kind/props). Used
|
|
186
|
+
* by top-nav / palette navigation so each top-nav page keeps its
|
|
187
|
+
* own tab set across page switches.
|
|
188
|
+
*
|
|
189
|
+
* Parameters:
|
|
190
|
+
* leafId The primary leaf.
|
|
191
|
+
* target { kind, props, title } — what the user clicked.
|
|
192
|
+
* currentTopNav Top-nav of the leaf's current page (may be null).
|
|
193
|
+
* targetTopNav Top-nav of the target kind (must be set).
|
|
194
|
+
*
|
|
195
|
+
* When `currentTopNav === targetTopNav` (already on the right
|
|
196
|
+
* page), the swap is skipped and we either focus an existing
|
|
197
|
+
* matching tab or replace the active one. This is what makes
|
|
198
|
+
* clicking the top-nav button for the page you're already on
|
|
199
|
+
* feel like "Home for this page" without resetting tabs. */
|
|
200
|
+
swapToPage(leafId, target, currentTopNav, targetTopNav, opts = {}) {
|
|
201
|
+
const n = this.get(leafId);
|
|
202
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
203
|
+
if (!target || !target.kind) return false;
|
|
204
|
+
n.pageTabs = n.pageTabs || {};
|
|
205
|
+
const recordHistory = opts.recordHistory !== false;
|
|
206
|
+
|
|
207
|
+
// Helper: mirror what replaceActiveTabContent does — push the
|
|
208
|
+
// outgoing tab onto the (incoming) tab's history stack so
|
|
209
|
+
// Backspace can walk back. `extra` is an optional annotation
|
|
210
|
+
// (e.g. `{ topNav: currentTopNav }`) for cross-page entries so
|
|
211
|
+
// navigateBack can re-route via swapToPage when popping.
|
|
212
|
+
const _pushPrev = (history, prev, next, extra) => {
|
|
213
|
+
if (!recordHistory || !prev || !prev.kind) return history;
|
|
214
|
+
const last = history[history.length - 1];
|
|
215
|
+
const sameAsPrev = last
|
|
216
|
+
&& last.kind === prev.kind
|
|
217
|
+
&& String(last.props?.id ?? '') === String(prev.props?.id ?? '');
|
|
218
|
+
const sameAsTarget = next
|
|
219
|
+
&& prev.kind === next.kind
|
|
220
|
+
&& String(prev.props?.id ?? '') === String(next.props?.id ?? '');
|
|
221
|
+
if (!sameAsPrev && !sameAsTarget) {
|
|
222
|
+
history.push({
|
|
223
|
+
kind: prev.kind,
|
|
224
|
+
props: prev.props || {},
|
|
225
|
+
title: prev.title || '',
|
|
226
|
+
...(extra || {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return history;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const same = currentTopNav && currentTopNav === targetTopNav;
|
|
233
|
+
if (same) {
|
|
234
|
+
const tabs = n.tabs || [];
|
|
235
|
+
const matchIdx = tabs.findIndex((t) =>
|
|
236
|
+
t.kind === target.kind
|
|
237
|
+
&& String(t.props?.id ?? '') === String(target.props?.id ?? ''));
|
|
238
|
+
if (matchIdx >= 0) {
|
|
239
|
+
n.activeTabIdx = matchIdx;
|
|
240
|
+
} else if (tabs.length === 0) {
|
|
241
|
+
n.tabs = [{
|
|
242
|
+
kind: target.kind,
|
|
243
|
+
props: target.props || {},
|
|
244
|
+
title: target.title || target.kind,
|
|
245
|
+
history: [],
|
|
246
|
+
}];
|
|
247
|
+
n.activeTabIdx = 0;
|
|
248
|
+
} else {
|
|
249
|
+
const prev = tabs[n.activeTabIdx];
|
|
250
|
+
const history = Array.isArray(prev?.history) ? prev.history : [];
|
|
251
|
+
_pushPrev(history, prev, target, null);
|
|
252
|
+
tabs[n.activeTabIdx] = {
|
|
253
|
+
kind: target.kind,
|
|
254
|
+
props: target.props || {},
|
|
255
|
+
title: target.title || target.kind,
|
|
256
|
+
history,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
_syncActiveTab(n);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Snapshot the source tab BEFORE archiving — used to seed the
|
|
264
|
+
// target tab's history with a cross-page back entry.
|
|
265
|
+
const srcTabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
266
|
+
const srcActive = srcTabs[n.activeTabIdx];
|
|
267
|
+
const sourceEntry = (currentTopNav && srcActive?.kind) ? {
|
|
268
|
+
kind: srcActive.kind,
|
|
269
|
+
props: srcActive.props || {},
|
|
270
|
+
title: srcActive.title || '',
|
|
271
|
+
topNav: currentTopNav,
|
|
272
|
+
} : null;
|
|
273
|
+
|
|
274
|
+
// Archive the current page — preserve `history` so the user
|
|
275
|
+
// doesn't lose in-tile back depth when they walk away and
|
|
276
|
+
// return to this page.
|
|
277
|
+
if (currentTopNav && srcTabs.length > 0) {
|
|
278
|
+
// Don't archive transient tabs (add-row forms etc.) — they
|
|
279
|
+
// shouldn't reappear when the user returns to this page.
|
|
280
|
+
const archived = srcTabs
|
|
281
|
+
.filter((t) => !t.transient)
|
|
282
|
+
.map((t) => ({
|
|
283
|
+
kind: t.kind, props: { ...(t.props || {}) }, title: t.title,
|
|
284
|
+
history: Array.isArray(t.history) ? t.history.slice() : [],
|
|
285
|
+
}));
|
|
286
|
+
n.pageTabs[currentTopNav] = {
|
|
287
|
+
tabs: archived,
|
|
288
|
+
activeTabIdx: Math.max(0, Math.min(archived.length - 1, n.activeTabIdx || 0)),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Restore the target page, or initialize fresh.
|
|
293
|
+
const saved = n.pageTabs[targetTopNav];
|
|
294
|
+
if (saved && Array.isArray(saved.tabs) && saved.tabs.length > 0) {
|
|
295
|
+
n.tabs = saved.tabs.map((t) => ({
|
|
296
|
+
kind: t.kind, props: { ...(t.props || {}) }, title: t.title,
|
|
297
|
+
history: Array.isArray(t.history) ? t.history.slice() : [],
|
|
298
|
+
}));
|
|
299
|
+
n.activeTabIdx = Math.max(0,
|
|
300
|
+
Math.min(n.tabs.length - 1, saved.activeTabIdx || 0));
|
|
301
|
+
// The caller asked for a specific entity (props.id) OR for a
|
|
302
|
+
// page kind that ISN'T the nav category itself (e.g. `settings`
|
|
303
|
+
// lives under the `home` topNav). In both cases we must surface
|
|
304
|
+
// the requested target rather than silently showing whatever
|
|
305
|
+
// the restored page happened to hold. A bare top-nav click
|
|
306
|
+
// (target.kind === targetTopNav, no id) falls through and just
|
|
307
|
+
// restores the saved tabs as-is.
|
|
308
|
+
const wantsTarget = target.props?.id != null
|
|
309
|
+
|| target.kind !== targetTopNav;
|
|
310
|
+
if (wantsTarget) {
|
|
311
|
+
const wantId = target.props?.id != null;
|
|
312
|
+
const matchIdx = n.tabs.findIndex((t) =>
|
|
313
|
+
t.kind === target.kind
|
|
314
|
+
&& (!wantId
|
|
315
|
+
|| String(t.props?.id ?? '') === String(target.props.id)));
|
|
316
|
+
if (matchIdx >= 0) {
|
|
317
|
+
n.activeTabIdx = matchIdx;
|
|
318
|
+
} else {
|
|
319
|
+
const history = [];
|
|
320
|
+
if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
|
|
321
|
+
n.tabs.push({
|
|
322
|
+
kind: target.kind,
|
|
323
|
+
props: target.props || {},
|
|
324
|
+
title: target.title || target.kind,
|
|
325
|
+
history,
|
|
326
|
+
});
|
|
327
|
+
n.activeTabIdx = n.tabs.length - 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
const history = [];
|
|
332
|
+
if (sourceEntry) _pushPrev(history, sourceEntry, target, { topNav: currentTopNav });
|
|
333
|
+
n.tabs = [{
|
|
334
|
+
kind: target.kind,
|
|
335
|
+
props: target.props || {},
|
|
336
|
+
title: target.title || target.kind,
|
|
337
|
+
history,
|
|
338
|
+
}];
|
|
339
|
+
n.activeTabIdx = 0;
|
|
340
|
+
}
|
|
341
|
+
_syncActiveTab(n);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Update any tab whose (kind, props.id) matches the given pair to
|
|
346
|
+
* reflect a new label. Called from the WM in response to the
|
|
347
|
+
* embedder's entity-renamed bus event, so tab titles stay live as
|
|
348
|
+
* the underlying entity is renamed. */
|
|
349
|
+
renameMatchingTabs(kind, id, newLabel) {
|
|
350
|
+
if (!kind || !id || !newLabel) return false;
|
|
351
|
+
let touched = false;
|
|
352
|
+
const visit = (tabs) => {
|
|
353
|
+
if (!Array.isArray(tabs)) return;
|
|
354
|
+
for (const t of tabs) {
|
|
355
|
+
if (t.kind === kind && String(t.props?.id ?? '') === String(id)) {
|
|
356
|
+
t.title = String(newLabel);
|
|
357
|
+
if (t.props) t.props.label = String(newLabel);
|
|
358
|
+
touched = true;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
for (const n of this.nodes.values()) {
|
|
363
|
+
if (n.kind !== 'leaf') continue;
|
|
364
|
+
visit(n.tabs);
|
|
365
|
+
if (n.pageTabs) {
|
|
366
|
+
for (const arc of Object.values(n.pageTabs)) visit(arc?.tabs);
|
|
367
|
+
}
|
|
368
|
+
_syncActiveTab(n);
|
|
369
|
+
}
|
|
370
|
+
return touched;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Replace the active tab's content in place, preserving every
|
|
374
|
+
* other tab in the leaf. This is the "in-tile navigation" path:
|
|
375
|
+
* clicking a row on a landing page, walking back via Backspace,
|
|
376
|
+
* or clicking a breadcrumb segment should swap THIS tab's
|
|
377
|
+
* content without disturbing the user's other open tabs.
|
|
378
|
+
*
|
|
379
|
+
* By default the previous content is pushed onto the active
|
|
380
|
+
* tab's `history` stack so the user can Backspace through their
|
|
381
|
+
* own click-path (per-tab "browser back"). Callers that are
|
|
382
|
+
* themselves popping the history pass `{ recordHistory: false }`
|
|
383
|
+
* so we don't double-record. Same applies to programmatic loads
|
|
384
|
+
* that aren't user navigations.
|
|
385
|
+
*
|
|
386
|
+
* Falls back to `setLeafContent` for empty leaves so the WM
|
|
387
|
+
* never has to special-case the initial load. Returns true on
|
|
388
|
+
* success. */
|
|
389
|
+
replaceActiveTabContent(leafId, content, title = '', opts = {}) {
|
|
390
|
+
const n = this.get(leafId);
|
|
391
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
392
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
393
|
+
if (tabs.length === 0) {
|
|
394
|
+
return this.setLeafContent(leafId, content, title);
|
|
395
|
+
}
|
|
396
|
+
if (!content) return false;
|
|
397
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
398
|
+
const prev = tabs[idx];
|
|
399
|
+
const recordHistory = opts.recordHistory !== false;
|
|
400
|
+
const history = Array.isArray(prev?.history) ? prev.history : [];
|
|
401
|
+
if (recordHistory && prev && prev.kind) {
|
|
402
|
+
// Avoid pushing duplicates when the user re-clicks the
|
|
403
|
+
// same row — same kind + same primary id → no-op.
|
|
404
|
+
const last = history[history.length - 1];
|
|
405
|
+
const sameAsPrev = last
|
|
406
|
+
&& last.kind === prev.kind
|
|
407
|
+
&& String(last.props?.id ?? '') === String(prev.props?.id ?? '');
|
|
408
|
+
const sameAsTarget = prev.kind === content.kind
|
|
409
|
+
&& String(prev.props?.id ?? '') === String(content.props?.id ?? '');
|
|
410
|
+
if (!sameAsPrev && !sameAsTarget) {
|
|
411
|
+
history.push({
|
|
412
|
+
kind: prev.kind,
|
|
413
|
+
props: prev.props || {},
|
|
414
|
+
title: prev.title || '',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
tabs[idx] = {
|
|
419
|
+
kind: content.kind,
|
|
420
|
+
props: content.props || {},
|
|
421
|
+
title: title || content.kind || '',
|
|
422
|
+
history,
|
|
423
|
+
};
|
|
424
|
+
_syncActiveTab(n);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Merge `patch` into the active tab's `props`. Used when an
|
|
429
|
+
* in-tile editor surfaces metadata that should be captured in
|
|
430
|
+
* history snapshots (e.g. which sub-tab is open) so Backspace
|
|
431
|
+
* restores the user's view, not the default landing. Does not
|
|
432
|
+
* re-render or push history — pure metadata write. */
|
|
433
|
+
updateActiveTabProps(leafId, patch) {
|
|
434
|
+
const n = this.get(leafId);
|
|
435
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
436
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
437
|
+
if (tabs.length === 0) return false;
|
|
438
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
439
|
+
const cur = tabs[idx];
|
|
440
|
+
if (!cur) return false;
|
|
441
|
+
cur.props = { ...(cur.props || {}), ...(patch || {}) };
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Pop one entry off the active tab's history stack and return it,
|
|
446
|
+
* or null when empty. Doesn't mutate the tab beyond the stack —
|
|
447
|
+
* the caller is expected to apply the returned content via
|
|
448
|
+
* `replaceActiveTabContent(..., { recordHistory: false })`. */
|
|
449
|
+
popActiveTabHistory(leafId) {
|
|
450
|
+
const n = this.get(leafId);
|
|
451
|
+
if (!n || n.kind !== 'leaf') return null;
|
|
452
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
453
|
+
if (tabs.length === 0) return null;
|
|
454
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
455
|
+
const cur = tabs[idx];
|
|
456
|
+
const history = Array.isArray(cur?.history) ? cur.history : [];
|
|
457
|
+
if (history.length === 0) return null;
|
|
458
|
+
return history.pop();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Inspect the active tab's history stack without popping. Used
|
|
462
|
+
* by the keymap to show / hide the back affordance and for
|
|
463
|
+
* diagnostics. */
|
|
464
|
+
activeTabHistory(leafId) {
|
|
465
|
+
const n = this.get(leafId);
|
|
466
|
+
if (!n || n.kind !== 'leaf') return [];
|
|
467
|
+
const tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
468
|
+
if (tabs.length === 0) return [];
|
|
469
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, n.activeTabIdx || 0));
|
|
470
|
+
const cur = tabs[idx];
|
|
471
|
+
return Array.isArray(cur?.history) ? cur.history : [];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Append a new tab to a leaf and focus it. Returns the new tab's
|
|
475
|
+
* index, or -1 on failure. Panel tiles never grow tabs (they're
|
|
476
|
+
* chrome). */
|
|
477
|
+
appendLeafTab(leafId, content, title = '', opts = {}) {
|
|
478
|
+
const n = this.get(leafId);
|
|
479
|
+
if (!n || n.kind !== 'leaf') return -1;
|
|
480
|
+
if (_isPanel(n)) return -1;
|
|
481
|
+
if (!content) return -1;
|
|
482
|
+
const tab = {
|
|
483
|
+
kind: content.kind,
|
|
484
|
+
props: content.props || {},
|
|
485
|
+
title: title || content.kind || '',
|
|
486
|
+
// Transient tabs (e.g. an add-row form) are not persisted /
|
|
487
|
+
// restored — see serialize() + the pageTabs archive.
|
|
488
|
+
...(opts.transient ? { transient: true } : {}),
|
|
489
|
+
};
|
|
490
|
+
n.tabs = Array.isArray(n.tabs) ? n.tabs : [];
|
|
491
|
+
n.tabs.push(tab);
|
|
492
|
+
n.activeTabIdx = n.tabs.length - 1;
|
|
493
|
+
_syncActiveTab(n);
|
|
494
|
+
return n.activeTabIdx;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Switch the active tab on a leaf. No-op if `idx` is out of range. */
|
|
498
|
+
setActiveLeafTab(leafId, idx) {
|
|
499
|
+
const n = this.get(leafId);
|
|
500
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
501
|
+
if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return false;
|
|
502
|
+
n.activeTabIdx = idx;
|
|
503
|
+
_syncActiveTab(n);
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Remove a tab. If the active tab was removed, the previous one
|
|
508
|
+
* becomes active. If the last tab was removed, the leaf's content
|
|
509
|
+
* drops to null (and the WM should treat that as an empty tile —
|
|
510
|
+
* same as before tabs existed). Returns the (possibly clamped)
|
|
511
|
+
* active index after the operation, or -1 on failure. */
|
|
512
|
+
removeLeafTab(leafId, idx) {
|
|
513
|
+
const n = this.get(leafId);
|
|
514
|
+
if (!n || n.kind !== 'leaf') return -1;
|
|
515
|
+
if (!Array.isArray(n.tabs) || idx < 0 || idx >= n.tabs.length) return -1;
|
|
516
|
+
n.tabs.splice(idx, 1);
|
|
517
|
+
if (n.tabs.length === 0) {
|
|
518
|
+
n.activeTabIdx = 0;
|
|
519
|
+
_syncActiveTab(n);
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
if (idx < n.activeTabIdx) n.activeTabIdx -= 1;
|
|
523
|
+
else if (idx === n.activeTabIdx) n.activeTabIdx = Math.max(0, idx - 1);
|
|
524
|
+
_syncActiveTab(n);
|
|
525
|
+
return n.activeTabIdx;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Bulk-close every tab in a leaf except the one at `keepIdx`.
|
|
529
|
+
* Used by the tab right-click "Close others" action. */
|
|
530
|
+
closeOtherTabs(leafId, keepIdx) {
|
|
531
|
+
const n = this.get(leafId);
|
|
532
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
533
|
+
const tabs = n.tabs || [];
|
|
534
|
+
if (keepIdx < 0 || keepIdx >= tabs.length) return false;
|
|
535
|
+
n.tabs = [tabs[keepIdx]];
|
|
536
|
+
n.activeTabIdx = 0;
|
|
537
|
+
_syncActiveTab(n);
|
|
538
|
+
return true;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Close every tab after `idx`. */
|
|
542
|
+
closeTabsAfter(leafId, idx) {
|
|
543
|
+
const n = this.get(leafId);
|
|
544
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
545
|
+
const tabs = n.tabs || [];
|
|
546
|
+
if (idx < 0 || idx >= tabs.length - 1) return false;
|
|
547
|
+
n.tabs = tabs.slice(0, idx + 1);
|
|
548
|
+
if (n.activeTabIdx > idx) n.activeTabIdx = idx;
|
|
549
|
+
_syncActiveTab(n);
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Close every tab before `idx`. */
|
|
554
|
+
closeTabsBefore(leafId, idx) {
|
|
555
|
+
const n = this.get(leafId);
|
|
556
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
557
|
+
const tabs = n.tabs || [];
|
|
558
|
+
if (idx <= 0 || idx >= tabs.length) return false;
|
|
559
|
+
n.tabs = tabs.slice(idx);
|
|
560
|
+
n.activeTabIdx = Math.max(0, n.activeTabIdx - idx);
|
|
561
|
+
_syncActiveTab(n);
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Reorder a tab from `from` to `to` within the same leaf. */
|
|
566
|
+
moveLeafTab(leafId, from, to) {
|
|
567
|
+
const n = this.get(leafId);
|
|
568
|
+
if (!n || n.kind !== 'leaf') return false;
|
|
569
|
+
const tabs = n.tabs || [];
|
|
570
|
+
if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return false;
|
|
571
|
+
const moved = tabs.splice(from, 1)[0];
|
|
572
|
+
tabs.splice(to, 0, moved);
|
|
573
|
+
// Keep the moved tab focused if it was the active one.
|
|
574
|
+
if (n.activeTabIdx === from) n.activeTabIdx = to;
|
|
575
|
+
else if (from < n.activeTabIdx && to >= n.activeTabIdx) n.activeTabIdx -= 1;
|
|
576
|
+
else if (from > n.activeTabIdx && to <= n.activeTabIdx) n.activeTabIdx += 1;
|
|
577
|
+
_syncActiveTab(n);
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Split a leaf in the given direction; existing content stays in the
|
|
583
|
+
* original leaf, a new empty leaf is added next to it. Returns the
|
|
584
|
+
* new leaf id, or null on failure.
|
|
585
|
+
*/
|
|
586
|
+
split(leafId, dir /* 'h' | 'v' */) {
|
|
587
|
+
const leaf = this.get(leafId);
|
|
588
|
+
if (!leaf || leaf.kind !== 'leaf') return null;
|
|
589
|
+
|
|
590
|
+
const newLeaf = makeLeaf();
|
|
591
|
+
// Case 1: root split. Wrap root in a new Split.
|
|
592
|
+
if (!leaf.parentId) {
|
|
593
|
+
const wrap = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
|
|
594
|
+
this._register(newLeaf);
|
|
595
|
+
this._register(wrap);
|
|
596
|
+
this.rootId = wrap.id;
|
|
597
|
+
leaf.parentId = wrap.id;
|
|
598
|
+
newLeaf.parentId = wrap.id;
|
|
599
|
+
this.focus(newLeaf.id);
|
|
600
|
+
return newLeaf.id;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const parent = this.get(leaf.parentId);
|
|
604
|
+
const idx = parent.children.indexOf(leaf.id);
|
|
605
|
+
if (idx < 0) return null;
|
|
606
|
+
|
|
607
|
+
// Case 2: parent already matches the split direction — insert
|
|
608
|
+
// the new leaf as a sibling.
|
|
609
|
+
if (parent.dir === dir) {
|
|
610
|
+
parent.children.splice(idx + 1, 0, newLeaf.id);
|
|
611
|
+
const avg = parent.sizes.reduce((a, b) => a + b, 0) / parent.children.length;
|
|
612
|
+
parent.sizes.splice(idx + 1, 0, avg);
|
|
613
|
+
newLeaf.parentId = parent.id;
|
|
614
|
+
this._register(newLeaf);
|
|
615
|
+
this.focus(newLeaf.id);
|
|
616
|
+
return newLeaf.id;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Case 3: cross-direction — wrap the original leaf in a new
|
|
620
|
+
// Split that contains [leaf, newLeaf]. Replace leaf in parent.
|
|
621
|
+
const wrap = makeSplit({ dir, children: [leaf.id, newLeaf.id] });
|
|
622
|
+
wrap.parentId = parent.id;
|
|
623
|
+
parent.children[idx] = wrap.id;
|
|
624
|
+
leaf.parentId = wrap.id;
|
|
625
|
+
newLeaf.parentId = wrap.id;
|
|
626
|
+
this._register(newLeaf);
|
|
627
|
+
this._register(wrap);
|
|
628
|
+
this.focus(newLeaf.id);
|
|
629
|
+
return newLeaf.id;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Close a leaf, collapsing any single-child splits that result. If
|
|
634
|
+
* the last leaf is closed, leaves an empty tree (no root) — caller
|
|
635
|
+
* is expected to repopulate.
|
|
636
|
+
*/
|
|
637
|
+
close(leafId) {
|
|
638
|
+
const leaf = this.get(leafId);
|
|
639
|
+
if (!leaf || leaf.kind !== 'leaf') return false;
|
|
640
|
+
|
|
641
|
+
// Root leaf — clear the tree.
|
|
642
|
+
if (!leaf.parentId) {
|
|
643
|
+
this.nodes.clear();
|
|
644
|
+
this.rootId = null;
|
|
645
|
+
this.focusedLeafId = null;
|
|
646
|
+
this.focusStack = [];
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const parent = this.get(leaf.parentId);
|
|
651
|
+
const idx = parent.children.indexOf(leaf.id);
|
|
652
|
+
parent.children.splice(idx, 1);
|
|
653
|
+
parent.sizes.splice(idx, 1);
|
|
654
|
+
this.nodes.delete(leaf.id);
|
|
655
|
+
this.focusStack = this.focusStack.filter((id) => id !== leaf.id);
|
|
656
|
+
|
|
657
|
+
// Parent collapse — if a Split has only one child left, replace
|
|
658
|
+
// it with that child in *its* parent (or as root).
|
|
659
|
+
this._collapse(parent);
|
|
660
|
+
|
|
661
|
+
if (this.focusedLeafId === leaf.id) {
|
|
662
|
+
const remaining = this.leaves();
|
|
663
|
+
this.focusedLeafId = remaining.length ? remaining[0].id : null;
|
|
664
|
+
if (this.focusedLeafId) this.focus(this.focusedLeafId);
|
|
665
|
+
}
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
_collapse(node) {
|
|
670
|
+
if (!node || node.kind !== 'split') return;
|
|
671
|
+
if (node.children.length > 1) return;
|
|
672
|
+
if (node.children.length === 0) {
|
|
673
|
+
// Empty split — remove from its parent (or clear root).
|
|
674
|
+
if (!node.parentId) {
|
|
675
|
+
this.nodes.delete(node.id);
|
|
676
|
+
this.rootId = null;
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const gp = this.get(node.parentId);
|
|
680
|
+
const idx = gp.children.indexOf(node.id);
|
|
681
|
+
gp.children.splice(idx, 1);
|
|
682
|
+
gp.sizes.splice(idx, 1);
|
|
683
|
+
this.nodes.delete(node.id);
|
|
684
|
+
this._collapse(gp);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// Exactly one child — promote it.
|
|
688
|
+
const only = this.get(node.children[0]);
|
|
689
|
+
if (!node.parentId) {
|
|
690
|
+
this.rootId = only.id;
|
|
691
|
+
only.parentId = null;
|
|
692
|
+
} else {
|
|
693
|
+
const gp = this.get(node.parentId);
|
|
694
|
+
const idx = gp.children.indexOf(node.id);
|
|
695
|
+
gp.children[idx] = only.id;
|
|
696
|
+
only.parentId = gp.id;
|
|
697
|
+
}
|
|
698
|
+
this.nodes.delete(node.id);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ── Directional focus ──────────────────────────────────────────
|
|
702
|
+
/**
|
|
703
|
+
* Move focus from current leaf in the given direction. Walks up the
|
|
704
|
+
* tree looking for an ancestor split whose direction matches the
|
|
705
|
+
* move (h for left/right, v for up/down) and which has a sibling on
|
|
706
|
+
* the requested side; then descends along the matching edge.
|
|
707
|
+
*/
|
|
708
|
+
focusDir(dir /* 'left' | 'right' | 'up' | 'down' */) {
|
|
709
|
+
const cur = this.focused();
|
|
710
|
+
if (!cur) return false;
|
|
711
|
+
const wantDir = (dir === 'left' || dir === 'right') ? 'h' : 'v';
|
|
712
|
+
const forward = (dir === 'right' || dir === 'down');
|
|
713
|
+
|
|
714
|
+
let child = cur;
|
|
715
|
+
let parent = child.parentId ? this.get(child.parentId) : null;
|
|
716
|
+
while (parent) {
|
|
717
|
+
if (parent.dir === wantDir) {
|
|
718
|
+
const idx = parent.children.indexOf(child.id);
|
|
719
|
+
const sibIdx = forward ? idx + 1 : idx - 1;
|
|
720
|
+
if (sibIdx >= 0 && sibIdx < parent.children.length) {
|
|
721
|
+
const target = this._descendEdge(parent.children[sibIdx],
|
|
722
|
+
wantDir, !forward);
|
|
723
|
+
if (target) {
|
|
724
|
+
this.focus(target);
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
child = parent;
|
|
730
|
+
parent = child.parentId ? this.get(child.parentId) : null;
|
|
731
|
+
}
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Walk down to a leaf. For the matching direction we take the
|
|
737
|
+
* `edge` end (first/last). For the orthogonal direction we just
|
|
738
|
+
* take the first child.
|
|
739
|
+
*/
|
|
740
|
+
_descendEdge(nodeId, matchDir, takeFirst) {
|
|
741
|
+
let n = this.get(nodeId);
|
|
742
|
+
while (n && n.kind !== 'leaf') {
|
|
743
|
+
const idx = n.dir === matchDir
|
|
744
|
+
? (takeFirst ? 0 : n.children.length - 1)
|
|
745
|
+
: 0;
|
|
746
|
+
n = this.get(n.children[idx]);
|
|
747
|
+
}
|
|
748
|
+
return n ? n.id : null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── Move tile ──────────────────────────────────────────────────
|
|
752
|
+
/**
|
|
753
|
+
* Move the focused leaf one position in the given direction. Simple
|
|
754
|
+
* version: swap with the immediate sibling in the matching parent;
|
|
755
|
+
* if no such sibling, no-op (deep moves come later if needed).
|
|
756
|
+
*/
|
|
757
|
+
moveDir(dir) {
|
|
758
|
+
const cur = this.focused();
|
|
759
|
+
if (!cur || !cur.parentId) return false;
|
|
760
|
+
const wantDir = (dir === 'left' || dir === 'right') ? 'h' : 'v';
|
|
761
|
+
const forward = (dir === 'right' || dir === 'down');
|
|
762
|
+
|
|
763
|
+
let child = cur;
|
|
764
|
+
let parent = this.get(child.parentId);
|
|
765
|
+
while (parent && parent.dir !== wantDir) {
|
|
766
|
+
child = parent;
|
|
767
|
+
parent = child.parentId ? this.get(child.parentId) : null;
|
|
768
|
+
}
|
|
769
|
+
if (!parent) return false;
|
|
770
|
+
const idx = parent.children.indexOf(child.id);
|
|
771
|
+
const swap = forward ? idx + 1 : idx - 1;
|
|
772
|
+
if (swap < 0 || swap >= parent.children.length) return false;
|
|
773
|
+
[parent.children[idx], parent.children[swap]] =
|
|
774
|
+
[parent.children[swap], parent.children[idx]];
|
|
775
|
+
[parent.sizes[idx], parent.sizes[swap]] =
|
|
776
|
+
[parent.sizes[swap], parent.sizes[idx]];
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ── Resize ──────────────────────────────────────────────────────
|
|
781
|
+
setSplitSizes(splitId, sizes) {
|
|
782
|
+
const n = this.get(splitId);
|
|
783
|
+
if (!n || n.kind !== 'split') return false;
|
|
784
|
+
if (sizes.length !== n.children.length) return false;
|
|
785
|
+
n.sizes = sizes.slice();
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Serialization (for desktops persistence) ──────────────────
|
|
790
|
+
serialize() {
|
|
791
|
+
if (!this.rootId) return null;
|
|
792
|
+
const dump = (id) => {
|
|
793
|
+
const n = this.get(id);
|
|
794
|
+
if (n.kind === 'leaf') {
|
|
795
|
+
// Transient tabs (add-row forms etc.) are not persisted.
|
|
796
|
+
const liveTabs = (Array.isArray(n.tabs) ? n.tabs : [])
|
|
797
|
+
.filter((t) => !t.transient)
|
|
798
|
+
.map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
|
|
799
|
+
return {
|
|
800
|
+
kind: 'leaf',
|
|
801
|
+
title: n.title,
|
|
802
|
+
content: n.content,
|
|
803
|
+
tabs: liveTabs,
|
|
804
|
+
activeTabIdx: Math.max(0, Math.min(liveTabs.length - 1, n.activeTabIdx || 0)),
|
|
805
|
+
// Page-archive: per-top-nav-kind saved tab lists so
|
|
806
|
+
// switching to a previously-visited page restores
|
|
807
|
+
// its tabs across reloads.
|
|
808
|
+
pageTabs: n.pageTabs
|
|
809
|
+
? Object.fromEntries(
|
|
810
|
+
Object.entries(n.pageTabs).map(([k, v]) => {
|
|
811
|
+
const pt = (v.tabs || [])
|
|
812
|
+
.filter((t) => !t.transient)
|
|
813
|
+
.map((t) => ({ kind: t.kind, props: t.props, title: t.title }));
|
|
814
|
+
return [k, {
|
|
815
|
+
tabs: pt,
|
|
816
|
+
activeTabIdx: Math.max(0, Math.min(pt.length - 1, v.activeTabIdx || 0)),
|
|
817
|
+
}];
|
|
818
|
+
}))
|
|
819
|
+
: {},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
kind: 'split', dir: n.dir, sizes: n.sizes.slice(),
|
|
824
|
+
children: n.children.map(dump),
|
|
825
|
+
};
|
|
826
|
+
};
|
|
827
|
+
return {
|
|
828
|
+
root: dump(this.rootId),
|
|
829
|
+
focusedTitle: this.focused()?.title || null,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
static deserialize(blob) {
|
|
834
|
+
const t = new TileTree();
|
|
835
|
+
if (!blob || !blob.root) return t;
|
|
836
|
+
const build = (raw) => {
|
|
837
|
+
if (raw.kind === 'leaf') {
|
|
838
|
+
// Forward-compat with the pre-tabs layout: an old
|
|
839
|
+
// serialized leaf carries `content` but no `tabs`.
|
|
840
|
+
// makeLeaf will derive a single-tab list from `content`.
|
|
841
|
+
const leaf = makeLeaf({ content: raw.content, title: raw.title });
|
|
842
|
+
if (Array.isArray(raw.tabs) && raw.tabs.length > 0) {
|
|
843
|
+
leaf.tabs = raw.tabs.map((tab) => ({
|
|
844
|
+
kind: tab.kind,
|
|
845
|
+
props: tab.props || {},
|
|
846
|
+
title: tab.title || tab.kind || '',
|
|
847
|
+
}));
|
|
848
|
+
leaf.activeTabIdx = Math.max(0,
|
|
849
|
+
Math.min(leaf.tabs.length - 1, raw.activeTabIdx || 0));
|
|
850
|
+
_syncActiveTab(leaf);
|
|
851
|
+
}
|
|
852
|
+
if (raw.pageTabs && typeof raw.pageTabs === 'object') {
|
|
853
|
+
leaf.pageTabs = {};
|
|
854
|
+
for (const [k, v] of Object.entries(raw.pageTabs)) {
|
|
855
|
+
if (!v || !Array.isArray(v.tabs)) continue;
|
|
856
|
+
leaf.pageTabs[k] = {
|
|
857
|
+
tabs: v.tabs.map((t) => ({
|
|
858
|
+
kind: t.kind,
|
|
859
|
+
props: t.props || {},
|
|
860
|
+
title: t.title || t.kind || '',
|
|
861
|
+
})),
|
|
862
|
+
activeTabIdx: v.activeTabIdx || 0,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return leaf;
|
|
867
|
+
}
|
|
868
|
+
const children = raw.children.map(build);
|
|
869
|
+
const split = makeSplit({
|
|
870
|
+
dir: raw.dir, children: children.map((c) => c.id),
|
|
871
|
+
sizes: raw.sizes,
|
|
872
|
+
});
|
|
873
|
+
children.forEach((c) => t._register(c));
|
|
874
|
+
return split;
|
|
875
|
+
};
|
|
876
|
+
const root = build(blob.root);
|
|
877
|
+
t.setRoot(root);
|
|
878
|
+
return t;
|
|
879
|
+
}
|
|
880
|
+
}
|