beads-ui 0.2.0 → 0.3.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.
- package/CHANGES.md +14 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +103 -0
- package/app/data/providers.js +7 -138
- package/app/data/sort.js +47 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +128 -0
- package/app/data/subscriptions-store.js +227 -0
- package/app/main.js +346 -66
- package/app/protocol.js +23 -17
- package/app/protocol.md +18 -15
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/styles.css +222 -197
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +13 -198
- package/app/utils/priority-badge.js +1 -2
- package/app/utils/status-badge.js +1 -1
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -1
- package/app/utils/type-badge.js +1 -3
- package/app/views/board.js +172 -148
- package/app/views/detail.js +79 -66
- package/app/views/epics.js +127 -74
- package/app/views/issue-dialog.js +9 -15
- package/app/views/issue-row.js +2 -3
- package/app/views/list.js +105 -104
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +30 -34
- package/app/ws.js +10 -10
- 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 +34 -84
- 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 +5 -4
- package/server/app.js +2 -0
- package/server/bd.js +4 -2
- package/server/cli/commands.js +5 -2
- package/server/cli/daemon.js +19 -5
- package/server/cli/index.js +2 -2
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +2 -1
- package/server/config.js +13 -6
- package/server/db.js +3 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +224 -0
- package/server/subscriptions.js +289 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +8 -8
- package/server/ws.js +457 -229
package/app/main.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { MessageType } from './protocol.js'
|
|
3
|
+
*/
|
|
1
4
|
import { html, render } from 'lit-html';
|
|
5
|
+
import { createListSelectors } from './data/list-selectors.js';
|
|
2
6
|
import { createDataLayer } from './data/providers.js';
|
|
7
|
+
import { createSubscriptionIssueStores } from './data/subscription-issue-stores.js';
|
|
8
|
+
import { createSubscriptionStore } from './data/subscriptions-store.js';
|
|
3
9
|
import { createHashRouter, parseHash } from './router.js';
|
|
4
10
|
import { createStore } from './state.js';
|
|
5
11
|
import { showToast } from './utils/toast.js';
|
|
@@ -14,6 +20,7 @@ import { createWsClient } from './ws.js';
|
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* Bootstrap the SPA shell with two panels.
|
|
23
|
+
*
|
|
17
24
|
* @param {HTMLElement} root_element - The container element to render into.
|
|
18
25
|
*/
|
|
19
26
|
export function bootstrap(root_element) {
|
|
@@ -43,10 +50,55 @@ export function bootstrap(root_element) {
|
|
|
43
50
|
const detail_mount = document.getElementById('detail-panel');
|
|
44
51
|
if (list_mount && issues_root && epics_root && board_root && detail_mount) {
|
|
45
52
|
const client = createWsClient();
|
|
53
|
+
// Subscriptions: wire client events and expose subscribe/unsubscribe helpers
|
|
54
|
+
const subscriptions = createSubscriptionStore((type, payload) =>
|
|
55
|
+
client.send(type, payload)
|
|
56
|
+
);
|
|
57
|
+
// Per-subscription stores (source of truth)
|
|
58
|
+
const sub_issue_stores = createSubscriptionIssueStores();
|
|
59
|
+
// Route per-subscription push envelopes to the owning store
|
|
60
|
+
client.on('snapshot', (payload) => {
|
|
61
|
+
const p = /** @type {any} */ (payload);
|
|
62
|
+
const id = p && typeof p.id === 'string' ? p.id : '';
|
|
63
|
+
const store = id ? sub_issue_stores.getStore(id) : null;
|
|
64
|
+
if (store && p && p.type === 'snapshot') {
|
|
65
|
+
try {
|
|
66
|
+
store.applyPush(p);
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
client.on('upsert', (payload) => {
|
|
73
|
+
const p = /** @type {any} */ (payload);
|
|
74
|
+
const id = p && typeof p.id === 'string' ? p.id : '';
|
|
75
|
+
const store = id ? sub_issue_stores.getStore(id) : null;
|
|
76
|
+
if (store && p && p.type === 'upsert') {
|
|
77
|
+
try {
|
|
78
|
+
store.applyPush(p);
|
|
79
|
+
} catch {
|
|
80
|
+
// ignore
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
client.on('delete', (payload) => {
|
|
85
|
+
const p = /** @type {any} */ (payload);
|
|
86
|
+
const id = p && typeof p.id === 'string' ? p.id : '';
|
|
87
|
+
const store = id ? sub_issue_stores.getStore(id) : null;
|
|
88
|
+
if (store && p && p.type === 'delete') {
|
|
89
|
+
try {
|
|
90
|
+
store.applyPush(p);
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
// Derived list selectors: render from per-subscription snapshots
|
|
97
|
+
const listSelectors = createListSelectors(sub_issue_stores);
|
|
46
98
|
// Show toasts for WebSocket connectivity changes
|
|
47
99
|
/** @type {boolean} */
|
|
48
100
|
let had_disconnect = false;
|
|
49
|
-
if (typeof
|
|
101
|
+
if (typeof client.onConnection === 'function') {
|
|
50
102
|
/** @type {(s: 'connecting'|'open'|'closed'|'reconnecting') => void} */
|
|
51
103
|
const onConn = (s) => {
|
|
52
104
|
if (s === 'reconnecting' || s === 'closed') {
|
|
@@ -57,11 +109,11 @@ export function bootstrap(root_element) {
|
|
|
57
109
|
showToast('Reconnected', 'success', 2200);
|
|
58
110
|
}
|
|
59
111
|
};
|
|
60
|
-
|
|
112
|
+
client.onConnection(onConn);
|
|
61
113
|
}
|
|
62
114
|
// Load persisted filters (status/search/type) from localStorage
|
|
63
115
|
/** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
|
|
64
|
-
let
|
|
116
|
+
let persisted_filters = { status: 'all', search: '', type: '' };
|
|
65
117
|
try {
|
|
66
118
|
const raw = window.localStorage.getItem('beads-ui.filters');
|
|
67
119
|
if (raw) {
|
|
@@ -82,7 +134,7 @@ export function bootstrap(root_element) {
|
|
|
82
134
|
}
|
|
83
135
|
parsed_type = first_valid;
|
|
84
136
|
}
|
|
85
|
-
|
|
137
|
+
persisted_filters = {
|
|
86
138
|
status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
|
|
87
139
|
obj.status
|
|
88
140
|
)
|
|
@@ -121,7 +173,7 @@ export function bootstrap(root_element) {
|
|
|
121
173
|
if (obj && typeof obj === 'object') {
|
|
122
174
|
const cf = String(obj.closed_filter || 'today');
|
|
123
175
|
if (cf === 'today' || cf === '3' || cf === '7') {
|
|
124
|
-
persistedBoard.closed_filter =
|
|
176
|
+
persistedBoard.closed_filter = cf;
|
|
125
177
|
}
|
|
126
178
|
}
|
|
127
179
|
}
|
|
@@ -130,7 +182,7 @@ export function bootstrap(root_element) {
|
|
|
130
182
|
}
|
|
131
183
|
|
|
132
184
|
const store = createStore({
|
|
133
|
-
filters:
|
|
185
|
+
filters: persisted_filters,
|
|
134
186
|
view: last_view,
|
|
135
187
|
board: persistedBoard
|
|
136
188
|
});
|
|
@@ -142,7 +194,7 @@ export function bootstrap(root_element) {
|
|
|
142
194
|
*/
|
|
143
195
|
const transport = async (type, payload) => {
|
|
144
196
|
try {
|
|
145
|
-
return await client.send(/** @type {
|
|
197
|
+
return await client.send(/** @type {MessageType} */ (type), payload);
|
|
146
198
|
} catch {
|
|
147
199
|
return [];
|
|
148
200
|
}
|
|
@@ -155,7 +207,7 @@ export function bootstrap(root_element) {
|
|
|
155
207
|
// Global New Issue dialog (UI-106) mounted at root so it is always visible
|
|
156
208
|
const new_issue_dialog = createNewIssueDialog(
|
|
157
209
|
root_element,
|
|
158
|
-
(type, payload) => client.send(
|
|
210
|
+
(type, payload) => client.send(type, payload),
|
|
159
211
|
router,
|
|
160
212
|
store
|
|
161
213
|
);
|
|
@@ -171,16 +223,35 @@ export function bootstrap(root_element) {
|
|
|
171
223
|
// ignore missing header
|
|
172
224
|
}
|
|
173
225
|
|
|
226
|
+
// Local transport shim: for list-issues, serve from local listSelectors;
|
|
227
|
+
// otherwise forward to ws transport for mutations/show.
|
|
228
|
+
/**
|
|
229
|
+
* @param {MessageType} type
|
|
230
|
+
* @param {unknown} payload
|
|
231
|
+
*/
|
|
232
|
+
const listTransport = async (type, payload) => {
|
|
233
|
+
if (type === 'list-issues') {
|
|
234
|
+
try {
|
|
235
|
+
return listSelectors.selectIssuesFor('tab:issues');
|
|
236
|
+
} catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return transport(type, payload);
|
|
241
|
+
};
|
|
242
|
+
|
|
174
243
|
const issues_view = createListView(
|
|
175
244
|
list_mount,
|
|
176
|
-
|
|
245
|
+
/** @type {any} */ (listTransport),
|
|
177
246
|
(hash) => {
|
|
178
247
|
const id = parseHash(hash);
|
|
179
248
|
if (id) {
|
|
180
249
|
router.gotoIssue(id);
|
|
181
250
|
}
|
|
182
251
|
},
|
|
183
|
-
store
|
|
252
|
+
store,
|
|
253
|
+
subscriptions,
|
|
254
|
+
sub_issue_stores
|
|
184
255
|
);
|
|
185
256
|
// Persist filter changes to localStorage
|
|
186
257
|
store.subscribe((s) => {
|
|
@@ -225,12 +296,17 @@ export function bootstrap(root_element) {
|
|
|
225
296
|
/** @type {ReturnType<typeof createDetailView> | null} */
|
|
226
297
|
let detail = null;
|
|
227
298
|
// Mount details into the dialog body only
|
|
228
|
-
detail = createDetailView(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
299
|
+
detail = createDetailView(
|
|
300
|
+
dialog.getMount(),
|
|
301
|
+
transport,
|
|
302
|
+
(hash) => {
|
|
303
|
+
const id = parseHash(hash);
|
|
304
|
+
if (id) {
|
|
305
|
+
router.gotoIssue(id);
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
sub_issue_stores
|
|
309
|
+
);
|
|
234
310
|
|
|
235
311
|
// If router already set a selected id (deep-link), open dialog now
|
|
236
312
|
const initial_id = store.getState().selected_id;
|
|
@@ -240,9 +316,21 @@ export function bootstrap(root_element) {
|
|
|
240
316
|
if (detail) {
|
|
241
317
|
void detail.load(initial_id);
|
|
242
318
|
}
|
|
319
|
+
// Ensure detail subscription is active on initial deep-link
|
|
320
|
+
const client_id = `detail:${initial_id}`;
|
|
321
|
+
const spec = { type: 'issue-detail', params: { id: initial_id } };
|
|
322
|
+
// Register store first to avoid dropping the initial snapshot
|
|
323
|
+
try {
|
|
324
|
+
sub_issue_stores.register(client_id, spec);
|
|
325
|
+
} catch {
|
|
326
|
+
// ignore
|
|
327
|
+
}
|
|
328
|
+
void subscriptions.subscribeList(client_id, spec).catch(() => {});
|
|
243
329
|
}
|
|
244
330
|
|
|
245
331
|
// Open/close dialog based on selected_id (always dialog; no page variant)
|
|
332
|
+
/** @type {null | (() => Promise<void>)} */
|
|
333
|
+
let unsub_detail = null;
|
|
246
334
|
store.subscribe((s) => {
|
|
247
335
|
const id = s.selected_id;
|
|
248
336
|
if (id) {
|
|
@@ -251,6 +339,26 @@ export function bootstrap(root_element) {
|
|
|
251
339
|
if (detail) {
|
|
252
340
|
void detail.load(id);
|
|
253
341
|
}
|
|
342
|
+
// Wire per-issue subscription for detail
|
|
343
|
+
const client_id = `detail:${id}`;
|
|
344
|
+
const spec = { type: 'issue-detail', params: { id } };
|
|
345
|
+
// Ensure per-subscription issue store exists before subscribing
|
|
346
|
+
try {
|
|
347
|
+
sub_issue_stores.register(client_id, spec);
|
|
348
|
+
} catch {
|
|
349
|
+
// ignore
|
|
350
|
+
}
|
|
351
|
+
// Subscribe server-side
|
|
352
|
+
void subscriptions
|
|
353
|
+
.subscribeList(client_id, spec)
|
|
354
|
+
.then((unsub) => {
|
|
355
|
+
// Unsubscribe previous if any
|
|
356
|
+
if (unsub_detail) {
|
|
357
|
+
void unsub_detail().catch(() => {});
|
|
358
|
+
}
|
|
359
|
+
unsub_detail = unsub;
|
|
360
|
+
})
|
|
361
|
+
.catch(() => {});
|
|
254
362
|
} else {
|
|
255
363
|
try {
|
|
256
364
|
dialog.close();
|
|
@@ -261,69 +369,237 @@ export function bootstrap(root_element) {
|
|
|
261
369
|
detail.clear();
|
|
262
370
|
}
|
|
263
371
|
detail_mount.hidden = true;
|
|
372
|
+
if (unsub_detail) {
|
|
373
|
+
void unsub_detail().catch(() => {});
|
|
374
|
+
unsub_detail = null;
|
|
375
|
+
}
|
|
264
376
|
}
|
|
265
377
|
});
|
|
266
378
|
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
// arrives, suppress a trailing watcher-only full refresh for a short
|
|
270
|
-
// window to avoid duplicate work and flicker.
|
|
271
|
-
/** @type {number} */
|
|
272
|
-
let suppress_full_until = 0;
|
|
273
|
-
client.on('issues-changed', (payload) => {
|
|
274
|
-
const s = store.getState();
|
|
275
|
-
const hint_ids =
|
|
276
|
-
payload && payload.hint && Array.isArray(payload.hint.ids)
|
|
277
|
-
? /** @type {string[]} */ (payload.hint.ids)
|
|
278
|
-
: null;
|
|
379
|
+
// Removed: issues-changed handling. All views re-render from
|
|
380
|
+
// per-subscription stores which are updated by snapshot/upsert/delete.
|
|
279
381
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
382
|
+
// Toggle route shells on view/detail change and persist
|
|
383
|
+
const data = createDataLayer(transport);
|
|
384
|
+
const epics_view = createEpicsView(
|
|
385
|
+
epics_root,
|
|
386
|
+
data,
|
|
387
|
+
(id) => router.gotoIssue(id),
|
|
388
|
+
subscriptions,
|
|
389
|
+
sub_issue_stores
|
|
390
|
+
);
|
|
391
|
+
const board_view = createBoardView(
|
|
392
|
+
board_root,
|
|
393
|
+
data,
|
|
394
|
+
(id) => router.gotoIssue(id),
|
|
395
|
+
store,
|
|
396
|
+
subscriptions,
|
|
397
|
+
sub_issue_stores
|
|
398
|
+
);
|
|
399
|
+
// Preload epics when switching to view
|
|
400
|
+
/**
|
|
401
|
+
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
402
|
+
*/
|
|
403
|
+
// --- Subscriptions: tab-level management and filter-driven updates ---
|
|
404
|
+
/** @type {null | (() => Promise<void>)} */
|
|
405
|
+
let unsub_issues_tab = null;
|
|
406
|
+
/** @type {null | (() => Promise<void>)} */
|
|
407
|
+
let unsub_epics_tab = null;
|
|
408
|
+
/** @type {null | (() => Promise<void>)} */
|
|
409
|
+
let unsub_board_ready = null;
|
|
410
|
+
/** @type {null | (() => Promise<void>)} */
|
|
411
|
+
let unsub_board_in_progress = null;
|
|
412
|
+
/** @type {null | (() => Promise<void>)} */
|
|
413
|
+
let unsub_board_closed = null;
|
|
414
|
+
/** @type {null | (() => Promise<void>)} */
|
|
415
|
+
let unsub_board_blocked = null;
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Compute subscription spec for Issues tab based on filters.
|
|
419
|
+
*
|
|
420
|
+
* @param {{ status?: string }} filters
|
|
421
|
+
* @returns {{ type: string, params?: Record<string, string|number|boolean> }}
|
|
422
|
+
*/
|
|
423
|
+
function computeIssuesSpec(filters) {
|
|
424
|
+
const st = String(filters?.status || 'all');
|
|
425
|
+
if (st === 'ready') {
|
|
426
|
+
return { type: 'ready-issues' };
|
|
427
|
+
}
|
|
428
|
+
if (st === 'in_progress') {
|
|
429
|
+
return { type: 'in-progress-issues' };
|
|
289
430
|
}
|
|
431
|
+
if (st === 'closed') {
|
|
432
|
+
return { type: 'closed-issues' };
|
|
433
|
+
}
|
|
434
|
+
// "all" and "open" map to all-issues; client filters apply locally
|
|
435
|
+
return { type: 'all-issues' };
|
|
436
|
+
}
|
|
290
437
|
|
|
291
|
-
|
|
438
|
+
/** @type {string|null} */
|
|
439
|
+
let last_issues_spec_key = null;
|
|
440
|
+
/**
|
|
441
|
+
* Ensure only the active tab has subscriptions; clean up previous.
|
|
442
|
+
*
|
|
443
|
+
* @param {{ view: 'issues'|'epics'|'board', filters: any }} s
|
|
444
|
+
*/
|
|
445
|
+
function ensureTabSubscriptions(s) {
|
|
446
|
+
// Issues tab
|
|
447
|
+
if (s.view === 'issues') {
|
|
448
|
+
const spec = computeIssuesSpec(s.filters || {});
|
|
449
|
+
const key = JSON.stringify(spec);
|
|
450
|
+
// Register store first to capture the initial snapshot
|
|
451
|
+
try {
|
|
452
|
+
sub_issue_stores.register('tab:issues', spec);
|
|
453
|
+
} catch {
|
|
454
|
+
// ignore
|
|
455
|
+
}
|
|
456
|
+
// Only (re)subscribe if not yet subscribed or the spec changed
|
|
457
|
+
if (!unsub_issues_tab || key !== last_issues_spec_key) {
|
|
458
|
+
void subscriptions
|
|
459
|
+
.subscribeList('tab:issues', spec)
|
|
460
|
+
.then((unsub) => {
|
|
461
|
+
unsub_issues_tab = unsub;
|
|
462
|
+
last_issues_spec_key = key;
|
|
463
|
+
})
|
|
464
|
+
.catch(() => {
|
|
465
|
+
// ignore transport errors; retry on next change
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
} else if (unsub_issues_tab) {
|
|
469
|
+
void unsub_issues_tab().catch(() => {});
|
|
470
|
+
unsub_issues_tab = null;
|
|
471
|
+
last_issues_spec_key = null;
|
|
472
|
+
try {
|
|
473
|
+
sub_issue_stores.unregister('tab:issues');
|
|
474
|
+
} catch {
|
|
475
|
+
// ignore
|
|
476
|
+
}
|
|
477
|
+
}
|
|
292
478
|
|
|
293
|
-
//
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
479
|
+
// Epics tab
|
|
480
|
+
if (s.view === 'epics') {
|
|
481
|
+
// Register store first to avoid race with initial snapshot
|
|
482
|
+
try {
|
|
483
|
+
sub_issue_stores.register('tab:epics', { type: 'epics' });
|
|
484
|
+
} catch {
|
|
485
|
+
// ignore
|
|
486
|
+
}
|
|
487
|
+
void subscriptions
|
|
488
|
+
.subscribeList('tab:epics', { type: 'epics' })
|
|
489
|
+
.then((unsub) => {
|
|
490
|
+
unsub_epics_tab = unsub;
|
|
491
|
+
})
|
|
492
|
+
.catch(() => {});
|
|
493
|
+
} else if (unsub_epics_tab) {
|
|
494
|
+
void unsub_epics_tab().catch(() => {});
|
|
495
|
+
unsub_epics_tab = null;
|
|
496
|
+
try {
|
|
497
|
+
sub_issue_stores.unregister('tab:epics');
|
|
498
|
+
} catch {
|
|
499
|
+
// ignore
|
|
301
500
|
}
|
|
302
501
|
}
|
|
303
502
|
|
|
304
|
-
//
|
|
305
|
-
if (
|
|
306
|
-
if (!
|
|
307
|
-
|
|
308
|
-
|
|
503
|
+
// Board tab subscribes to lists used by columns
|
|
504
|
+
if (s.view === 'board') {
|
|
505
|
+
if (!unsub_board_ready) {
|
|
506
|
+
try {
|
|
507
|
+
sub_issue_stores.register('tab:board:ready', {
|
|
508
|
+
type: 'ready-issues'
|
|
509
|
+
});
|
|
510
|
+
} catch {
|
|
511
|
+
// ignore
|
|
512
|
+
}
|
|
513
|
+
void subscriptions
|
|
514
|
+
.subscribeList('tab:board:ready', { type: 'ready-issues' })
|
|
515
|
+
.then((u) => (unsub_board_ready = u))
|
|
516
|
+
.catch(() => {});
|
|
517
|
+
}
|
|
518
|
+
if (!unsub_board_in_progress) {
|
|
519
|
+
try {
|
|
520
|
+
sub_issue_stores.register('tab:board:in-progress', {
|
|
521
|
+
type: 'in-progress-issues'
|
|
522
|
+
});
|
|
523
|
+
} catch {
|
|
524
|
+
// ignore
|
|
525
|
+
}
|
|
526
|
+
void subscriptions
|
|
527
|
+
.subscribeList('tab:board:in-progress', {
|
|
528
|
+
type: 'in-progress-issues'
|
|
529
|
+
})
|
|
530
|
+
.then((u) => (unsub_board_in_progress = u))
|
|
531
|
+
.catch(() => {});
|
|
532
|
+
}
|
|
533
|
+
if (!unsub_board_closed) {
|
|
534
|
+
try {
|
|
535
|
+
sub_issue_stores.register('tab:board:closed', {
|
|
536
|
+
type: 'closed-issues'
|
|
537
|
+
});
|
|
538
|
+
} catch {
|
|
539
|
+
// ignore
|
|
540
|
+
}
|
|
541
|
+
void subscriptions
|
|
542
|
+
.subscribeList('tab:board:closed', { type: 'closed-issues' })
|
|
543
|
+
.then((u) => (unsub_board_closed = u))
|
|
544
|
+
.catch(() => {});
|
|
545
|
+
}
|
|
546
|
+
if (!unsub_board_blocked) {
|
|
547
|
+
try {
|
|
548
|
+
sub_issue_stores.register('tab:board:blocked', {
|
|
549
|
+
type: 'blocked-issues'
|
|
550
|
+
});
|
|
551
|
+
} catch {
|
|
552
|
+
// ignore
|
|
553
|
+
}
|
|
554
|
+
void subscriptions
|
|
555
|
+
.subscribeList('tab:board:blocked', { type: 'blocked-issues' })
|
|
556
|
+
.then((u) => (unsub_board_blocked = u))
|
|
557
|
+
.catch(() => {});
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
// Unsubscribe all board lists when leaving the board view
|
|
561
|
+
if (unsub_board_ready) {
|
|
562
|
+
void unsub_board_ready().catch(() => {});
|
|
563
|
+
unsub_board_ready = null;
|
|
564
|
+
try {
|
|
565
|
+
sub_issue_stores.unregister('tab:board:ready');
|
|
566
|
+
} catch {
|
|
567
|
+
// ignore
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (unsub_board_in_progress) {
|
|
571
|
+
void unsub_board_in_progress().catch(() => {});
|
|
572
|
+
unsub_board_in_progress = null;
|
|
573
|
+
try {
|
|
574
|
+
sub_issue_stores.unregister('tab:board:in-progress');
|
|
575
|
+
} catch {
|
|
576
|
+
// ignore
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (unsub_board_closed) {
|
|
580
|
+
void unsub_board_closed().catch(() => {});
|
|
581
|
+
unsub_board_closed = null;
|
|
582
|
+
try {
|
|
583
|
+
sub_issue_stores.unregister('tab:board:closed');
|
|
584
|
+
} catch {
|
|
585
|
+
// ignore
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (unsub_board_blocked) {
|
|
589
|
+
void unsub_board_blocked().catch(() => {});
|
|
590
|
+
unsub_board_blocked = null;
|
|
591
|
+
try {
|
|
592
|
+
sub_issue_stores.unregister('tab:board:blocked');
|
|
593
|
+
} catch {
|
|
594
|
+
// ignore
|
|
309
595
|
}
|
|
310
596
|
}
|
|
311
597
|
}
|
|
312
|
-
}
|
|
598
|
+
}
|
|
313
599
|
|
|
314
|
-
// Toggle route shells on view/detail change and persist
|
|
315
|
-
const data = createDataLayer(/** @type {any} */ (transport), client.on);
|
|
316
|
-
const epics_view = createEpicsView(epics_root, data, (id) =>
|
|
317
|
-
router.gotoIssue(id)
|
|
318
|
-
);
|
|
319
|
-
const board_view = createBoardView(
|
|
320
|
-
board_root,
|
|
321
|
-
data,
|
|
322
|
-
(id) => router.gotoIssue(id),
|
|
323
|
-
store
|
|
324
|
-
);
|
|
325
|
-
// Preload epics when switching to view
|
|
326
600
|
/**
|
|
601
|
+
* Manage route visibility and list subscriptions per view.
|
|
602
|
+
*
|
|
327
603
|
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
328
604
|
*/
|
|
329
605
|
const onRouteChange = (s) => {
|
|
@@ -334,6 +610,9 @@ export function bootstrap(root_element) {
|
|
|
334
610
|
board_root.hidden = s.view !== 'board';
|
|
335
611
|
// detail_mount visibility handled in subscription above
|
|
336
612
|
}
|
|
613
|
+
// Ensure subscriptions for the active tab before loading the view to
|
|
614
|
+
// avoid empty initial renders due to racing list-delta.
|
|
615
|
+
ensureTabSubscriptions(s);
|
|
337
616
|
if (!s.selected_id && s.view === 'epics') {
|
|
338
617
|
void epics_view.load();
|
|
339
618
|
}
|
|
@@ -350,12 +629,13 @@ export function bootstrap(root_element) {
|
|
|
350
629
|
// Ensure initial state is reflected (fixes reload on #/epics)
|
|
351
630
|
onRouteChange(store.getState());
|
|
352
631
|
|
|
632
|
+
// Removed redundant filter-change subscription: handled by ensureTabSubscriptions
|
|
633
|
+
|
|
353
634
|
// Keyboard shortcuts: Ctrl/Cmd+N opens new issue; Ctrl/Cmd+Enter submits inside dialog
|
|
354
635
|
window.addEventListener('keydown', (ev) => {
|
|
355
636
|
const is_modifier = ev.ctrlKey || ev.metaKey;
|
|
356
637
|
const key = String(ev.key || '').toLowerCase();
|
|
357
|
-
/** @type {HTMLElement} */
|
|
358
|
-
const target = /** @type {any} */ (ev.target);
|
|
638
|
+
const target = /** @type {HTMLElement} */ (ev.target);
|
|
359
639
|
const tag =
|
|
360
640
|
target && target.tagName ? String(target.tagName).toLowerCase() : '';
|
|
361
641
|
const is_editable =
|
package/app/protocol.js
CHANGED
|
@@ -6,17 +6,10 @@
|
|
|
6
6
|
* - Client → Server uses RequestEnvelope.
|
|
7
7
|
* - Server → Client uses ReplyEnvelope.
|
|
8
8
|
* - Every request is correlated by `id` in replies.
|
|
9
|
-
* - Server can also send unsolicited events (e.g., `
|
|
10
|
-
*
|
|
11
|
-
* Versioning
|
|
12
|
-
* - Increment `PROTOCOL_VERSION` on breaking changes.
|
|
13
|
-
* - Add new message types without breaking existing ones when possible.
|
|
9
|
+
* - Server can also send unsolicited events (e.g., subscription `snapshot`).
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
|
-
/** @
|
|
17
|
-
export const PROTOCOL_VERSION = '1.0.0';
|
|
18
|
-
|
|
19
|
-
/** @typedef {'list-issues'|'show-issue'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'subscribe-updates'|'issues-changed'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'} MessageType */
|
|
12
|
+
/** @typedef {'list-issues'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'|'subscribe-list'|'unsubscribe-list'|'snapshot'|'upsert'|'delete'} MessageType */
|
|
20
13
|
|
|
21
14
|
/**
|
|
22
15
|
* @typedef {Object} RequestEnvelope
|
|
@@ -44,24 +37,28 @@ export const PROTOCOL_VERSION = '1.0.0';
|
|
|
44
37
|
/** @type {MessageType[]} */
|
|
45
38
|
export const MESSAGE_TYPES = /** @type {const} */ ([
|
|
46
39
|
'list-issues',
|
|
47
|
-
'show-issue',
|
|
48
40
|
'update-status',
|
|
49
41
|
'edit-text',
|
|
50
42
|
'update-priority',
|
|
51
43
|
'create-issue',
|
|
52
44
|
'list-ready',
|
|
53
|
-
'subscribe-updates',
|
|
54
|
-
'issues-changed',
|
|
55
45
|
'dep-add',
|
|
56
46
|
'dep-remove',
|
|
57
47
|
'epic-status',
|
|
58
48
|
'update-assignee',
|
|
59
49
|
'label-add',
|
|
60
|
-
'label-remove'
|
|
50
|
+
'label-remove',
|
|
51
|
+
'subscribe-list',
|
|
52
|
+
'unsubscribe-list',
|
|
53
|
+
// vNext per-subscription full-issue push events
|
|
54
|
+
'snapshot',
|
|
55
|
+
'upsert',
|
|
56
|
+
'delete'
|
|
61
57
|
]);
|
|
62
58
|
|
|
63
59
|
/**
|
|
64
60
|
* Generate a lexically sortable request id.
|
|
61
|
+
*
|
|
65
62
|
* @returns {string}
|
|
66
63
|
*/
|
|
67
64
|
export function nextId() {
|
|
@@ -72,6 +69,7 @@ export function nextId() {
|
|
|
72
69
|
|
|
73
70
|
/**
|
|
74
71
|
* Create a request envelope.
|
|
72
|
+
*
|
|
75
73
|
* @param {MessageType} type - Message type.
|
|
76
74
|
* @param {unknown} [payload] - Message payload.
|
|
77
75
|
* @param {string} [id] - Optional id; generated if omitted.
|
|
@@ -83,6 +81,7 @@ export function makeRequest(type, payload, id = nextId()) {
|
|
|
83
81
|
|
|
84
82
|
/**
|
|
85
83
|
* Create a successful reply envelope for a given request.
|
|
84
|
+
*
|
|
86
85
|
* @param {RequestEnvelope} req - Original request.
|
|
87
86
|
* @param {unknown} [payload] - Reply payload.
|
|
88
87
|
* @returns {ReplyEnvelope}
|
|
@@ -93,10 +92,11 @@ export function makeOk(req, payload) {
|
|
|
93
92
|
|
|
94
93
|
/**
|
|
95
94
|
* Create an error reply envelope for a given request.
|
|
95
|
+
*
|
|
96
96
|
* @param {RequestEnvelope} req - Original request.
|
|
97
|
-
* @param {string} code
|
|
98
|
-
* @param {string} message
|
|
99
|
-
* @param {unknown} [details]
|
|
97
|
+
* @param {string} code
|
|
98
|
+
* @param {string} message
|
|
99
|
+
* @param {unknown} [details]
|
|
100
100
|
* @returns {ReplyEnvelope}
|
|
101
101
|
*/
|
|
102
102
|
export function makeError(req, code, message, details) {
|
|
@@ -110,6 +110,7 @@ export function makeError(req, code, message, details) {
|
|
|
110
110
|
|
|
111
111
|
/**
|
|
112
112
|
* Check if a value is a plain object.
|
|
113
|
+
*
|
|
113
114
|
* @param {unknown} value
|
|
114
115
|
* @returns {value is Record<string, unknown>}
|
|
115
116
|
*/
|
|
@@ -119,6 +120,7 @@ function isRecord(value) {
|
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
122
|
* Type guard for MessageType values.
|
|
123
|
+
*
|
|
122
124
|
* @param {unknown} value
|
|
123
125
|
* @returns {value is MessageType}
|
|
124
126
|
*/
|
|
@@ -131,6 +133,7 @@ export function isMessageType(value) {
|
|
|
131
133
|
|
|
132
134
|
/**
|
|
133
135
|
* Type guard for RequestEnvelope.
|
|
136
|
+
*
|
|
134
137
|
* @param {unknown} value
|
|
135
138
|
* @returns {value is RequestEnvelope}
|
|
136
139
|
*/
|
|
@@ -147,6 +150,7 @@ export function isRequest(value) {
|
|
|
147
150
|
|
|
148
151
|
/**
|
|
149
152
|
* Type guard for ReplyEnvelope.
|
|
153
|
+
*
|
|
150
154
|
* @param {unknown} value
|
|
151
155
|
* @returns {value is ReplyEnvelope}
|
|
152
156
|
*/
|
|
@@ -162,7 +166,7 @@ export function isReply(value) {
|
|
|
162
166
|
return false;
|
|
163
167
|
}
|
|
164
168
|
if (value.ok === false) {
|
|
165
|
-
const err =
|
|
169
|
+
const err = value.error;
|
|
166
170
|
if (
|
|
167
171
|
!isRecord(err) ||
|
|
168
172
|
typeof err.code !== 'string' ||
|
|
@@ -177,6 +181,7 @@ export function isReply(value) {
|
|
|
177
181
|
/**
|
|
178
182
|
* Normalize and validate an incoming JSON value as a RequestEnvelope.
|
|
179
183
|
* Throws a user-friendly error if invalid.
|
|
184
|
+
*
|
|
180
185
|
* @param {unknown} json
|
|
181
186
|
* @returns {RequestEnvelope}
|
|
182
187
|
*/
|
|
@@ -189,6 +194,7 @@ export function decodeRequest(json) {
|
|
|
189
194
|
|
|
190
195
|
/**
|
|
191
196
|
* Normalize and validate an incoming JSON value as a ReplyEnvelope.
|
|
197
|
+
*
|
|
192
198
|
* @param {unknown} json
|
|
193
199
|
* @returns {ReplyEnvelope}
|
|
194
200
|
*/
|