flexdesk 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/LICENSE +0 -0
- package/README.md +2261 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3373 -238
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/chunk-UWO43BZU.js +111 -0
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3373 -238
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +1131 -253
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3254 -2112
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -345
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +12 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +38 -1
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +26 -4
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-CWPMLX67.js +0 -475
- package/dist/chunk-CWPMLX67.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
package/src/tiling/wm.js
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* wm.closeFocused() close the focused tile
|
|
10
10
|
* wm.focusDir(dir) / wm.moveFocused(dir)
|
|
11
11
|
* wm.toggleManagedFocused() tile <-> managed window
|
|
12
|
-
* wm.togglePanel(side) left | right | bottom
|
|
13
|
-
* wm.isPanelOpen(side) boolean
|
|
12
|
+
* wm.togglePanel(side) left | right | bottom; false if withheld
|
|
13
|
+
* wm.isPanelOpen(side) boolean (open AND available)
|
|
14
|
+
* wm.isPanelAvailable(side) boolean
|
|
15
|
+
* wm.getPanelAvailability(side) { available, reason }
|
|
16
|
+
* wm.setPanelAvailability(side, o) runtime override; null clears it
|
|
17
|
+
* wm.onPanelAvailabilityChange(fn) subscribe; returns an unsubscribe
|
|
14
18
|
* wm.switchDesktop(idx) / wm.addDesktop() / wm.moveFocusedToDesktop(idx)
|
|
15
19
|
*/
|
|
16
20
|
|
|
@@ -22,6 +26,7 @@ import { installPanelKeyRouter } from './panel_keys.js';
|
|
|
22
26
|
import { ManagedWindow } from '../ui/components/managed_window.js';
|
|
23
27
|
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
24
28
|
import { createTabStrip } from './tab_strip.js';
|
|
29
|
+
import { PANEL_OFFERED, PANEL_SIDES, assertPanelSide, readPanelOffer } from './panel_availability.js';
|
|
25
30
|
|
|
26
31
|
const PANEL_KINDS = new Set(['panel:left', 'panel:right', 'panel:bottom']);
|
|
27
32
|
|
|
@@ -58,6 +63,17 @@ export class WindowManager {
|
|
|
58
63
|
this.ctx = ctx || {};
|
|
59
64
|
this.eventBus = eventBus || null;
|
|
60
65
|
this.onChange = onChange || (() => {});
|
|
66
|
+
// Panel availability. Runtime overrides (`setPanelAvailability`) win
|
|
67
|
+
// over the primary kind's declaration (`KindDef.panels`); the last
|
|
68
|
+
// availability announced, so a change is announced once; and who
|
|
69
|
+
// wants to hear about it. Before anything is mounted every side is
|
|
70
|
+
// offered, so a first primary that withholds one is announced.
|
|
71
|
+
this._panelOverrides = {};
|
|
72
|
+
this._panelAvailability = Object.fromEntries(PANEL_SIDES.map((s) => [s, PANEL_OFFERED]));
|
|
73
|
+
this._panelAvailabilityListeners = new Set();
|
|
74
|
+
this._reconcilingPanels = false;
|
|
75
|
+
/** >0 while a composite verb runs: see `_revealAt`. */
|
|
76
|
+
this._quietDepth = 0;
|
|
61
77
|
// C34. Whether the tile's float gesture takes only the tab on screen.
|
|
62
78
|
this.floatActiveTab = !!floatActiveTab;
|
|
63
79
|
|
|
@@ -132,6 +148,7 @@ export class WindowManager {
|
|
|
132
148
|
},
|
|
133
149
|
onFocusChange: () => this._notifyChange(),
|
|
134
150
|
onAfterRender: () => this._rehomeContainedWindows(),
|
|
151
|
+
onSplitResized: (splitId) => this._onSplitResized(splitId),
|
|
135
152
|
});
|
|
136
153
|
this._persistTimer = null;
|
|
137
154
|
// Window-id → leaf-id mapping so a managed-window demote can
|
|
@@ -201,6 +218,13 @@ export class WindowManager {
|
|
|
201
218
|
* so other surfaces (palette, top-bar toggles, page shortcuts) can
|
|
202
219
|
* subscribe through the existing event system. */
|
|
203
220
|
_notifyChange(reason = null) {
|
|
221
|
+
// A composite verb (a single-kind reveal) is ONE change: the verbs it
|
|
222
|
+
// is built from stay quiet while it runs, and it notifies once.
|
|
223
|
+
if (this._quietDepth > 0) return;
|
|
224
|
+
// Whatever changed may have changed the primary kind, and with it which
|
|
225
|
+
// panels are offered: put the tree and the announcement right FIRST,
|
|
226
|
+
// so every listener below sees the panels as they now are.
|
|
227
|
+
this._reconcilePanels();
|
|
204
228
|
try { this.onChange(reason); } catch (err) { console.error('[wm] onChange threw', err); }
|
|
205
229
|
try { this.eventBus?.emit?.('wm:changed', { reason, wm: this }); }
|
|
206
230
|
catch (err) { console.warn('[wm] event emit failed', err); }
|
|
@@ -244,10 +268,13 @@ export class WindowManager {
|
|
|
244
268
|
}
|
|
245
269
|
}
|
|
246
270
|
}
|
|
247
|
-
this._canonicalize(d.tree, d);
|
|
248
271
|
}
|
|
272
|
+
// A single kind is open once, whatever the saved layout says.
|
|
273
|
+
const deduped = this._dropDuplicateSingles();
|
|
274
|
+
for (const d of this.desktops.desktops) this._canonicalize(d.tree, d);
|
|
249
275
|
this.renderer.tree = this.desktops.active().tree;
|
|
250
276
|
this.renderer.render();
|
|
277
|
+
if (deduped) this._persist();
|
|
251
278
|
this._notifyChange();
|
|
252
279
|
}
|
|
253
280
|
|
|
@@ -259,6 +286,275 @@ export class WindowManager {
|
|
|
259
286
|
}, 500);
|
|
260
287
|
}
|
|
261
288
|
|
|
289
|
+
// ── Single-instance kinds (KindDef `single`) ────────────────────
|
|
290
|
+
//
|
|
291
|
+
// Some content holds heavy app-wide state and must exist once. The
|
|
292
|
+
// taxonomy says which kinds (`single: true`, or `single: '<prop>'` for one
|
|
293
|
+
// per value of that prop); the WM keeps three promises about them:
|
|
294
|
+
//
|
|
295
|
+
// - every OPEN path reveals the instance that exists — it switches to
|
|
296
|
+
// its desktop, activates its tab (restoring an archived page if that is
|
|
297
|
+
// where it is) and focuses its tile, or raises its window — instead of
|
|
298
|
+
// mounting a second one;
|
|
299
|
+
// - the COPY verbs (Alt+T / Alt+N / Alt+Shift+H / Alt+Shift+V, the tile
|
|
300
|
+
// menu's "Open in new tab" and "Open a copy in a window", the palette's
|
|
301
|
+
// split and window modes) are refused, the menus greyed with a reason;
|
|
302
|
+
// - a restored layout holding duplicates keeps the FIRST (desktop order,
|
|
303
|
+
// then tile order; live tabs before archived pages).
|
|
304
|
+
//
|
|
305
|
+
// MOVE verbs (float, dock, drag a tab, move to desktop) carry the one
|
|
306
|
+
// instance somewhere else and are untouched.
|
|
307
|
+
|
|
308
|
+
/** The instance key of a tab spec, or null when its kind is not single. */
|
|
309
|
+
_instanceKey(kind, props) {
|
|
310
|
+
return this.taxonomy.instanceKey(kind, props);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Whether `kind` exists once, and so refuses the copy verbs. */
|
|
314
|
+
isSingleKind(kind) {
|
|
315
|
+
return this.taxonomy.isSingle(kind);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Where the one instance of a single kind is open, or null — also null
|
|
319
|
+
* for a kind that is not single. One of:
|
|
320
|
+
* `{ where: 'window', windowId, idx, desktopIdx }`
|
|
321
|
+
* `{ where: 'tab', desktopIdx, leafId, idx }`
|
|
322
|
+
* `{ where: 'archive', desktopIdx, leafId, page, idx }` — a tab of a
|
|
323
|
+
* top-nav page the tile has switched away from (`pageTabs`). */
|
|
324
|
+
findInstance(kind, props = {}) {
|
|
325
|
+
const key = this._instanceKey(kind, props);
|
|
326
|
+
if (key == null) return null;
|
|
327
|
+
const same = (t) => !!t && this._instanceKey(t.kind, t.props) === key;
|
|
328
|
+
for (const [windowId, rec] of this._windowToLeaf) {
|
|
329
|
+
const idx = _windowTabSpecs(rec).findIndex(same);
|
|
330
|
+
if (idx >= 0) return { where: 'window', windowId, idx, desktopIdx: rec.desktopIdx };
|
|
331
|
+
}
|
|
332
|
+
const desktops = this.desktops.desktops;
|
|
333
|
+
for (const [desktopIdx, d] of desktops.entries()) {
|
|
334
|
+
for (const leaf of d.tree.leaves()) {
|
|
335
|
+
const idx = (leaf.tabs || []).findIndex(same);
|
|
336
|
+
if (idx >= 0) return { where: 'tab', desktopIdx, leafId: leaf.id, idx };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const [desktopIdx, d] of desktops.entries()) {
|
|
340
|
+
for (const leaf of d.tree.leaves()) {
|
|
341
|
+
for (const [page, arc] of this._archivedPages(leaf)) {
|
|
342
|
+
const idx = arc.tabs.findIndex(same);
|
|
343
|
+
if (idx >= 0) return { where: 'archive', desktopIdx, leafId: leaf.id, page, idx };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Reveal the instance of a single kind that is open: answers whether
|
|
351
|
+
* there was one. Every open path asks this first. */
|
|
352
|
+
revealInstance(kind, props = {}) {
|
|
353
|
+
const loc = this.findInstance(kind, props);
|
|
354
|
+
return loc ? this._revealAt(loc) : false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A leaf's archived pages that can come back — every `pageTabs` entry
|
|
358
|
+
* but the CURRENT page's. `swapToPage` restores an archive without
|
|
359
|
+
* deleting it, so the current page's entry is a stale copy of the live
|
|
360
|
+
* tabs, overwritten the next time the tile leaves the page; counting it
|
|
361
|
+
* would find every live instance twice. */
|
|
362
|
+
_archivedPages(leaf) {
|
|
363
|
+
const kind = leaf?.content?.kind;
|
|
364
|
+
const current = kind ? this.taxonomy.topNavFor(kind) || null : null;
|
|
365
|
+
return Object.entries(leaf?.pageTabs || {})
|
|
366
|
+
.filter(([page, arc]) => page !== current && Array.isArray(arc?.tabs));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Reveal `loc` with ONE change notification. The verbs a reveal is made
|
|
370
|
+
* of — `showWindowTab`, `switchDesktop` (directly or via `revealWindow`)
|
|
371
|
+
* — each notify on their own; inside a reveal they are quiet, and the
|
|
372
|
+
* reveal notifies once, as every other verb does. */
|
|
373
|
+
_revealAt(loc) {
|
|
374
|
+
this._quietDepth += 1;
|
|
375
|
+
let shown = false;
|
|
376
|
+
try { shown = this._revealAtQuietly(loc); }
|
|
377
|
+
finally { this._quietDepth -= 1; }
|
|
378
|
+
if (shown) this._notifyChange('single-reveal');
|
|
379
|
+
return shown;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
_revealAtQuietly(loc) {
|
|
383
|
+
if (loc.where === 'window') {
|
|
384
|
+
const rec = this._windowToLeaf.get(loc.windowId);
|
|
385
|
+
if (!rec) return false;
|
|
386
|
+
if (Array.isArray(rec.tabs) && rec.tabs.length) this.showWindowTab(loc.windowId, loc.idx);
|
|
387
|
+
// A window contained to a pane lives on that pane's desktop;
|
|
388
|
+
// `revealWindow` switches there. An uncontained one floats over
|
|
389
|
+
// every desktop and is already on screen.
|
|
390
|
+
this.revealWindow(rec.window);
|
|
391
|
+
// `show()` restores a minimised window and raises a visible one.
|
|
392
|
+
try { rec.window.show(); } catch (err) { console.warn('[wm] reveal: show failed', err); }
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
const tree = this.desktops.desktops[loc.desktopIdx]?.tree;
|
|
396
|
+
const leaf = tree?.get(loc.leafId);
|
|
397
|
+
if (!leaf || leaf.kind !== 'leaf') return false;
|
|
398
|
+
if (loc.where === 'archive') {
|
|
399
|
+
const tab = leaf.pageTabs[loc.page].tabs[loc.idx];
|
|
400
|
+
const key = this._instanceKey(tab.kind, tab.props);
|
|
401
|
+
const curKind = leaf.content?.kind;
|
|
402
|
+
const current = curKind ? this.taxonomy.topNavFor(curKind) || null : null;
|
|
403
|
+
this._swapToPage(tree, loc.leafId,
|
|
404
|
+
{ kind: tab.kind, props: tab.props || {}, title: tab.title },
|
|
405
|
+
current, loc.page, { recordHistory: false });
|
|
406
|
+
const at = (leaf.tabs || []).findIndex((t) => this._instanceKey(t.kind, t.props) === key);
|
|
407
|
+
if (at >= 0) tree.setActiveLeafTab(loc.leafId, at);
|
|
408
|
+
} else {
|
|
409
|
+
tree.setActiveLeafTab(loc.leafId, loc.idx);
|
|
410
|
+
}
|
|
411
|
+
tree.focus(loc.leafId);
|
|
412
|
+
// The tree is settled BEFORE the switch, so the switch's one repaint
|
|
413
|
+
// mounts the instance and nothing else first.
|
|
414
|
+
if (loc.desktopIdx !== this.desktops.activeIdx) {
|
|
415
|
+
this.switchDesktop(loc.desktopIdx);
|
|
416
|
+
} else {
|
|
417
|
+
this.renderer.render();
|
|
418
|
+
this._persist();
|
|
419
|
+
}
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** `TileTree.swapToPage`, and then no restored tab is a second copy of a
|
|
424
|
+
* single kind. Every page restore in the WM goes through here.
|
|
425
|
+
*
|
|
426
|
+
* A page archive (`pageTabs`) is a snapshot: the instance it lists can
|
|
427
|
+
* have moved to another tile or a window since — and the current page's
|
|
428
|
+
* own entry is a stale copy of the live tabs that comes back into play as
|
|
429
|
+
* soon as the tile leaves the page by a path other than `swapToPage`
|
|
430
|
+
* (`navigateActiveTab`). So a restored tab whose instance is live
|
|
431
|
+
* anywhere else is dropped from the tile and from the restored archive;
|
|
432
|
+
* the instance stays where it is. */
|
|
433
|
+
_swapToPage(tree, leafId, target, currentTopNav, targetTopNav, opts) {
|
|
434
|
+
const done = tree.swapToPage(leafId, target, currentTopNav, targetTopNav, opts);
|
|
435
|
+
if (done) this._dropRestoredCopies(tree, leafId, targetTopNav, target);
|
|
436
|
+
return done;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
_dropRestoredCopies(tree, leafId, page, target) {
|
|
440
|
+
const leaf = tree.get(leafId);
|
|
441
|
+
const tabs = Array.isArray(leaf?.tabs) ? leaf.tabs : null;
|
|
442
|
+
if (!tabs) return;
|
|
443
|
+
const elsewhere = this._liveInstanceKeys(tree, leafId);
|
|
444
|
+
const keeper = () => {
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
return (t) => {
|
|
447
|
+
const k = t ? this._instanceKey(t.kind, t.props) : null;
|
|
448
|
+
if (k == null) return true;
|
|
449
|
+
if (elsewhere.has(k) || seen.has(k)) return false;
|
|
450
|
+
seen.add(k);
|
|
451
|
+
return true;
|
|
452
|
+
};
|
|
453
|
+
};
|
|
454
|
+
const arc = leaf.pageTabs?.[page];
|
|
455
|
+
if (arc && Array.isArray(arc.tabs)) {
|
|
456
|
+
const kept = arc.tabs.filter(keeper());
|
|
457
|
+
if (kept.length !== arc.tabs.length) {
|
|
458
|
+
arc.tabs = kept;
|
|
459
|
+
arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const keep = keeper();
|
|
463
|
+
let active = leaf.activeTabIdx || 0;
|
|
464
|
+
let changed = false;
|
|
465
|
+
for (let i = 0; i < tabs.length;) {
|
|
466
|
+
if (keep(tabs[i])) { i++; continue; }
|
|
467
|
+
tabs.splice(i, 1);
|
|
468
|
+
if (i < active) active -= 1;
|
|
469
|
+
changed = true;
|
|
470
|
+
}
|
|
471
|
+
if (!changed) return;
|
|
472
|
+
if (!tabs.length) {
|
|
473
|
+
// Everything the archive held lives elsewhere: the tile shows
|
|
474
|
+
// what was asked for. (A single target that is live elsewhere was
|
|
475
|
+
// revealed before any restore, so this tab is never a copy.)
|
|
476
|
+
tabs.push({
|
|
477
|
+
kind: target.kind, props: target.props || {},
|
|
478
|
+
title: target.title || _tabTitle(target.kind, target.props), history: [],
|
|
479
|
+
});
|
|
480
|
+
active = 0;
|
|
481
|
+
}
|
|
482
|
+
tree.setActiveLeafTab(leafId, Math.max(0, Math.min(tabs.length - 1, active)));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The instance keys of every LIVE tab — windows, and every leaf of every
|
|
486
|
+
* desktop but `leafId` of `tree`. Archived pages are not live. */
|
|
487
|
+
_liveInstanceKeys(tree, leafId) {
|
|
488
|
+
const keys = new Set();
|
|
489
|
+
const add = (t) => {
|
|
490
|
+
const k = t ? this._instanceKey(t.kind, t.props) : null;
|
|
491
|
+
if (k != null) keys.add(k);
|
|
492
|
+
};
|
|
493
|
+
for (const [, rec] of this._windowToLeaf) _windowTabSpecs(rec).forEach(add);
|
|
494
|
+
for (const d of this.desktops.desktops) {
|
|
495
|
+
for (const leaf of d.tree.leaves()) {
|
|
496
|
+
if (d.tree === tree && leaf.id === leafId) continue;
|
|
497
|
+
(leaf.tabs || []).forEach(add);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return keys;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** A restored layout keeps the FIRST instance of each single kind: later
|
|
504
|
+
* live tabs are dropped (the tile shows the tab that took the dropped
|
|
505
|
+
* one's place, or closes when it held nothing else), and so are later
|
|
506
|
+
* archived ones. Answers whether anything was dropped. */
|
|
507
|
+
_dropDuplicateSingles() {
|
|
508
|
+
const seen = new Set();
|
|
509
|
+
let dropped = false;
|
|
510
|
+
const keep = (t) => {
|
|
511
|
+
const k = t ? this._instanceKey(t.kind, t.props) : null;
|
|
512
|
+
if (k == null) return true;
|
|
513
|
+
if (seen.has(k)) return false;
|
|
514
|
+
seen.add(k);
|
|
515
|
+
return true;
|
|
516
|
+
};
|
|
517
|
+
for (const d of this.desktops.desktops) {
|
|
518
|
+
const tree = d.tree;
|
|
519
|
+
for (const leaf of tree.leaves()) {
|
|
520
|
+
const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
|
|
521
|
+
let active = leaf.activeTabIdx || 0;
|
|
522
|
+
let changed = false;
|
|
523
|
+
for (let i = 0; i < tabs.length;) {
|
|
524
|
+
if (keep(tabs[i])) { i++; continue; }
|
|
525
|
+
tabs.splice(i, 1);
|
|
526
|
+
if (i < active) active -= 1;
|
|
527
|
+
changed = true;
|
|
528
|
+
}
|
|
529
|
+
if (!changed) continue;
|
|
530
|
+
dropped = true;
|
|
531
|
+
if (tabs.length) {
|
|
532
|
+
tree.setActiveLeafTab(leaf.id, Math.max(0, Math.min(tabs.length - 1, active)));
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
tree.close(leaf.id);
|
|
536
|
+
if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
|
|
537
|
+
if (!tree.leaves().some((l) => !String(l.content?.kind || '').startsWith('panel:'))) {
|
|
538
|
+
const spawned = this._spawnContentLeaf(tree);
|
|
539
|
+
if (spawned) this._seedHome(tree, spawned);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
for (const d of this.desktops.desktops) {
|
|
544
|
+
for (const leaf of d.tree.leaves()) {
|
|
545
|
+
for (const [page, arc] of this._archivedPages(leaf)) {
|
|
546
|
+
const kept = arc.tabs.filter(keep);
|
|
547
|
+
if (kept.length === arc.tabs.length) continue;
|
|
548
|
+
dropped = true;
|
|
549
|
+
if (!kept.length) { delete leaf.pageTabs[page]; continue; }
|
|
550
|
+
arc.tabs = kept;
|
|
551
|
+
arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return dropped;
|
|
556
|
+
}
|
|
557
|
+
|
|
262
558
|
// ── Content loading ─────────────────────────────────────────────
|
|
263
559
|
/** Load content into the primary tile (last-active non-panel leaf).
|
|
264
560
|
/** Walk back one step. Bound to Backspace.
|
|
@@ -485,13 +781,16 @@ export class WindowManager {
|
|
|
485
781
|
const tree = this._tree();
|
|
486
782
|
const prior = tree.popActiveTabHistory?.(id);
|
|
487
783
|
if (!prior || !prior.kind) return false;
|
|
784
|
+
// A single kind opened elsewhere since this entry was pushed is
|
|
785
|
+
// revealed there, not put back here as a second copy.
|
|
786
|
+
if (this.revealInstance(prior.kind, prior.props || {})) return true;
|
|
488
787
|
// Cross-page back entries carry a `topNav` marker — pop
|
|
489
788
|
// routes via swapToPage so we land on the correct page.
|
|
490
789
|
const curKind = tree.get(id)?.content?.kind;
|
|
491
790
|
const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
|
|
492
791
|
const title = prior.title || _tabTitle(prior.kind, prior.props);
|
|
493
792
|
if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
|
|
494
|
-
|
|
793
|
+
this._swapToPage(tree, id, { kind: prior.kind, props: prior.props || {}, title },
|
|
495
794
|
curTopNav, prior.topNav, { recordHistory: false });
|
|
496
795
|
} else {
|
|
497
796
|
tree.replaceActiveTabContent(id, { kind: prior.kind, props: prior.props || {} },
|
|
@@ -593,6 +892,7 @@ export class WindowManager {
|
|
|
593
892
|
* the leaf. Used by Backspace + breadcrumb segments + anything
|
|
594
893
|
* else that should walk WITHIN the tile rather than reset it. */
|
|
595
894
|
navigateActiveTab(leafId, kind, props = {}) {
|
|
895
|
+
if (this.revealInstance(kind, props)) return;
|
|
596
896
|
const tree = this._tree();
|
|
597
897
|
const leaf = tree.get(leafId);
|
|
598
898
|
if (!leaf || leaf.kind !== 'leaf') {
|
|
@@ -648,6 +948,7 @@ export class WindowManager {
|
|
|
648
948
|
* tab feature shipped), the behavior is identical to the old
|
|
649
949
|
* setLeafContent: one tab in, one tab out. */
|
|
650
950
|
openInLeaf(leafId, kind, props = {}) {
|
|
951
|
+
if (this.revealInstance(kind, props)) return;
|
|
651
952
|
const tree = this._tree();
|
|
652
953
|
const leaf = tree.get(leafId);
|
|
653
954
|
if (!leaf || leaf.kind !== 'leaf') {
|
|
@@ -665,6 +966,7 @@ export class WindowManager {
|
|
|
665
966
|
* previous mount, mounts the new kind into the same body element,
|
|
666
967
|
* and updates the window's title. */
|
|
667
968
|
openInWindow(winId, kind, props = {}) {
|
|
969
|
+
if (this.revealInstance(kind, props)) return;
|
|
668
970
|
const rec = this._windowToLeaf.get(winId);
|
|
669
971
|
if (!rec) { this.openInPrimary(kind, props); return; }
|
|
670
972
|
try { rec.mountInfo?.destroy?.(); } catch {}
|
|
@@ -706,6 +1008,7 @@ export class WindowManager {
|
|
|
706
1008
|
* If only panel tiles exist (or the tree is empty), spawn a new
|
|
707
1009
|
* content leaf and canonicalize so the panels wrap it. */
|
|
708
1010
|
openInPrimary(kind, props = {}) {
|
|
1011
|
+
if (this.revealInstance(kind, props)) return;
|
|
709
1012
|
const tree = this._tree();
|
|
710
1013
|
let leafId = tree.primaryLeafId();
|
|
711
1014
|
let spawned = false;
|
|
@@ -726,7 +1029,7 @@ export class WindowManager {
|
|
|
726
1029
|
: null;
|
|
727
1030
|
const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
|
|
728
1031
|
const title = _tabTitle(kind, props);
|
|
729
|
-
|
|
1032
|
+
this._swapToPage(tree, leafId,
|
|
730
1033
|
{ kind, props, title },
|
|
731
1034
|
currentTopNav, targetTopNav);
|
|
732
1035
|
// C35. THE SAME PAGE WITH DIFFERENT PROPS IS A DIFFERENT VIEW. swapToPage
|
|
@@ -788,6 +1091,7 @@ export class WindowManager {
|
|
|
788
1091
|
* pane in a split never dismounts the original). Returns the new
|
|
789
1092
|
* leaf id, or null if the leaf can't be split. */
|
|
790
1093
|
splitLeafWith(leafId, dir, kind, props = {}, title = '') {
|
|
1094
|
+
if (this.revealInstance(kind, props)) return null;
|
|
791
1095
|
const tree = this._tree();
|
|
792
1096
|
leafId = this._contentLeafId(leafId);
|
|
793
1097
|
const src = leafId ? tree.get(leafId) : null;
|
|
@@ -826,10 +1130,11 @@ export class WindowManager {
|
|
|
826
1130
|
return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
|
|
827
1131
|
}
|
|
828
1132
|
|
|
829
|
-
/** Alt+T — open the focused tile's content in a new tab on that tile.
|
|
1133
|
+
/** Alt+T — open the focused tile's content in a new tab on that tile.
|
|
1134
|
+
* A copy verb, so a single kind refuses it (and Alt+N, Alt+Shift+H/V). */
|
|
830
1135
|
openFocusedInTab() {
|
|
831
1136
|
const c = this._focusedContent();
|
|
832
|
-
if (!c) return;
|
|
1137
|
+
if (!c || this.isSingleKind(c.kind)) return;
|
|
833
1138
|
this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
|
|
834
1139
|
}
|
|
835
1140
|
|
|
@@ -837,7 +1142,7 @@ export class WindowManager {
|
|
|
837
1142
|
* Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
|
|
838
1143
|
openFocusedInWindow() {
|
|
839
1144
|
const c = this._focusedContent();
|
|
840
|
-
if (!c) return;
|
|
1145
|
+
if (!c || this.isSingleKind(c.kind)) return;
|
|
841
1146
|
this._navigateWindow(c.kind, c.props);
|
|
842
1147
|
}
|
|
843
1148
|
|
|
@@ -847,6 +1152,7 @@ export class WindowManager {
|
|
|
847
1152
|
splitFocusedWith(dir) {
|
|
848
1153
|
const c = this._focusedContent();
|
|
849
1154
|
if (!c) { this.split(dir); return; }
|
|
1155
|
+
if (this.isSingleKind(c.kind)) return;
|
|
850
1156
|
this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
|
|
851
1157
|
}
|
|
852
1158
|
|
|
@@ -3138,16 +3444,97 @@ export class WindowManager {
|
|
|
3138
3444
|
* edge (a recursive split) or closes that leaf. */
|
|
3139
3445
|
isPanelOpen(side) {
|
|
3140
3446
|
const d = this.desktops.active();
|
|
3141
|
-
return !!d.panels[side];
|
|
3447
|
+
return !!d.panels[side] && this.isPanelAvailable(side);
|
|
3142
3448
|
}
|
|
3143
3449
|
|
|
3450
|
+
/** Flip a panel. A withheld panel is not flipped: answers `false` and
|
|
3451
|
+
* touches nothing — not the remembered open state, not the layout, not
|
|
3452
|
+
* what is persisted. Answers `true` when it flipped. */
|
|
3144
3453
|
togglePanel(side) {
|
|
3454
|
+
if (!this.isPanelAvailable(side)) return false;
|
|
3145
3455
|
const d = this.desktops.active();
|
|
3146
3456
|
d.panels[side] = !d.panels[side];
|
|
3147
3457
|
this._canonicalize(this._tree(), d);
|
|
3148
3458
|
this.renderer.render();
|
|
3149
3459
|
this._persist();
|
|
3150
3460
|
this._notifyChange();
|
|
3461
|
+
return true;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
// ── Panel availability ──────────────────────────────────────────
|
|
3465
|
+
/** Whether the panel on `side` is offered on the active desktop right now:
|
|
3466
|
+
* `{ available, reason }`. A runtime override wins; else the primary
|
|
3467
|
+
* tile's kind says (`KindDef.panels`, see kind_taxonomy.js); else it is
|
|
3468
|
+
* offered. `desktops.panels` is untouched by any of this — it remembers
|
|
3469
|
+
* what the user OPENED, so a panel withheld while it was open comes back
|
|
3470
|
+
* open once it is offered again. */
|
|
3471
|
+
getPanelAvailability(side, tree = this._tree()) {
|
|
3472
|
+
assertPanelSide(side, 'getPanelAvailability');
|
|
3473
|
+
const override = this._panelOverrides[side];
|
|
3474
|
+
if (override) return override;
|
|
3475
|
+
const id = tree.primaryLeafId();
|
|
3476
|
+
const content = id ? tree.get(id)?.content : null;
|
|
3477
|
+
// A tile holding a floated window's place still stands for that content.
|
|
3478
|
+
const kind = content?.kind === PLACEHOLDER_KIND
|
|
3479
|
+
? content.props?.originalKind
|
|
3480
|
+
: content?.kind;
|
|
3481
|
+
return this.taxonomy.panelOffer(kind, side);
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
isPanelAvailable(side) {
|
|
3485
|
+
return this.getPanelAvailability(side).available;
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
/** Content's say, at runtime: `offer` is `true`, `false` or `{ reason }`
|
|
3489
|
+
* and beats the kind's declaration until it is cleared with `null`. The
|
|
3490
|
+
* override belongs to the shell, not to a tile — content that sets one
|
|
3491
|
+
* clears it when it goes (its `destroy`). */
|
|
3492
|
+
setPanelAvailability(side, offer) {
|
|
3493
|
+
assertPanelSide(side, 'setPanelAvailability');
|
|
3494
|
+
if (offer === null || offer === undefined) delete this._panelOverrides[side];
|
|
3495
|
+
else this._panelOverrides[side] = readPanelOffer(offer, 'setPanelAvailability');
|
|
3496
|
+
this._notifyChange('panel-availability');
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
/** Subscribe to availability changes. `fn({ panels, changed })` runs ONCE
|
|
3500
|
+
* per change, however many sides changed in it: `panels` is every side's
|
|
3501
|
+
* `{ available, reason }`, `changed` the sides that differ from the last
|
|
3502
|
+
* call. The same payload goes out on the bus as `wm:panels:availability`.
|
|
3503
|
+
* Returns the unsubscribe. */
|
|
3504
|
+
onPanelAvailabilityChange(fn) {
|
|
3505
|
+
this._panelAvailabilityListeners.add(fn);
|
|
3506
|
+
return () => this._panelAvailabilityListeners.delete(fn);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
/** Bring the active desktop's panel leaves in line with what is offered,
|
|
3510
|
+
* and announce a change of availability. Called from `_notifyChange`, so
|
|
3511
|
+
* every path that can change the primary kind passes through it. */
|
|
3512
|
+
_reconcilePanels() {
|
|
3513
|
+
if (this._reconcilingPanels || !this.desktops) return;
|
|
3514
|
+
this._reconcilingPanels = true;
|
|
3515
|
+
try {
|
|
3516
|
+
const d = this.desktops.active();
|
|
3517
|
+
const panels = {};
|
|
3518
|
+
for (const side of PANEL_SIDES) panels[side] = this.getPanelAvailability(side, d.tree);
|
|
3519
|
+
const shown = (side) => !!d.panels[side] && panels[side].available;
|
|
3520
|
+
if (d.tree.rootId && PANEL_SIDES.some((s) => shown(s) !== !!this._findPanelLeaf(s))) {
|
|
3521
|
+
this._canonicalize(d.tree, d);
|
|
3522
|
+
this.renderer.render();
|
|
3523
|
+
}
|
|
3524
|
+
const last = this._panelAvailability;
|
|
3525
|
+
const changed = PANEL_SIDES.filter((s) => last[s].available !== panels[s].available
|
|
3526
|
+
|| last[s].reason !== panels[s].reason);
|
|
3527
|
+
if (!changed.length) return;
|
|
3528
|
+
this._panelAvailability = panels;
|
|
3529
|
+
const payload = Object.freeze({ panels: Object.freeze(panels), changed, wm: this });
|
|
3530
|
+
for (const fn of [...this._panelAvailabilityListeners]) {
|
|
3531
|
+
try { fn(payload); } catch (err) { console.error('[wm] panel availability listener threw', err); }
|
|
3532
|
+
}
|
|
3533
|
+
try { this.eventBus?.emit?.('wm:panels:availability', payload); }
|
|
3534
|
+
catch (err) { console.warn('[wm] event emit failed', err); }
|
|
3535
|
+
} finally {
|
|
3536
|
+
this._reconcilingPanels = false;
|
|
3537
|
+
}
|
|
3151
3538
|
}
|
|
3152
3539
|
|
|
3153
3540
|
/** Rebuild the tree so panel:* leaves sit in the canonical positions:
|
|
@@ -3162,16 +3549,30 @@ export class WindowManager {
|
|
|
3162
3549
|
* Existing panel leaves are detached + re-attached (same id), so the
|
|
3163
3550
|
* renderer cache keeps the mounted content (nav filter state, etc.). */
|
|
3164
3551
|
_canonicalize(tree, desktop) {
|
|
3165
|
-
//
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
else if (kind === 'panel:bottom') panel.bottom = leaf;
|
|
3552
|
+
// 0. Which panels this tree shows: the ones the user has open AND the
|
|
3553
|
+
// primary content offers. A withheld panel is left out without
|
|
3554
|
+
// forgetting it was open (`desktop.panels` is not written).
|
|
3555
|
+
const open = {};
|
|
3556
|
+
for (const side of PANEL_SIDES) {
|
|
3557
|
+
open[side] = !!desktop.panels[side] && this.getPanelAvailability(side, tree).available;
|
|
3172
3558
|
}
|
|
3173
3559
|
|
|
3174
|
-
//
|
|
3560
|
+
// 1. Find existing panel leaves.
|
|
3561
|
+
const panel = _panelLeaves(tree);
|
|
3562
|
+
|
|
3563
|
+
// 2. Measure each panel that has no share yet BEFORE anything is
|
|
3564
|
+
// detached. Detaching collapses the panel's split, so this is the
|
|
3565
|
+
// last moment a size the tree still holds can be read: a panel
|
|
3566
|
+
// still at its default, or a stored blob that predates
|
|
3567
|
+
// `panelShares`. A panel that has a share keeps it: the tree holds
|
|
3568
|
+
// what was BUILT from that share, which the drag range may have
|
|
3569
|
+
// held back (step 5), and only a drag (_onSplitResized) changes
|
|
3570
|
+
// what the user asked for. A tree a structural change has just
|
|
3571
|
+
// left non-canonical measures nothing; see _recordPanelShares.
|
|
3572
|
+
this._recordPanelShares(tree, desktop);
|
|
3573
|
+
const share = desktop.panelShares;
|
|
3574
|
+
|
|
3575
|
+
// 3. Detach each panel leaf from its current parent. (We don't
|
|
3175
3576
|
// use tree.close because that deletes the node; we want to
|
|
3176
3577
|
// preserve identity so the renderer keeps the mounted body.)
|
|
3177
3578
|
const detach = (leaf) => {
|
|
@@ -3194,15 +3595,15 @@ export class WindowManager {
|
|
|
3194
3595
|
};
|
|
3195
3596
|
detach(panel.left); detach(panel.right); detach(panel.bottom);
|
|
3196
3597
|
|
|
3197
|
-
//
|
|
3598
|
+
// 4. The content subtree may legitimately be empty — the user
|
|
3198
3599
|
// is allowed to close the last main tile and let panels
|
|
3199
3600
|
// take over the entire WM area. If so, seed root from the
|
|
3200
3601
|
// first requested panel; otherwise content stays as root.
|
|
3201
3602
|
let seededSide = null;
|
|
3202
3603
|
if (!tree.rootId) {
|
|
3203
|
-
seededSide =
|
|
3204
|
-
:
|
|
3205
|
-
:
|
|
3604
|
+
seededSide = open.left ? 'left'
|
|
3605
|
+
: open.bottom ? 'bottom'
|
|
3606
|
+
: open.right ? 'right'
|
|
3206
3607
|
: null;
|
|
3207
3608
|
if (seededSide) {
|
|
3208
3609
|
const seed = panel[seededSide] || makeLeaf({
|
|
@@ -3221,8 +3622,30 @@ export class WindowManager {
|
|
|
3221
3622
|
// the "Empty desktop" placeholder).
|
|
3222
3623
|
}
|
|
3223
3624
|
|
|
3224
|
-
//
|
|
3625
|
+
// 5. Wrap content row with left + right (h-split), then wrap
|
|
3225
3626
|
// that with bottom (v-split) so bottom spans full width.
|
|
3627
|
+
//
|
|
3628
|
+
// Sizes: a panel with a share from step 2 gets back the same
|
|
3629
|
+
// share of the WM area; only a panel that has never had one (opened
|
|
3630
|
+
// for the first time on this desktop) takes the default 1:4. Left
|
|
3631
|
+
// sits INSIDE the right panel's split, so its slot share of that
|
|
3632
|
+
// inner split is its area share divided by the slot the right panel
|
|
3633
|
+
// leaves over, which keeps the Navigator's width when the right
|
|
3634
|
+
// panel is toggled. The drag range is applied here, to the slot
|
|
3635
|
+
// share of the split being built, for every side: a share taken at
|
|
3636
|
+
// a different time (the right panel dragged wide while the left was
|
|
3637
|
+
// closed) can ask for a slot past it, and no split is built that a
|
|
3638
|
+
// splitter drag could not produce. A canonical tree built from its
|
|
3639
|
+
// own measured shares therefore comes back at the same sizes.
|
|
3640
|
+
const willWrap = (side) => open[side] && seededSide !== side && !!tree.rootId;
|
|
3641
|
+
const DEFAULT_SHARE = 1 / 5;
|
|
3642
|
+
const slotShare = (side) => {
|
|
3643
|
+
const own = share[side];
|
|
3644
|
+
if (own == null) return null;
|
|
3645
|
+
if (side !== 'left' || !willWrap('right')) return _clampSlotShare(own);
|
|
3646
|
+
const right = _clampSlotShare(share.right ?? DEFAULT_SHARE);
|
|
3647
|
+
return _clampSlotShare(own / (1 - right));
|
|
3648
|
+
};
|
|
3226
3649
|
const wrap = (panelLeaf, side) => {
|
|
3227
3650
|
// Re-register the leaf if it was orphaned by detach.
|
|
3228
3651
|
if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
|
|
@@ -3240,9 +3663,9 @@ export class WindowManager {
|
|
|
3240
3663
|
const before = (side === 'left');
|
|
3241
3664
|
const dir = (side === 'bottom') ? 'v' : 'h';
|
|
3242
3665
|
const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
|
|
3243
|
-
const
|
|
3244
|
-
|
|
3245
|
-
:
|
|
3666
|
+
const kept = slotShare(side);
|
|
3667
|
+
const sizes = (kept != null) ? (before ? [kept, 1 - kept] : [1 - kept, kept])
|
|
3668
|
+
: before ? [1, 4] : [4, 1];
|
|
3246
3669
|
const split = {
|
|
3247
3670
|
id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
|
|
3248
3671
|
kind: 'split', parentId: null, dir, children, sizes,
|
|
@@ -3256,12 +3679,12 @@ export class WindowManager {
|
|
|
3256
3679
|
// Skip the side already consumed as the seed root (would
|
|
3257
3680
|
// otherwise wrap a fresh duplicate leaf around the existing
|
|
3258
3681
|
// panel-as-root).
|
|
3259
|
-
if (
|
|
3260
|
-
if (
|
|
3261
|
-
if (
|
|
3682
|
+
if (willWrap('left')) wrap(panel.left, 'left');
|
|
3683
|
+
if (willWrap('right')) wrap(panel.right, 'right');
|
|
3684
|
+
if (willWrap('bottom')) wrap(panel.bottom, 'bottom');
|
|
3262
3685
|
|
|
3263
|
-
// GC orphaned nodes (panel leaves whose
|
|
3264
|
-
//
|
|
3686
|
+
// GC orphaned nodes (panel leaves whose side is no longer shown —
|
|
3687
|
+
// closed, or withheld — stayed in tree.nodes after detach).
|
|
3265
3688
|
const reachable = new Set();
|
|
3266
3689
|
const walk = (id) => {
|
|
3267
3690
|
if (!id || reachable.has(id)) return;
|
|
@@ -3275,6 +3698,72 @@ export class WindowManager {
|
|
|
3275
3698
|
}
|
|
3276
3699
|
}
|
|
3277
3700
|
|
|
3701
|
+
/** A finished splitter drag of split `splitId` is a layout change like
|
|
3702
|
+
* any other: record the shares of the panels that split sizes (the ones
|
|
3703
|
+
* inside it; the Navigator sits inside the right panel's split) while
|
|
3704
|
+
* the tree is still canonical (a drag never changes its structure), then
|
|
3705
|
+
* save, so the dragged size is what the next load, or the next
|
|
3706
|
+
* structural change, gives back. */
|
|
3707
|
+
_onSplitResized(splitId) {
|
|
3708
|
+
this._recordPanelShares(this._tree(), this.desktops.active(), splitId);
|
|
3709
|
+
this._persist();
|
|
3710
|
+
this._notifyChange('split-resized');
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
/** Keep `desktop.panelShares` current: each panel's share of the WM area
|
|
3714
|
+
* along its own axis (width for left/right, height for bottom), kept on
|
|
3715
|
+
* the desktop record and serialized with it, so a panel the user closes
|
|
3716
|
+
* comes back at the size it had, even across a reload while it was
|
|
3717
|
+
* closed. With `resizedSplitId`, the panels inside that split are
|
|
3718
|
+
* measured (a drag); without, the panels that have no share yet are.
|
|
3719
|
+
* A panel that measures nothing (not on the tree, the root, or under a
|
|
3720
|
+
* split that is not one of its canonical panel splits) keeps the share
|
|
3721
|
+
* it last had. A share is recorded as measured, never clamped: the drag
|
|
3722
|
+
* range belongs to the slot of the split being built (step 5 of
|
|
3723
|
+
* _canonicalize), not to an area share. A stored value that is not a
|
|
3724
|
+
* share at all (not a finite number strictly between 0 and 1) is
|
|
3725
|
+
* dropped, so that panel opens at the default again. */
|
|
3726
|
+
_recordPanelShares(tree, desktop, resizedSplitId = null) {
|
|
3727
|
+
const panel = _panelLeaves(tree);
|
|
3728
|
+
for (const side of PANEL_SIDES) {
|
|
3729
|
+
const kept = _validShare(desktop.panelShares[side]);
|
|
3730
|
+
const measure = resizedSplitId == null ? kept == null
|
|
3731
|
+
: _isInside(tree, panel[side], resizedSplitId);
|
|
3732
|
+
const v = (measure ? this._panelShare(tree, panel[side], side) : null) ?? kept;
|
|
3733
|
+
if (v == null) delete desktop.panelShares[side];
|
|
3734
|
+
else desktop.panelShares[side] = v;
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
/** The share (0..1) of the WM area the `side` panel leaf takes along its
|
|
3739
|
+
* axis: the product of its shares in every ancestor split running that
|
|
3740
|
+
* way (splits across the axis do not change it). Only a CANONICAL
|
|
3741
|
+
* placement measures: the leaf's own parent is its `split-panel-<side>`
|
|
3742
|
+
* split and every ancestor is a panel split. Any other tree is one a
|
|
3743
|
+
* structural change has left in passing (the last content tile closed,
|
|
3744
|
+
* or a spawned content leaf wrapping the whole root), whose sizes are
|
|
3745
|
+
* not the user's; that, a missing or root leaf, or a degenerate split
|
|
3746
|
+
* gives null. */
|
|
3747
|
+
_panelShare(tree, leaf, side) {
|
|
3748
|
+
if (!leaf?.parentId) return null;
|
|
3749
|
+
if (!String(leaf.parentId).startsWith(`split-panel-${side}-`)) return null;
|
|
3750
|
+
const axis = side === 'bottom' ? 'v' : 'h';
|
|
3751
|
+
let frac = 1;
|
|
3752
|
+
let node = leaf;
|
|
3753
|
+
while (node.parentId) {
|
|
3754
|
+
const parent = tree.get(node.parentId);
|
|
3755
|
+
if (!parent || !String(parent.id).startsWith('split-panel-')) return null;
|
|
3756
|
+
if (parent.dir === axis) {
|
|
3757
|
+
const idx = parent.children.indexOf(node.id);
|
|
3758
|
+
const total = parent.sizes.reduce((a, b) => a + b, 0);
|
|
3759
|
+
if (idx < 0 || !(total > 0) || !(parent.sizes[idx] > 0)) return null;
|
|
3760
|
+
frac *= parent.sizes[idx] / total;
|
|
3761
|
+
}
|
|
3762
|
+
node = parent;
|
|
3763
|
+
}
|
|
3764
|
+
return (frac > 0 && frac < 1) ? frac : null;
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3278
3767
|
_findPanelLeaf(side) {
|
|
3279
3768
|
const kind = `panel:${side}`;
|
|
3280
3769
|
for (const leaf of this._tree().leaves()) {
|
|
@@ -3692,6 +4181,7 @@ export class WindowManager {
|
|
|
3692
4181
|
/** Spawn a fresh ManagedWindow with the requested content. No
|
|
3693
4182
|
* source leaf — closing the window just disposes the content. */
|
|
3694
4183
|
_navigateWindow(kind, props) {
|
|
4184
|
+
if (this.revealInstance(kind, props)) return;
|
|
3695
4185
|
const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
3696
4186
|
const contentEl = document.createElement('div');
|
|
3697
4187
|
contentEl.className = 'twm-window-content';
|
|
@@ -3820,6 +4310,7 @@ export class WindowManager {
|
|
|
3820
4310
|
* routing) but uses `appendLeafTab` so the existing content
|
|
3821
4311
|
* stays in place as a tab. */
|
|
3822
4312
|
openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
|
|
4313
|
+
if (this.revealInstance(kind, props)) return;
|
|
3823
4314
|
// Managed-window content: just open in the window — managed
|
|
3824
4315
|
// windows aren't tabbed (one window = one content).
|
|
3825
4316
|
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
@@ -3855,6 +4346,7 @@ export class WindowManager {
|
|
|
3855
4346
|
* "Add row" button, which passes no ctx) reliably lands as a sibling
|
|
3856
4347
|
* tab in the main tile rather than swapping its content. */
|
|
3857
4348
|
openInTabInPrimary(kind, props = {}, transient = false, background = false) {
|
|
4349
|
+
if (this.revealInstance(kind, props)) return;
|
|
3858
4350
|
const tree = this._tree();
|
|
3859
4351
|
const leafId = tree.primaryLeafId();
|
|
3860
4352
|
// No content tile on this desktop (e.g. a panels-only layout) —
|
|
@@ -3874,6 +4366,39 @@ export class WindowManager {
|
|
|
3874
4366
|
}
|
|
3875
4367
|
|
|
3876
4368
|
|
|
4369
|
+
/** The panel leaves of `tree`, by side. */
|
|
4370
|
+
function _panelLeaves(tree) {
|
|
4371
|
+
const panel = {};
|
|
4372
|
+
for (const leaf of tree.leaves()) {
|
|
4373
|
+
const kind = leaf.content?.kind;
|
|
4374
|
+
if (kind === 'panel:left') panel.left = leaf;
|
|
4375
|
+
else if (kind === 'panel:right') panel.right = leaf;
|
|
4376
|
+
else if (kind === 'panel:bottom') panel.bottom = leaf;
|
|
4377
|
+
}
|
|
4378
|
+
return panel;
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
/** Whether `node` sits (at any depth) inside the split `splitId`. */
|
|
4382
|
+
function _isInside(tree, node, splitId) {
|
|
4383
|
+
for (let id = node?.parentId; id; id = tree.get(id)?.parentId) {
|
|
4384
|
+
if (id === splitId) return true;
|
|
4385
|
+
}
|
|
4386
|
+
return false;
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
/** `v` if it is a share (a finite number strictly between 0 and 1), else
|
|
4390
|
+
* null. */
|
|
4391
|
+
function _validShare(v) {
|
|
4392
|
+
return (typeof v === 'number' && Number.isFinite(v) && v > 0 && v < 1) ? v : null;
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
/** A slot share of a split being built, held to the range a splitter drag
|
|
4396
|
+
* allows a slot: [MIN_SLOT_FRACTION, 1 - MIN_SLOT_FRACTION]. */
|
|
4397
|
+
function _clampSlotShare(v) {
|
|
4398
|
+
const min = TileRenderer.MIN_SLOT_FRACTION;
|
|
4399
|
+
return Math.min(Math.max(v, min), 1 - min);
|
|
4400
|
+
}
|
|
4401
|
+
|
|
3877
4402
|
/** Derive a tab's display title from the content it carries — entity
|
|
3878
4403
|
* label first, then id, then the bare kind. Used everywhere a tab is
|
|
3879
4404
|
* created, so the bar names what the user opened rather than the kind
|
|
@@ -3886,6 +4411,13 @@ function _tabTitle(kind, props) {
|
|
|
3886
4411
|
return kind || '';
|
|
3887
4412
|
}
|
|
3888
4413
|
|
|
4414
|
+
/** The tab specs a window record holds: a floated pane's list, or the one
|
|
4415
|
+
* content an Alt+N / adopted window carries. */
|
|
4416
|
+
function _windowTabSpecs(rec) {
|
|
4417
|
+
if (Array.isArray(rec?.tabs) && rec.tabs.length) return rec.tabs;
|
|
4418
|
+
return rec?.original ? [rec.original] : [];
|
|
4419
|
+
}
|
|
4420
|
+
|
|
3889
4421
|
/** R8. A leaf's tabs as the plain specs a window record holds — deep enough
|
|
3890
4422
|
* that editing one afterwards cannot reach back into the tree, which matters
|
|
3891
4423
|
* because a floated tab keeps being edited (`openInWindow` renames it) while
|