flexdesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/THIRD_PARTY_LICENSES.md +53 -0
- package/css/base.css +3439 -0
- package/css/flexdesk.css +5797 -0
- package/css/overrides.css +2349 -0
- package/css/reset.css +46 -0
- package/css/tokens.css +137 -0
- package/dist/charts.js +780 -0
- package/dist/charts.js.map +7 -0
- package/dist/chunk-3PHCPZHT.js +222 -0
- package/dist/chunk-3PHCPZHT.js.map +7 -0
- package/dist/chunk-CT4YXXLP.js +1770 -0
- package/dist/chunk-CT4YXXLP.js.map +7 -0
- package/dist/chunk-DRYCDMEG.js +2217 -0
- package/dist/chunk-DRYCDMEG.js.map +7 -0
- package/dist/chunk-DVU44T77.js +855 -0
- package/dist/chunk-DVU44T77.js.map +7 -0
- package/dist/chunk-FL5KFNQH.js +640 -0
- package/dist/chunk-FL5KFNQH.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +117 -0
- package/dist/chunk-FOOS3T5L.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +32 -0
- package/dist/chunk-JYWURG5T.js.map +7 -0
- package/dist/chunk-TLZUUFOE.js +276 -0
- package/dist/chunk-TLZUUFOE.js.map +7 -0
- package/dist/chunk-UCJ2WD4D.js +625 -0
- package/dist/chunk-UCJ2WD4D.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +205 -0
- package/dist/chunk-WVFGV5FT.js.map +7 -0
- package/dist/core.js +1098 -0
- package/dist/core.js.map +7 -0
- package/dist/editor.js +1661 -0
- package/dist/editor.js.map +7 -0
- package/dist/flexdesk.css +5797 -0
- package/dist/host.js +132 -0
- package/dist/host.js.map +7 -0
- package/dist/reset.css +46 -0
- package/dist/tile_registry-6WZPTUZV.js +28 -0
- package/dist/tile_registry-6WZPTUZV.js.map +7 -0
- package/dist/tiles.js +2068 -0
- package/dist/tiles.js.map +7 -0
- package/dist/tokens.css +137 -0
- package/dist/widgets.js +5438 -0
- package/dist/widgets.js.map +7 -0
- package/dist/wm.js +4830 -0
- package/dist/wm.js.map +7 -0
- package/package.json +80 -0
- package/src/charts/chart_types.js +699 -0
- package/src/charts/data_series_plot_window.js +761 -0
- package/src/charts/downsample.js +119 -0
- package/src/charts/plot_config.js +176 -0
- package/src/charts/plot_popout_window.js +1560 -0
- package/src/charts/plotly_wrapper.js +432 -0
- package/src/core/event_bus.js +230 -0
- package/src/core/logging.js +300 -0
- package/src/core/settings.js +813 -0
- package/src/core/state_guard.js +382 -0
- package/src/core/state_guard_config.js +309 -0
- package/src/core/state_machine.js +258 -0
- package/src/editor/editor_pane.js +264 -0
- package/src/editor/monaco_editor_factory.js +449 -0
- package/src/editor/monaco_loader.js +74 -0
- package/src/editor/notebook_search.js +460 -0
- package/src/editor/notebook_tab_bar.js +456 -0
- package/src/editor/notebook_undo_manager.js +108 -0
- package/src/editor/split_pane_container.js +197 -0
- package/src/help/help_modal.js +409 -0
- package/src/help/help_registry.js +65 -0
- package/src/host/host.js +69 -0
- package/src/host/pywebview_host.js +133 -0
- package/src/tiles/config_schema.js +256 -0
- package/src/tiles/layout_persistence.js +662 -0
- package/src/tiles/tile_base.js +785 -0
- package/src/tiles/tile_grid.js +905 -0
- package/src/tiles/tile_registry.js +136 -0
- package/src/tiling/command_palette.js +328 -0
- package/src/tiling/content_registry.js +110 -0
- package/src/tiling/desktops.js +133 -0
- package/src/tiling/entity_sources.js +179 -0
- package/src/tiling/keymap.js +156 -0
- package/src/tiling/kind_taxonomy.js +172 -0
- package/src/tiling/landing_table.js +717 -0
- package/src/tiling/loading_overlay.js +33 -0
- package/src/tiling/nav_panel.js +142 -0
- package/src/tiling/page_factory.js +215 -0
- package/src/tiling/panel_keys.js +145 -0
- package/src/tiling/shell.js +437 -0
- package/src/tiling/tile_breadcrumb.js +222 -0
- package/src/tiling/tile_renderer.js +459 -0
- package/src/tiling/tile_tab_menu.js +340 -0
- package/src/tiling/tile_tree.js +880 -0
- package/src/tiling/wm.js +1352 -0
- package/src/ui/base/component_base.js +30 -0
- package/src/ui/base/controller_base.js +48 -0
- package/src/ui/base/page_base.js +102 -0
- package/src/ui/components/about_dialog.js +224 -0
- package/src/ui/components/action_dropdown.js +389 -0
- package/src/ui/components/attribute_list_editor.js +652 -0
- package/src/ui/components/autocomplete_field.js +732 -0
- package/src/ui/components/computing_status_window.js +81 -0
- package/src/ui/components/confirm_dialog.js +307 -0
- package/src/ui/components/context_menu.js +87 -0
- package/src/ui/components/data_table.js +2228 -0
- package/src/ui/components/detail_header.js +177 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/gallery_picker.js +147 -0
- package/src/ui/components/inline_renamer.js +129 -0
- package/src/ui/components/managed_window.js +804 -0
- package/src/ui/components/modal.js +512 -0
- package/src/ui/components/notification_history.js +264 -0
- package/src/ui/components/slide_out_panel.js +185 -0
- package/src/ui/components/slider_field.js +122 -0
- package/src/ui/components/sortable_list.js +309 -0
- package/src/ui/components/table_state_store.js +81 -0
- package/src/ui/components/toast.js +59 -0
- package/src/ui/components/tree_view.js +460 -0
- package/src/ui/components/window_taskbar.js +143 -0
- package/src/ui/controllers/panel_state_machine.js +634 -0
- package/src/ui/controllers/window_chrome_controller.js +659 -0
- package/src/ui/notification_center.js +576 -0
- package/src/ui/utils/overlay_scrollbar.js +492 -0
- package/src/ui/utils/raf_resize_observer.js +31 -0
- package/src/ui/utils/tooltip_service.js +406 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* desktops.js — independent tile trees per virtual desktop, persisted
|
|
3
|
+
* through the host's `state` capability under the logical key `desktops`.
|
|
4
|
+
* Where that key actually lands is the embedder's business (see the host's
|
|
5
|
+
* `resolvePath`); this module only names it.
|
|
6
|
+
*
|
|
7
|
+
* Each desktop = { id, label, tree (TileTree), windows ([]), panels }.
|
|
8
|
+
* Switching desktops swaps the active tree under the renderer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TileTree, makeLeaf } from './tile_tree.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_PANEL_STATE = {
|
|
14
|
+
left: true,
|
|
15
|
+
right: true,
|
|
16
|
+
bottom: true,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `seed()` yields the leaf a fresh (or emptied) desktop starts with. It comes
|
|
21
|
+
* from the taxonomy's root kind — see WindowManager, which supplies it. The
|
|
22
|
+
* WM used to hardcode `{ kind: 'home', title: 'Home' }` here, which meant an
|
|
23
|
+
* embedder with a different ontology still got an EcoAgent Home tile it had
|
|
24
|
+
* never registered, and got the content_registry placeholder instead. There is
|
|
25
|
+
* deliberately NO default: a silent 'home' fallback is the exact bug this
|
|
26
|
+
* parameter exists to remove.
|
|
27
|
+
*/
|
|
28
|
+
function _makeDesktop(label, seed) {
|
|
29
|
+
const tree = new TileTree();
|
|
30
|
+
tree.setRoot(makeLeaf(seed()));
|
|
31
|
+
return {
|
|
32
|
+
id: `desk-${Math.random().toString(36).slice(2, 8)}`,
|
|
33
|
+
label,
|
|
34
|
+
tree,
|
|
35
|
+
windows: [],
|
|
36
|
+
// boot default: left + right + bottom all open. Names are
|
|
37
|
+
// assigned by wm._canonicalize via PANEL_TITLES.
|
|
38
|
+
panels: { ...DEFAULT_PANEL_STATE },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class DesktopManager {
|
|
43
|
+
/** @param {{seed: () => object}} opts `seed` builds the root leaf. Required. */
|
|
44
|
+
constructor({ seed } = {}) {
|
|
45
|
+
if (typeof seed !== 'function') {
|
|
46
|
+
throw new Error('DesktopManager: a `seed` function is required (the taxonomy root leaf)');
|
|
47
|
+
}
|
|
48
|
+
this.seed = seed;
|
|
49
|
+
this.desktops = [_makeDesktop('1', seed)];
|
|
50
|
+
this.activeIdx = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
active() { return this.desktops[this.activeIdx]; }
|
|
54
|
+
|
|
55
|
+
switchTo(idx) {
|
|
56
|
+
if (idx < 0 || idx >= this.desktops.length) return false;
|
|
57
|
+
if (idx === this.activeIdx) return false;
|
|
58
|
+
this.activeIdx = idx;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ensureCount(n) {
|
|
63
|
+
while (this.desktops.length < n) {
|
|
64
|
+
this.desktops.push(_makeDesktop(String(this.desktops.length + 1), this.seed));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
addDesktop(label = null) {
|
|
69
|
+
const d = _makeDesktop(label || String(this.desktops.length + 1), this.seed);
|
|
70
|
+
this.desktops.push(d);
|
|
71
|
+
return d;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
serialize() {
|
|
75
|
+
return {
|
|
76
|
+
activeIdx: this.activeIdx,
|
|
77
|
+
desktops: this.desktops.map((d) => ({
|
|
78
|
+
id: d.id, label: d.label,
|
|
79
|
+
tree: d.tree.serialize(),
|
|
80
|
+
panels: { ...d.panels },
|
|
81
|
+
// windows serialized at WM level since we don't own
|
|
82
|
+
// managed-window state in this module.
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static deserialize(blob, { seed } = {}) {
|
|
88
|
+
const m = new DesktopManager({ seed });
|
|
89
|
+
if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
|
|
90
|
+
m.desktops = blob.desktops.map((raw) => ({
|
|
91
|
+
id: raw.id || `desk-${Math.random().toString(36).slice(2,8)}`,
|
|
92
|
+
label: raw.label || '?',
|
|
93
|
+
tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
|
|
94
|
+
windows: [],
|
|
95
|
+
panels: { ...DEFAULT_PANEL_STATE, ...(raw.panels || {}) },
|
|
96
|
+
}));
|
|
97
|
+
// Empty tree → seed with the taxonomy root so the desktop is usable.
|
|
98
|
+
for (const d of m.desktops) {
|
|
99
|
+
if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
|
|
100
|
+
}
|
|
101
|
+
m.activeIdx = Math.min(Math.max(0, blob.activeIdx | 0), m.desktops.length - 1);
|
|
102
|
+
return m;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Persistence helpers. They take a host `state` capability; a host without
|
|
108
|
+
* one is legal and they no-op. We don't fail loudly — the WM stays functional
|
|
109
|
+
* against a host that cannot persist (e.g. the standalone demo).
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
const DESKTOPS_KEY = 'desktops';
|
|
113
|
+
|
|
114
|
+
export async function loadDesktops(state) {
|
|
115
|
+
if (!state) return null;
|
|
116
|
+
try {
|
|
117
|
+
return await state.read(DESKTOPS_KEY);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.warn('[desktops] load failed', err);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function saveDesktops(state, blob) {
|
|
125
|
+
if (!state) return false;
|
|
126
|
+
try {
|
|
127
|
+
await state.write(DESKTOPS_KEY, blob);
|
|
128
|
+
return true;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn('[desktops] save failed', err);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entity_sources.js — the entity-source PORT.
|
|
3
|
+
*
|
|
4
|
+
* The command palette, the tile hamburger menu, and any other "find me a
|
|
5
|
+
* thing" surface need a list of every searchable entity. Where those
|
|
6
|
+
* entities come from — which endpoint returns them, what a row looks
|
|
7
|
+
* like, what to call it — is the EMBEDDER's knowledge, not the shell's.
|
|
8
|
+
*
|
|
9
|
+
* So the source list is INJECTED. The embedder describes its sources and
|
|
10
|
+
* hands them to `createEntityCatalog()` at boot. Nothing in this file
|
|
11
|
+
* names an entity type or a bridge route.
|
|
12
|
+
*
|
|
13
|
+
* ## EntitySource
|
|
14
|
+
*
|
|
15
|
+
* navKind The content kind used to open a row via `wm.navigate`.
|
|
16
|
+
* The taxonomy resolves its icon + label.
|
|
17
|
+
*
|
|
18
|
+
* list `(api) => Promise<rawList>` — per-source fetcher. Used by
|
|
19
|
+
* the fan-out path, and by `loadOne` when a caller wants a
|
|
20
|
+
* subset rather than the whole catalog.
|
|
21
|
+
*
|
|
22
|
+
* pluck? `(raw) => Array` — extract the actual list when the
|
|
23
|
+
* endpoint wraps it. Defaults to "raw is an array, else
|
|
24
|
+
* raw.items if that's an array, else []".
|
|
25
|
+
*
|
|
26
|
+
* shape `(row) => { id, label, hint? } | null` — normalise one raw
|
|
27
|
+
* row to the record shape every search surface consumes.
|
|
28
|
+
* Return `null` to skip the row.
|
|
29
|
+
*
|
|
30
|
+
* ## Aggregation
|
|
31
|
+
*
|
|
32
|
+
* An embedder whose backend can answer for every source in ONE call
|
|
33
|
+
* passes `aggregate: (api) => Promise<{ [navKind]: rawList } | null>`.
|
|
34
|
+
* The catalog prefers it and fans out only for the navKinds it failed to
|
|
35
|
+
* answer for. Absent ⇒ straight fan-out. Absent capability, absent key:
|
|
36
|
+
* the shell never probes for a named endpoint of its own accord.
|
|
37
|
+
*
|
|
38
|
+
* Errors from any single source are logged and treated as "empty", so
|
|
39
|
+
* one broken endpoint can't blank the entire palette.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
|
|
43
|
+
if (!Array.isArray(sources)) {
|
|
44
|
+
throw new TypeError('createEntityCatalog: `sources` must be an array');
|
|
45
|
+
}
|
|
46
|
+
for (const s of sources) {
|
|
47
|
+
if (!s?.navKind || typeof s.list !== 'function' || typeof s.shape !== 'function') {
|
|
48
|
+
throw new TypeError(
|
|
49
|
+
`createEntityCatalog: source '${s?.navKind ?? '?'}' needs navKind + list() + shape()`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (aggregate != null && typeof aggregate !== 'function') {
|
|
53
|
+
throw new TypeError('createEntityCatalog: `aggregate` must be a function when present');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const _sources = Object.freeze(sources.map((s) => Object.freeze({ ...s })));
|
|
57
|
+
const _byKind = new Map(_sources.map((s) => [s.navKind, s]));
|
|
58
|
+
const _aliases = Object.freeze({ ...aliases });
|
|
59
|
+
|
|
60
|
+
const get = (navKind) => _byKind.get(navKind) || null;
|
|
61
|
+
|
|
62
|
+
/** Unwrap one source's raw payload into a row array. */
|
|
63
|
+
const _extract = (src, raw) => {
|
|
64
|
+
const list = src.pluck
|
|
65
|
+
? src.pluck(raw)
|
|
66
|
+
: (Array.isArray(raw) ? raw
|
|
67
|
+
: Array.isArray(raw?.items) ? raw.items
|
|
68
|
+
: []);
|
|
69
|
+
return Array.isArray(list) ? list : [];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Shape a source's rows, dropping the ones `shape` rejects or
|
|
73
|
+
* throws on. Shared by the palette AND the tile menu so their
|
|
74
|
+
* semantics cannot drift apart — the menu used to re-implement
|
|
75
|
+
* this loop by hand. */
|
|
76
|
+
const shapeRows = (navKind, rows) => {
|
|
77
|
+
const src = _byKind.get(navKind);
|
|
78
|
+
if (!src || !Array.isArray(rows)) return [];
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
let shaped;
|
|
82
|
+
try { shaped = src.shape(row); }
|
|
83
|
+
catch (err) {
|
|
84
|
+
console.warn('[entity-catalog]', navKind, 'shape failed', err, row);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!shaped || !shaped.id) continue;
|
|
88
|
+
out.push(shaped);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Fetch ONE source's raw rows. Used by surfaces that only care
|
|
94
|
+
* about a subset of the catalog. */
|
|
95
|
+
const loadOne = async (navKind, api) => {
|
|
96
|
+
const src = _byKind.get(navKind);
|
|
97
|
+
if (!src || !api) return [];
|
|
98
|
+
try { return _extract(src, await src.list(api)); }
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.warn('[entity-catalog]', navKind, 'fetch failed', err);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** Fetch every source's raw rows, grouped by navKind.
|
|
106
|
+
*
|
|
107
|
+
* The aggregator runs first when the embedder supplied one; the
|
|
108
|
+
* fan-out only fires for sources it didn't answer for (it threw, it
|
|
109
|
+
* isn't there, or it doesn't know a newly-added navKind yet).
|
|
110
|
+
*
|
|
111
|
+
* Shape: `{ [navKind]: rawRow[] }` — every navKind is always
|
|
112
|
+
* present (empty on failure) so consumers can destructure safely. */
|
|
113
|
+
const loadGrouped = async (api) => {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const s of _sources) out[s.navKind] = [];
|
|
116
|
+
if (!api) return out;
|
|
117
|
+
|
|
118
|
+
let bulk = null;
|
|
119
|
+
if (aggregate) {
|
|
120
|
+
try { bulk = await aggregate(api); }
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.warn('[entity-catalog] aggregate failed, falling back to fan-out', err);
|
|
123
|
+
}
|
|
124
|
+
if (bulk && typeof bulk !== 'object') bulk = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const missing = [];
|
|
128
|
+
for (const src of _sources) {
|
|
129
|
+
const raw = bulk ? bulk[src.navKind] : undefined;
|
|
130
|
+
if (raw == null) { missing.push(src); continue; }
|
|
131
|
+
out[src.navKind] = _extract(src, raw);
|
|
132
|
+
}
|
|
133
|
+
if (missing.length === 0) return out;
|
|
134
|
+
|
|
135
|
+
await Promise.all(missing.map(async (src) => {
|
|
136
|
+
out[src.navKind] = await loadOne(src.navKind, api);
|
|
137
|
+
}));
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Flat-and-shaped form for search surfaces. Builds on
|
|
142
|
+
* `loadGrouped` so there is a single fetch path and the palette /
|
|
143
|
+
* explorer can't drift apart in coverage. */
|
|
144
|
+
const loadAll = async (api) => {
|
|
145
|
+
const grouped = await loadGrouped(api);
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const src of _sources) {
|
|
148
|
+
for (const shaped of shapeRows(src.navKind, grouped[src.navKind] || [])) {
|
|
149
|
+
out.push({ kind: src.navKind, ...shaped });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Resolve a user-typed type prefix to a list of canonical navKinds.
|
|
156
|
+
* Accepts the embedder's aliases first, then navKinds verbatim,
|
|
157
|
+
* then the underscore spelling of a hyphenated kind. Returns an
|
|
158
|
+
* array of navKinds on hit, `null` otherwise. */
|
|
159
|
+
const resolveTypePrefix = (prefix) => {
|
|
160
|
+
const p = String(prefix || '').toLowerCase().trim();
|
|
161
|
+
if (!p) return null;
|
|
162
|
+
if (_aliases[p]) return _aliases[p];
|
|
163
|
+
for (const src of _sources) {
|
|
164
|
+
if (src.navKind === p) return [src.navKind];
|
|
165
|
+
if (src.navKind.replace(/-/g, '_') === p) return [src.navKind];
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
sources: _sources,
|
|
172
|
+
get,
|
|
173
|
+
loadOne,
|
|
174
|
+
loadGrouped,
|
|
175
|
+
loadAll,
|
|
176
|
+
shapeRows,
|
|
177
|
+
resolveTypePrefix,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keymap.js — Alt-based shortcuts for the tiling window manager.
|
|
3
|
+
*
|
|
4
|
+
* Alt+H split focused tile horizontally (new empty pane)
|
|
5
|
+
* Alt+V split focused tile vertically (new empty pane)
|
|
6
|
+
* Alt+Shift+H open focused tile's content in a horizontal split
|
|
7
|
+
* Alt+Shift+V open focused tile's content in a vertical split
|
|
8
|
+
* Alt+T open focused tile's content in a new tab
|
|
9
|
+
* Alt+N open focused tile's content in a new window
|
|
10
|
+
* Alt+W close focused tile
|
|
11
|
+
* Alt+F promote focused tile <-> managed window (moves it)
|
|
12
|
+
* Alt+ArrowLeft/Right/Up/Down move focus
|
|
13
|
+
* Alt+Shift+Arrow… swap focused tile with neighbour
|
|
14
|
+
* Alt+1..9 switch desktop
|
|
15
|
+
* Alt+Shift+1..9 move focused tile to desktop N
|
|
16
|
+
* Ctrl+ArrowLeft/Right iterate virtual desktops
|
|
17
|
+
* Shift+ArrowLeft/Right iterate bottom-panel tabs (when present)
|
|
18
|
+
* Ctrl+Tab / Ctrl+Shift+Tab next / previous main-panel tab (focused tile)
|
|
19
|
+
* F1..F8 jump to the Nth top-nav page
|
|
20
|
+
* Ctrl+K open command palette
|
|
21
|
+
* ? open Help (lists every shortcut) — bound globally
|
|
22
|
+
* by help_modal.js, not here
|
|
23
|
+
* Esc close palette / managed-modal
|
|
24
|
+
*
|
|
25
|
+
* The full user-facing reference is the Help modal's "Keyboard shortcuts"
|
|
26
|
+
* topic (ui/js/help/help_service.js) — keep that table in sync when
|
|
27
|
+
* binding new chords here.
|
|
28
|
+
*
|
|
29
|
+
* The handler refuses to act when focus is in an editable field, unless
|
|
30
|
+
* the chord uses the Alt modifier (which is never typed into a field).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export function installKeymap({ wm, palette }) {
|
|
34
|
+
document.addEventListener('keydown', (e) => {
|
|
35
|
+
const inField = e.target?.closest?.(
|
|
36
|
+
'input, textarea, select, [contenteditable="true"]');
|
|
37
|
+
|
|
38
|
+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !e.shiftKey) {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
palette.toggle();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// ── Ctrl+Tab / Ctrl+Shift+Tab → next / previous main-panel tab in the
|
|
44
|
+
// focused tile (wraps). Works everywhere (Tab isn't a text key here);
|
|
45
|
+
// no-op when the tile has a single tab.
|
|
46
|
+
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === 'Tab') {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
wm.cycleFocusedTab(e.shiftKey ? -1 : 1);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (e.key === 'Escape') {
|
|
52
|
+
if (palette.isOpen()) { palette.close(); return; }
|
|
53
|
+
// Let managed-window system handle its own Esc.
|
|
54
|
+
}
|
|
55
|
+
// Backspace = walk one step up the breadcrumb hierarchy. Skips
|
|
56
|
+
// when the user is typing in a field so it doesn't steal
|
|
57
|
+
// text-deletion.
|
|
58
|
+
if (e.key === 'Backspace' && !inField && !e.metaKey && !e.ctrlKey) {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
wm.navigateBack?.();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── F1..F8 → jump straight to the Nth top-nav page. F-keys never
|
|
65
|
+
// type, but we still skip while a field is focused so editing
|
|
66
|
+
// flows (query editor, filters) keep keyboard focus.
|
|
67
|
+
const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
|
|
68
|
+
if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
69
|
+
const btns = document.querySelectorAll(
|
|
70
|
+
'.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn');
|
|
71
|
+
const idx = Number(fMatch[1]) - 1;
|
|
72
|
+
if (idx < btns.length) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
btns[idx].click();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Shift+Left/Right → previous/next bottom-panel tab. No-op when
|
|
80
|
+
// no bottom panel is mounted. Skipped in fields so Shift+Arrow
|
|
81
|
+
// keeps selecting text there.
|
|
82
|
+
if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !inField
|
|
83
|
+
&& (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
84
|
+
const tabs = [...document.querySelectorAll('.twm-bp__tabs .twm-bp__tab')];
|
|
85
|
+
if (tabs.length) {
|
|
86
|
+
const cur = tabs.findIndex((t) => t.classList.contains('twm-bp__tab--on'));
|
|
87
|
+
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
|
88
|
+
const next = ((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
tabs[next].click();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Ctrl+Left/Right → iterate virtual desktops (wraps).
|
|
96
|
+
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !inField
|
|
97
|
+
&& (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
wm.cycleDesktop(e.key === 'ArrowRight' ? 1 : -1);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!e.altKey) return;
|
|
104
|
+
// Browser Alt+letter triggers menu-access in some browsers — always preventDefault.
|
|
105
|
+
const key = e.key.toLowerCase();
|
|
106
|
+
|
|
107
|
+
// Desktop switch / move — Alt+digit
|
|
108
|
+
if (/^[1-9]$/.test(e.key)) {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
const idx = Number(e.key) - 1;
|
|
111
|
+
if (e.shiftKey) wm.moveFocusedToDesktop(idx);
|
|
112
|
+
else wm.switchDesktop(idx);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
switch (key) {
|
|
117
|
+
// Plain Alt+H/V split into an empty pane; with Shift they open
|
|
118
|
+
// the focused tile's own content in the new split.
|
|
119
|
+
case 'h':
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
e.shiftKey ? wm.splitFocusedWith('h') : wm.split('h');
|
|
122
|
+
return;
|
|
123
|
+
case 'v':
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
e.shiftKey ? wm.splitFocusedWith('v') : wm.split('v');
|
|
126
|
+
return;
|
|
127
|
+
case 't': e.preventDefault(); wm.openFocusedInTab(); return;
|
|
128
|
+
case 'n': e.preventDefault(); wm.openFocusedInWindow(); return;
|
|
129
|
+
case 'w': e.preventDefault(); wm.closeFocused(); return;
|
|
130
|
+
case 'f': e.preventDefault(); wm.toggleManagedFocused(); return;
|
|
131
|
+
case 'arrowleft':
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
e.shiftKey ? wm.moveFocused('left') : wm.focusDir('left');
|
|
134
|
+
return;
|
|
135
|
+
case 'arrowright':
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
e.shiftKey ? wm.moveFocused('right') : wm.focusDir('right');
|
|
138
|
+
return;
|
|
139
|
+
case 'arrowup':
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
e.shiftKey ? wm.moveFocused('up') : wm.focusDir('up');
|
|
142
|
+
return;
|
|
143
|
+
case 'arrowdown':
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
e.shiftKey ? wm.moveFocused('down') : wm.focusDir('down');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Suppress accidental browser menu-access on bare Alt+letter
|
|
150
|
+
// chords we don't bind (so the user doesn't get a Firefox/Chromium
|
|
151
|
+
// menu popping open mid-flow).
|
|
152
|
+
if (key.length === 1 && /[a-z]/.test(key) && !inField) {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kind_taxonomy.js — the taxonomy PORT.
|
|
3
|
+
*
|
|
4
|
+
* A "kind" is a content type the user can navigate to. The tiling shell
|
|
5
|
+
* needs to know four things about one: what to call it, what icon it
|
|
6
|
+
* wears, which top-nav slot owns it, and what sits above it. It must
|
|
7
|
+
* NOT know what any of them mean.
|
|
8
|
+
*
|
|
9
|
+
* So the taxonomy is INJECTED, not imported. The embedder describes its
|
|
10
|
+
* own ontology and hands it to `createTaxonomy()` at boot; the shell
|
|
11
|
+
* closes over the result. Nothing in this file names a kind, and the
|
|
12
|
+
* framework runs unchanged on a completely different vocabulary.
|
|
13
|
+
*
|
|
14
|
+
* Consumed by:
|
|
15
|
+
*
|
|
16
|
+
* - install.js → top-nav buttons + active-state highlight
|
|
17
|
+
* - tile_breadcrumb.js → breadcrumb segments
|
|
18
|
+
* - wm.js navigateBack → Backspace parent
|
|
19
|
+
* - command_palette.js → palette chip strip + row icons
|
|
20
|
+
* - tile_tab_menu.js → which sources a tile's hamburger lists
|
|
21
|
+
*
|
|
22
|
+
* ## KindDef
|
|
23
|
+
*
|
|
24
|
+
* label Long form, used in breadcrumb segments + palette.
|
|
25
|
+
* shortLabel Optional ≤4-char chip text for the top-nav.
|
|
26
|
+
* Falls back to `label` when omitted.
|
|
27
|
+
* icon Material Symbols name.
|
|
28
|
+
* topNav Top-nav category this kind belongs to. Determines which
|
|
29
|
+
* button lights up + which segment the breadcrumb inserts
|
|
30
|
+
* after the root. Omit on the top-nav kind itself.
|
|
31
|
+
* isTopNav True iff this kind owns a slot in the top-nav strip.
|
|
32
|
+
* order Sort key for top-nav display (lower = leftward).
|
|
33
|
+
* appGlobal True iff the kind is not scoped to the root entity (a
|
|
34
|
+
* preferences page, say). Its breadcrumb omits the root.
|
|
35
|
+
* sources Entity-source ids the tile hamburger lists when a tile
|
|
36
|
+
* is on this page. Only meaningful on top-nav kinds; the
|
|
37
|
+
* root's list is the fallback.
|
|
38
|
+
*
|
|
39
|
+
* ancestors? `(props) => [{ kind, id, label? }]` — the entity-level
|
|
40
|
+
* ancestors of ONE instance, root-most first, EXCLUDING
|
|
41
|
+
* the top-nav segment and the node itself. This is the
|
|
42
|
+
* hook that replaces every hardcoded "split the id to
|
|
43
|
+
* find its parent" branch: naming conventions are the
|
|
44
|
+
* embedder's business, not the shell's.
|
|
45
|
+
* labelOf? `(props) => string` — display label for ONE instance.
|
|
46
|
+
* Defaults to `props.label`, then `props.id`.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Build a taxonomy from an embedder's kind map. Validated eagerly and
|
|
50
|
+
* frozen — a typo in a `topNav` pointer is a boot error, not a chrome
|
|
51
|
+
* surface that silently renders nothing six clicks later. */
|
|
52
|
+
export function createTaxonomy({ kinds, root } = {}) {
|
|
53
|
+
if (!kinds || typeof kinds !== 'object') {
|
|
54
|
+
throw new TypeError('createTaxonomy: `kinds` must be an object of KindDefs');
|
|
55
|
+
}
|
|
56
|
+
if (!root || !kinds[root]) {
|
|
57
|
+
throw new Error(`createTaxonomy: root kind '${root}' is not in the taxonomy`);
|
|
58
|
+
}
|
|
59
|
+
for (const [kind, def] of Object.entries(kinds)) {
|
|
60
|
+
if (!def || typeof def !== 'object') {
|
|
61
|
+
throw new TypeError(`createTaxonomy: '${kind}' is not a KindDef`);
|
|
62
|
+
}
|
|
63
|
+
if (def.topNav && !kinds[def.topNav]) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`createTaxonomy: '${kind}' hangs off unknown topNav '${def.topNav}'`);
|
|
66
|
+
}
|
|
67
|
+
if (def.isTopNav && def.topNav) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`createTaxonomy: '${kind}' is both a top-nav kind and owned by one`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const _kinds = Object.freeze({ ...kinds });
|
|
73
|
+
|
|
74
|
+
/** Lookup. `undefined` for unknown kinds — chrome code treats that
|
|
75
|
+
* as "render the kind name verbatim". */
|
|
76
|
+
const meta = (kind) => _kinds[kind];
|
|
77
|
+
|
|
78
|
+
/** Top-nav category id that owns `kind`. For a top-nav kind itself,
|
|
79
|
+
* returns the same kind. `undefined` when the kind isn't in the
|
|
80
|
+
* taxonomy (caller renders a fallback). */
|
|
81
|
+
const topNavFor = (kind) => {
|
|
82
|
+
const m = _kinds[kind];
|
|
83
|
+
if (!m) return undefined;
|
|
84
|
+
return m.isTopNav ? kind : m.topNav;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Backspace target — walk one step up the KIND hierarchy. A
|
|
88
|
+
* top-nav page goes to the root; everything else goes to its
|
|
89
|
+
* top-nav. The root itself returns `null`. */
|
|
90
|
+
const parentKindFor = (kind) => {
|
|
91
|
+
if (kind === root) return null;
|
|
92
|
+
const m = _kinds[kind];
|
|
93
|
+
if (!m) return null;
|
|
94
|
+
if (m.isTopNav) return root;
|
|
95
|
+
return m.topNav || root;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Entity-level ancestors of one instance, root-most first. Empty
|
|
99
|
+
* unless the KindDef supplies an `ancestors` hook. Normalised to
|
|
100
|
+
* `{ kind, props }` so callers hand the result straight to
|
|
101
|
+
* `wm.navigate`. A throwing hook is a warning, never a crash: the
|
|
102
|
+
* breadcrumb degrades to its generic form. */
|
|
103
|
+
const ancestors = (kind, props) => {
|
|
104
|
+
const fn = _kinds[kind]?.ancestors;
|
|
105
|
+
if (typeof fn !== 'function') return [];
|
|
106
|
+
let out;
|
|
107
|
+
try { out = fn(props || {}); }
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.warn('[taxonomy] ancestors() threw for', kind, err);
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
if (!Array.isArray(out)) return [];
|
|
113
|
+
return out
|
|
114
|
+
.filter((a) => a && a.kind)
|
|
115
|
+
.map((a) => ({ kind: a.kind, props: { id: a.id, label: a.label ?? a.id } }));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The nearest entity-level ancestor, or `null`. */
|
|
119
|
+
const parentOf = (kind, props) => {
|
|
120
|
+
const chain = ancestors(kind, props);
|
|
121
|
+
return chain.length ? chain[chain.length - 1] : null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Display label for one instance of `kind`. */
|
|
125
|
+
const labelOf = (kind, props) => {
|
|
126
|
+
const fn = _kinds[kind]?.labelOf;
|
|
127
|
+
if (typeof fn === 'function') {
|
|
128
|
+
try {
|
|
129
|
+
const s = fn(props || {});
|
|
130
|
+
if (s) return String(s);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.warn('[taxonomy] labelOf() threw for', kind, err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (props?.label) return String(props.label);
|
|
136
|
+
return props?.id != null ? String(props.id) : '';
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Entity-source ids a tile on `kind` should offer. Falls back to
|
|
140
|
+
* the root's list, which is the "everything" default. */
|
|
141
|
+
const sourcesFor = (kind) => {
|
|
142
|
+
const s = _kinds[kind]?.sources ?? _kinds[root]?.sources;
|
|
143
|
+
return Array.isArray(s) ? s : [];
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Top-nav entries in display order:
|
|
147
|
+
* { kind, label (short), longLabel, icon }
|
|
148
|
+
* `label` is the chip text; `longLabel` is what the palette shows. */
|
|
149
|
+
const topNavEntries = () =>
|
|
150
|
+
Object.entries(_kinds)
|
|
151
|
+
.filter(([, m]) => m.isTopNav)
|
|
152
|
+
.sort((a, b) => (a[1].order ?? 1000) - (b[1].order ?? 1000))
|
|
153
|
+
.map(([kind, m]) => ({
|
|
154
|
+
kind,
|
|
155
|
+
label: m.shortLabel || m.label,
|
|
156
|
+
longLabel: m.label,
|
|
157
|
+
icon: m.icon,
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
root,
|
|
162
|
+
kinds: _kinds,
|
|
163
|
+
meta,
|
|
164
|
+
topNavFor,
|
|
165
|
+
parentKindFor,
|
|
166
|
+
ancestors,
|
|
167
|
+
parentOf,
|
|
168
|
+
labelOf,
|
|
169
|
+
sourcesFor,
|
|
170
|
+
topNavEntries,
|
|
171
|
+
});
|
|
172
|
+
}
|