flexdesk 0.17.18 → 0.18.1

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.
Files changed (126) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7547 -5891
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +30 -1
  6. package/dist/charts.js +83 -13
  7. package/dist/charts.js.map +2 -2
  8. package/dist/chunk-3BLIVOFD.js +778 -0
  9. package/dist/chunk-3BLIVOFD.js.map +7 -0
  10. package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
  11. package/dist/chunk-4X4SY3CP.js.map +7 -0
  12. package/dist/chunk-DNJ3N4T4.js +82 -0
  13. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  14. package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
  15. package/dist/chunk-HL3IIHU3.js.map +7 -0
  16. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  17. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  18. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  19. package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
  20. package/dist/chunk-NFZJ6P42.js.map +7 -0
  21. package/dist/chunk-PAI2ZUWV.js +260 -0
  22. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  23. package/dist/chunk-Q7BALEPX.js +527 -0
  24. package/dist/chunk-Q7BALEPX.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
  26. package/dist/chunk-QRBOIUU3.js.map +7 -0
  27. package/dist/chunk-QXAOTGCC.js +40 -0
  28. package/dist/chunk-QXAOTGCC.js.map +7 -0
  29. package/dist/chunk-UKP22RUG.js +219 -0
  30. package/dist/chunk-UKP22RUG.js.map +7 -0
  31. package/dist/chunk-VLOAI3HS.js +43 -0
  32. package/dist/chunk-VLOAI3HS.js.map +7 -0
  33. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  34. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7547 -5891
  38. package/dist/flow.js +29 -97
  39. package/dist/flow.js.map +3 -3
  40. package/dist/git.js +1356 -0
  41. package/dist/git.js.map +7 -0
  42. package/dist/reset.css +1 -1
  43. package/dist/tiles.js +20 -15
  44. package/dist/tiles.js.map +2 -2
  45. package/dist/tokens.css +30 -1
  46. package/dist/widgets.js +4146 -1199
  47. package/dist/widgets.js.map +4 -4
  48. package/dist/wm.js +120 -32
  49. package/dist/wm.js.map +2 -2
  50. package/docs/README.md +9 -4
  51. package/docs/best-practices.md +2 -2
  52. package/docs/components/charts.md +40 -0
  53. package/docs/components/chat-shell.md +204 -0
  54. package/docs/components/conversation.md +10 -0
  55. package/docs/components/filter-builder.md +292 -0
  56. package/docs/components/forms-and-dialogs.md +30 -0
  57. package/docs/components/git.md +355 -0
  58. package/docs/components/input-fields.md +191 -2
  59. package/docs/components/keymap.md +8 -0
  60. package/docs/components/menus.md +86 -2
  61. package/docs/components/panels-and-desktops.md +16 -5
  62. package/docs/components/shell.md +108 -1
  63. package/docs/components/workspace-picker.md +193 -0
  64. package/docs/concepts/building-and-packaging.md +2 -2
  65. package/docs/concepts/packages-and-entry-points.md +14 -13
  66. package/docs/concepts/state-guard.md +3 -3
  67. package/docs/quickstart.md +1 -1
  68. package/git.js +13 -0
  69. package/package.json +6 -1
  70. package/src/charts/plot_config_editor.js +92 -6
  71. package/src/charts/plot_grid.js +6 -1
  72. package/src/flow/kit/dom.js +28 -119
  73. package/src/flow/kit/step_picker.js +1 -1
  74. package/src/flow/kit/value_picker.js +1 -1
  75. package/src/flow/outline/menu.js +1 -1
  76. package/src/git/diff_viewer.js +212 -0
  77. package/src/git/diff_window.js +51 -0
  78. package/src/git/git_graph.js +401 -0
  79. package/src/git/git_ops.js +61 -0
  80. package/src/git/git_panel.js +755 -0
  81. package/src/git/index.js +9 -0
  82. package/src/git/line_diff.js +202 -0
  83. package/src/tiles/tile_base.js +5 -4
  84. package/src/tiling/desktops.js +10 -8
  85. package/src/tiling/keymap.js +6 -5
  86. package/src/tiling/kind_taxonomy.js +15 -2
  87. package/src/tiling/shell.js +124 -17
  88. package/src/tiling/tile_renderer.js +2 -3
  89. package/src/ui/components/action_dropdown.js +4 -78
  90. package/src/ui/components/anchored_layer.js +255 -0
  91. package/src/ui/components/chat_shell.js +926 -0
  92. package/src/ui/components/chat_state_machine.js +223 -0
  93. package/src/ui/components/choice_controls.js +45 -2
  94. package/src/ui/components/color_field.js +3 -2
  95. package/src/ui/components/color_list_field.js +3 -2
  96. package/src/ui/components/combobox.js +422 -0
  97. package/src/ui/components/context_menu.js +8 -2
  98. package/src/ui/components/conversation_view.js +17 -2
  99. package/src/ui/components/data_table.js +2 -2
  100. package/src/ui/components/filter_builder.js +791 -0
  101. package/src/ui/components/filter_model.js +682 -0
  102. package/src/ui/components/inline_renamer.js +10 -5
  103. package/src/ui/components/managed_window.js +24 -47
  104. package/src/ui/components/modal.js +104 -117
  105. package/src/ui/components/option_list.js +160 -0
  106. package/src/ui/components/popover.js +94 -0
  107. package/src/ui/components/recent_items.js +169 -0
  108. package/src/ui/components/select.js +322 -0
  109. package/src/ui/components/tag_input.js +77 -8
  110. package/src/ui/components/workspace_picker.js +1253 -0
  111. package/src/ui/utils/fire_event.js +17 -0
  112. package/src/ui/utils/focusable.js +25 -0
  113. package/src/ui/utils/modal_host.js +27 -0
  114. package/src/ui/utils/tooltip_service.js +81 -14
  115. package/widgets.js +12 -2
  116. package/css/base.css +0 -10547
  117. package/css/overrides.css +0 -2596
  118. package/dist/chunk-2Z2ENHR3.js.map +0 -7
  119. package/dist/chunk-7JI7HHSY.js +0 -107
  120. package/dist/chunk-7JI7HHSY.js.map +0 -7
  121. package/dist/chunk-7OL6F2AM.js.map +0 -7
  122. package/dist/chunk-MIW7D6WC.js +0 -526
  123. package/dist/chunk-MIW7D6WC.js.map +0 -7
  124. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  125. package/dist/chunk-XJNADRGR.js.map +0 -7
  126. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,160 @@
1
+ /**
2
+ * THE ROWS `createSelect`, `createCombobox` AND `createTagInput`'s SUGGESTIONS
3
+ * DRAW: a `role="listbox"` of options, each with a glyph, a label, a second line,
4
+ * a hint at the end of the row and, when disabled, its reason; `{ group, options }`
5
+ * sections under a heading; one active row that the arrow keys walk.
6
+ *
7
+ * Focus never comes here. The text box or trigger that holds it names the active
8
+ * row with `aria-activedescendant` (`onActive`), and a press on a row is
9
+ * prevented from taking focus, so that a list row can be chosen with the box
10
+ * still holding the caret, and no blur closes the list under the pointer.
11
+ *
12
+ * `aria-selected` says which row IS the current value; the active row is a class.
13
+ * They are different things in a select, where the highlight walks away from the
14
+ * value and comes back. Every word is built with `textContent`: these lists carry
15
+ * text users wrote.
16
+ *
17
+ * An internal part: not exported from `@flexdesk/widgets`.
18
+ */
19
+
20
+ let SEQ = 0;
21
+
22
+ function node(tag, className, text) {
23
+ const n = document.createElement(tag);
24
+ if (className) n.className = className;
25
+ if (text !== undefined && text !== null) n.textContent = String(text);
26
+ return n;
27
+ }
28
+
29
+ /** `text` with the first run of `needle` in a `<mark>`, so it is plain why a row is offered. */
30
+ function marked(target, text, needle) {
31
+ const at = needle ? text.toLowerCase().indexOf(needle.toLowerCase()) : -1;
32
+ if (at < 0) { target.textContent = text; return; }
33
+ target.append(text.slice(0, at), node('mark', 'twm-option-list__match', text.slice(at, at + needle.length)),
34
+ text.slice(at + needle.length));
35
+ }
36
+
37
+ /**
38
+ * @param {object} o
39
+ * @param {(option: object) => void} o.onPick A row was clicked. Never a disabled one.
40
+ * @param {(id: string|null) => void} [o.onActive] The active row's id changed.
41
+ */
42
+ export function createOptionList({ onPick, onActive = null }) {
43
+ const id = `twm-option-list-${++SEQ}`;
44
+ const el = node('div', 'twm-option-list');
45
+ el.id = id;
46
+ el.setAttribute('role', 'listbox');
47
+
48
+ /** Every row drawn: `{ option, row }`. */
49
+ let rows = [];
50
+ let active = -1;
51
+
52
+ const paintActive = () => {
53
+ rows.forEach(({ row }, i) => row.classList.toggle('twm-option-list__row--active', i === active));
54
+ const row = rows[active]?.row;
55
+ // jsdom does not implement it, and a test harness is no reason to drop it.
56
+ row?.scrollIntoView?.({ block: 'nearest' });
57
+ onActive?.(row ? row.id : null);
58
+ };
59
+
60
+ const rowFor = (option, { glyphs, mark, isCurrent }) => {
61
+ const row = node('div', 'twm-option-list__row');
62
+ row.id = `${id}-${rows.length}`;
63
+ row.setAttribute('role', 'option');
64
+ row.setAttribute('aria-selected', String(!!isCurrent?.(option)));
65
+ if (option.disabled) row.setAttribute('aria-disabled', 'true');
66
+ row.title = option.description ? `${option.label} — ${option.description}` : option.label;
67
+ if (glyphs) {
68
+ const glyph = node('span', 'twm-option-list__glyph material-symbols-outlined', option.icon || '');
69
+ glyph.setAttribute('aria-hidden', 'true');
70
+ row.appendChild(glyph);
71
+ }
72
+ const words = node('span', 'twm-option-list__words');
73
+ const label = node('span', 'twm-option-list__label');
74
+ marked(label, option.label, mark);
75
+ words.appendChild(label);
76
+ if (option.description) words.appendChild(node('span', 'twm-option-list__description', option.description));
77
+ if (option.disabled && option.reason) words.appendChild(node('span', 'twm-choice__reason', option.reason));
78
+ row.appendChild(words);
79
+ if (option.hint) row.appendChild(node('span', 'twm-option-list__hint', option.hint));
80
+ return row;
81
+ };
82
+
83
+ const enabled = () => rows.map((r, i) => (r.option.disabled ? -1 : i)).filter((i) => i >= 0);
84
+
85
+ // A press on the list leaves focus where it is. See the header.
86
+ el.addEventListener('mousedown', (ev) => ev.preventDefault());
87
+ el.addEventListener('click', (ev) => {
88
+ const row = ev.target.closest?.('[role="option"]');
89
+ const hit = rows.find((r) => r.row === row);
90
+ if (hit && !hit.option.disabled) onPick(hit.option);
91
+ });
92
+
93
+ return {
94
+ el,
95
+ id,
96
+ /**
97
+ * Draw `sections` (`[{ heading, items }]`, as `choiceSections` gives them).
98
+ * @param {object} [opts]
99
+ * @param {string} [opts.mark] The text to mark in each label.
100
+ * @param {(option: object) => boolean} [opts.isCurrent] Is this the current value?
101
+ */
102
+ setRows(sections, { mark = '', isCurrent = null } = {}) {
103
+ rows = [];
104
+ active = -1;
105
+ el.replaceChildren();
106
+ const glyphs = sections.some((s) => s.items.some((o) => o.icon));
107
+ for (const { heading, items } of sections) {
108
+ let parent = el;
109
+ if (heading != null) {
110
+ parent = node('div', 'twm-option-list__section');
111
+ parent.setAttribute('role', 'group');
112
+ parent.setAttribute('aria-label', heading);
113
+ const title = node('div', 'twm-choice__group', heading);
114
+ title.setAttribute('aria-hidden', 'true');
115
+ parent.appendChild(title);
116
+ el.appendChild(parent);
117
+ }
118
+ for (const option of items) {
119
+ const row = rowFor(option, { glyphs, mark, isCurrent });
120
+ rows.push({ option, row });
121
+ parent.appendChild(row);
122
+ }
123
+ }
124
+ paintActive();
125
+ },
126
+ /** One line of text in place of the rows: nothing matched, still loading, a failure. */
127
+ setMessage(text) {
128
+ rows = [];
129
+ active = -1;
130
+ el.replaceChildren(node('div', 'twm-option-list__message', text));
131
+ paintActive();
132
+ },
133
+ /** How many rows are drawn. */
134
+ get size() { return rows.length; },
135
+ /** The drawn options, in order. */
136
+ options: () => rows.map((r) => r.option),
137
+ /** The active row's option, or null. */
138
+ activeOption: () => rows[active]?.option ?? null,
139
+ /** Make row `index` active; -1, or a row that is disabled, makes none active. */
140
+ setActive(index) {
141
+ active = rows[index] && !rows[index].option.disabled ? index : -1;
142
+ paintActive();
143
+ },
144
+ /** The next row that may be chosen, by `step`, wrapping. From none: the first or the last. */
145
+ move(step) {
146
+ const stops = enabled();
147
+ if (!stops.length) return;
148
+ const at = stops.indexOf(active);
149
+ const next = at < 0 ? (step > 0 ? 0 : stops.length - 1) : (at + step + stops.length) % stops.length;
150
+ active = stops[next];
151
+ paintActive();
152
+ },
153
+ /** The first (`step` 1) or last (`step` -1) row that may be chosen. */
154
+ moveToEnd(step) {
155
+ const stops = enabled();
156
+ active = stops.length ? stops[step > 0 ? 0 : stops.length - 1] : -1;
157
+ paintActive();
158
+ },
159
+ };
160
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * A POPOVER — a panel of rich content hung off the element that opened it. (0.18.0)
3
+ *
4
+ * `role="dialog"`, on an anchored layer (`anchored_layer.js`, which says how it is
5
+ * placed and what closes it). Focus moves into it on open and comes back to the
6
+ * anchor on close. Opening one from the anchor that holds an open one closes it,
7
+ * a toggle; opening one anywhere else closes the open one, unless that one owns
8
+ * the new anchor (`anchored_layer.js`).
9
+ *
10
+ * TAB STAYS INSIDE, cycling the panel's controls, as a modal's and a `CategoryPicker`'s
11
+ * do. The panel is mounted at the end of the page, so past its edge lies whatever comes
12
+ * next in the document, which is not the anchor's neighbour and, over a modal, is behind
13
+ * it. Escape is the way out.
14
+ */
15
+ import { openAnchoredLayer } from './anchored_layer.js';
16
+ import { collectFocusable } from '../utils/focusable.js';
17
+
18
+ /** The open popovers, to find the one an anchor holds. */
19
+ const OPEN = new Set();
20
+
21
+ /** What a call answers when nothing opened. */
22
+ const nothingOpened = (toggled) => ({ el: null, close() {}, closed: Promise.resolve(), reposition() {}, toggled });
23
+
24
+ /** Tab at the panel's edge goes round to the other edge. */
25
+ function keepTabInside(el) {
26
+ el.addEventListener('keydown', (ev) => {
27
+ if (ev.key !== 'Tab' || ev.defaultPrevented) return;
28
+ const controls = collectFocusable(el);
29
+ if (!controls.length) { ev.preventDefault(); return; }
30
+ const first = controls[0];
31
+ const last = controls[controls.length - 1];
32
+ const active = el.ownerDocument.activeElement;
33
+ if (ev.shiftKey ? (active === first || active === el) : active === last) {
34
+ ev.preventDefault();
35
+ (ev.shiftKey ? last : first).focus();
36
+ }
37
+ });
38
+ }
39
+
40
+ /**
41
+ * @param {object} o
42
+ * @param {HTMLElement} o.anchor What opened it; focus returns here.
43
+ * @param {HTMLElement} o.content The popover's body.
44
+ * @param {string} [o.label] Its accessible name.
45
+ * @param {string} [o.className] Extra classes on the panel.
46
+ * @param {HTMLElement} [o.focus] What takes focus on open; else the panel.
47
+ * @param {'start'|'end'} [o.align] Which anchor edge it lines up with first.
48
+ * @param {string} [o.inside] A selector for floating things of your own that
49
+ * are not "outside" it.
50
+ * @param {(reason: string) => void} [o.onClose] After it is gone.
51
+ * @returns {{el: HTMLElement|null, close: (reason?: string) => void, closed: Promise<void>,
52
+ * reposition: () => void, toggled: boolean}}
53
+ * `el` is null when nothing opened, and `onClose` is not called for it: `toggled`
54
+ * is true when the call only CLOSED the popover its anchor held, false when
55
+ * the anchor has left the document.
56
+ */
57
+ export function openPopover({ anchor, content, label = '', className = '', focus = null, align = 'start',
58
+ inside = null, onClose = null }) {
59
+ for (const open of OPEN) {
60
+ if (anchor && open.anchor === anchor) {
61
+ open.close('toggle');
62
+ return nothingOpened(true);
63
+ }
64
+ }
65
+
66
+ const el = document.createElement('div');
67
+ el.className = `twm-popover${className ? ` ${className}` : ''}`;
68
+ el.setAttribute('role', 'dialog');
69
+ if (label) el.setAttribute('aria-label', label);
70
+ el.tabIndex = -1;
71
+ el.appendChild(content);
72
+ keepTabInside(el);
73
+
74
+ let settle;
75
+ const closed = new Promise((resolve) => { settle = resolve; });
76
+ const layer = openAnchoredLayer({
77
+ anchor, el, align, gap: 4, inside,
78
+ onClose(reason) {
79
+ OPEN.delete(handle);
80
+ anchor?.classList?.remove('twm-is-open');
81
+ anchor?.setAttribute?.('aria-expanded', 'false');
82
+ onClose?.(reason);
83
+ settle();
84
+ },
85
+ });
86
+ if (!layer) return nothingOpened(false);
87
+ const handle = { el, anchor, closed, toggled: false, close: layer.close, reposition: layer.place };
88
+ OPEN.add(handle);
89
+ anchor?.classList?.add('twm-is-open');
90
+ if (anchor && !anchor.hasAttribute?.('aria-haspopup')) anchor.setAttribute?.('aria-haspopup', 'dialog');
91
+ anchor?.setAttribute?.('aria-expanded', 'true');
92
+ (focus || el).focus?.({ preventScroll: true });
93
+ return handle;
94
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * recent_items.js — a most-recent-first list kept through the Host's `state`
3
+ * capability, so it survives a restart.
4
+ *
5
+ * const recent = createRecentItems({ host, key: 'recent-workspaces' });
6
+ * await recent.load();
7
+ * recent.add({ id: 'w1', name: 'North' });
8
+ * recent.list(); // [{ id: 'w1', name: 'North', openedAt: 1730... }]
9
+ *
10
+ * An entry is any plain object; `identity` says which two are the same one, and
11
+ * `add` stamps `openedAt`. `capacity` is per `groupOf(entry)` when that is
12
+ * given, so one busy kind of entry never pushes another kind out.
13
+ *
14
+ * ══ A READ THAT FAILS IS NOT AN EMPTY LIST ═════════════════════════════════
15
+ *
16
+ * A `host.state.read` that rejects says nothing about what is stored, so until
17
+ * a read succeeds the stored list is never written: writing would replace
18
+ * entries nobody has seen. Changes made in the meantime show in `list()` at
19
+ * once, are kept, are replayed onto the stored list when it is read, and each
20
+ * of them asks for the read again. A read that answers something other than an
21
+ * array is a read: the store is empty.
22
+ *
23
+ * Writes run one at a time and the last one carries the latest list, because a
24
+ * host may answer them out of order and the older list must not win.
25
+ *
26
+ * A host with no `state` capability keeps the list in memory for this session.
27
+ */
28
+
29
+ /**
30
+ * @param {object} options
31
+ * @param {object} options.host - The Host; its `state` keeps the list
32
+ * @param {string} options.key - The state key the list is stored under
33
+ * @param {(entry: object) => string} [options.identity] - Which entries are the same one
34
+ * @param {(entry: unknown) => boolean} [options.isValid] - Stored entries that fail this
35
+ * are dropped on read, as is one that makes it, `identity` or `groupOf` throw, so
36
+ * `identity` may assume an entry's shape.
37
+ * @param {number} [options.capacity=10] - The most entries kept, per group
38
+ * @param {(entry: object) => string} [options.groupOf] - Groups the capacity counts within
39
+ * @param {() => void} [options.onChange] - After every change, and after a read that
40
+ * replayed changes
41
+ * @param {object} [options.logger] - Its `warn` hears a failed read or write
42
+ */
43
+ export function createRecentItems({
44
+ host,
45
+ key,
46
+ identity = (entry) => entry.id,
47
+ isValid = (entry) => !!entry && typeof entry === 'object' && !!identity(entry),
48
+ capacity = 10,
49
+ groupOf = () => '',
50
+ onChange = null,
51
+ logger = null,
52
+ }) {
53
+ const state = host?.state ?? null;
54
+ let entries = [];
55
+ let loaded = !state;
56
+ /** The changes made while the stored list is unread, oldest first: list => list. */
57
+ let pending = [];
58
+ let reading = null;
59
+ let writing = false;
60
+ let writeAgain = false;
61
+
62
+ const capped = (list) => {
63
+ const counts = new Map();
64
+ return list.filter((entry) => {
65
+ const group = groupOf(entry);
66
+ const count = (counts.get(group) ?? 0) + 1;
67
+ counts.set(group, count);
68
+ return count <= capacity;
69
+ });
70
+ };
71
+
72
+ const without = (list, entry) => {
73
+ const id = identity(entry);
74
+ return list.filter((other) => identity(other) !== id);
75
+ };
76
+
77
+ async function write() {
78
+ if (!state) return;
79
+ if (writing) {
80
+ writeAgain = true;
81
+ return;
82
+ }
83
+ writing = true;
84
+ do {
85
+ writeAgain = false;
86
+ try {
87
+ await state.write(key, entries);
88
+ } catch (err) {
89
+ logger?.warn?.(`Could not save the recent list "${key}"`, err);
90
+ }
91
+ } while (writeAgain);
92
+ writing = false;
93
+ }
94
+
95
+ /** A stored entry the list can hold: everything it goes through accepts it. */
96
+ const usable = (entry) => {
97
+ try {
98
+ if (!isValid(entry)) return false;
99
+ identity(entry);
100
+ groupOf(entry);
101
+ return true;
102
+ } catch (err) {
103
+ logger?.warn?.(`Dropped an unreadable entry of the recent list "${key}"`, err);
104
+ return false;
105
+ }
106
+ };
107
+
108
+ async function read() {
109
+ let stored;
110
+ try {
111
+ const saved = await state.read(key);
112
+ stored = Array.isArray(saved) ? capped(saved.filter(usable)) : [];
113
+ } catch (err) {
114
+ logger?.warn?.(`Could not read the recent list "${key}"; the stored list is left unchanged until it can be read`, err);
115
+ return false;
116
+ }
117
+ const replay = pending;
118
+ pending = [];
119
+ loaded = true;
120
+ entries = replay.reduce((list, apply) => apply(list), stored);
121
+ if (replay.length > 0) {
122
+ onChange?.();
123
+ void write();
124
+ }
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * Read the stored list. Call it once at startup, before anything lists; a
130
+ * change made while the list is unread calls it again. Once a read has
131
+ * succeeded it does not read again.
132
+ * @returns {Promise<boolean>} Whether the stored list has been read
133
+ */
134
+ function load() {
135
+ if (!state) return Promise.resolve(false);
136
+ if (loaded) return Promise.resolve(true);
137
+ reading ??= read().finally(() => { reading = null; });
138
+ return reading;
139
+ }
140
+
141
+ function change(apply) {
142
+ entries = apply(entries);
143
+ onChange?.();
144
+ if (loaded) {
145
+ void write();
146
+ return;
147
+ }
148
+ pending.push(apply);
149
+ void load();
150
+ }
151
+
152
+ return Object.freeze({
153
+ load,
154
+ /** @returns {ReadonlyArray<object>} Most recent first */
155
+ list: () => Object.freeze(entries.map((entry) => Object.freeze({ ...entry }))),
156
+ /** Put an entry at the top, dropping the older one with its identity. */
157
+ add(entry) {
158
+ const stamped = { ...entry, openedAt: Date.now() };
159
+ change((list) => capped([stamped, ...without(list, stamped)]));
160
+ },
161
+ /** Drop the entry with this one's identity. */
162
+ remove(entry) {
163
+ change((list) => without(list, entry));
164
+ },
165
+ clear() {
166
+ change(() => []);
167
+ },
168
+ });
169
+ }