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/main.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
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';
|
|
3
|
-
import {
|
|
7
|
+
import { createSubscriptionIssueStores } from './data/subscription-issue-stores.js';
|
|
8
|
+
import { createSubscriptionStore } from './data/subscriptions-store.js';
|
|
9
|
+
import { createHashRouter, parseHash } from './router.js';
|
|
4
10
|
import { createStore } from './state.js';
|
|
11
|
+
import { showToast } from './utils/toast.js';
|
|
5
12
|
import { createBoardView } from './views/board.js';
|
|
6
13
|
import { createDetailView } from './views/detail.js';
|
|
7
14
|
import { createEpicsView } from './views/epics.js';
|
|
15
|
+
import { createIssueDialog } from './views/issue-dialog.js';
|
|
8
16
|
import { createListView } from './views/list.js';
|
|
9
17
|
import { createTopNav } from './views/nav.js';
|
|
18
|
+
import { createNewIssueDialog } from './views/new-issue-dialog.js';
|
|
10
19
|
import { createWsClient } from './ws.js';
|
|
11
20
|
|
|
12
21
|
/**
|
|
@@ -40,22 +49,81 @@ export function bootstrap(root_element) {
|
|
|
40
49
|
const detail_mount = document.getElementById('detail-panel');
|
|
41
50
|
if (list_mount && issues_root && epics_root && board_root && detail_mount) {
|
|
42
51
|
const client = createWsClient();
|
|
52
|
+
// Subscriptions: wire client events and expose subscribe/unsubscribe helpers
|
|
53
|
+
const subscriptions = createSubscriptionStore((type, payload) =>
|
|
54
|
+
client.send(type, payload)
|
|
55
|
+
);
|
|
56
|
+
// Per-subscription stores (source of truth)
|
|
57
|
+
const sub_issue_stores = createSubscriptionIssueStores();
|
|
58
|
+
// Route per-subscription push envelopes to the owning store
|
|
59
|
+
client.on('snapshot', (payload) => {
|
|
60
|
+
const p = /** @type {any} */ (payload);
|
|
61
|
+
const id = p && typeof p.id === 'string' ? p.id : '';
|
|
62
|
+
const store = id ? sub_issue_stores.getStore(id) : null;
|
|
63
|
+
if (store && p && p.type === 'snapshot') {
|
|
64
|
+
try {
|
|
65
|
+
store.applyPush(p);
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
client.on('upsert', (payload) => {
|
|
72
|
+
const p = /** @type {any} */ (payload);
|
|
73
|
+
const id = p && typeof p.id === 'string' ? p.id : '';
|
|
74
|
+
const store = id ? sub_issue_stores.getStore(id) : null;
|
|
75
|
+
if (store && p && p.type === 'upsert') {
|
|
76
|
+
try {
|
|
77
|
+
store.applyPush(p);
|
|
78
|
+
} catch {
|
|
79
|
+
// ignore
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
client.on('delete', (payload) => {
|
|
84
|
+
const p = /** @type {any} */ (payload);
|
|
85
|
+
const id = p && typeof p.id === 'string' ? p.id : '';
|
|
86
|
+
const store = id ? sub_issue_stores.getStore(id) : null;
|
|
87
|
+
if (store && p && p.type === 'delete') {
|
|
88
|
+
try {
|
|
89
|
+
store.applyPush(p);
|
|
90
|
+
} catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
// Derived list selectors: render from per-subscription snapshots
|
|
96
|
+
const listSelectors = createListSelectors(sub_issue_stores);
|
|
97
|
+
// Show toasts for WebSocket connectivity changes
|
|
98
|
+
/** @type {boolean} */
|
|
99
|
+
let had_disconnect = false;
|
|
100
|
+
if (typeof client.onConnection === 'function') {
|
|
101
|
+
/** @type {(s: 'connecting'|'open'|'closed'|'reconnecting') => void} */
|
|
102
|
+
const onConn = (s) => {
|
|
103
|
+
if (s === 'reconnecting' || s === 'closed') {
|
|
104
|
+
had_disconnect = true;
|
|
105
|
+
showToast('Connection lost. Reconnecting…', 'error', 4000);
|
|
106
|
+
} else if (s === 'open' && had_disconnect) {
|
|
107
|
+
had_disconnect = false;
|
|
108
|
+
showToast('Reconnected', 'success', 2200);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
client.onConnection(onConn);
|
|
112
|
+
}
|
|
43
113
|
// Load persisted filters (status/search/type) from localStorage
|
|
44
114
|
/** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
|
|
45
|
-
let
|
|
115
|
+
let persisted_filters = { status: 'all', search: '', type: '' };
|
|
46
116
|
try {
|
|
47
117
|
const raw = window.localStorage.getItem('beads-ui.filters');
|
|
48
118
|
if (raw) {
|
|
49
119
|
const obj = JSON.parse(raw);
|
|
50
120
|
if (obj && typeof obj === 'object') {
|
|
51
121
|
const ALLOWED = ['bug', 'feature', 'task', 'epic', 'chore'];
|
|
52
|
-
/** @type {string} */
|
|
53
122
|
let parsed_type = '';
|
|
54
123
|
if (typeof obj.type === 'string' && ALLOWED.includes(obj.type)) {
|
|
55
124
|
parsed_type = obj.type;
|
|
56
125
|
} else if (Array.isArray(obj.types)) {
|
|
57
126
|
// Backwards compatibility: pick first valid from previous array format
|
|
58
|
-
/** @type {string} */
|
|
59
127
|
let first_valid = '';
|
|
60
128
|
for (const it of obj.types) {
|
|
61
129
|
if (ALLOWED.includes(String(it))) {
|
|
@@ -65,7 +133,7 @@ export function bootstrap(root_element) {
|
|
|
65
133
|
}
|
|
66
134
|
parsed_type = first_valid;
|
|
67
135
|
}
|
|
68
|
-
|
|
136
|
+
persisted_filters = {
|
|
69
137
|
status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
|
|
70
138
|
obj.status
|
|
71
139
|
)
|
|
@@ -94,7 +162,29 @@ export function bootstrap(root_element) {
|
|
|
94
162
|
} catch {
|
|
95
163
|
// ignore
|
|
96
164
|
}
|
|
97
|
-
|
|
165
|
+
// Load board preferences
|
|
166
|
+
/** @type {{ closed_filter: 'today'|'3'|'7' }} */
|
|
167
|
+
let persistedBoard = { closed_filter: 'today' };
|
|
168
|
+
try {
|
|
169
|
+
const raw_board = window.localStorage.getItem('beads-ui.board');
|
|
170
|
+
if (raw_board) {
|
|
171
|
+
const obj = JSON.parse(raw_board);
|
|
172
|
+
if (obj && typeof obj === 'object') {
|
|
173
|
+
const cf = String(obj.closed_filter || 'today');
|
|
174
|
+
if (cf === 'today' || cf === '3' || cf === '7') {
|
|
175
|
+
persistedBoard.closed_filter = cf;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// ignore parse errors
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const store = createStore({
|
|
184
|
+
filters: persisted_filters,
|
|
185
|
+
view: last_view,
|
|
186
|
+
board: persistedBoard
|
|
187
|
+
});
|
|
98
188
|
const router = createHashRouter(store);
|
|
99
189
|
router.start();
|
|
100
190
|
/**
|
|
@@ -103,7 +193,7 @@ export function bootstrap(root_element) {
|
|
|
103
193
|
*/
|
|
104
194
|
const transport = async (type, payload) => {
|
|
105
195
|
try {
|
|
106
|
-
return await client.send(/** @type {
|
|
196
|
+
return await client.send(/** @type {MessageType} */ (type), payload);
|
|
107
197
|
} catch {
|
|
108
198
|
return [];
|
|
109
199
|
}
|
|
@@ -113,16 +203,54 @@ export function bootstrap(root_element) {
|
|
|
113
203
|
createTopNav(nav_mount, store, router);
|
|
114
204
|
}
|
|
115
205
|
|
|
206
|
+
// Global New Issue dialog (UI-106) mounted at root so it is always visible
|
|
207
|
+
const new_issue_dialog = createNewIssueDialog(
|
|
208
|
+
root_element,
|
|
209
|
+
(type, payload) => client.send(type, payload),
|
|
210
|
+
router,
|
|
211
|
+
store
|
|
212
|
+
);
|
|
213
|
+
// Header button
|
|
214
|
+
try {
|
|
215
|
+
const btn_new = /** @type {HTMLButtonElement|null} */ (
|
|
216
|
+
document.getElementById('new-issue-btn')
|
|
217
|
+
);
|
|
218
|
+
if (btn_new) {
|
|
219
|
+
btn_new.addEventListener('click', () => new_issue_dialog.open());
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// ignore missing header
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Local transport shim: for list-issues, serve from local listSelectors;
|
|
226
|
+
// otherwise forward to ws transport for mutations/show.
|
|
227
|
+
/**
|
|
228
|
+
* @param {MessageType} type
|
|
229
|
+
* @param {unknown} payload
|
|
230
|
+
*/
|
|
231
|
+
const listTransport = async (type, payload) => {
|
|
232
|
+
if (type === 'list-issues') {
|
|
233
|
+
try {
|
|
234
|
+
return listSelectors.selectIssuesFor('tab:issues');
|
|
235
|
+
} catch {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return transport(type, payload);
|
|
240
|
+
};
|
|
241
|
+
|
|
116
242
|
const issues_view = createListView(
|
|
117
243
|
list_mount,
|
|
118
|
-
|
|
244
|
+
/** @type {any} */ (listTransport),
|
|
119
245
|
(hash) => {
|
|
120
|
-
const id = hash
|
|
246
|
+
const id = parseHash(hash);
|
|
121
247
|
if (id) {
|
|
122
248
|
router.gotoIssue(id);
|
|
123
249
|
}
|
|
124
250
|
},
|
|
125
|
-
store
|
|
251
|
+
store,
|
|
252
|
+
subscriptions,
|
|
253
|
+
sub_issue_stores
|
|
126
254
|
);
|
|
127
255
|
// Persist filter changes to localStorage
|
|
128
256
|
store.subscribe((s) => {
|
|
@@ -137,85 +265,354 @@ export function bootstrap(root_element) {
|
|
|
137
265
|
// ignore
|
|
138
266
|
}
|
|
139
267
|
});
|
|
268
|
+
// Persist board preferences
|
|
269
|
+
store.subscribe((s) => {
|
|
270
|
+
try {
|
|
271
|
+
window.localStorage.setItem(
|
|
272
|
+
'beads-ui.board',
|
|
273
|
+
JSON.stringify({ closed_filter: s.board.closed_filter })
|
|
274
|
+
);
|
|
275
|
+
} catch {
|
|
276
|
+
// ignore
|
|
277
|
+
}
|
|
278
|
+
});
|
|
140
279
|
void issues_view.load();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
280
|
+
|
|
281
|
+
// Dialog for issue details (UI-104)
|
|
282
|
+
const dialog = createIssueDialog(detail_mount, store, () => {
|
|
283
|
+
// Close: clear selection and return to current view
|
|
284
|
+
const s = store.getState();
|
|
285
|
+
store.setState({ selected_id: null });
|
|
286
|
+
try {
|
|
287
|
+
/** @type {'issues'|'epics'|'board'} */
|
|
288
|
+
const v = s.view || 'issues';
|
|
289
|
+
router.gotoView(v);
|
|
290
|
+
} catch {
|
|
291
|
+
// ignore
|
|
145
292
|
}
|
|
146
293
|
});
|
|
147
294
|
|
|
148
|
-
|
|
295
|
+
/** @type {ReturnType<typeof createDetailView> | null} */
|
|
296
|
+
let detail = null;
|
|
297
|
+
// Mount details into the dialog body only
|
|
298
|
+
detail = createDetailView(
|
|
299
|
+
dialog.getMount(),
|
|
300
|
+
transport,
|
|
301
|
+
(hash) => {
|
|
302
|
+
const id = parseHash(hash);
|
|
303
|
+
if (id) {
|
|
304
|
+
router.gotoIssue(id);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
sub_issue_stores
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// If router already set a selected id (deep-link), open dialog now
|
|
311
|
+
const initial_id = store.getState().selected_id;
|
|
312
|
+
if (initial_id) {
|
|
313
|
+
detail_mount.hidden = false;
|
|
314
|
+
dialog.open(initial_id);
|
|
315
|
+
if (detail) {
|
|
316
|
+
void detail.load(initial_id);
|
|
317
|
+
}
|
|
318
|
+
// Ensure detail subscription is active on initial deep-link
|
|
319
|
+
const client_id = `detail:${initial_id}`;
|
|
320
|
+
const spec = { type: 'issue-detail', params: { id: initial_id } };
|
|
321
|
+
// Register store first to avoid dropping the initial snapshot
|
|
322
|
+
try {
|
|
323
|
+
sub_issue_stores.register(client_id, spec);
|
|
324
|
+
} catch {
|
|
325
|
+
// ignore
|
|
326
|
+
}
|
|
327
|
+
void subscriptions.subscribeList(client_id, spec).catch(() => {});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Open/close dialog based on selected_id (always dialog; no page variant)
|
|
331
|
+
/** @type {null | (() => Promise<void>)} */
|
|
332
|
+
let unsub_detail = null;
|
|
149
333
|
store.subscribe((s) => {
|
|
150
334
|
const id = s.selected_id;
|
|
151
335
|
if (id) {
|
|
152
|
-
|
|
336
|
+
detail_mount.hidden = false;
|
|
337
|
+
dialog.open(id);
|
|
338
|
+
if (detail) {
|
|
339
|
+
void detail.load(id);
|
|
340
|
+
}
|
|
341
|
+
// Wire per-issue subscription for detail
|
|
342
|
+
const client_id = `detail:${id}`;
|
|
343
|
+
const spec = { type: 'issue-detail', params: { id } };
|
|
344
|
+
// Ensure per-subscription issue store exists before subscribing
|
|
345
|
+
try {
|
|
346
|
+
sub_issue_stores.register(client_id, spec);
|
|
347
|
+
} catch {
|
|
348
|
+
// ignore
|
|
349
|
+
}
|
|
350
|
+
// Subscribe server-side
|
|
351
|
+
void subscriptions
|
|
352
|
+
.subscribeList(client_id, spec)
|
|
353
|
+
.then((unsub) => {
|
|
354
|
+
// Unsubscribe previous if any
|
|
355
|
+
if (unsub_detail) {
|
|
356
|
+
void unsub_detail().catch(() => {});
|
|
357
|
+
}
|
|
358
|
+
unsub_detail = unsub;
|
|
359
|
+
})
|
|
360
|
+
.catch(() => {});
|
|
153
361
|
} else {
|
|
154
|
-
|
|
362
|
+
try {
|
|
363
|
+
dialog.close();
|
|
364
|
+
} catch {
|
|
365
|
+
// ignore
|
|
366
|
+
}
|
|
367
|
+
if (detail) {
|
|
368
|
+
detail.clear();
|
|
369
|
+
}
|
|
370
|
+
detail_mount.hidden = true;
|
|
371
|
+
if (unsub_detail) {
|
|
372
|
+
void unsub_detail().catch(() => {});
|
|
373
|
+
unsub_detail = null;
|
|
374
|
+
}
|
|
155
375
|
}
|
|
156
376
|
});
|
|
157
377
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
if (initialId) {
|
|
161
|
-
void detail.load(initialId);
|
|
162
|
-
} else {
|
|
163
|
-
detail.clear();
|
|
164
|
-
}
|
|
378
|
+
// Removed: issues-changed handling. All views re-render from
|
|
379
|
+
// per-subscription stores which are updated by snapshot/upsert/delete.
|
|
165
380
|
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
381
|
+
// Toggle route shells on view/detail change and persist
|
|
382
|
+
const data = createDataLayer(transport);
|
|
383
|
+
const epics_view = createEpicsView(
|
|
384
|
+
epics_root,
|
|
385
|
+
data,
|
|
386
|
+
(id) => router.gotoIssue(id),
|
|
387
|
+
subscriptions,
|
|
388
|
+
sub_issue_stores
|
|
389
|
+
);
|
|
390
|
+
const board_view = createBoardView(
|
|
391
|
+
board_root,
|
|
392
|
+
data,
|
|
393
|
+
(id) => router.gotoIssue(id),
|
|
394
|
+
store,
|
|
395
|
+
subscriptions,
|
|
396
|
+
sub_issue_stores
|
|
397
|
+
);
|
|
398
|
+
// Preload epics when switching to view
|
|
399
|
+
/**
|
|
400
|
+
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
401
|
+
*/
|
|
402
|
+
// --- Subscriptions: tab-level management and filter-driven updates ---
|
|
403
|
+
/** @type {null | (() => Promise<void>)} */
|
|
404
|
+
let unsub_issues_tab = null;
|
|
405
|
+
/** @type {null | (() => Promise<void>)} */
|
|
406
|
+
let unsub_epics_tab = null;
|
|
407
|
+
/** @type {null | (() => Promise<void>)} */
|
|
408
|
+
let unsub_board_ready = null;
|
|
409
|
+
/** @type {null | (() => Promise<void>)} */
|
|
410
|
+
let unsub_board_in_progress = null;
|
|
411
|
+
/** @type {null | (() => Promise<void>)} */
|
|
412
|
+
let unsub_board_closed = null;
|
|
413
|
+
/** @type {null | (() => Promise<void>)} */
|
|
414
|
+
let unsub_board_blocked = null;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Compute subscription spec for Issues tab based on filters.
|
|
418
|
+
* @param {{ status?: string }} filters
|
|
419
|
+
* @returns {{ type: string, params?: Record<string, string|number|boolean> }}
|
|
420
|
+
*/
|
|
421
|
+
function computeIssuesSpec(filters) {
|
|
422
|
+
const st = String(filters?.status || 'all');
|
|
423
|
+
if (st === 'ready') {
|
|
424
|
+
return { type: 'ready-issues' };
|
|
425
|
+
}
|
|
426
|
+
if (st === 'in_progress') {
|
|
427
|
+
return { type: 'in-progress-issues' };
|
|
428
|
+
}
|
|
429
|
+
if (st === 'closed') {
|
|
430
|
+
return { type: 'closed-issues' };
|
|
431
|
+
}
|
|
432
|
+
// "all" and "open" map to all-issues; client filters apply locally
|
|
433
|
+
return { type: 'all-issues' };
|
|
434
|
+
}
|
|
173
435
|
|
|
174
|
-
|
|
436
|
+
/** @type {string|null} */
|
|
437
|
+
let last_issues_spec_key = null;
|
|
438
|
+
/**
|
|
439
|
+
* Ensure only the active tab has subscriptions; clean up previous.
|
|
440
|
+
* @param {{ view: 'issues'|'epics'|'board', filters: any }} s
|
|
441
|
+
*/
|
|
442
|
+
function ensureTabSubscriptions(s) {
|
|
443
|
+
// Issues tab
|
|
444
|
+
if (s.view === 'issues') {
|
|
445
|
+
const spec = computeIssuesSpec(s.filters || {});
|
|
446
|
+
const key = JSON.stringify(spec);
|
|
447
|
+
// Register store first to capture the initial snapshot
|
|
448
|
+
try {
|
|
449
|
+
sub_issue_stores.register('tab:issues', spec);
|
|
450
|
+
} catch {
|
|
451
|
+
// ignore
|
|
452
|
+
}
|
|
453
|
+
// Only (re)subscribe if not yet subscribed or the spec changed
|
|
454
|
+
if (!unsub_issues_tab || key !== last_issues_spec_key) {
|
|
455
|
+
void subscriptions
|
|
456
|
+
.subscribeList('tab:issues', spec)
|
|
457
|
+
.then((unsub) => {
|
|
458
|
+
unsub_issues_tab = unsub;
|
|
459
|
+
last_issues_spec_key = key;
|
|
460
|
+
})
|
|
461
|
+
.catch(() => {
|
|
462
|
+
// ignore transport errors; retry on next change
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
} else if (unsub_issues_tab) {
|
|
466
|
+
void unsub_issues_tab().catch(() => {});
|
|
467
|
+
unsub_issues_tab = null;
|
|
468
|
+
last_issues_spec_key = null;
|
|
469
|
+
try {
|
|
470
|
+
sub_issue_stores.unregister('tab:issues');
|
|
471
|
+
} catch {
|
|
472
|
+
// ignore
|
|
473
|
+
}
|
|
474
|
+
}
|
|
175
475
|
|
|
176
|
-
//
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
476
|
+
// Epics tab
|
|
477
|
+
if (s.view === 'epics') {
|
|
478
|
+
// Register store first to avoid race with initial snapshot
|
|
479
|
+
try {
|
|
480
|
+
sub_issue_stores.register('tab:epics', { type: 'epics' });
|
|
481
|
+
} catch {
|
|
482
|
+
// ignore
|
|
483
|
+
}
|
|
484
|
+
void subscriptions
|
|
485
|
+
.subscribeList('tab:epics', { type: 'epics' })
|
|
486
|
+
.then((unsub) => {
|
|
487
|
+
unsub_epics_tab = unsub;
|
|
488
|
+
})
|
|
489
|
+
.catch(() => {});
|
|
490
|
+
} else if (unsub_epics_tab) {
|
|
491
|
+
void unsub_epics_tab().catch(() => {});
|
|
492
|
+
unsub_epics_tab = null;
|
|
493
|
+
try {
|
|
494
|
+
sub_issue_stores.unregister('tab:epics');
|
|
495
|
+
} catch {
|
|
496
|
+
// ignore
|
|
184
497
|
}
|
|
185
498
|
}
|
|
186
499
|
|
|
187
|
-
//
|
|
188
|
-
if (
|
|
189
|
-
if (!
|
|
190
|
-
|
|
500
|
+
// Board tab subscribes to lists used by columns
|
|
501
|
+
if (s.view === 'board') {
|
|
502
|
+
if (!unsub_board_ready) {
|
|
503
|
+
try {
|
|
504
|
+
sub_issue_stores.register('tab:board:ready', {
|
|
505
|
+
type: 'ready-issues'
|
|
506
|
+
});
|
|
507
|
+
} catch {
|
|
508
|
+
// ignore
|
|
509
|
+
}
|
|
510
|
+
void subscriptions
|
|
511
|
+
.subscribeList('tab:board:ready', { type: 'ready-issues' })
|
|
512
|
+
.then((u) => (unsub_board_ready = u))
|
|
513
|
+
.catch(() => {});
|
|
514
|
+
}
|
|
515
|
+
if (!unsub_board_in_progress) {
|
|
516
|
+
try {
|
|
517
|
+
sub_issue_stores.register('tab:board:in-progress', {
|
|
518
|
+
type: 'in-progress-issues'
|
|
519
|
+
});
|
|
520
|
+
} catch {
|
|
521
|
+
// ignore
|
|
522
|
+
}
|
|
523
|
+
void subscriptions
|
|
524
|
+
.subscribeList('tab:board:in-progress', {
|
|
525
|
+
type: 'in-progress-issues'
|
|
526
|
+
})
|
|
527
|
+
.then((u) => (unsub_board_in_progress = u))
|
|
528
|
+
.catch(() => {});
|
|
529
|
+
}
|
|
530
|
+
if (!unsub_board_closed) {
|
|
531
|
+
try {
|
|
532
|
+
sub_issue_stores.register('tab:board:closed', {
|
|
533
|
+
type: 'closed-issues'
|
|
534
|
+
});
|
|
535
|
+
} catch {
|
|
536
|
+
// ignore
|
|
537
|
+
}
|
|
538
|
+
void subscriptions
|
|
539
|
+
.subscribeList('tab:board:closed', { type: 'closed-issues' })
|
|
540
|
+
.then((u) => (unsub_board_closed = u))
|
|
541
|
+
.catch(() => {});
|
|
542
|
+
}
|
|
543
|
+
if (!unsub_board_blocked) {
|
|
544
|
+
try {
|
|
545
|
+
sub_issue_stores.register('tab:board:blocked', {
|
|
546
|
+
type: 'blocked-issues'
|
|
547
|
+
});
|
|
548
|
+
} catch {
|
|
549
|
+
// ignore
|
|
550
|
+
}
|
|
551
|
+
void subscriptions
|
|
552
|
+
.subscribeList('tab:board:blocked', { type: 'blocked-issues' })
|
|
553
|
+
.then((u) => (unsub_board_blocked = u))
|
|
554
|
+
.catch(() => {});
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
// Unsubscribe all board lists when leaving the board view
|
|
558
|
+
if (unsub_board_ready) {
|
|
559
|
+
void unsub_board_ready().catch(() => {});
|
|
560
|
+
unsub_board_ready = null;
|
|
561
|
+
try {
|
|
562
|
+
sub_issue_stores.unregister('tab:board:ready');
|
|
563
|
+
} catch {
|
|
564
|
+
// ignore
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (unsub_board_in_progress) {
|
|
568
|
+
void unsub_board_in_progress().catch(() => {});
|
|
569
|
+
unsub_board_in_progress = null;
|
|
570
|
+
try {
|
|
571
|
+
sub_issue_stores.unregister('tab:board:in-progress');
|
|
572
|
+
} catch {
|
|
573
|
+
// ignore
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (unsub_board_closed) {
|
|
577
|
+
void unsub_board_closed().catch(() => {});
|
|
578
|
+
unsub_board_closed = null;
|
|
579
|
+
try {
|
|
580
|
+
sub_issue_stores.unregister('tab:board:closed');
|
|
581
|
+
} catch {
|
|
582
|
+
// ignore
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (unsub_board_blocked) {
|
|
586
|
+
void unsub_board_blocked().catch(() => {});
|
|
587
|
+
unsub_board_blocked = null;
|
|
588
|
+
try {
|
|
589
|
+
sub_issue_stores.unregister('tab:board:blocked');
|
|
590
|
+
} catch {
|
|
591
|
+
// ignore
|
|
592
|
+
}
|
|
191
593
|
}
|
|
192
594
|
}
|
|
193
|
-
}
|
|
595
|
+
}
|
|
194
596
|
|
|
195
|
-
// Toggle route shells on view/detail change and persist
|
|
196
|
-
const data = createDataLayer(/** @type {any} */ (transport), client.on);
|
|
197
|
-
const epics_view = createEpicsView(epics_root, data, (id) =>
|
|
198
|
-
router.gotoIssue(id)
|
|
199
|
-
);
|
|
200
|
-
const board_view = createBoardView(board_root, data, (id) =>
|
|
201
|
-
router.gotoIssue(id)
|
|
202
|
-
);
|
|
203
|
-
// Preload epics when switching to view
|
|
204
597
|
/**
|
|
598
|
+
* Manage route visibility and list subscriptions per view.
|
|
205
599
|
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
206
600
|
*/
|
|
207
601
|
const onRouteChange = (s) => {
|
|
208
|
-
const showDetail = Boolean(s.selected_id);
|
|
209
602
|
if (issues_root && epics_root && board_root && detail_mount) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
603
|
+
// Underlying route visibility is controlled only by selected view
|
|
604
|
+
issues_root.hidden = s.view !== 'issues';
|
|
605
|
+
epics_root.hidden = s.view !== 'epics';
|
|
606
|
+
board_root.hidden = s.view !== 'board';
|
|
607
|
+
// detail_mount visibility handled in subscription above
|
|
214
608
|
}
|
|
215
|
-
|
|
609
|
+
// Ensure subscriptions for the active tab before loading the view to
|
|
610
|
+
// avoid empty initial renders due to racing list-delta.
|
|
611
|
+
ensureTabSubscriptions(s);
|
|
612
|
+
if (!s.selected_id && s.view === 'epics') {
|
|
216
613
|
void epics_view.load();
|
|
217
614
|
}
|
|
218
|
-
if (!
|
|
615
|
+
if (!s.selected_id && s.view === 'board') {
|
|
219
616
|
void board_view.load();
|
|
220
617
|
}
|
|
221
618
|
try {
|
|
@@ -227,6 +624,31 @@ export function bootstrap(root_element) {
|
|
|
227
624
|
store.subscribe(onRouteChange);
|
|
228
625
|
// Ensure initial state is reflected (fixes reload on #/epics)
|
|
229
626
|
onRouteChange(store.getState());
|
|
627
|
+
|
|
628
|
+
// Removed redundant filter-change subscription: handled by ensureTabSubscriptions
|
|
629
|
+
|
|
630
|
+
// Keyboard shortcuts: Ctrl/Cmd+N opens new issue; Ctrl/Cmd+Enter submits inside dialog
|
|
631
|
+
window.addEventListener('keydown', (ev) => {
|
|
632
|
+
const is_modifier = ev.ctrlKey || ev.metaKey;
|
|
633
|
+
const key = String(ev.key || '').toLowerCase();
|
|
634
|
+
const target = /** @type {HTMLElement} */ (ev.target);
|
|
635
|
+
const tag =
|
|
636
|
+
target && target.tagName ? String(target.tagName).toLowerCase() : '';
|
|
637
|
+
const is_editable =
|
|
638
|
+
tag === 'input' ||
|
|
639
|
+
tag === 'textarea' ||
|
|
640
|
+
tag === 'select' ||
|
|
641
|
+
(target &&
|
|
642
|
+
typeof target.isContentEditable === 'boolean' &&
|
|
643
|
+
target.isContentEditable);
|
|
644
|
+
if (is_modifier && key === 'n') {
|
|
645
|
+
// Do not hijack when typing in inputs; common UX
|
|
646
|
+
if (!is_editable) {
|
|
647
|
+
ev.preventDefault();
|
|
648
|
+
new_issue_dialog.open();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
});
|
|
230
652
|
}
|
|
231
653
|
}
|
|
232
654
|
|