beads-ui 0.1.2 → 0.3.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/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- package/docs/quickstart.md +0 -142
package/app/views/list.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
/* global NodeListOf */
|
|
2
1
|
import { html, render } from 'lit-html';
|
|
2
|
+
import { createListSelectors } from '../data/list-selectors.js';
|
|
3
|
+
import { cmpClosedDesc } from '../data/sort.js';
|
|
3
4
|
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
5
|
+
import { issueHashFor } from '../utils/issue-url.js';
|
|
4
6
|
// issueDisplayId not used directly in this file; rendered in shared row
|
|
5
7
|
import { statusLabel } from '../utils/status.js';
|
|
6
8
|
import { createIssueRowRenderer } from './issue-row.js';
|
|
@@ -8,7 +10,7 @@ import { createIssueRowRenderer } from './issue-row.js';
|
|
|
8
10
|
// List view implementation; requires a transport send function.
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
|
-
* @typedef {{ id: string, title?: string, status?:
|
|
13
|
+
* @typedef {{ id: string, title?: string, status?: 'closed'|'open'|'in_progress', priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -17,9 +19,30 @@ import { createIssueRowRenderer } from './issue-row.js';
|
|
|
17
19
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
18
20
|
* @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
|
|
19
21
|
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
|
|
22
|
+
* @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
|
|
23
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
|
|
20
24
|
* @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
|
|
21
25
|
*/
|
|
22
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Create the Issues List view.
|
|
28
|
+
* @param {HTMLElement} mount_element
|
|
29
|
+
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn
|
|
30
|
+
* @param {(hash: string) => void} [navigateFn]
|
|
31
|
+
* @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store]
|
|
32
|
+
* @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
|
|
33
|
+
* @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
34
|
+
* @returns {{ load: () => Promise<void>, destroy: () => void }}
|
|
35
|
+
*/
|
|
36
|
+
export function createListView(
|
|
37
|
+
mount_element,
|
|
38
|
+
sendFn,
|
|
39
|
+
navigateFn,
|
|
40
|
+
store,
|
|
41
|
+
_subscriptions = undefined,
|
|
42
|
+
issue_stores = undefined
|
|
43
|
+
) {
|
|
44
|
+
// Touch unused param to satisfy lint rules without impacting behavior
|
|
45
|
+
/** @type {any} */ (void _subscriptions);
|
|
23
46
|
/** @type {string} */
|
|
24
47
|
let status_filter = 'all';
|
|
25
48
|
/** @type {string} */
|
|
@@ -35,8 +58,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
35
58
|
// Shared row renderer (used in template below)
|
|
36
59
|
const row_renderer = createIssueRowRenderer({
|
|
37
60
|
navigate: (id) => {
|
|
38
|
-
const nav =
|
|
39
|
-
|
|
61
|
+
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
62
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
63
|
+
const view = store ? store.getState().view : 'issues';
|
|
64
|
+
nav(issueHashFor(view, id));
|
|
40
65
|
},
|
|
41
66
|
onUpdate: updateInline,
|
|
42
67
|
requestRender: doRender,
|
|
@@ -51,12 +76,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
51
76
|
* @param {Event} ev
|
|
52
77
|
*/
|
|
53
78
|
const onStatusChange = async (ev) => {
|
|
54
|
-
/** @type {HTMLSelectElement} */
|
|
55
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
79
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
56
80
|
status_filter = sel.value;
|
|
57
81
|
if (store) {
|
|
58
82
|
store.setState({
|
|
59
|
-
filters: { status:
|
|
83
|
+
filters: { status: status_filter }
|
|
60
84
|
});
|
|
61
85
|
}
|
|
62
86
|
// Always reload on status changes
|
|
@@ -70,8 +94,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
70
94
|
* @param {Event} ev
|
|
71
95
|
*/
|
|
72
96
|
const onSearchInput = (ev) => {
|
|
73
|
-
/** @type {HTMLInputElement} */
|
|
74
|
-
const input = /** @type {any} */ (ev.currentTarget);
|
|
97
|
+
const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
|
|
75
98
|
search_text = input.value;
|
|
76
99
|
if (store) {
|
|
77
100
|
store.setState({ filters: { search: search_text } });
|
|
@@ -84,8 +107,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
84
107
|
* @param {Event} ev
|
|
85
108
|
*/
|
|
86
109
|
const onTypeChange = (ev) => {
|
|
87
|
-
/** @type {HTMLSelectElement} */
|
|
88
|
-
const sel = /** @type {any} */ (ev.currentTarget);
|
|
110
|
+
const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
|
|
89
111
|
type_filter = sel.value || '';
|
|
90
112
|
if (store) {
|
|
91
113
|
store.setState({ filters: { type: type_filter } });
|
|
@@ -103,12 +125,13 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
103
125
|
}
|
|
104
126
|
}
|
|
105
127
|
// Initial values are reflected via bound `.value` in the template
|
|
128
|
+
// Compose helpers: centralize membership + entity selection + sorting
|
|
129
|
+
const selectors = issue_stores ? createListSelectors(issue_stores) : null;
|
|
106
130
|
|
|
107
131
|
/**
|
|
108
132
|
* Build lit-html template for the list view.
|
|
109
133
|
*/
|
|
110
134
|
function template() {
|
|
111
|
-
/** @type {Issue[]} */
|
|
112
135
|
let filtered = issues_cache;
|
|
113
136
|
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
114
137
|
filtered = filtered.filter(
|
|
@@ -128,6 +151,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
128
151
|
(it) => String(it.issue_type || '') === String(type_filter)
|
|
129
152
|
);
|
|
130
153
|
}
|
|
154
|
+
// Sorting: closed list is a special case → sort by closed_at desc only
|
|
155
|
+
if (status_filter === 'closed') {
|
|
156
|
+
filtered = filtered.slice().sort(cmpClosedDesc);
|
|
157
|
+
}
|
|
131
158
|
|
|
132
159
|
return html`
|
|
133
160
|
<div class="panel__header">
|
|
@@ -164,7 +191,12 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
164
191
|
<div class="muted" style="padding:10px 12px;">No issues</div>
|
|
165
192
|
</div>`
|
|
166
193
|
: html`<div class="issues-block">
|
|
167
|
-
<table
|
|
194
|
+
<table
|
|
195
|
+
class="table"
|
|
196
|
+
role="grid"
|
|
197
|
+
aria-rowcount=${String(filtered.length)}
|
|
198
|
+
aria-colcount="6"
|
|
199
|
+
>
|
|
168
200
|
<colgroup>
|
|
169
201
|
<col style="width: 100px" />
|
|
170
202
|
<col style="width: 120px" />
|
|
@@ -174,16 +206,16 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
174
206
|
<col style="width: 130px" />
|
|
175
207
|
</colgroup>
|
|
176
208
|
<thead>
|
|
177
|
-
<tr>
|
|
178
|
-
<th>ID</th>
|
|
179
|
-
<th>Type</th>
|
|
180
|
-
<th>Title</th>
|
|
181
|
-
<th>Status</th>
|
|
182
|
-
<th>Assignee</th>
|
|
183
|
-
<th>Priority</th>
|
|
209
|
+
<tr role="row">
|
|
210
|
+
<th role="columnheader">ID</th>
|
|
211
|
+
<th role="columnheader">Type</th>
|
|
212
|
+
<th role="columnheader">Title</th>
|
|
213
|
+
<th role="columnheader">Status</th>
|
|
214
|
+
<th role="columnheader">Assignee</th>
|
|
215
|
+
<th role="columnheader">Priority</th>
|
|
184
216
|
</tr>
|
|
185
217
|
</thead>
|
|
186
|
-
<tbody>
|
|
218
|
+
<tbody role="rowgroup">
|
|
187
219
|
${filtered.map((it) => row_renderer(it))}
|
|
188
220
|
</tbody>
|
|
189
221
|
</table>
|
|
@@ -204,40 +236,55 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
204
236
|
// no separate ready checkbox when using select option
|
|
205
237
|
|
|
206
238
|
/**
|
|
207
|
-
*
|
|
239
|
+
* Update minimal fields inline via ws mutations and refresh that row's data.
|
|
240
|
+
* @param {string} id
|
|
241
|
+
* @param {{ [k: string]: any }} patch
|
|
242
|
+
*/
|
|
243
|
+
async function updateInline(id, patch) {
|
|
244
|
+
try {
|
|
245
|
+
// Dispatch specific mutations based on provided keys
|
|
246
|
+
if (typeof patch.title === 'string') {
|
|
247
|
+
await sendFn('edit-text', { id, field: 'title', value: patch.title });
|
|
248
|
+
}
|
|
249
|
+
if (typeof patch.assignee === 'string') {
|
|
250
|
+
await sendFn('update-assignee', { id, assignee: patch.assignee });
|
|
251
|
+
}
|
|
252
|
+
if (typeof patch.status === 'string') {
|
|
253
|
+
await sendFn('update-status', { id, status: patch.status });
|
|
254
|
+
}
|
|
255
|
+
if (typeof patch.priority === 'number') {
|
|
256
|
+
await sendFn('update-priority', { id, priority: patch.priority });
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// ignore failures; UI state remains as-is
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Load issues from local push stores and re-render.
|
|
208
265
|
*/
|
|
209
266
|
async function load() {
|
|
210
267
|
// Preserve scroll position to avoid jarring jumps on live refresh
|
|
211
|
-
/** @type {HTMLElement|null} */
|
|
212
|
-
const beforeEl = /** @type {any} */ (
|
|
268
|
+
const beforeEl = /** @type {HTMLElement|null} */ (
|
|
213
269
|
mount_element.querySelector('#list-root')
|
|
214
270
|
);
|
|
215
271
|
const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
|
|
216
|
-
|
|
217
|
-
const filters = {};
|
|
218
|
-
if (status_filter !== 'all' && status_filter !== 'ready') {
|
|
219
|
-
filters.status = status_filter;
|
|
220
|
-
}
|
|
221
|
-
if (status_filter === 'ready') {
|
|
222
|
-
filters.ready = true;
|
|
223
|
-
}
|
|
224
|
-
/** @type {unknown} */
|
|
225
|
-
let result;
|
|
272
|
+
// Compose items from subscriptions membership and issues store entities
|
|
226
273
|
try {
|
|
227
|
-
|
|
274
|
+
if (selectors) {
|
|
275
|
+
issues_cache = /** @type {Issue[]} */ (
|
|
276
|
+
selectors.selectIssuesFor('tab:issues')
|
|
277
|
+
);
|
|
278
|
+
} else {
|
|
279
|
+
issues_cache = [];
|
|
280
|
+
}
|
|
228
281
|
} catch {
|
|
229
|
-
result = [];
|
|
230
|
-
}
|
|
231
|
-
if (!Array.isArray(result)) {
|
|
232
282
|
issues_cache = [];
|
|
233
|
-
} else {
|
|
234
|
-
issues_cache = /** @type {Issue[]} */ (result);
|
|
235
283
|
}
|
|
236
284
|
doRender();
|
|
237
285
|
// Restore scroll position if possible
|
|
238
286
|
try {
|
|
239
|
-
/** @type {HTMLElement|null} */
|
|
240
|
-
const afterEl = /** @type {any} */ (
|
|
287
|
+
const afterEl = /** @type {HTMLElement|null} */ (
|
|
241
288
|
mount_element.querySelector('#list-root')
|
|
242
289
|
);
|
|
243
290
|
if (afterEl && prevScroll > 0) {
|
|
@@ -251,14 +298,64 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
251
298
|
// Keyboard navigation
|
|
252
299
|
mount_element.tabIndex = 0;
|
|
253
300
|
mount_element.addEventListener('keydown', (ev) => {
|
|
254
|
-
|
|
255
|
-
|
|
301
|
+
// Grid cell Up/Down navigation when focus is inside the table and not within
|
|
302
|
+
// an editable control (input/textarea/select). Preserves column position.
|
|
303
|
+
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
|
304
|
+
const tgt = /** @type {HTMLElement} */ (ev.target);
|
|
305
|
+
const table =
|
|
306
|
+
tgt && typeof tgt.closest === 'function'
|
|
307
|
+
? tgt.closest('#list-root table.table')
|
|
308
|
+
: null;
|
|
309
|
+
if (table) {
|
|
310
|
+
// Do not intercept when inside native editable controls
|
|
311
|
+
const in_editable = Boolean(
|
|
312
|
+
tgt &&
|
|
313
|
+
typeof tgt.closest === 'function' &&
|
|
314
|
+
(tgt.closest('input') ||
|
|
315
|
+
tgt.closest('textarea') ||
|
|
316
|
+
tgt.closest('select'))
|
|
317
|
+
);
|
|
318
|
+
if (!in_editable) {
|
|
319
|
+
const cell =
|
|
320
|
+
tgt && typeof tgt.closest === 'function' ? tgt.closest('td') : null;
|
|
321
|
+
if (cell && cell.parentElement) {
|
|
322
|
+
const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
|
|
323
|
+
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
324
|
+
row.parentElement
|
|
325
|
+
);
|
|
326
|
+
if (tbody && tbody.querySelectorAll) {
|
|
327
|
+
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
328
|
+
const row_idx = Math.max(0, rows.indexOf(row));
|
|
329
|
+
const col_idx = cell.cellIndex || 0;
|
|
330
|
+
const next_idx =
|
|
331
|
+
ev.key === 'ArrowDown'
|
|
332
|
+
? Math.min(row_idx + 1, rows.length - 1)
|
|
333
|
+
: Math.max(row_idx - 1, 0);
|
|
334
|
+
const next_row = rows[next_idx];
|
|
335
|
+
const next_cell =
|
|
336
|
+
next_row && next_row.cells ? next_row.cells[col_idx] : null;
|
|
337
|
+
if (next_cell) {
|
|
338
|
+
const focusable = /** @type {HTMLElement|null} */ (
|
|
339
|
+
next_cell.querySelector(
|
|
340
|
+
'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
|
|
341
|
+
)
|
|
342
|
+
);
|
|
343
|
+
if (focusable && typeof focusable.focus === 'function') {
|
|
344
|
+
ev.preventDefault();
|
|
345
|
+
focusable.focus();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const tbody = /** @type {HTMLTableSectionElement|null} */ (
|
|
256
356
|
mount_element.querySelector('#list-root tbody')
|
|
257
357
|
);
|
|
258
|
-
|
|
259
|
-
const items = tbody
|
|
260
|
-
? tbody.querySelectorAll('tr')
|
|
261
|
-
: /** @type {any} */ ([]);
|
|
358
|
+
const items = tbody ? tbody.querySelectorAll('tr') : [];
|
|
262
359
|
if (items.length === 0) {
|
|
263
360
|
return;
|
|
264
361
|
}
|
|
@@ -298,8 +395,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
298
395
|
const current = items[idx];
|
|
299
396
|
const id = current ? current.getAttribute('data-issue-id') : '';
|
|
300
397
|
if (id) {
|
|
301
|
-
const nav =
|
|
302
|
-
|
|
398
|
+
const nav = navigateFn || ((h) => (window.location.hash = h));
|
|
399
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
400
|
+
const view = store ? store.getState().view : 'issues';
|
|
401
|
+
nav(issueHashFor(view, id));
|
|
303
402
|
}
|
|
304
403
|
}
|
|
305
404
|
});
|
|
@@ -338,6 +437,20 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
338
437
|
});
|
|
339
438
|
}
|
|
340
439
|
|
|
440
|
+
// Live updates: recompose and re-render when issue stores change
|
|
441
|
+
if (selectors) {
|
|
442
|
+
selectors.subscribe(() => {
|
|
443
|
+
try {
|
|
444
|
+
issues_cache = /** @type {Issue[]} */ (
|
|
445
|
+
selectors.selectIssuesFor('tab:issues')
|
|
446
|
+
);
|
|
447
|
+
doRender();
|
|
448
|
+
} catch {
|
|
449
|
+
// ignore
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
341
454
|
return {
|
|
342
455
|
load,
|
|
343
456
|
destroy() {
|
|
@@ -348,46 +461,4 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
|
|
|
348
461
|
}
|
|
349
462
|
}
|
|
350
463
|
};
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Update minimal fields inline via ws mutations and refresh that row's data.
|
|
354
|
-
* @param {string} id
|
|
355
|
-
* @param {{ [k: string]: any }} patch
|
|
356
|
-
*/
|
|
357
|
-
async function updateInline(id, patch) {
|
|
358
|
-
try {
|
|
359
|
-
// Dispatch specific mutations based on provided keys
|
|
360
|
-
if (typeof patch.title === 'string') {
|
|
361
|
-
await sendFn('edit-text', { id, field: 'title', value: patch.title });
|
|
362
|
-
}
|
|
363
|
-
if (typeof patch.assignee === 'string') {
|
|
364
|
-
await sendFn('update-assignee', { id, assignee: patch.assignee });
|
|
365
|
-
}
|
|
366
|
-
if (typeof patch.status === 'string') {
|
|
367
|
-
await sendFn('update-status', { id, status: patch.status });
|
|
368
|
-
}
|
|
369
|
-
if (typeof patch.priority === 'number') {
|
|
370
|
-
await sendFn('update-priority', { id, priority: patch.priority });
|
|
371
|
-
}
|
|
372
|
-
// Refresh the item from backend
|
|
373
|
-
/** @type {any} */
|
|
374
|
-
const full = await sendFn('show-issue', { id });
|
|
375
|
-
// Replace in cache
|
|
376
|
-
const idx = issues_cache.findIndex((x) => x.id === id);
|
|
377
|
-
if (idx >= 0 && full && typeof full === 'object') {
|
|
378
|
-
issues_cache[idx] = /** @type {Issue} */ ({
|
|
379
|
-
id: full.id,
|
|
380
|
-
title: full.title,
|
|
381
|
-
status: full.status,
|
|
382
|
-
priority: full.priority,
|
|
383
|
-
issue_type: full.issue_type,
|
|
384
|
-
assignee: full.assignee,
|
|
385
|
-
labels: Array.isArray(full.labels) ? full.labels : []
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
doRender();
|
|
389
|
-
} catch {
|
|
390
|
-
// ignore failures; UI state remains as-is
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
464
|
}
|