flexdesk 0.17.18 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +1 -0
- package/css/flexdesk.css +7544 -5916
- package/css/reset.css +1 -1
- package/css/tokens.css +23 -1
- package/dist/charts.js +10 -6
- package/dist/charts.js.map +1 -1
- package/dist/chunk-5W4EEKSK.js +778 -0
- package/dist/chunk-5W4EEKSK.js.map +7 -0
- package/dist/chunk-DNJ3N4T4.js +82 -0
- package/dist/chunk-DNJ3N4T4.js.map +7 -0
- package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
- package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
- package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
- package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
- package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
- package/dist/chunk-PAI2ZUWV.js +260 -0
- package/dist/chunk-PAI2ZUWV.js.map +7 -0
- package/dist/chunk-Q7BALEPX.js +527 -0
- package/dist/chunk-Q7BALEPX.js.map +7 -0
- package/dist/chunk-QXAOTGCC.js +40 -0
- package/dist/chunk-QXAOTGCC.js.map +7 -0
- package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
- package/dist/chunk-RKVYIXDC.js.map +7 -0
- package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
- package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
- package/dist/chunk-UKP22RUG.js +219 -0
- package/dist/chunk-UKP22RUG.js.map +7 -0
- package/dist/chunk-VLOAI3HS.js +43 -0
- package/dist/chunk-VLOAI3HS.js.map +7 -0
- package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
- package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
- package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
- package/dist/chunk-ZL3VKINP.js.map +7 -0
- package/dist/editor.js +3 -2
- package/dist/editor.js.map +1 -1
- package/dist/flexdesk.css +7544 -5916
- package/dist/flow.js +29 -97
- package/dist/flow.js.map +3 -3
- package/dist/git.js +1356 -0
- package/dist/git.js.map +7 -0
- package/dist/reset.css +1 -1
- package/dist/tiles.js +20 -15
- package/dist/tiles.js.map +2 -2
- package/dist/tokens.css +23 -1
- package/dist/widgets.js +3967 -1188
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +120 -32
- package/dist/wm.js.map +2 -2
- package/docs/README.md +9 -4
- package/docs/best-practices.md +2 -2
- package/docs/components/chat-shell.md +198 -0
- package/docs/components/conversation.md +10 -0
- package/docs/components/filter-builder.md +246 -0
- package/docs/components/forms-and-dialogs.md +22 -0
- package/docs/components/git.md +355 -0
- package/docs/components/input-fields.md +167 -2
- package/docs/components/keymap.md +8 -0
- package/docs/components/menus.md +82 -2
- package/docs/components/panels-and-desktops.md +16 -5
- package/docs/components/shell.md +104 -1
- package/docs/components/workspace-picker.md +149 -0
- package/docs/concepts/building-and-packaging.md +2 -2
- package/docs/concepts/packages-and-entry-points.md +14 -13
- package/docs/concepts/state-guard.md +3 -3
- package/docs/quickstart.md +1 -1
- package/git.js +13 -0
- package/package.json +6 -1
- package/src/flow/kit/dom.js +28 -119
- package/src/flow/kit/step_picker.js +1 -1
- package/src/flow/kit/value_picker.js +1 -1
- package/src/flow/outline/menu.js +1 -1
- package/src/git/diff_viewer.js +212 -0
- package/src/git/diff_window.js +51 -0
- package/src/git/git_graph.js +401 -0
- package/src/git/git_ops.js +61 -0
- package/src/git/git_panel.js +755 -0
- package/src/git/index.js +9 -0
- package/src/git/line_diff.js +202 -0
- package/src/tiles/tile_base.js +5 -4
- package/src/tiling/desktops.js +10 -8
- package/src/tiling/keymap.js +6 -5
- package/src/tiling/kind_taxonomy.js +15 -2
- package/src/tiling/shell.js +124 -17
- package/src/tiling/tile_renderer.js +2 -3
- package/src/ui/components/action_dropdown.js +4 -78
- package/src/ui/components/anchored_layer.js +255 -0
- package/src/ui/components/chat_shell.js +920 -0
- package/src/ui/components/chat_state_machine.js +223 -0
- package/src/ui/components/choice_controls.js +45 -2
- package/src/ui/components/color_field.js +3 -2
- package/src/ui/components/color_list_field.js +3 -2
- package/src/ui/components/combobox.js +422 -0
- package/src/ui/components/conversation_view.js +17 -2
- package/src/ui/components/data_table.js +2 -2
- package/src/ui/components/filter_builder.js +766 -0
- package/src/ui/components/filter_model.js +614 -0
- package/src/ui/components/managed_window.js +24 -47
- package/src/ui/components/modal.js +79 -109
- package/src/ui/components/option_list.js +160 -0
- package/src/ui/components/popover.js +94 -0
- package/src/ui/components/recent_items.js +169 -0
- package/src/ui/components/select.js +322 -0
- package/src/ui/components/tag_input.js +77 -8
- package/src/ui/components/workspace_picker.js +1155 -0
- package/src/ui/utils/fire_event.js +17 -0
- package/src/ui/utils/focusable.js +25 -0
- package/src/ui/utils/modal_host.js +27 -0
- package/widgets.js +12 -2
- package/css/base.css +0 -10547
- package/css/overrides.css +0 -2596
- package/dist/chunk-7JI7HHSY.js +0 -107
- package/dist/chunk-7JI7HHSY.js.map +0 -7
- package/dist/chunk-MIW7D6WC.js +0 -526
- package/dist/chunk-MIW7D6WC.js.map +0 -7
- package/dist/chunk-PGZ4EHSE.js.map +0 -7
- package/dist/chunk-XJNADRGR.js.map +0 -7
- /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
package/src/git/index.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* line_diff.js — what changed between two texts, as lines to draw.
|
|
3
|
+
*
|
|
4
|
+
* Two ways in, one shape out:
|
|
5
|
+
*
|
|
6
|
+
* computeLineDiff(before, after) the two texts themselves, compared here
|
|
7
|
+
* parseUnifiedDiff(text) what `git diff` printed, read here
|
|
8
|
+
*
|
|
9
|
+
* Both answer an array of DiffLine:
|
|
10
|
+
*
|
|
11
|
+
* { kind: 'context' | 'add' | 'del', oldLine, newLine, text }
|
|
12
|
+
* { kind: 'gap', oldLine: null, newLine: null, text: '', hidden } unchanged lines left out
|
|
13
|
+
* { kind: 'hunk' | 'meta' | 'file', oldLine: null, newLine: null, text }
|
|
14
|
+
*
|
|
15
|
+
* `oldLine` and `newLine` are 1-based, and null on the side a line is not on.
|
|
16
|
+
* There is no DOM here and nothing about what the text is: a consumer that
|
|
17
|
+
* compares only part of a file says where that part starts (`beforeStart`,
|
|
18
|
+
* `afterStart`) and the numbers are the file's.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Past this many differing lines a side, computeLineDiff answers null rather than compare. */
|
|
22
|
+
export const LINE_DIFF_LIMIT = 1500;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A text as its lines. CRLF and LF are the same line end, and a final line end
|
|
26
|
+
* does not make a last empty line.
|
|
27
|
+
* @param {string|null|undefined} text
|
|
28
|
+
* @returns {string[]}
|
|
29
|
+
*/
|
|
30
|
+
export function splitLines(text) {
|
|
31
|
+
const s = String(text ?? '');
|
|
32
|
+
if (s === '') return [];
|
|
33
|
+
const lines = s.split(/\r?\n/);
|
|
34
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The edit script of two line arrays, by longest common subsequence: deletions before additions. */
|
|
39
|
+
function editScript(a, b) {
|
|
40
|
+
const n = a.length;
|
|
41
|
+
const m = b.length;
|
|
42
|
+
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
43
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
44
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
45
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const ops = [];
|
|
49
|
+
let i = 0;
|
|
50
|
+
let j = 0;
|
|
51
|
+
while (i < n || j < m) {
|
|
52
|
+
if (i < n && j < m && a[i] === b[j]) { ops.push(['context', i, j]); i++; j++; }
|
|
53
|
+
else if (i < n && (j >= m || lcs[i + 1][j] >= lcs[i][j + 1])) { ops.push(['del', i, -1]); i++; }
|
|
54
|
+
else { ops.push(['add', -1, j]); j++; }
|
|
55
|
+
}
|
|
56
|
+
return ops;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compare two texts line by line.
|
|
61
|
+
*
|
|
62
|
+
* Unchanged lines at the start and the end are set aside first, so `maxLines`
|
|
63
|
+
* limits the part that actually differs: a long file with a small edit is
|
|
64
|
+
* compared, however long it is.
|
|
65
|
+
*
|
|
66
|
+
* @param {string|null|undefined} before
|
|
67
|
+
* @param {string|null|undefined} after
|
|
68
|
+
* @param {Object} [options]
|
|
69
|
+
* @param {number} [options.context=3] - Unchanged lines kept around each change; `Infinity` keeps them all.
|
|
70
|
+
* @param {number} [options.maxLines=LINE_DIFF_LIMIT]
|
|
71
|
+
* @param {number} [options.beforeStart=1] - The line number of `before`'s first line.
|
|
72
|
+
* @param {number} [options.afterStart=1] - The line number of `after`'s first line.
|
|
73
|
+
* @returns {Array<Object>|null} DiffLines; `[]` when the texts have the same lines, `null` when too long to compare.
|
|
74
|
+
*/
|
|
75
|
+
export function computeLineDiff(before, after, {
|
|
76
|
+
context = 3,
|
|
77
|
+
maxLines = LINE_DIFF_LIMIT,
|
|
78
|
+
beforeStart = 1,
|
|
79
|
+
afterStart = 1,
|
|
80
|
+
} = {}) {
|
|
81
|
+
const a = splitLines(before);
|
|
82
|
+
const b = splitLines(after);
|
|
83
|
+
|
|
84
|
+
let head = 0;
|
|
85
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head++;
|
|
86
|
+
let tail = 0;
|
|
87
|
+
while (tail < a.length - head && tail < b.length - head
|
|
88
|
+
&& a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++;
|
|
89
|
+
|
|
90
|
+
const midA = a.slice(head, a.length - tail);
|
|
91
|
+
const midB = b.slice(head, b.length - tail);
|
|
92
|
+
if (midA.length === 0 && midB.length === 0) return [];
|
|
93
|
+
if (midA.length > maxLines || midB.length > maxLines) return null;
|
|
94
|
+
|
|
95
|
+
const ops = [];
|
|
96
|
+
for (let k = 0; k < head; k++) ops.push(['context', k, k]);
|
|
97
|
+
for (const [kind, i, j] of editScript(midA, midB)) {
|
|
98
|
+
ops.push([kind, i < 0 ? -1 : i + head, j < 0 ? -1 : j + head]);
|
|
99
|
+
}
|
|
100
|
+
for (let k = tail; k > 0; k--) ops.push(['context', a.length - k, b.length - k]);
|
|
101
|
+
|
|
102
|
+
// A line is kept when a change is within `context` lines of it, either way.
|
|
103
|
+
const keep = new Uint8Array(ops.length);
|
|
104
|
+
let reach = -1;
|
|
105
|
+
for (let k = 0; k < ops.length; k++) {
|
|
106
|
+
if (ops[k][0] !== 'context') reach = k + context;
|
|
107
|
+
if (k <= reach) keep[k] = 1;
|
|
108
|
+
}
|
|
109
|
+
reach = ops.length;
|
|
110
|
+
for (let k = ops.length - 1; k >= 0; k--) {
|
|
111
|
+
if (ops[k][0] !== 'context') reach = k - context;
|
|
112
|
+
if (k >= reach) keep[k] = 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const out = [];
|
|
116
|
+
let skipped = 0;
|
|
117
|
+
ops.forEach(([kind, i, j], k) => {
|
|
118
|
+
if (!keep[k]) { skipped++; return; }
|
|
119
|
+
if (skipped > 0 && out.length > 0) {
|
|
120
|
+
out.push({ kind: 'gap', oldLine: null, newLine: null, text: '', hidden: skipped });
|
|
121
|
+
}
|
|
122
|
+
skipped = 0;
|
|
123
|
+
out.push({
|
|
124
|
+
kind,
|
|
125
|
+
oldLine: i < 0 ? null : beforeStart + i,
|
|
126
|
+
newLine: j < 0 ? null : afterStart + j,
|
|
127
|
+
text: j < 0 ? a[i] : b[j],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read a unified diff into DiffLines: `diff --git`, `index`, `---` and `+++`
|
|
137
|
+
* headers are left out, a `@@` line is a `hunk`, and `\ No newline at end of
|
|
138
|
+
* file`, `Binary files … differ` and `rename from …`/`rename to …` are `meta`,
|
|
139
|
+
* so that a rename with no change in it is still something to read. A diff of several files has a
|
|
140
|
+
* `file` line before each file's hunks; a diff of one file has none.
|
|
141
|
+
* @param {string|null|undefined} text
|
|
142
|
+
* @returns {Array<Object>}
|
|
143
|
+
*/
|
|
144
|
+
export function parseUnifiedDiff(text) {
|
|
145
|
+
const lines = String(text ?? '').split(/\r?\n/);
|
|
146
|
+
const fileHeaders = lines.filter((l) => l.startsWith('diff --git ')).length;
|
|
147
|
+
const out = [];
|
|
148
|
+
const header = (kind, line) => out.push({ kind, oldLine: null, newLine: null, text: line });
|
|
149
|
+
let oldNo = 0;
|
|
150
|
+
let newNo = 0;
|
|
151
|
+
let oldLeft = 0;
|
|
152
|
+
let newLeft = 0;
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
155
|
+
const sign = line[0];
|
|
156
|
+
const body = line.slice(1);
|
|
157
|
+
if (sign === '-') {
|
|
158
|
+
out.push({ kind: 'del', oldLine: oldNo++, newLine: null, text: body });
|
|
159
|
+
oldLeft--;
|
|
160
|
+
} else if (sign === '+') {
|
|
161
|
+
out.push({ kind: 'add', oldLine: null, newLine: newNo++, text: body });
|
|
162
|
+
newLeft--;
|
|
163
|
+
} else if (sign === '\\') {
|
|
164
|
+
header('meta', line);
|
|
165
|
+
} else {
|
|
166
|
+
// A context line; a tool that strips trailing blanks leaves it empty.
|
|
167
|
+
out.push({ kind: 'context', oldLine: oldNo++, newLine: newNo++, text: body });
|
|
168
|
+
oldLeft--;
|
|
169
|
+
newLeft--;
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const hunk = HUNK_RE.exec(line);
|
|
174
|
+
if (hunk) {
|
|
175
|
+
oldNo = Number(hunk[1]);
|
|
176
|
+
newNo = Number(hunk[3]);
|
|
177
|
+
oldLeft = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
|
178
|
+
newLeft = hunk[4] === undefined ? 1 : Number(hunk[4]);
|
|
179
|
+
header('hunk', line);
|
|
180
|
+
} else if (line.startsWith('\\') || /^Binary files .* differ$/.test(line) || /^rename (from|to) /.test(line)) {
|
|
181
|
+
header('meta', line);
|
|
182
|
+
} else if (line.startsWith('diff --git ') && fileHeaders > 1) {
|
|
183
|
+
header('file', / b\/(.*)$/.exec(line)?.[1] ?? line.slice('diff --git '.length));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How many lines a diff adds and removes, for a one-glance summary.
|
|
191
|
+
* @param {Array<Object>} lines - DiffLines.
|
|
192
|
+
* @returns {{ added: number, removed: number }}
|
|
193
|
+
*/
|
|
194
|
+
export function diffStats(lines) {
|
|
195
|
+
let added = 0;
|
|
196
|
+
let removed = 0;
|
|
197
|
+
for (const l of lines) {
|
|
198
|
+
if (l.kind === 'add') added++;
|
|
199
|
+
else if (l.kind === 'del') removed++;
|
|
200
|
+
}
|
|
201
|
+
return { added, removed };
|
|
202
|
+
}
|
package/src/tiles/tile_base.js
CHANGED
|
@@ -10,6 +10,7 @@ import { openRawTracesWindow } from '../charts/plot_popout_window.js';
|
|
|
10
10
|
import { formatVariableLabel } from './config_schema.js';
|
|
11
11
|
import { ActionDropdown } from '../ui/components/action_dropdown.js';
|
|
12
12
|
import { modalHost } from '../ui/components/modal.js';
|
|
13
|
+
import { fireEvent } from '../ui/utils/fire_event.js';
|
|
13
14
|
// The host is INJECTED, not fetched from a global.
|
|
14
15
|
//
|
|
15
16
|
// This imported `appHost()` from ui/js/ecoagent/app_host.js — a tile reaching into
|
|
@@ -771,7 +772,7 @@ export class TileBase {
|
|
|
771
772
|
});
|
|
772
773
|
|
|
773
774
|
// Also dispatch DOM event for alternative handling
|
|
774
|
-
this.element
|
|
775
|
+
if (this.element) fireEvent(this.element, 'tile:config-request', {
|
|
775
776
|
bubbles: true,
|
|
776
777
|
detail: {
|
|
777
778
|
tileId: this.id,
|
|
@@ -779,7 +780,7 @@ export class TileBase {
|
|
|
779
780
|
config: this.config,
|
|
780
781
|
schema: this.getConfigSchema()
|
|
781
782
|
}
|
|
782
|
-
})
|
|
783
|
+
});
|
|
783
784
|
}
|
|
784
785
|
|
|
785
786
|
/**
|
|
@@ -791,13 +792,13 @@ export class TileBase {
|
|
|
791
792
|
const removeBtn = this.element?.querySelector('.tile-remove-btn');
|
|
792
793
|
|
|
793
794
|
// Only dispatch DOM event - let the dashboard handle confirmation
|
|
794
|
-
this.element
|
|
795
|
+
if (this.element) fireEvent(this.element, 'tile:remove-request', {
|
|
795
796
|
bubbles: true,
|
|
796
797
|
detail: {
|
|
797
798
|
tileId: this.id,
|
|
798
799
|
anchorElement: removeBtn
|
|
799
800
|
}
|
|
800
|
-
})
|
|
801
|
+
});
|
|
801
802
|
}
|
|
802
803
|
|
|
803
804
|
/**
|
package/src/tiling/desktops.js
CHANGED
|
@@ -217,11 +217,11 @@ export class DesktopManager {
|
|
|
217
217
|
* nothing, and an app can put almost anything in a title.
|
|
218
218
|
*
|
|
219
219
|
* And strict is not hostile to an old blob. A key the loader has always given
|
|
220
|
-
* a default may be absent: a desktop's `id`, `label` and `panels`, the
|
|
221
|
-
* later releases added (`panelShares`, `group`, `fixed`), and a leaf's
|
|
222
|
-
* and `pageTabs`. What `serialize()` has always written and the loader
|
|
223
|
-
* do without (`desktops`, a
|
|
224
|
-
*
|
|
220
|
+
* a default may be absent: a desktop's `id`, `label`, `tree` and `panels`, the
|
|
221
|
+
* keys later releases added (`panelShares`, `group`, `fixed`), and a leaf's
|
|
222
|
+
* `tabs` and `pageTabs`. What `serialize()` has always written and the loader
|
|
223
|
+
* cannot do without (`desktops`, a split's `dir`, `children` and `sizes`) may
|
|
224
|
+
* not.
|
|
225
225
|
*/
|
|
226
226
|
static deserialize(blob, { seed, panelDefaults = null } = {}) {
|
|
227
227
|
const m = new DesktopManager({ seed, panelDefaults });
|
|
@@ -231,9 +231,11 @@ export class DesktopManager {
|
|
|
231
231
|
m.desktops = blob.desktops.map((raw, i) => {
|
|
232
232
|
const at = `desktops[${i}]`;
|
|
233
233
|
_expect(_isRecord(raw), at, 'a desktop, an object', raw);
|
|
234
|
-
// `null`
|
|
235
|
-
//
|
|
236
|
-
|
|
234
|
+
// `null` (serialize() writes that) and no `tree` key at all (a host
|
|
235
|
+
// that composes the blob writes that) are a desktop with nothing on
|
|
236
|
+
// it, which is seeded below. Refusing one would set its siblings
|
|
237
|
+
// aside with it, and the next save would overwrite them.
|
|
238
|
+
_expect(raw.tree == null || (_isRecord(raw.tree) && _isRecord(raw.tree.root)),
|
|
237
239
|
`${at}.tree`, '{ root }, or null for an empty desktop', raw.tree);
|
|
238
240
|
_expect(raw.panels == null || _isRecord(raw.panels), `${at}.panels`, 'an object', raw.panels);
|
|
239
241
|
// The VALUES of `panels` and `panelShares` are not checked: the loader
|
package/src/tiling/keymap.js
CHANGED
|
@@ -126,7 +126,8 @@ export function keyboardShortcutsTopic(shortcuts = KEYMAP_SHORTCUTS) {
|
|
|
126
126
|
// is to be left unprevented (an F-key with no Nth top-nav button, Esc). `label`
|
|
127
127
|
// is the shortcuts table's words; an action without one is not listed.
|
|
128
128
|
|
|
129
|
-
const DEFAULT_NAV_SELECTOR = '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn'
|
|
129
|
+
const DEFAULT_NAV_SELECTOR = '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn, '
|
|
130
|
+
+ '.twm-fl-bar .twm-fl-item[data-kind]';
|
|
130
131
|
const DIRS = ['left', 'right', 'up', 'down'];
|
|
131
132
|
const _where = (d) => (d === 'up' || d === 'down' ? d : `on the ${d}`);
|
|
132
133
|
|
|
@@ -173,10 +174,10 @@ function _action(name) {
|
|
|
173
174
|
if (m[1] === 'nav') {
|
|
174
175
|
return {
|
|
175
176
|
label: `Jump to top-nav page ${n + 1}`,
|
|
176
|
-
// The
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
177
|
+
// The default finds the top bar's buttons and the page rail's. An
|
|
178
|
+
// embedder that draws its page buttons in markup of its own would
|
|
179
|
+
// otherwise find F1..F8 silently doing nothing. Passing `navSelector`
|
|
180
|
+
// is how such an embedder keeps the keys.
|
|
180
181
|
run: ({ navSelector }) => {
|
|
181
182
|
const btns = document.querySelectorAll(navSelector || DEFAULT_NAV_SELECTOR);
|
|
182
183
|
if (n >= btns.length) return false;
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
* after the root. Omit on the top-nav kind itself.
|
|
31
31
|
* isTopNav True iff this kind owns a slot in the top-nav strip.
|
|
32
32
|
* order Sort key for top-nav display (lower = leftward).
|
|
33
|
+
* railPin `'bottom'`: on a page rail (`chrome.rail`), this top-nav kind
|
|
34
|
+
* stands apart at the bottom, after every kind that is not
|
|
35
|
+
* pinned, whatever its `order`. The top strip ignores it.
|
|
33
36
|
* appGlobal True iff the kind is not scoped to the root entity (a
|
|
34
37
|
* preferences page, say). Its breadcrumb omits the root.
|
|
35
38
|
* sources Entity-source ids the tile hamburger lists when a tile
|
|
@@ -104,6 +107,14 @@ export function createTaxonomy({ kinds, root } = {}) {
|
|
|
104
107
|
throw new Error(
|
|
105
108
|
`createTaxonomy: '${kind}' is both a top-nav kind and owned by one`);
|
|
106
109
|
}
|
|
110
|
+
if (def.railPin !== undefined) {
|
|
111
|
+
if (def.railPin !== 'bottom') {
|
|
112
|
+
throw new TypeError(`createTaxonomy: '${kind}'.railPin must be 'bottom'`);
|
|
113
|
+
}
|
|
114
|
+
if (!def.isTopNav) {
|
|
115
|
+
throw new Error(`createTaxonomy: '${kind}' has a railPin but is not a top-nav kind`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
107
118
|
if (def.panels !== undefined) {
|
|
108
119
|
if (!def.panels || typeof def.panels !== 'object' || Array.isArray(def.panels)) {
|
|
109
120
|
throw new TypeError(`createTaxonomy: '${kind}'.panels must be an object`);
|
|
@@ -232,8 +243,9 @@ export function createTaxonomy({ kinds, root } = {}) {
|
|
|
232
243
|
};
|
|
233
244
|
|
|
234
245
|
/** Top-nav entries in display order:
|
|
235
|
-
* { kind, label (short), longLabel, icon }
|
|
236
|
-
* `label` is the chip text; `longLabel` is what the palette shows
|
|
246
|
+
* { kind, label (short), longLabel, icon, railPin }
|
|
247
|
+
* `label` is the chip text; `longLabel` is what the palette shows;
|
|
248
|
+
* `railPin` is the KindDef's, or null. */
|
|
237
249
|
const topNavEntries = () =>
|
|
238
250
|
Object.entries(_kinds)
|
|
239
251
|
.filter(([, m]) => m.isTopNav)
|
|
@@ -243,6 +255,7 @@ export function createTaxonomy({ kinds, root } = {}) {
|
|
|
243
255
|
label: m.shortLabel || m.label,
|
|
244
256
|
longLabel: m.label,
|
|
245
257
|
icon: m.icon,
|
|
258
|
+
railPin: m.railPin ?? null,
|
|
246
259
|
}));
|
|
247
260
|
|
|
248
261
|
/** Whether `kind` exists once (KindDef `single`). */
|
package/src/tiling/shell.js
CHANGED
|
@@ -138,9 +138,13 @@ import { openForm } from '../ui/components/modal.js';
|
|
|
138
138
|
* commands, after `palette.commands`'
|
|
139
139
|
* own, unless `palette: false`. Each needs
|
|
140
140
|
* the other: either alone is a boot error.
|
|
141
|
-
* @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
|
|
141
|
+
* @param {object} [cfg.chrome] { topNav?, rail?, paletteButton?, desktops?,
|
|
142
142
|
* panelToggles?: { left?, right?, bottom? },
|
|
143
143
|
* zoom?, menuBar? }
|
|
144
|
+
* `rail` is the element that becomes the
|
|
145
|
+
* vertical page rail (see `mountPageRail`),
|
|
146
|
+
* instead of `topNav`: giving both is a boot
|
|
147
|
+
* error, since F1..F12 press one row of pages.
|
|
144
148
|
* `zoom` is C31: the element the content
|
|
145
149
|
* zoom control is painted into — see
|
|
146
150
|
* zoom.js. Absent, there is no control and
|
|
@@ -185,9 +189,9 @@ export async function createShell({
|
|
|
185
189
|
desktopBar = {},
|
|
186
190
|
menuBar: menuBarCfg = null,
|
|
187
191
|
chrome = {},
|
|
188
|
-
// An embedder that
|
|
189
|
-
//
|
|
190
|
-
//
|
|
192
|
+
// An embedder that draws its page buttons in markup of its own passes the
|
|
193
|
+
// selector they match, and F1..F8 keep working. Omitted, the default finds
|
|
194
|
+
// `chrome.topNav`'s buttons and `chrome.rail`'s.
|
|
191
195
|
navSelector = null,
|
|
192
196
|
keymap = null,
|
|
193
197
|
} = {}) {
|
|
@@ -198,6 +202,9 @@ export async function createShell({
|
|
|
198
202
|
}
|
|
199
203
|
if (!taxonomy) throw new Error('createShell: a taxonomy is required');
|
|
200
204
|
if (!entities) throw new Error('createShell: an entity catalog is required');
|
|
205
|
+
if (chrome.topNav && chrome.rail) {
|
|
206
|
+
throw new TypeError('createShell: `chrome.topNav` and `chrome.rail` both paint the pages; give one');
|
|
207
|
+
}
|
|
201
208
|
if (chrome.menuBar && !menuBarCfg?.menus) {
|
|
202
209
|
throw new TypeError('createShell: `chrome.menuBar` needs `menuBar: { menus }`');
|
|
203
210
|
}
|
|
@@ -223,6 +230,7 @@ export async function createShell({
|
|
|
223
230
|
|
|
224
231
|
let wm = null;
|
|
225
232
|
let topNavEl = null;
|
|
233
|
+
let rail = null;
|
|
226
234
|
let desktopsEl = null;
|
|
227
235
|
let toggles = null;
|
|
228
236
|
|
|
@@ -231,6 +239,7 @@ export async function createShell({
|
|
|
231
239
|
syncPanelToggles(toggles, wm);
|
|
232
240
|
syncDesktopBar(desktopsEl, wm);
|
|
233
241
|
syncTopNav(topNavEl, wm);
|
|
242
|
+
rail?.sync();
|
|
234
243
|
};
|
|
235
244
|
|
|
236
245
|
wm = new WindowManager({
|
|
@@ -277,16 +286,18 @@ export async function createShell({
|
|
|
277
286
|
shortcuts: keymapShortcuts(bindings),
|
|
278
287
|
...paletteCfg, commands,
|
|
279
288
|
});
|
|
280
|
-
// `navSelector` lets an embedder
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
289
|
+
// `navSelector` lets an embedder with page buttons of its own keep F1..F8
|
|
290
|
+
// working. Omitted, the default finds `chrome.topNav`'s and `chrome.rail`'s.
|
|
291
|
+
// The keymap also binds `?` to help, so `dispose()` takes the help shortcut
|
|
292
|
+
// off with it.
|
|
284
293
|
const disposeKeymap = installKeymap({ wm, palette, navSelector, bindings, logger });
|
|
285
294
|
|
|
286
295
|
// Chrome — painted into the ELEMENTS the embedder handed over. An absent
|
|
287
296
|
// key means absent chrome, not a crash.
|
|
288
297
|
const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
|
|
289
|
-
|
|
298
|
+
const topNav = mountTopNav(chrome.topNav, taxonomy, wm);
|
|
299
|
+
topNavEl = topNav?.el ?? null;
|
|
300
|
+
rail = chrome.rail ? mountPageRail(chrome.rail, { wm }) : null;
|
|
290
301
|
desktopsEl = mountDesktopBar(chrome.desktops, wm, desktopBar || {});
|
|
291
302
|
// C31. The content zoom. Painted with the rest of the chrome and, unlike the
|
|
292
303
|
// rest, AWAITED before the first mount: its saved value is applied to `root`
|
|
@@ -320,6 +331,7 @@ export async function createShell({
|
|
|
320
331
|
chrome: Object.freeze({
|
|
321
332
|
paletteButtonEl: paletteBtn,
|
|
322
333
|
topNavEl,
|
|
334
|
+
railEl: rail?.el ?? null,
|
|
323
335
|
desktopsEl,
|
|
324
336
|
panelToggles: toggles,
|
|
325
337
|
// `get`/`set` so an embedder can drive the zoom from its own settings
|
|
@@ -355,6 +367,10 @@ export async function createShell({
|
|
|
355
367
|
// persist it under the host the old shell was given. The element
|
|
356
368
|
// goes; the next shell paints its own.
|
|
357
369
|
try { desktopsEl?.remove(); } catch { /* ignore */ }
|
|
370
|
+
// The rail's and the top nav's elements are the embedder's and outlive
|
|
371
|
+
// this shell; their buttons and listeners close over THIS wm, so they go.
|
|
372
|
+
try { rail?.dispose(); } catch { /* ignore */ }
|
|
373
|
+
try { topNav?.dispose(); } catch { /* ignore */ }
|
|
358
374
|
// Its listeners are on the document; a rebuilt shell paints its own.
|
|
359
375
|
try { menuBar?.dispose(); } catch { /* ignore */ }
|
|
360
376
|
},
|
|
@@ -385,27 +401,118 @@ function mountTopNav(hostEl, taxonomy, wm) {
|
|
|
385
401
|
`;
|
|
386
402
|
hostEl.appendChild(btn);
|
|
387
403
|
}
|
|
388
|
-
|
|
404
|
+
const onClick = (e) => {
|
|
389
405
|
const btn = e.target.closest('[data-kind]');
|
|
390
406
|
if (!btn) return;
|
|
391
407
|
wm.openInPrimary(btn.dataset.kind);
|
|
392
|
-
}
|
|
393
|
-
|
|
408
|
+
};
|
|
409
|
+
hostEl.addEventListener('click', onClick);
|
|
410
|
+
return {
|
|
411
|
+
el: hostEl,
|
|
412
|
+
dispose: () => {
|
|
413
|
+
hostEl.removeEventListener('click', onClick);
|
|
414
|
+
hostEl.replaceChildren();
|
|
415
|
+
},
|
|
416
|
+
};
|
|
394
417
|
}
|
|
395
418
|
|
|
396
|
-
|
|
397
|
-
|
|
419
|
+
/** The page the primary tile is on. topNavFor maps an entity kind onto its owning
|
|
420
|
+
* top-nav category. Falls back to the primary kind so unknown kinds at least try
|
|
421
|
+
* to match a button. */
|
|
422
|
+
function activePageKind(wm) {
|
|
398
423
|
const tree = wm.desktops.active().tree;
|
|
399
424
|
const primaryId = tree.primaryLeafId();
|
|
400
425
|
const primaryKind = primaryId ? tree.get(primaryId)?.content?.kind : null;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
426
|
+
return wm.taxonomy.topNavFor(primaryKind) || primaryKind;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function syncTopNav(hostEl, wm) {
|
|
430
|
+
if (!hostEl) return;
|
|
431
|
+
const topNavKind = activePageKind(wm);
|
|
404
432
|
hostEl.querySelectorAll('[data-kind]').forEach((b) => {
|
|
405
433
|
b.classList.toggle('twm-top-nav__btn--on', b.dataset.kind === topNavKind);
|
|
406
434
|
});
|
|
407
435
|
}
|
|
408
436
|
|
|
437
|
+
/**
|
|
438
|
+
* The vertical page rail: one icon button per top-nav kind, painted into the
|
|
439
|
+
* element it is given, in `order`, with the kinds whose KindDef says
|
|
440
|
+
* `railPin: 'bottom'` after the rest and held at the bottom of the bar. A button
|
|
441
|
+
* opens its kind in the primary tile. The active one is `.twm-fl-item--on` and
|
|
442
|
+
* `aria-current="page"`.
|
|
443
|
+
*
|
|
444
|
+
* `createShell` calls this for `chrome.rail`, and keeps it in step. An embedder
|
|
445
|
+
* that builds its own WindowManager calls it itself and calls `sync()` when the
|
|
446
|
+
* window manager changes.
|
|
447
|
+
*
|
|
448
|
+
* Its tooltips carry no placement: `TooltipService` places anything inside
|
|
449
|
+
* `.twm-fl-bar` on the right, and flips it at the window's edge.
|
|
450
|
+
*
|
|
451
|
+
* @param {Element} hostEl
|
|
452
|
+
* @param {object} cfg
|
|
453
|
+
* @param {object} cfg.wm the window manager; its taxonomy names the pages
|
|
454
|
+
* @param {(wm: object) => (string|null)} [cfg.activeKind] which page to light, or
|
|
455
|
+
* null for none. Default: the primary tile's
|
|
456
|
+
* page, by `taxonomy.topNavFor`.
|
|
457
|
+
* @param {(btn: HTMLButtonElement, entry: object) => void} [cfg.decorate] called
|
|
458
|
+
* once per button, with its `topNavEntries()`
|
|
459
|
+
* entry, to add a class or change its tooltip.
|
|
460
|
+
* @returns {{ el: Element, sync: () => void, dispose: () => void }}
|
|
461
|
+
*/
|
|
462
|
+
export function mountPageRail(hostEl, { wm, activeKind = activePageKind, decorate = null } = {}) {
|
|
463
|
+
if (!hostEl || typeof hostEl.appendChild !== 'function') {
|
|
464
|
+
throw new TypeError('mountPageRail: `hostEl` must be an element');
|
|
465
|
+
}
|
|
466
|
+
if (!wm) throw new TypeError('mountPageRail: `wm` is required');
|
|
467
|
+
hostEl.classList.add('twm-fl-bar');
|
|
468
|
+
if (!hostEl.hasAttribute('aria-label')) hostEl.setAttribute('aria-label', 'Pages');
|
|
469
|
+
|
|
470
|
+
const entries = wm.taxonomy.topNavEntries();
|
|
471
|
+
const buttons = [
|
|
472
|
+
...entries.filter((k) => k.railPin !== 'bottom'),
|
|
473
|
+
...entries.filter((k) => k.railPin === 'bottom'),
|
|
474
|
+
].map((k) => {
|
|
475
|
+
const btn = document.createElement('button');
|
|
476
|
+
btn.type = 'button';
|
|
477
|
+
btn.className = 'twm-fl-item twm-has-tooltip';
|
|
478
|
+
btn.classList.toggle('twm-fl-item--bottom', k.railPin === 'bottom');
|
|
479
|
+
btn.dataset.kind = k.kind;
|
|
480
|
+
btn.dataset.tooltip = k.longLabel;
|
|
481
|
+
btn.setAttribute('aria-label', k.longLabel);
|
|
482
|
+
const glyph = document.createElement('span');
|
|
483
|
+
glyph.className = 'material-symbols-outlined';
|
|
484
|
+
glyph.setAttribute('aria-hidden', 'true');
|
|
485
|
+
glyph.textContent = k.icon;
|
|
486
|
+
btn.appendChild(glyph);
|
|
487
|
+
decorate?.(btn, k);
|
|
488
|
+
return btn;
|
|
489
|
+
});
|
|
490
|
+
hostEl.replaceChildren(...buttons);
|
|
491
|
+
|
|
492
|
+
const onClick = (e) => {
|
|
493
|
+
const btn = e.target.closest('[data-kind]');
|
|
494
|
+
if (btn) wm.openInPrimary(btn.dataset.kind);
|
|
495
|
+
};
|
|
496
|
+
hostEl.addEventListener('click', onClick);
|
|
497
|
+
|
|
498
|
+
return Object.freeze({
|
|
499
|
+
el: hostEl,
|
|
500
|
+
sync: () => {
|
|
501
|
+
const active = activeKind(wm);
|
|
502
|
+
for (const btn of buttons) {
|
|
503
|
+
const on = btn.dataset.kind === active;
|
|
504
|
+
btn.classList.toggle('twm-fl-item--on', on);
|
|
505
|
+
if (on) btn.setAttribute('aria-current', 'page');
|
|
506
|
+
else btn.removeAttribute('aria-current');
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
dispose: () => {
|
|
510
|
+
hostEl.removeEventListener('click', onClick);
|
|
511
|
+
hostEl.replaceChildren();
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
409
516
|
/**
|
|
410
517
|
* The palette button, REBOUND rather than reused.
|
|
411
518
|
*
|
|
@@ -817,8 +817,7 @@ export class TileRenderer {
|
|
|
817
817
|
//
|
|
818
818
|
// No `e.button` test, unlike the pull: `dblclick` is dispatched for the
|
|
819
819
|
// primary button only. No `preventDefault`, and NO NEW CSS: the strip
|
|
820
|
-
// is already `user-select: none` (`css/
|
|
821
|
-
// into the generated `css/flexdesk.css:4743`), so a double-click here
|
|
820
|
+
// is already `user-select: none` (`css/flexdesk.css`), so a double-click here
|
|
822
821
|
// cannot word-select the title on its way to floating the pane.
|
|
823
822
|
chrome.addEventListener('dblclick', (e) => {
|
|
824
823
|
if (e.target.closest(CHROME_NO_FLOAT)) return;
|
|
@@ -1783,7 +1782,7 @@ export class TileRenderer {
|
|
|
1783
1782
|
/** The rectangle a release would fill.
|
|
1784
1783
|
*
|
|
1785
1784
|
* IT IS THE WINDOW DROP'S OWN PREVIEW ELEMENT — same two classes, same
|
|
1786
|
-
* stylesheet rules (`css/
|
|
1785
|
+
* stylesheet rules (`css/flexdesk.css`, `.twm-snap-preview`) — so a tab drop
|
|
1787
1786
|
* and a window drop cannot come to disagree about what a drop looks like.
|
|
1788
1787
|
* `--viewport` is what makes `position: fixed` apply, and that is required
|
|
1789
1788
|
* rather than cosmetic: the rectangle came from `getBoundingClientRect` on
|