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
package/src/tiling/wm.js
ADDED
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wm.js — WindowManager facade. Owns the desktop manager, the active
|
|
3
|
+
* tree's renderer, the managed-window stack, and the panel-tile state.
|
|
4
|
+
* Exposes a small imperative API consumed by the palette, keymap, top
|
|
5
|
+
* bar, bottom bar, and nav panel.
|
|
6
|
+
*
|
|
7
|
+
* wm.openInPrimary(kind, props) load a content into the primary tile
|
|
8
|
+
* wm.split(dir) split the focused tile
|
|
9
|
+
* wm.closeFocused() close the focused tile
|
|
10
|
+
* wm.focusDir(dir) / wm.moveFocused(dir)
|
|
11
|
+
* wm.toggleManagedFocused() tile <-> managed window
|
|
12
|
+
* wm.togglePanel(side) left | right | bottom
|
|
13
|
+
* wm.isPanelOpen(side) boolean
|
|
14
|
+
* wm.switchDesktop(idx) / wm.addDesktop() / wm.moveFocusedToDesktop(idx)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { DesktopManager, saveDesktops, loadDesktops } from './desktops.js';
|
|
18
|
+
import { TileRenderer } from './tile_renderer.js';
|
|
19
|
+
import { makeLeaf } from './tile_tree.js';
|
|
20
|
+
import { PLACEHOLDER_KIND } from './content_registry.js';
|
|
21
|
+
import { installPanelKeyRouter } from './panel_keys.js';
|
|
22
|
+
import { ManagedWindow } from '../ui/components/managed_window.js';
|
|
23
|
+
import { showContextMenu } from '../ui/components/context_menu.js';
|
|
24
|
+
|
|
25
|
+
const PANEL_KINDS = new Set(['panel:left', 'panel:right', 'panel:bottom']);
|
|
26
|
+
|
|
27
|
+
const PANEL_TITLES = {
|
|
28
|
+
left: 'Navigator',
|
|
29
|
+
right: 'Inspector',
|
|
30
|
+
bottom: 'Console',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class WindowManager {
|
|
34
|
+
constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content }) {
|
|
35
|
+
if (!taxonomy) throw new Error('WindowManager: a taxonomy is required');
|
|
36
|
+
if (!content || typeof content.mount !== 'function') {
|
|
37
|
+
throw new Error('WindowManager: a content registry is required '
|
|
38
|
+
+ '(createContentRegistry / createShell owns it)');
|
|
39
|
+
}
|
|
40
|
+
this.rootEl = rootEl;
|
|
41
|
+
// The shell-scoped content registry (kind -> factory). NOT a module
|
|
42
|
+
// singleton: two shells on one page must not share a kind table, and
|
|
43
|
+
// nothing may register a kind after the first mount.
|
|
44
|
+
this.content = content;
|
|
45
|
+
this.api = api || null;
|
|
46
|
+
// The host port. `this.api` stays: 20 domain page factories still read
|
|
47
|
+
// `ctx.api` — that coupling is P3's problem, not the host port's.
|
|
48
|
+
this.host = host || null;
|
|
49
|
+
// The injected ontology (see tiling/kind_taxonomy.js). The WM asks it
|
|
50
|
+
// for Backspace parents and top-nav sections; it names no kind itself.
|
|
51
|
+
this.taxonomy = taxonomy;
|
|
52
|
+
// Bus events the shell reacts to. Absent key ⇒ no subscription.
|
|
53
|
+
this.events = events || {};
|
|
54
|
+
this.ctx = ctx || {};
|
|
55
|
+
this.eventBus = eventBus || null;
|
|
56
|
+
this.onChange = onChange || (() => {});
|
|
57
|
+
|
|
58
|
+
// The leaf a fresh or emptied desktop starts with. Comes from the
|
|
59
|
+
// taxonomy's root kind — NOT a hardcoded 'home'. An embedder with a
|
|
60
|
+
// different ontology used to get an EcoAgent Home tile it had never
|
|
61
|
+
// registered, and therefore the content_registry placeholder.
|
|
62
|
+
this._rootLeaf = () => {
|
|
63
|
+
const kind = this.taxonomy.root;
|
|
64
|
+
return {
|
|
65
|
+
content: { kind, props: {} },
|
|
66
|
+
title: this.taxonomy.meta(kind)?.label || kind,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
this.desktops = new DesktopManager({ seed: this._rootLeaf });
|
|
70
|
+
this.renderer = new TileRenderer({
|
|
71
|
+
root: rootEl,
|
|
72
|
+
tree: this.desktops.active().tree,
|
|
73
|
+
content,
|
|
74
|
+
ctx: {
|
|
75
|
+
...this.ctx,
|
|
76
|
+
wm: this,
|
|
77
|
+
onLeafAction: (leafId, action) => this._leafAction(leafId, action),
|
|
78
|
+
onLeafTabAction: (leafId, action, data) =>
|
|
79
|
+
this._leafTabAction(leafId, action, data),
|
|
80
|
+
},
|
|
81
|
+
onFocusChange: () => this._notifyChange(),
|
|
82
|
+
});
|
|
83
|
+
this._persistTimer = null;
|
|
84
|
+
// Window-id → leaf-id mapping so a managed-window demote can
|
|
85
|
+
// restore its original tile slot.
|
|
86
|
+
this._windowToLeaf = new Map();
|
|
87
|
+
|
|
88
|
+
// Central keyboard router: panels register key handlers and the
|
|
89
|
+
// router dispatches each keydown only to the panel inside the
|
|
90
|
+
// currently focused leaf (see panel_keys.js). One authority for
|
|
91
|
+
// "which panel owns the keyboard" instead of every panel guessing
|
|
92
|
+
// from document.activeElement.
|
|
93
|
+
this.panelKeys = installPanelKeyRouter(this);
|
|
94
|
+
|
|
95
|
+
// Live-update tab titles when entities are renamed. The embedder
|
|
96
|
+
// names the event (`events.entityRenamed`); its per-entity editors
|
|
97
|
+
// fire it on Save. Walks every leaf's `tabs` and `pageTabs` archive
|
|
98
|
+
// so an archived page's tab labels stay accurate too. No event
|
|
99
|
+
// configured ⇒ no subscription, and tab titles are simply static.
|
|
100
|
+
const renamedEvent = this.events.entityRenamed;
|
|
101
|
+
if (renamedEvent) {
|
|
102
|
+
this.eventBus?.on?.(renamedEvent, ({ kind, entityId, label } = {}) => {
|
|
103
|
+
if (!kind || !entityId || !label) return;
|
|
104
|
+
let touched = false;
|
|
105
|
+
for (const d of this.desktops.desktops) {
|
|
106
|
+
if (d.tree.renameMatchingTabs?.(kind, entityId, label)) {
|
|
107
|
+
touched = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (touched) {
|
|
111
|
+
this.renderer.render();
|
|
112
|
+
this._persist();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Emit on bus + call onChange. Use this instead of the bare callback
|
|
119
|
+
* so other surfaces (palette, top-bar toggles, page shortcuts) can
|
|
120
|
+
* subscribe through the existing event system. */
|
|
121
|
+
_notifyChange(reason = null) {
|
|
122
|
+
try { this.onChange(reason); } catch (err) { console.error('[wm] onChange threw', err); }
|
|
123
|
+
try { this.eventBus?.emit?.('wm:changed', { reason, wm: this }); }
|
|
124
|
+
catch (err) { console.warn('[wm] event emit failed', err); }
|
|
125
|
+
// Emit the canonical `workspace:tabs:activated` event for the
|
|
126
|
+
// current primary leaf — the existing sidebars (ProjectSidebar
|
|
127
|
+
// etc.) already listen for this to highlight the active row.
|
|
128
|
+
try {
|
|
129
|
+
const tree = this._tree();
|
|
130
|
+
const id = tree.primaryLeafId();
|
|
131
|
+
const leaf = id ? tree.get(id) : null;
|
|
132
|
+
const kind = leaf?.content?.kind;
|
|
133
|
+
if (kind && kind !== 'window-placeholder') {
|
|
134
|
+
this.eventBus?.emit?.('workspace:tabs:activated', {
|
|
135
|
+
kind,
|
|
136
|
+
entityId: leaf.content.props?.id ?? null,
|
|
137
|
+
label: leaf.title,
|
|
138
|
+
from: reason || 'wm',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} catch (_) {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Persistence ─────────────────────────────────────────────────
|
|
145
|
+
async load() {
|
|
146
|
+
const blob = await loadDesktops(this.host?.state);
|
|
147
|
+
if (blob) {
|
|
148
|
+
this.desktops = DesktopManager.deserialize(blob, { seed: this._rootLeaf });
|
|
149
|
+
this.renderer.tree = this.desktops.active().tree;
|
|
150
|
+
}
|
|
151
|
+
// Normalize: managed windows don't survive a reload, so any
|
|
152
|
+
// window-placeholder leaves restore their original content.
|
|
153
|
+
for (const d of this.desktops.desktops) {
|
|
154
|
+
for (const leaf of d.tree.leaves()) {
|
|
155
|
+
if (leaf.content?.kind === PLACEHOLDER_KIND) {
|
|
156
|
+
const p = leaf.content.props || {};
|
|
157
|
+
if (p.originalKind) {
|
|
158
|
+
d.tree.setLeafContent(leaf.id,
|
|
159
|
+
{ kind: p.originalKind, props: p.originalProps || {} },
|
|
160
|
+
p.originalTitle || p.originalKind);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this._canonicalize(d.tree, d);
|
|
165
|
+
}
|
|
166
|
+
this.renderer.tree = this.desktops.active().tree;
|
|
167
|
+
this.renderer.render();
|
|
168
|
+
this._notifyChange();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_persist() {
|
|
172
|
+
if (this._persistTimer) clearTimeout(this._persistTimer);
|
|
173
|
+
this._persistTimer = setTimeout(async () => {
|
|
174
|
+
this._persistTimer = null;
|
|
175
|
+
await saveDesktops(this.host?.state, this.desktops.serialize());
|
|
176
|
+
}, 500);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Content loading ─────────────────────────────────────────────
|
|
180
|
+
/** Load content into the primary tile (last-active non-panel leaf).
|
|
181
|
+
/** Walk back one step. Bound to Backspace.
|
|
182
|
+
*
|
|
183
|
+
* Priority order:
|
|
184
|
+
* 1. Active tab's per-tab history stack (browser-style "back"):
|
|
185
|
+
* any in-tile navigation (relationships click-through,
|
|
186
|
+
* landing-row open, "Open" buttons inside tables) pushed
|
|
187
|
+
* the previous state, and Backspace pops it. This is the
|
|
188
|
+
* canonical user-facing back affordance.
|
|
189
|
+
* 2. When the history is empty (the user landed on this page
|
|
190
|
+
* via top-nav / palette / direct link), fall back to the
|
|
191
|
+
* canonical taxonomy walk: a sub-page goes to its
|
|
192
|
+
* top-nav, a top-nav page goes to Home.
|
|
193
|
+
*
|
|
194
|
+
* Adding a new kind to the injected taxonomy wires the fallback path
|
|
195
|
+
* for Backspace + breadcrumb + top-nav highlight in one shot.
|
|
196
|
+
* Adding a new "Open from X" click that should be a back-able
|
|
197
|
+
* navigation just needs to use one of the in-tile routing
|
|
198
|
+
* methods (openFromContext / openInLeaf / navigateActiveTab) —
|
|
199
|
+
* they all record history by default. */
|
|
200
|
+
navigateBack() {
|
|
201
|
+
const tree = this._tree();
|
|
202
|
+
// Prefer the FOCUSED leaf — that's where the user was just
|
|
203
|
+
// interacting (Open click, breadcrumb step, etc.). Falls back
|
|
204
|
+
// to the primary leaf for keyboard-driven Backspace when no
|
|
205
|
+
// tile is explicitly focused (panels excluded). If the focused
|
|
206
|
+
// leaf is a panel, walk back on the primary content leaf
|
|
207
|
+
// instead (Backspace from the nav panel should still navigate
|
|
208
|
+
// the user's content area).
|
|
209
|
+
const focusedId = tree.focusedLeafId;
|
|
210
|
+
const primaryId = tree.primaryLeafId();
|
|
211
|
+
const focusedLeaf = focusedId ? tree.get(focusedId) : null;
|
|
212
|
+
const focusedKind = focusedLeaf?.content?.kind || '';
|
|
213
|
+
const id = (focusedId && focusedKind
|
|
214
|
+
&& !focusedKind.startsWith('panel:')
|
|
215
|
+
&& focusedKind !== 'window-placeholder')
|
|
216
|
+
? focusedId : primaryId;
|
|
217
|
+
if (!id) return;
|
|
218
|
+
// (1) Per-tab history pop, if anything was recorded.
|
|
219
|
+
const prior = tree.popActiveTabHistory?.(id);
|
|
220
|
+
if (prior && prior.kind) {
|
|
221
|
+
// Cross-page back entries carry a `topNav` marker — pop
|
|
222
|
+
// routes via swapToPage so we land on the correct page.
|
|
223
|
+
const leafNow = tree.get(id);
|
|
224
|
+
const curKind = leafNow?.content?.kind;
|
|
225
|
+
const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
|
|
226
|
+
if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
|
|
227
|
+
tree.swapToPage?.(
|
|
228
|
+
id,
|
|
229
|
+
{ kind: prior.kind, props: prior.props || {},
|
|
230
|
+
title: prior.title || _tabTitle(prior.kind, prior.props) },
|
|
231
|
+
curTopNav, prior.topNav,
|
|
232
|
+
{ recordHistory: false },
|
|
233
|
+
);
|
|
234
|
+
} else {
|
|
235
|
+
tree.replaceActiveTabContent(
|
|
236
|
+
id,
|
|
237
|
+
{ kind: prior.kind, props: prior.props || {} },
|
|
238
|
+
prior.title || _tabTitle(prior.kind, prior.props),
|
|
239
|
+
{ recordHistory: false },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
tree.focus(id);
|
|
243
|
+
this.renderer.render();
|
|
244
|
+
this._persist();
|
|
245
|
+
this._notifyChange('navigate-back');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// (2) Taxonomy fallback — the user's path is gone, so walk the
|
|
249
|
+
// canonical parent instead. An ENTITY-level parent wins (the
|
|
250
|
+
// instance one level up, e.g. the parent an id names); failing
|
|
251
|
+
// that, the KIND-level parent (this page's section).
|
|
252
|
+
const leaf = tree.get(id);
|
|
253
|
+
if (!leaf?.content) return;
|
|
254
|
+
const { kind, props } = leaf.content;
|
|
255
|
+
const up = this.taxonomy.parentOf(kind, props);
|
|
256
|
+
if (up) { this.navigateActiveTab(id, up.kind, up.props); return; }
|
|
257
|
+
const parent = this.taxonomy.parentKindFor(kind);
|
|
258
|
+
if (parent) this.navigateActiveTab(id, parent);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** True when `navigateBack` would do something user-visible — i.e.
|
|
262
|
+
* the active tab has per-tab history, OR the current kind has a
|
|
263
|
+
* taxonomy parent. Used by the breadcrumb to grey out / hide the
|
|
264
|
+
* Back button when there's nowhere to go. */
|
|
265
|
+
canNavigateBack() {
|
|
266
|
+
const tree = this._tree();
|
|
267
|
+
const focusedId = tree.focusedLeafId;
|
|
268
|
+
const primaryId = tree.primaryLeafId();
|
|
269
|
+
const focusedLeaf = focusedId ? tree.get(focusedId) : null;
|
|
270
|
+
const focusedKind = focusedLeaf?.content?.kind || '';
|
|
271
|
+
const id = (focusedId && focusedKind
|
|
272
|
+
&& !focusedKind.startsWith('panel:')
|
|
273
|
+
&& focusedKind !== 'window-placeholder')
|
|
274
|
+
? focusedId : primaryId;
|
|
275
|
+
if (!id) return false;
|
|
276
|
+
const history = tree.activeTabHistory?.(id) || [];
|
|
277
|
+
if (history.length > 0) return true;
|
|
278
|
+
const leaf = tree.get(id);
|
|
279
|
+
const kind = leaf?.content?.kind;
|
|
280
|
+
return !!(kind && this.taxonomy.parentKindFor(kind));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Persist editor sub-state (e.g. active sub-tab) into the active
|
|
284
|
+
* tab's WM props so future history snapshots restore the user's
|
|
285
|
+
* view rather than the default landing. Pure metadata write — no
|
|
286
|
+
* re-render, no history push. */
|
|
287
|
+
updateActiveTabProps(leafId, patch) {
|
|
288
|
+
const tree = this._tree();
|
|
289
|
+
if (!tree?.updateActiveTabProps) return;
|
|
290
|
+
tree.updateActiveTabProps(leafId, patch);
|
|
291
|
+
this._persist();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Navigate inside the current tab — preserves every other tab in
|
|
295
|
+
* the leaf. Used by Backspace + breadcrumb segments + anything
|
|
296
|
+
* else that should walk WITHIN the tile rather than reset it. */
|
|
297
|
+
navigateActiveTab(leafId, kind, props = {}) {
|
|
298
|
+
const tree = this._tree();
|
|
299
|
+
const leaf = tree.get(leafId);
|
|
300
|
+
if (!leaf || leaf.kind !== 'leaf') {
|
|
301
|
+
this.openInPrimary(kind, props);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
|
|
305
|
+
tree.focus(leafId);
|
|
306
|
+
this.renderer.render();
|
|
307
|
+
this._persist();
|
|
308
|
+
this._notifyChange('navigate-active-tab');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Route a navigation request to the right container based on
|
|
312
|
+
* where it came from:
|
|
313
|
+
*
|
|
314
|
+
* - `ctx.windowId` set → replace the managed-window content (the
|
|
315
|
+
* user is navigating inside a windowed tile; the window stays).
|
|
316
|
+
* - `ctx.leafId` set AND that leaf is NOT `panel:left` → replace
|
|
317
|
+
* the leaf's content in place (the user is in a split tile;
|
|
318
|
+
* the click stays in that tile).
|
|
319
|
+
* - Otherwise (palette, top-nav shortcuts, click in the
|
|
320
|
+
* navigator panel, backspace, plain bus event) → primary tile.
|
|
321
|
+
*
|
|
322
|
+
* The left nav (`panel:left`) is the only tile whose clicks
|
|
323
|
+
* intentionally jump out to the primary tile — every other
|
|
324
|
+
* container is self-contained. */
|
|
325
|
+
openFromContext(ctx, kind, props = {}) {
|
|
326
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
327
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (ctx?.leafId) {
|
|
331
|
+
const tree = this._tree();
|
|
332
|
+
const leaf = tree.get(ctx.leafId);
|
|
333
|
+
const k = leaf?.content?.kind;
|
|
334
|
+
if (leaf && k && k !== 'panel:left' && !k.startsWith('panel:')) {
|
|
335
|
+
this.openInLeaf(ctx.leafId, kind, props);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
this.openInPrimary(kind, props);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Replace the leaf's **active tab** in place — preserves every
|
|
343
|
+
* other tab. This is the routing for navigation that originated
|
|
344
|
+
* INSIDE the tile: clicking a row on a landing page, walking a
|
|
345
|
+
* breadcrumb segment, Backspace. Outside-the-tile navigation
|
|
346
|
+
* (top-nav, palette, side-nav) uses `openInPrimary` instead, which
|
|
347
|
+
* resets the leaf to a single fresh tab.
|
|
348
|
+
*
|
|
349
|
+
* For a leaf that has only one tab (the common case before the
|
|
350
|
+
* tab feature shipped), the behavior is identical to the old
|
|
351
|
+
* setLeafContent: one tab in, one tab out. */
|
|
352
|
+
openInLeaf(leafId, kind, props = {}) {
|
|
353
|
+
const tree = this._tree();
|
|
354
|
+
const leaf = tree.get(leafId);
|
|
355
|
+
if (!leaf || leaf.kind !== 'leaf') {
|
|
356
|
+
this.openInPrimary(kind, props);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
tree.replaceActiveTabContent(leafId, { kind, props }, _tabTitle(kind, props));
|
|
360
|
+
tree.focus(leafId);
|
|
361
|
+
this.renderer.render();
|
|
362
|
+
this._persist();
|
|
363
|
+
this._notifyChange();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Replace a managed window's content in place. Tears down the
|
|
367
|
+
* previous mount, mounts the new kind into the same contentEl,
|
|
368
|
+
* and updates the window's title. */
|
|
369
|
+
openInWindow(winId, kind, props = {}) {
|
|
370
|
+
const rec = this._windowToLeaf.get(winId);
|
|
371
|
+
if (!rec) { this.openInPrimary(kind, props); return; }
|
|
372
|
+
try { rec.mountInfo?.destroy?.(); } catch {}
|
|
373
|
+
rec.contentEl.innerHTML = '';
|
|
374
|
+
const mountInfo = this.content.mount(kind, rec.contentEl, props,
|
|
375
|
+
{ ...this.ctx, wm: this, windowId: winId });
|
|
376
|
+
rec.mountInfo = mountInfo;
|
|
377
|
+
rec.original = {
|
|
378
|
+
kind, props: { ...(props || {}) },
|
|
379
|
+
title: mountInfo?.title || kind,
|
|
380
|
+
};
|
|
381
|
+
// Update window title (DOM + the ManagedWindow instance).
|
|
382
|
+
try {
|
|
383
|
+
const titleEl = rec.window.element?.querySelector('.twm-managed-window__title');
|
|
384
|
+
if (titleEl) titleEl.textContent = rec.original.title;
|
|
385
|
+
if (rec.window) rec.window.title = rec.original.title;
|
|
386
|
+
} catch {}
|
|
387
|
+
// `rec.original` now holds the latest content, so a later "back to
|
|
388
|
+
// tile" docks the current view (not the kind first promoted). The
|
|
389
|
+
// window owns no tile in the tree, so there is no leaf to update.
|
|
390
|
+
this._persist();
|
|
391
|
+
this._notifyChange('window-content-changed');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Load content into the primary tile (last-active non-panel leaf).
|
|
395
|
+
* If only panel tiles exist (or the tree is empty), spawn a new
|
|
396
|
+
* content leaf and canonicalize so the panels wrap it. */
|
|
397
|
+
openInPrimary(kind, props = {}) {
|
|
398
|
+
const tree = this._tree();
|
|
399
|
+
let leafId = tree.primaryLeafId();
|
|
400
|
+
let spawned = false;
|
|
401
|
+
if (!leafId) {
|
|
402
|
+
leafId = this._spawnContentLeaf(tree);
|
|
403
|
+
spawned = true;
|
|
404
|
+
}
|
|
405
|
+
if (!leafId) return;
|
|
406
|
+
// Page-aware swap: archive the leaf's current page tabs and
|
|
407
|
+
// restore (or initialize) the target page's. This is what makes
|
|
408
|
+
// a tile remember its tab set when the user clicks away to
|
|
409
|
+
// another top-nav page and back. `swapToPage` is a no-op when
|
|
410
|
+
// the leaf has no current page (just-spawned content leaf) —
|
|
411
|
+
// the default branch initializes a fresh single tab.
|
|
412
|
+
const leaf = tree.get(leafId);
|
|
413
|
+
const currentTopNav = leaf?.content?.kind
|
|
414
|
+
? this.taxonomy.topNavFor(leaf.content.kind) || null
|
|
415
|
+
: null;
|
|
416
|
+
const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
|
|
417
|
+
const title = _tabTitle(kind, props);
|
|
418
|
+
tree.swapToPage(leafId,
|
|
419
|
+
{ kind, props, title },
|
|
420
|
+
currentTopNav, targetTopNav);
|
|
421
|
+
tree.focus(leafId);
|
|
422
|
+
if (spawned) this._canonicalize(tree, this.desktops.active());
|
|
423
|
+
this.renderer.render();
|
|
424
|
+
this._persist();
|
|
425
|
+
this._notifyChange();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Split / close / focus / move ────────────────────────────────
|
|
429
|
+
split(dir) {
|
|
430
|
+
const tree = this._tree();
|
|
431
|
+
const focused = tree.focusedLeafId;
|
|
432
|
+
if (!focused) return;
|
|
433
|
+
tree.split(focused, dir);
|
|
434
|
+
this.renderer.render();
|
|
435
|
+
this._persist();
|
|
436
|
+
this._notifyChange();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
|
|
440
|
+
* created sibling — the "open this content in a new split" primitive
|
|
441
|
+
* behind the code-pane split buttons, the per-pane context menu and
|
|
442
|
+
* Alt+Shift+H / Alt+Shift+V. The source leaf keeps its content
|
|
443
|
+
* untouched; the new pane gets a FRESH mount (so e.g. opening a Code
|
|
444
|
+
* pane in a split never dismounts the original). Returns the new
|
|
445
|
+
* leaf id, or null if the leaf can't be split. */
|
|
446
|
+
splitLeafWith(leafId, dir, kind, props = {}, title = '') {
|
|
447
|
+
const tree = this._tree();
|
|
448
|
+
const src = leafId ? tree.get(leafId) : null;
|
|
449
|
+
if (!src || src.kind !== 'leaf') return null;
|
|
450
|
+
const newId = tree.split(leafId, dir);
|
|
451
|
+
if (!newId) return null;
|
|
452
|
+
tree.setLeafContent(newId, { kind, props }, title || _tabTitle(kind, props));
|
|
453
|
+
tree.focus(newId);
|
|
454
|
+
this.renderer.render();
|
|
455
|
+
this._persist();
|
|
456
|
+
this._notifyChange('split-with');
|
|
457
|
+
return newId;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Resolve the focused tile's active content for the keyboard-driven
|
|
461
|
+
* "open focused tile elsewhere" chords (Alt+T / Alt+N / Alt+Shift+H /
|
|
462
|
+
* Alt+Shift+V). Returns null for panels, window placeholders and
|
|
463
|
+
* empty tiles — none of which can be meaningfully duplicated. */
|
|
464
|
+
_focusedContent() {
|
|
465
|
+
const tree = this._tree();
|
|
466
|
+
const id = tree.focusedLeafId;
|
|
467
|
+
const leaf = id ? tree.get(id) : null;
|
|
468
|
+
if (!leaf || leaf.kind !== 'leaf' || !leaf.content) return null;
|
|
469
|
+
const kind = leaf.content.kind;
|
|
470
|
+
if (!kind || kind === PLACEHOLDER_KIND || kind.startsWith('panel:')) return null;
|
|
471
|
+
return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Alt+T — open the focused tile's content in a new tab on that tile. */
|
|
475
|
+
openFocusedInTab() {
|
|
476
|
+
const c = this._focusedContent();
|
|
477
|
+
if (!c) return;
|
|
478
|
+
this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Alt+N — open the focused tile's content in a fresh managed window.
|
|
482
|
+
* Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
|
|
483
|
+
openFocusedInWindow() {
|
|
484
|
+
const c = this._focusedContent();
|
|
485
|
+
if (!c) return;
|
|
486
|
+
this._navigateWindow(c.kind, c.props);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Alt+Shift+H / Alt+Shift+V — split the focused tile and open a fresh
|
|
490
|
+
* copy of its content in the new pane. Falls back to an empty split
|
|
491
|
+
* when the focused tile has no duplicable content (e.g. a panel). */
|
|
492
|
+
splitFocusedWith(dir) {
|
|
493
|
+
const c = this._focusedContent();
|
|
494
|
+
if (!c) { this.split(dir); return; }
|
|
495
|
+
this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
closeFocused() {
|
|
499
|
+
const tree = this._tree();
|
|
500
|
+
const focusedId = tree.focusedLeafId;
|
|
501
|
+
if (!focusedId) return;
|
|
502
|
+
const leaf = tree.get(focusedId);
|
|
503
|
+
const kind = leaf?.content?.kind;
|
|
504
|
+
|
|
505
|
+
// Closing a placeholder closes its window — the window's onClose
|
|
506
|
+
// handler restores the leaf, then we close that leaf too.
|
|
507
|
+
if (kind === PLACEHOLDER_KIND) {
|
|
508
|
+
const winId = leaf.content?.props?.windowId;
|
|
509
|
+
const rec = winId ? this._windowToLeaf.get(winId) : null;
|
|
510
|
+
if (rec) {
|
|
511
|
+
// Drop the mapping so the onClose handler doesn't restore.
|
|
512
|
+
this._windowToLeaf.delete(winId);
|
|
513
|
+
try { rec.window.close({ force: true }); } catch {}
|
|
514
|
+
}
|
|
515
|
+
tree.close(focusedId);
|
|
516
|
+
}
|
|
517
|
+
// Closing a panel: also flip the desktop's panel state off so
|
|
518
|
+
// the top-bar toggle button reflects the closure.
|
|
519
|
+
else if (PANEL_KINDS.has(kind)) {
|
|
520
|
+
const d = this.desktops.active();
|
|
521
|
+
const side = kind.split(':')[1];
|
|
522
|
+
d.panels[side] = false;
|
|
523
|
+
this._canonicalize(tree, d);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
tree.close(focusedId);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Re-canonicalize so the remaining panels fill the area
|
|
530
|
+
// properly (no leftover wrap from the content leaf). The user
|
|
531
|
+
// is allowed to close the last content tile — the panels can
|
|
532
|
+
// fully occupy the WM. A new content leaf is spawned later by
|
|
533
|
+
// openInPrimary when something needs it.
|
|
534
|
+
this._canonicalize(tree, this.desktops.active());
|
|
535
|
+
|
|
536
|
+
this.renderer.render();
|
|
537
|
+
this._persist();
|
|
538
|
+
this._notifyChange('tile-closed');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Close the ACTIVE tab of a leaf (e.g. a Cancel button inside the
|
|
542
|
+
* tab's content). Mirrors the tab-strip × handler: remove just that
|
|
543
|
+
* tab, or close the whole tile if it was the last one. Lets content
|
|
544
|
+
* self-close without nuking sibling tabs. */
|
|
545
|
+
closeActiveTab(leafId) {
|
|
546
|
+
const tree = this._tree();
|
|
547
|
+
const leaf = leafId ? tree.get(leafId) : null;
|
|
548
|
+
if (!leaf || leaf.kind !== 'leaf') {
|
|
549
|
+
// No tabbed leaf (e.g. content in a managed window) — fall
|
|
550
|
+
// back to closing the focused container.
|
|
551
|
+
this.closeFocused();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const tabs = leaf.tabs || [];
|
|
555
|
+
if (tabs.length <= 1) {
|
|
556
|
+
tree.focus(leafId);
|
|
557
|
+
this.closeFocused();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const idx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
561
|
+
tree.removeLeafTab(leafId, idx);
|
|
562
|
+
tree.focus(leafId);
|
|
563
|
+
this.renderer.render();
|
|
564
|
+
this._persist();
|
|
565
|
+
this._notifyChange('twm-tab-close');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Insert a new empty content leaf at the centre of the layout
|
|
569
|
+
* (wraps the current root in an h-split with the content on the
|
|
570
|
+
* left, 4:1 ratio). If the tree is empty, becomes the root. */
|
|
571
|
+
_spawnContentLeaf(tree) {
|
|
572
|
+
const newLeaf = makeLeaf({ content: null, title: '' });
|
|
573
|
+
if (!tree.rootId) { tree.setRoot(newLeaf); return newLeaf.id; }
|
|
574
|
+
tree._register(newLeaf);
|
|
575
|
+
const split = {
|
|
576
|
+
id: `split-spawn-${Date.now().toString(36)}`,
|
|
577
|
+
kind: 'split', parentId: null, dir: 'h',
|
|
578
|
+
children: [newLeaf.id, tree.rootId], sizes: [4, 1],
|
|
579
|
+
};
|
|
580
|
+
tree.nodes.set(split.id, split);
|
|
581
|
+
const oldRoot = tree.get(tree.rootId);
|
|
582
|
+
if (oldRoot) oldRoot.parentId = split.id;
|
|
583
|
+
newLeaf.parentId = split.id;
|
|
584
|
+
tree.rootId = split.id;
|
|
585
|
+
tree.focus(newLeaf.id);
|
|
586
|
+
return newLeaf.id;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
focusDir(dir) {
|
|
590
|
+
if (this._tree().focusDir(dir)) {
|
|
591
|
+
this.renderer._updateFocusClasses();
|
|
592
|
+
this._notifyChange();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The `.twm-leaf` DOM element of the currently focused leaf on the
|
|
597
|
+
* active desktop, or null. The panel-key router consults this to
|
|
598
|
+
* decide which panel owns the keyboard — it is the single source of
|
|
599
|
+
* truth for "the selected tile". */
|
|
600
|
+
focusedLeafEl() {
|
|
601
|
+
const id = this._tree().focusedLeafId;
|
|
602
|
+
return id ? this.renderer.leafEl(id) : null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
moveFocused(dir) {
|
|
606
|
+
if (this._tree().moveDir(dir)) {
|
|
607
|
+
this.renderer.render();
|
|
608
|
+
this._persist();
|
|
609
|
+
this._notifyChange();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── Tile <-> Managed window ─────────────────────────────────────
|
|
614
|
+
/** Promote the focused tile into a managed window and CLOSE the
|
|
615
|
+
* source tile — promoting means the content leaves the grid, so the
|
|
616
|
+
* origin slot is removed rather than left as an empty placeholder.
|
|
617
|
+
* "Back to tile" re-docks the content into the primary tile. */
|
|
618
|
+
toggleManagedFocused() {
|
|
619
|
+
const tree = this._tree();
|
|
620
|
+
const focused = tree.focused();
|
|
621
|
+
if (!focused || !focused.content) return;
|
|
622
|
+
if (PANEL_KINDS.has(focused.content.kind)) return;
|
|
623
|
+
if (focused.content.kind === PLACEHOLDER_KIND) return;
|
|
624
|
+
|
|
625
|
+
const leafId = focused.id;
|
|
626
|
+
const desktopIdx = this.desktops.activeIdx;
|
|
627
|
+
const original = {
|
|
628
|
+
kind: focused.content.kind,
|
|
629
|
+
props: { ...(focused.content.props || {}) },
|
|
630
|
+
title: focused.title,
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const contentEl = document.createElement('div');
|
|
634
|
+
contentEl.className = 'twm-window-content';
|
|
635
|
+
contentEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;';
|
|
636
|
+
|
|
637
|
+
const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
638
|
+
const mountInfo = this.content.mount(original.kind, contentEl, original.props,
|
|
639
|
+
{ ...this.ctx, wm: this, windowId: winId });
|
|
640
|
+
const win = new ManagedWindow({
|
|
641
|
+
id: winId,
|
|
642
|
+
title: mountInfo?.title || original.title,
|
|
643
|
+
icon: 'web_asset',
|
|
644
|
+
content: contentEl,
|
|
645
|
+
canMinimize: true,
|
|
646
|
+
canMaximize: true,
|
|
647
|
+
canResize: true,
|
|
648
|
+
modal: false,
|
|
649
|
+
onClose: () => this._onManagedWindowClosed(winId, mountInfo),
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
this._windowToLeaf.set(winId, {
|
|
653
|
+
// Promoting CLOSES the source tile — the content lives in the
|
|
654
|
+
// window now, not the tree. leafId is null so "back to tile"
|
|
655
|
+
// re-docks into the primary tile (see _onManagedWindowClosed).
|
|
656
|
+
leafId: null, desktopIdx, original, mountInfo, window: win,
|
|
657
|
+
contentEl,
|
|
658
|
+
// Set to true by bringBackWindow so the close path knows to
|
|
659
|
+
// restore the content instead of destroying it.
|
|
660
|
+
_demoting: false,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// Close the origin tile so it doesn't linger as an empty
|
|
664
|
+
// "(window)" placeholder. Re-canonicalize so panels reflow; the
|
|
665
|
+
// desktop is allowed to end up with no content tile (panels can
|
|
666
|
+
// fill it, and openInPrimary re-spawns one on demand) — same as
|
|
667
|
+
// closeFocused on the last tile.
|
|
668
|
+
tree.close(leafId);
|
|
669
|
+
this._canonicalize(tree, this.desktops.active());
|
|
670
|
+
|
|
671
|
+
this.renderer.render();
|
|
672
|
+
win.show();
|
|
673
|
+
this._decorateManagedWindow(win, winId);
|
|
674
|
+
this._persist();
|
|
675
|
+
this._notifyChange('window-promoted');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Dock the window's content back into the desktop's primary tile
|
|
679
|
+
* (the source tile was closed on promote), then close the window. */
|
|
680
|
+
bringBackWindow(windowId) {
|
|
681
|
+
const rec = this._windowToLeaf.get(windowId);
|
|
682
|
+
if (!rec) return;
|
|
683
|
+
rec._demoting = true;
|
|
684
|
+
try { rec.window.close({ force: true }); }
|
|
685
|
+
catch (err) { console.warn('[wm] bringBack: close failed', err); }
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Re-home a managed window to another desktop. The window itself
|
|
689
|
+
* stays on screen (managed windows are global) and leaves no tile
|
|
690
|
+
* behind on either desktop; only its "home" changes, so bringing it
|
|
691
|
+
* back will land on the new desktop's primary tile. */
|
|
692
|
+
moveWindowToDesktop(windowId, targetIdx) {
|
|
693
|
+
const rec = this._windowToLeaf.get(windowId);
|
|
694
|
+
if (!rec) return;
|
|
695
|
+
if (rec.desktopIdx === targetIdx) return;
|
|
696
|
+
this.desktops.ensureCount(targetIdx + 1);
|
|
697
|
+
rec.desktopIdx = targetIdx;
|
|
698
|
+
this._persist();
|
|
699
|
+
this._notifyChange('window-moved');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
_onManagedWindowClosed(winId, mountInfo) {
|
|
703
|
+
const rec = this._windowToLeaf.get(winId);
|
|
704
|
+
this._windowToLeaf.delete(winId);
|
|
705
|
+
if (!rec) return;
|
|
706
|
+
try { mountInfo?.destroy?.(); } catch {}
|
|
707
|
+
const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
|
|
708
|
+
if (!tree) return;
|
|
709
|
+
|
|
710
|
+
if (rec._demoting) {
|
|
711
|
+
// "Back to tile": the source tile was closed when the window was
|
|
712
|
+
// promoted, so dock the (latest) content into the desktop's
|
|
713
|
+
// primary tile as a new tab, spawning a tile when it has none.
|
|
714
|
+
let pid = tree.primaryLeafId();
|
|
715
|
+
let spawned = false;
|
|
716
|
+
if (!pid) { pid = this._spawnContentLeaf(tree); spawned = true; }
|
|
717
|
+
if (pid) {
|
|
718
|
+
tree.appendLeafTab(pid,
|
|
719
|
+
{ kind: rec.original.kind, props: rec.original.props },
|
|
720
|
+
rec.original.title);
|
|
721
|
+
tree.focus(pid);
|
|
722
|
+
if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// Plain close (rec._demoting === false): the window and its content
|
|
726
|
+
// are discarded. The source tile was already closed on promote, so
|
|
727
|
+
// there is nothing left in the tree to clean up.
|
|
728
|
+
if (this.desktops.active().tree === tree) this.renderer.render();
|
|
729
|
+
this._persist();
|
|
730
|
+
this._notifyChange(rec._demoting ? 'window-demoted' : 'window-closed');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Post-show DOM hook: inject a "back to tile" button into the
|
|
734
|
+
* window chrome and wire a right-click context menu on the topbar. */
|
|
735
|
+
_decorateManagedWindow(win, winId) {
|
|
736
|
+
const topbar = win.element?.querySelector?.('.twm-managed-window__topbar');
|
|
737
|
+
const buttons = topbar?.querySelector?.('.twm-managed-window__buttons');
|
|
738
|
+
if (!buttons) return;
|
|
739
|
+
const backBtn = document.createElement('button');
|
|
740
|
+
backBtn.type = 'button';
|
|
741
|
+
backBtn.className = 'twm-managed-window__btn managed-window__btn--demote';
|
|
742
|
+
backBtn.title = 'Back to tile';
|
|
743
|
+
backBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:14px">close_fullscreen</span>`;
|
|
744
|
+
backBtn.addEventListener('click', (e) => {
|
|
745
|
+
e.stopPropagation();
|
|
746
|
+
this.bringBackWindow(winId);
|
|
747
|
+
});
|
|
748
|
+
const closeBtn = buttons.querySelector('.twm-managed-window__btn--close');
|
|
749
|
+
if (closeBtn) buttons.insertBefore(backBtn, closeBtn);
|
|
750
|
+
else buttons.appendChild(backBtn);
|
|
751
|
+
|
|
752
|
+
topbar.addEventListener('contextmenu', (e) => {
|
|
753
|
+
e.preventDefault();
|
|
754
|
+
const rec = this._windowToLeaf.get(winId);
|
|
755
|
+
if (!rec) return;
|
|
756
|
+
const items = [
|
|
757
|
+
{ label: 'Back to tile', icon: 'close_fullscreen', action: 'back' },
|
|
758
|
+
];
|
|
759
|
+
if (this.desktops.desktops.length > 1) {
|
|
760
|
+
items.push({ separator: true });
|
|
761
|
+
for (const [i, d] of this.desktops.desktops.entries()) {
|
|
762
|
+
if (i === rec.desktopIdx) continue;
|
|
763
|
+
items.push({
|
|
764
|
+
label: `Move to desktop ${d.label}`, icon: 'sweep',
|
|
765
|
+
action: `move:${i}`,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
items.push({ separator: true });
|
|
770
|
+
items.push({ label: 'Close window', icon: 'close',
|
|
771
|
+
action: 'close', danger: true });
|
|
772
|
+
showContextMenu(e.clientX, e.clientY, items, (action) => {
|
|
773
|
+
if (action === 'back') this.bringBackWindow(winId);
|
|
774
|
+
else if (action === 'close') {
|
|
775
|
+
try { win.close({ force: true }); } catch {}
|
|
776
|
+
}
|
|
777
|
+
else if (action?.startsWith?.('move:')) {
|
|
778
|
+
this.moveWindowToDesktop(winId, Number(action.slice(5)));
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ── Panel-tiles (left nav / right / bottom) ─────────────────────
|
|
785
|
+
/** Panels are virtual: they live in the active desktop's tree as
|
|
786
|
+
* leaves with content kinds 'panel:left', 'panel:right',
|
|
787
|
+
* 'panel:bottom'. Toggling either inserts them at the appropriate
|
|
788
|
+
* edge (a recursive split) or closes that leaf. */
|
|
789
|
+
isPanelOpen(side) {
|
|
790
|
+
const d = this.desktops.active();
|
|
791
|
+
return !!d.panels[side];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
togglePanel(side) {
|
|
795
|
+
const d = this.desktops.active();
|
|
796
|
+
d.panels[side] = !d.panels[side];
|
|
797
|
+
this._canonicalize(this._tree(), d);
|
|
798
|
+
this.renderer.render();
|
|
799
|
+
this._persist();
|
|
800
|
+
this._notifyChange();
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Rebuild the tree so panel:* leaves sit in the canonical positions:
|
|
804
|
+
*
|
|
805
|
+
* v-split
|
|
806
|
+
* ├ h-split ← the "content row"
|
|
807
|
+
* │ ├ panel:left (if open)
|
|
808
|
+
* │ ├ content (whatever non-panel subtree)
|
|
809
|
+
* │ └ panel:right (if open)
|
|
810
|
+
* └ panel:bottom (if open, full width below the content row)
|
|
811
|
+
*
|
|
812
|
+
* Existing panel leaves are detached + re-attached (same id), so the
|
|
813
|
+
* renderer cache keeps the mounted content (nav filter state, etc.). */
|
|
814
|
+
_canonicalize(tree, desktop) {
|
|
815
|
+
// 1. Find existing panel leaves.
|
|
816
|
+
const panel = {};
|
|
817
|
+
for (const leaf of tree.leaves()) {
|
|
818
|
+
const kind = leaf.content?.kind;
|
|
819
|
+
if (kind === 'panel:left') panel.left = leaf;
|
|
820
|
+
else if (kind === 'panel:right') panel.right = leaf;
|
|
821
|
+
else if (kind === 'panel:bottom') panel.bottom = leaf;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 2. Detach each panel leaf from its current parent. (We don't
|
|
825
|
+
// use tree.close because that deletes the node; we want to
|
|
826
|
+
// preserve identity so the renderer keeps the mounted body.)
|
|
827
|
+
const detach = (leaf) => {
|
|
828
|
+
if (!leaf) return;
|
|
829
|
+
const pid = leaf.parentId;
|
|
830
|
+
if (!pid) {
|
|
831
|
+
tree.rootId = null;
|
|
832
|
+
leaf.parentId = null;
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const parent = tree.get(pid);
|
|
836
|
+
if (!parent) return;
|
|
837
|
+
const idx = parent.children.indexOf(leaf.id);
|
|
838
|
+
if (idx >= 0) {
|
|
839
|
+
parent.children.splice(idx, 1);
|
|
840
|
+
parent.sizes.splice(idx, 1);
|
|
841
|
+
}
|
|
842
|
+
leaf.parentId = null;
|
|
843
|
+
tree._collapse(parent);
|
|
844
|
+
};
|
|
845
|
+
detach(panel.left); detach(panel.right); detach(panel.bottom);
|
|
846
|
+
|
|
847
|
+
// 3. The content subtree may legitimately be empty — the user
|
|
848
|
+
// is allowed to close the last main tile and let panels
|
|
849
|
+
// take over the entire WM area. If so, seed root from the
|
|
850
|
+
// first requested panel; otherwise content stays as root.
|
|
851
|
+
let seededSide = null;
|
|
852
|
+
if (!tree.rootId) {
|
|
853
|
+
seededSide = desktop.panels.left ? 'left'
|
|
854
|
+
: desktop.panels.bottom ? 'bottom'
|
|
855
|
+
: desktop.panels.right ? 'right'
|
|
856
|
+
: null;
|
|
857
|
+
if (seededSide) {
|
|
858
|
+
const seed = panel[seededSide] || makeLeaf({
|
|
859
|
+
content: { kind: `panel:${seededSide}`, props: {} },
|
|
860
|
+
title: PANEL_TITLES[seededSide] || seededSide,
|
|
861
|
+
});
|
|
862
|
+
if (panel[seededSide] && PANEL_TITLES[seededSide]) {
|
|
863
|
+
panel[seededSide].title = PANEL_TITLES[seededSide];
|
|
864
|
+
}
|
|
865
|
+
if (!panel[seededSide]) tree._register(seed);
|
|
866
|
+
if (!tree.nodes.has(seed.id)) tree.nodes.set(seed.id, seed);
|
|
867
|
+
seed.parentId = null;
|
|
868
|
+
tree.rootId = seed.id;
|
|
869
|
+
}
|
|
870
|
+
// else: nothing requested; tree stays empty (renderer shows
|
|
871
|
+
// the "Empty desktop" placeholder).
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// 4. Wrap content row with left + right (h-split), then wrap
|
|
875
|
+
// that with bottom (v-split) so bottom spans full width.
|
|
876
|
+
const wrap = (panelLeaf, side) => {
|
|
877
|
+
// Re-register the leaf if it was orphaned by detach.
|
|
878
|
+
if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
|
|
879
|
+
tree.nodes.set(panelLeaf.id, panelLeaf);
|
|
880
|
+
}
|
|
881
|
+
const leaf = panelLeaf || makeLeaf({
|
|
882
|
+
content: { kind: `panel:${side}`, props: {} },
|
|
883
|
+
title: PANEL_TITLES[side] || side,
|
|
884
|
+
});
|
|
885
|
+
// Heal old leaves whose stored title is still 'left'/etc.
|
|
886
|
+
if (panelLeaf && PANEL_TITLES[side] && panelLeaf.title !== PANEL_TITLES[side]) {
|
|
887
|
+
panelLeaf.title = PANEL_TITLES[side];
|
|
888
|
+
}
|
|
889
|
+
if (!panelLeaf) tree._register(leaf);
|
|
890
|
+
const before = (side === 'left');
|
|
891
|
+
const dir = (side === 'bottom') ? 'v' : 'h';
|
|
892
|
+
const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
|
|
893
|
+
const sizes = (side === 'bottom') ? [4, 1]
|
|
894
|
+
: (side === 'left') ? [1, 4]
|
|
895
|
+
: [4, 1];
|
|
896
|
+
const split = {
|
|
897
|
+
id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
|
|
898
|
+
kind: 'split', parentId: null, dir, children, sizes,
|
|
899
|
+
};
|
|
900
|
+
tree.nodes.set(split.id, split);
|
|
901
|
+
const oldRoot = tree.get(tree.rootId);
|
|
902
|
+
if (oldRoot) oldRoot.parentId = split.id;
|
|
903
|
+
leaf.parentId = split.id;
|
|
904
|
+
tree.rootId = split.id;
|
|
905
|
+
};
|
|
906
|
+
// Skip the side already consumed as the seed root (would
|
|
907
|
+
// otherwise wrap a fresh duplicate leaf around the existing
|
|
908
|
+
// panel-as-root).
|
|
909
|
+
if (desktop.panels.left && seededSide !== 'left' && tree.rootId) wrap(panel.left, 'left');
|
|
910
|
+
if (desktop.panels.right && seededSide !== 'right' && tree.rootId) wrap(panel.right, 'right');
|
|
911
|
+
if (desktop.panels.bottom && seededSide !== 'bottom' && tree.rootId) wrap(panel.bottom, 'bottom');
|
|
912
|
+
|
|
913
|
+
// GC orphaned nodes (panel leaves whose `panels.X` flipped to
|
|
914
|
+
// false stayed in tree.nodes after detach).
|
|
915
|
+
const reachable = new Set();
|
|
916
|
+
const walk = (id) => {
|
|
917
|
+
if (!id || reachable.has(id)) return;
|
|
918
|
+
reachable.add(id);
|
|
919
|
+
const n = tree.get(id);
|
|
920
|
+
if (n?.kind === 'split') n.children.forEach(walk);
|
|
921
|
+
};
|
|
922
|
+
walk(tree.rootId);
|
|
923
|
+
for (const id of [...tree.nodes.keys()]) {
|
|
924
|
+
if (!reachable.has(id)) tree.nodes.delete(id);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
_findPanelLeaf(side) {
|
|
929
|
+
const kind = `panel:${side}`;
|
|
930
|
+
for (const leaf of this._tree().leaves()) {
|
|
931
|
+
if (leaf.content?.kind === kind) return leaf;
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── Desktops ────────────────────────────────────────────────────
|
|
937
|
+
switchDesktop(idx) {
|
|
938
|
+
this.desktops.ensureCount(idx + 1);
|
|
939
|
+
if (!this.desktops.switchTo(idx)) return;
|
|
940
|
+
const d = this.desktops.active();
|
|
941
|
+
// Runtime-created desktops (ensureCount / addDesktop) are bare
|
|
942
|
+
// home-leaf trees whose panels haven't been materialized yet —
|
|
943
|
+
// only desktops present at load() were canonicalized. Without
|
|
944
|
+
// this, the new desktop shows no panels while the toggle buttons
|
|
945
|
+
// (which read `d.panels`) report them open. Canonicalize on every
|
|
946
|
+
// switch: it's idempotent for already-canonical trees.
|
|
947
|
+
this._canonicalize(d.tree, d);
|
|
948
|
+
this.renderer.tree = d.tree;
|
|
949
|
+
this.renderer.render();
|
|
950
|
+
this._persist();
|
|
951
|
+
this._notifyChange();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
addDesktop() {
|
|
955
|
+
this.desktops.addDesktop();
|
|
956
|
+
this._notifyChange();
|
|
957
|
+
this._persist();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Step to the previous (-1) or next (+1) existing desktop, wrapping
|
|
961
|
+
* around. No-op with a single desktop. Iterates only desktops that
|
|
962
|
+
* already exist — use switchDesktop(idx) to create-on-demand. */
|
|
963
|
+
cycleDesktop(dir) {
|
|
964
|
+
const m = this.desktops;
|
|
965
|
+
const n = m.desktops.length;
|
|
966
|
+
if (n <= 1) return;
|
|
967
|
+
const next = (m.activeIdx + (dir < 0 ? -1 : 1) + n) % n;
|
|
968
|
+
this.switchDesktop(next);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Cycle the focused tile's main-panel tabs (Ctrl+Tab / Ctrl+Shift+Tab).
|
|
972
|
+
* Wraps; no-op when the focused leaf has 0 or 1 tabs. */
|
|
973
|
+
cycleFocusedTab(dir) {
|
|
974
|
+
const tree = this._tree();
|
|
975
|
+
const leafId = tree.focusedLeafId;
|
|
976
|
+
const leaf = leafId ? tree.get(leafId) : null;
|
|
977
|
+
const tabs = (leaf && Array.isArray(leaf.tabs)) ? leaf.tabs : [];
|
|
978
|
+
if (tabs.length <= 1) return;
|
|
979
|
+
const cur = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
|
|
980
|
+
const next = (cur + (dir < 0 ? -1 : 1) + tabs.length) % tabs.length;
|
|
981
|
+
this._leafTabAction(leafId, 'switch', { idx: next });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
removeDesktop(idx) {
|
|
985
|
+
const m = this.desktops;
|
|
986
|
+
if (m.desktops.length <= 1) return false;
|
|
987
|
+
if (idx < 0 || idx >= m.desktops.length) return false;
|
|
988
|
+
m.desktops.splice(idx, 1);
|
|
989
|
+
if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
|
|
990
|
+
this.renderer.tree = m.active().tree;
|
|
991
|
+
this.renderer.render();
|
|
992
|
+
this._persist();
|
|
993
|
+
this._notifyChange();
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
moveFocusedToDesktop(idx) {
|
|
998
|
+
const tree = this._tree();
|
|
999
|
+
const focused = tree.focused();
|
|
1000
|
+
if (!focused || !focused.content) return;
|
|
1001
|
+
const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
|
|
1002
|
+
this.desktops.ensureCount(idx + 1);
|
|
1003
|
+
const target = this.desktops.desktops[idx];
|
|
1004
|
+
// Place into the target tree's primary leaf.
|
|
1005
|
+
const targetTree = target.tree;
|
|
1006
|
+
const primary = targetTree.primaryLeafId();
|
|
1007
|
+
if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
|
|
1008
|
+
// Remove from current desktop.
|
|
1009
|
+
tree.close(focused.id);
|
|
1010
|
+
if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
|
|
1011
|
+
this.renderer.render();
|
|
1012
|
+
this._persist();
|
|
1013
|
+
this._notifyChange();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ── Internals ───────────────────────────────────────────────────
|
|
1017
|
+
_tree() { return this.desktops.active().tree; }
|
|
1018
|
+
|
|
1019
|
+
_leafAction(leafId, action) {
|
|
1020
|
+
const tree = this._tree();
|
|
1021
|
+
tree.focus(leafId);
|
|
1022
|
+
if (action === 'close') this.closeFocused();
|
|
1023
|
+
else if (action === 'promote') this.toggleManagedFocused();
|
|
1024
|
+
// Chrome split buttons mirror Alt+H / Alt+V: structural split of
|
|
1025
|
+
// this tile into a fresh empty pane (the user then fills it).
|
|
1026
|
+
else if (action === 'split-h') this.split('h');
|
|
1027
|
+
else if (action === 'split-v') this.split('v');
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Tab-strip event dispatcher. The renderer fires actions
|
|
1031
|
+
* (`switch` / `close` / `move` / `open-menu`) and the WM
|
|
1032
|
+
* translates them into tile-tree mutations + a re-render. */
|
|
1033
|
+
_leafTabAction(leafId, action, data = {}) {
|
|
1034
|
+
const tree = this._tree();
|
|
1035
|
+
const leaf = tree.get(leafId);
|
|
1036
|
+
if (!leaf || leaf.kind !== 'leaf') return;
|
|
1037
|
+
if (action === 'switch') {
|
|
1038
|
+
tree.setActiveLeafTab(leafId, data.idx);
|
|
1039
|
+
tree.focus(leafId);
|
|
1040
|
+
this.renderer.render();
|
|
1041
|
+
this._persist();
|
|
1042
|
+
this._notifyChange('tab-switch');
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (action === 'close') {
|
|
1046
|
+
const tabs = leaf.tabs || [];
|
|
1047
|
+
// Closing the last remaining tab closes the tile — same
|
|
1048
|
+
// behavior as Alt+W on a single-tab leaf today.
|
|
1049
|
+
if (tabs.length <= 1) {
|
|
1050
|
+
tree.focus(leafId);
|
|
1051
|
+
this.closeFocused();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
tree.removeLeafTab(leafId, data.idx);
|
|
1055
|
+
tree.focus(leafId);
|
|
1056
|
+
this.renderer.render();
|
|
1057
|
+
this._persist();
|
|
1058
|
+
this._notifyChange('twm-tab-close');
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (action === 'move') {
|
|
1062
|
+
tree.moveLeafTab(leafId, data.from, data.to);
|
|
1063
|
+
this.renderer.render();
|
|
1064
|
+
this._persist();
|
|
1065
|
+
this._notifyChange('tab-move');
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
if (action === 'menu') {
|
|
1069
|
+
this._showTabContextMenu(leafId, data.idx, data.x, data.y);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (action === 'close-others') {
|
|
1073
|
+
tree.closeOtherTabs(leafId, data.idx);
|
|
1074
|
+
this.renderer.render();
|
|
1075
|
+
this._persist();
|
|
1076
|
+
this._notifyChange('tab-close-others');
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (action === 'close-right') {
|
|
1080
|
+
tree.closeTabsAfter(leafId, data.idx);
|
|
1081
|
+
this.renderer.render();
|
|
1082
|
+
this._persist();
|
|
1083
|
+
this._notifyChange('tab-close-right');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (action === 'close-left') {
|
|
1087
|
+
tree.closeTabsBefore(leafId, data.idx);
|
|
1088
|
+
this.renderer.render();
|
|
1089
|
+
this._persist();
|
|
1090
|
+
this._notifyChange('tab-close-left');
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (action === 'open-menu') {
|
|
1094
|
+
// Defer to a per-WM callback if anyone wired one (the
|
|
1095
|
+
// hamburger menu mounts via this hook). The renderer hands
|
|
1096
|
+
// us screen coordinates so the menu can anchor below the
|
|
1097
|
+
// hamburger button.
|
|
1098
|
+
try {
|
|
1099
|
+
this.ctx?.onTileTabMenu?.(leafId, data.x, data.y);
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
console.warn('[wm] tab menu hook failed', err);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Central navigation entry point. Every nav action — left menu,
|
|
1107
|
+
* breadcrumb, landing-row click, in-tile reference, panel
|
|
1108
|
+
* reference, entity-to-entity — should route through this so the
|
|
1109
|
+
* one holistic routing concept lives in one place.
|
|
1110
|
+
*
|
|
1111
|
+
* A link declares its target as TWO axes:
|
|
1112
|
+
*
|
|
1113
|
+
* `opts.dest` — WHERE the content lands:
|
|
1114
|
+
* 'main' → the primary content tile (default).
|
|
1115
|
+
* The nav panel always uses this.
|
|
1116
|
+
* 'origin' → the tile/window the click came from
|
|
1117
|
+
* (needs `ctx`). Breadcrumbs use this so
|
|
1118
|
+
* a segment click stays in the current
|
|
1119
|
+
* tile (and inside a floating window when
|
|
1120
|
+
* windowed).
|
|
1121
|
+
* 'window' → a fresh managed window.
|
|
1122
|
+
* `opts.newTab` — HOW it lands in that destination:
|
|
1123
|
+
* false → replace the destination's active tab
|
|
1124
|
+
* (default). true → append a NEW tab.
|
|
1125
|
+
* Ignored for 'window' (one window = one
|
|
1126
|
+
* content).
|
|
1127
|
+
*
|
|
1128
|
+
* `opts.ctx` — the caller's mount context (`leafId`,
|
|
1129
|
+
* `windowId`); required for `dest:'origin'`.
|
|
1130
|
+
* `opts.transient` — the appended tab is not persisted/restored
|
|
1131
|
+
* (e.g. an add-row form). Only meaningful with
|
|
1132
|
+
* `newTab:true`.
|
|
1133
|
+
*
|
|
1134
|
+
* Back-compat: the legacy `opts.target` enum still works and maps
|
|
1135
|
+
* onto the axes — 'auto'→origin, 'tab'→origin+newTab,
|
|
1136
|
+
* 'primary'→main, 'window'→window. Prefer the two-axis form.
|
|
1137
|
+
*
|
|
1138
|
+
* The lower-level primitives (`openFromContext`,
|
|
1139
|
+
* `openInTabFromContext`, `openInTabInPrimary`, `openInPrimary`,
|
|
1140
|
+
* `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
|
|
1141
|
+
navigate(kind, props = {}, opts = {}) {
|
|
1142
|
+
const { ctx = null, transient = false } = opts;
|
|
1143
|
+
// Resolve the two axes, honoring the legacy `target` alias.
|
|
1144
|
+
let { dest = 'main', newTab = false } = opts;
|
|
1145
|
+
if (opts.target != null) {
|
|
1146
|
+
switch (opts.target) {
|
|
1147
|
+
case 'window': dest = 'window'; newTab = false; break;
|
|
1148
|
+
case 'primary': dest = 'main'; newTab = false; break;
|
|
1149
|
+
case 'tab': dest = 'origin'; newTab = true; break;
|
|
1150
|
+
case 'split-h': dest = 'split-h'; break;
|
|
1151
|
+
case 'split-v': dest = 'split-v'; break;
|
|
1152
|
+
case 'auto':
|
|
1153
|
+
default: dest = 'origin'; newTab = false; break;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
// Split destinations: split the originating tile (or, lacking a
|
|
1157
|
+
// tile context, the focused tile) and mount a fresh instance in
|
|
1158
|
+
// the new pane. This is the routing for the code-pane "open in
|
|
1159
|
+
// horizontal/vertical split" buttons.
|
|
1160
|
+
if (dest === 'split-h' || dest === 'split-v') {
|
|
1161
|
+
const dir = dest === 'split-h' ? 'h' : 'v';
|
|
1162
|
+
const leafId = ctx?.leafId || this._tree().focusedLeafId;
|
|
1163
|
+
return this.splitLeafWith(leafId, dir, kind, props, _tabTitle(kind, props));
|
|
1164
|
+
}
|
|
1165
|
+
if (dest === 'window') return this._navigateWindow(kind, props);
|
|
1166
|
+
if (dest === 'main') {
|
|
1167
|
+
return newTab
|
|
1168
|
+
? this.openInTabInPrimary(kind, props, transient)
|
|
1169
|
+
: this.openInPrimary(kind, props);
|
|
1170
|
+
}
|
|
1171
|
+
// dest === 'origin'
|
|
1172
|
+
return newTab
|
|
1173
|
+
? this._navigateTab(ctx, kind, props, transient)
|
|
1174
|
+
: this._navigateAuto(ctx, kind, props);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
_navigateAuto(ctx, kind, props) {
|
|
1178
|
+
// Windowed content → replace window in place.
|
|
1179
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
1180
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
// In-tile content → replace ACTIVE TAB only (preserves siblings).
|
|
1184
|
+
// Panel tiles (left/right/bottom) fall through — their clicks
|
|
1185
|
+
// target the primary content tile, not the panel itself.
|
|
1186
|
+
if (ctx?.leafId) {
|
|
1187
|
+
const tree = this._tree();
|
|
1188
|
+
const leaf = tree.get(ctx.leafId);
|
|
1189
|
+
const k = leaf?.content?.kind;
|
|
1190
|
+
if (leaf && k && !k.startsWith('panel:')) {
|
|
1191
|
+
this.openInLeaf(ctx.leafId, kind, props);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// Outside the tile (top-nav, panel, palette) → primary tile,
|
|
1196
|
+
// page-aware swap that preserves per-page tab archives.
|
|
1197
|
+
this.openInPrimary(kind, props);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
_navigateTab(ctx, kind, props, transient = false) {
|
|
1201
|
+
// Windows aren't tabbed — "open in tab" inside a window just
|
|
1202
|
+
// replaces the window's content.
|
|
1203
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
1204
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
this.openInTabFromContext(ctx || {}, kind, props, transient);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** Spawn a fresh ManagedWindow with the requested content. No
|
|
1211
|
+
* source leaf — closing the window just disposes the content. */
|
|
1212
|
+
_navigateWindow(kind, props) {
|
|
1213
|
+
const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
|
|
1214
|
+
const contentEl = document.createElement('div');
|
|
1215
|
+
contentEl.className = 'twm-window-content';
|
|
1216
|
+
contentEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;';
|
|
1217
|
+
const title = _tabTitle(kind, props);
|
|
1218
|
+
const mountInfo = this.content.mount(kind, contentEl, props,
|
|
1219
|
+
{ ...this.ctx, wm: this, windowId: winId });
|
|
1220
|
+
const win = new ManagedWindow({
|
|
1221
|
+
id: winId,
|
|
1222
|
+
title: mountInfo?.title || title,
|
|
1223
|
+
icon: 'web_asset',
|
|
1224
|
+
content: contentEl,
|
|
1225
|
+
canMinimize: true,
|
|
1226
|
+
canMaximize: true,
|
|
1227
|
+
canResize: true,
|
|
1228
|
+
modal: false,
|
|
1229
|
+
onClose: () => this._onManagedWindowClosed(winId, mountInfo),
|
|
1230
|
+
});
|
|
1231
|
+
// leafId is null — `_onManagedWindowClosed` already short-circuits
|
|
1232
|
+
// both branches when there's no source leaf, so the close path
|
|
1233
|
+
// just disposes the content and drops the map entry.
|
|
1234
|
+
this._windowToLeaf.set(winId, {
|
|
1235
|
+
leafId: null,
|
|
1236
|
+
desktopIdx: this.desktops.activeIdx,
|
|
1237
|
+
original: { kind, props: { ...(props || {}) },
|
|
1238
|
+
title: mountInfo?.title || title },
|
|
1239
|
+
mountInfo, window: win, contentEl,
|
|
1240
|
+
_demoting: false,
|
|
1241
|
+
});
|
|
1242
|
+
win.show();
|
|
1243
|
+
// Cascade: a fresh window centers by default, so opening one from
|
|
1244
|
+
// another window would land exactly on top — reading as the
|
|
1245
|
+
// calling window being replaced. Offset by the current window
|
|
1246
|
+
// count (cycling every 6) so each new window is slightly inset
|
|
1247
|
+
// from the last.
|
|
1248
|
+
const _n = this._windowToLeaf.size; // includes this new window
|
|
1249
|
+
if (_n > 1 && typeof win._applyPosition === 'function') {
|
|
1250
|
+
const step = ((_n - 1) % 6) * 28;
|
|
1251
|
+
win.x = (win.x || 0) + step;
|
|
1252
|
+
win.y = (win.y || 0) + step;
|
|
1253
|
+
win._applyPosition();
|
|
1254
|
+
}
|
|
1255
|
+
this._decorateManagedWindow(win, winId);
|
|
1256
|
+
this._notifyChange('window-spawned');
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** Browser-style tab context menu. Items reflect the leaf's current
|
|
1260
|
+
* tab list — "Close others" is hidden when only one tab is open,
|
|
1261
|
+
* "Close to the right / left" are hidden at the edges. The actual
|
|
1262
|
+
* mutations route back through `_leafTabAction` so persistence +
|
|
1263
|
+
* notify stay in one place. */
|
|
1264
|
+
_showTabContextMenu(leafId, idx, x, y) {
|
|
1265
|
+
const tree = this._tree();
|
|
1266
|
+
const leaf = tree.get(leafId);
|
|
1267
|
+
if (!leaf || leaf.kind !== 'leaf') return;
|
|
1268
|
+
const tabs = leaf.tabs || [];
|
|
1269
|
+
if (tabs.length === 0) return;
|
|
1270
|
+
const items = [
|
|
1271
|
+
{ label: 'Close tab', icon: 'close', action: 'close' },
|
|
1272
|
+
];
|
|
1273
|
+
if (tabs.length > 1) {
|
|
1274
|
+
items.push({ label: 'Close other tabs', icon: 'tab_close', action: 'close-others' });
|
|
1275
|
+
}
|
|
1276
|
+
if (idx < tabs.length - 1) {
|
|
1277
|
+
items.push({ label: 'Close tabs to the right', icon: 'chevron_right',
|
|
1278
|
+
action: 'close-right' });
|
|
1279
|
+
}
|
|
1280
|
+
if (idx > 0) {
|
|
1281
|
+
items.push({ label: 'Close tabs to the left', icon: 'chevron_left',
|
|
1282
|
+
action: 'close-left' });
|
|
1283
|
+
}
|
|
1284
|
+
showContextMenu(x, y, items, (action) => {
|
|
1285
|
+
this._leafTabAction(leafId, action, { idx });
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** Open content in a new tab on the leaf where the call originated.
|
|
1290
|
+
* Mirrors `openFromContext` (windowed / split-leaf / primary
|
|
1291
|
+
* routing) but uses `appendLeafTab` so the existing content
|
|
1292
|
+
* stays in place as a tab. */
|
|
1293
|
+
openInTabFromContext(ctx, kind, props = {}, transient = false) {
|
|
1294
|
+
// Managed-window content: just open in the window — managed
|
|
1295
|
+
// windows aren't tabbed (one window = one content).
|
|
1296
|
+
if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
|
|
1297
|
+
this.openInWindow(ctx.windowId, kind, props);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const tree = this._tree();
|
|
1301
|
+
let leafId = ctx?.leafId;
|
|
1302
|
+
if (!leafId) leafId = tree.primaryLeafId();
|
|
1303
|
+
const leaf = leafId ? tree.get(leafId) : null;
|
|
1304
|
+
const k = leaf?.content?.kind;
|
|
1305
|
+
if (!leaf || !leafId || (k && k.startsWith('panel:'))) {
|
|
1306
|
+
// Panel tiles can't host content tabs — fall back to the
|
|
1307
|
+
// primary leaf (matches openFromContext fall-through).
|
|
1308
|
+
this.openInPrimary(kind, props);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
|
|
1312
|
+
tree.focus(leafId);
|
|
1313
|
+
this.renderer.render();
|
|
1314
|
+
this._persist();
|
|
1315
|
+
this._notifyChange('tab-open');
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Open content as a NEW tab in the primary content tile, regardless
|
|
1319
|
+
* of where the call came from (the `dest:'main', newTab:true` path).
|
|
1320
|
+
* Unlike `openInTabFromContext`, this never falls back to a replace:
|
|
1321
|
+
* it targets the primary leaf directly and always appends, so a
|
|
1322
|
+
* click from outside the tile system (e.g. the bottom-panel
|
|
1323
|
+
* "Add row" button, which passes no ctx) reliably lands as a sibling
|
|
1324
|
+
* tab in the main tile rather than swapping its content. */
|
|
1325
|
+
openInTabInPrimary(kind, props = {}, transient = false) {
|
|
1326
|
+
const tree = this._tree();
|
|
1327
|
+
const leafId = tree.primaryLeafId();
|
|
1328
|
+
// No content tile on this desktop (e.g. a panels-only layout) —
|
|
1329
|
+
// open in a managed window rather than spawning a bare tile. The
|
|
1330
|
+
// caller asked for "a tab in the main tile"; with no main tile to
|
|
1331
|
+
// tab into, a floating window is the least-surprising fallback.
|
|
1332
|
+
if (!leafId) { this._navigateWindow(kind, props); return; }
|
|
1333
|
+
tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
|
|
1334
|
+
tree.focus(leafId);
|
|
1335
|
+
this.renderer.render();
|
|
1336
|
+
this._persist();
|
|
1337
|
+
this._notifyChange('tab-open');
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
/** Derive a tab's display title from the content it carries — entity
|
|
1343
|
+
* label first, then id, then the bare kind. Used everywhere a tab is
|
|
1344
|
+
* created, so the bar names what the user opened rather than the kind
|
|
1345
|
+
* of thing it is. The rename-bus listener in the WM keeps these in
|
|
1346
|
+
* sync when the underlying entity is renamed. */
|
|
1347
|
+
function _tabTitle(kind, props) {
|
|
1348
|
+
if (props && typeof props.label === 'string' && props.label) return props.label;
|
|
1349
|
+
if (props && (typeof props.id === 'string' || typeof props.id === 'number')
|
|
1350
|
+
&& String(props.id)) return String(props.id);
|
|
1351
|
+
return kind || '';
|
|
1352
|
+
}
|