beads-ui 0.3.0 → 0.4.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 +26 -0
- package/README.md +15 -6
- package/app/main.bundle.js +617 -0
- package/app/main.bundle.js.map +7 -0
- package/bin/bdui.js +2 -1
- package/package.json +27 -16
- package/server/app.js +39 -35
- package/server/bd.js +6 -2
- package/server/cli/commands.js +12 -8
- package/server/cli/daemon.js +20 -5
- package/server/cli/index.js +19 -31
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +4 -2
- package/server/config.js +3 -2
- package/server/db.js +9 -6
- package/server/index.js +10 -4
- package/server/list-adapters.js +9 -3
- package/server/logging.js +23 -0
- package/server/subscriptions.js +12 -0
- package/server/validators.js +2 -0
- package/server/watcher.js +10 -5
- package/server/ws.js +31 -10
- package/app/data/list-selectors.js +0 -98
- package/app/data/providers.js +0 -76
- package/app/data/sort.js +0 -45
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -102
- package/app/data/subscriptions-store.js +0 -219
- package/app/main.js +0 -702
- package/app/protocol.js +0 -196
- package/app/protocol.md +0 -66
- package/app/router.js +0 -114
- package/app/state.js +0 -103
- package/app/utils/issue-id-renderer.js +0 -71
- package/app/utils/issue-id.js +0 -10
- package/app/utils/issue-type.js +0 -27
- package/app/utils/issue-url.js +0 -9
- package/app/utils/markdown.js +0 -22
- package/app/utils/priority-badge.js +0 -47
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -32
- package/app/utils/status.js +0 -23
- package/app/utils/toast.js +0 -34
- package/app/utils/type-badge.js +0 -33
- package/app/views/board.js +0 -535
- package/app/views/detail.js +0 -1249
- package/app/views/epics.js +0 -280
- package/app/views/issue-dialog.js +0 -163
- package/app/views/issue-row.js +0 -190
- package/app/views/list.js +0 -464
- package/app/views/nav.js +0 -67
- package/app/views/new-issue-dialog.js +0 -345
- package/app/ws.js +0 -279
- package/docs/adr/001-push-only-lists.md +0 -134
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
- package/docs/architecture.md +0 -194
- package/docs/data-exchange-subscription-plan.md +0 -198
- package/docs/db-watching.md +0 -30
- package/docs/migration-v2.md +0 -54
- package/docs/protocol/issues-push-v2.md +0 -179
- package/docs/subscription-issue-store.md +0 -112
package/app/main.js
DELETED
|
@@ -1,702 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { MessageType } from './protocol.js'
|
|
3
|
-
*/
|
|
4
|
-
import { html, render } from 'lit-html';
|
|
5
|
-
import { createListSelectors } from './data/list-selectors.js';
|
|
6
|
-
import { createDataLayer } from './data/providers.js';
|
|
7
|
-
import { createSubscriptionIssueStores } from './data/subscription-issue-stores.js';
|
|
8
|
-
import { createSubscriptionStore } from './data/subscriptions-store.js';
|
|
9
|
-
import { createHashRouter, parseHash } from './router.js';
|
|
10
|
-
import { createStore } from './state.js';
|
|
11
|
-
import { showToast } from './utils/toast.js';
|
|
12
|
-
import { createBoardView } from './views/board.js';
|
|
13
|
-
import { createDetailView } from './views/detail.js';
|
|
14
|
-
import { createEpicsView } from './views/epics.js';
|
|
15
|
-
import { createIssueDialog } from './views/issue-dialog.js';
|
|
16
|
-
import { createListView } from './views/list.js';
|
|
17
|
-
import { createTopNav } from './views/nav.js';
|
|
18
|
-
import { createNewIssueDialog } from './views/new-issue-dialog.js';
|
|
19
|
-
import { createWsClient } from './ws.js';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Bootstrap the SPA shell with two panels.
|
|
23
|
-
* @param {HTMLElement} root_element - The container element to render into.
|
|
24
|
-
*/
|
|
25
|
-
export function bootstrap(root_element) {
|
|
26
|
-
// Render route shells (nav is mounted in header)
|
|
27
|
-
const shell = html`
|
|
28
|
-
<section id="issues-root" class="route issues">
|
|
29
|
-
<aside id="list-panel" class="panel"></aside>
|
|
30
|
-
</section>
|
|
31
|
-
<section id="epics-root" class="route epics" hidden></section>
|
|
32
|
-
<section id="board-root" class="route board" hidden></section>
|
|
33
|
-
<section id="detail-panel" class="route detail" hidden></section>
|
|
34
|
-
`;
|
|
35
|
-
render(shell, root_element);
|
|
36
|
-
|
|
37
|
-
/** @type {HTMLElement|null} */
|
|
38
|
-
const nav_mount = document.getElementById('top-nav');
|
|
39
|
-
/** @type {HTMLElement|null} */
|
|
40
|
-
const issues_root = document.getElementById('issues-root');
|
|
41
|
-
/** @type {HTMLElement|null} */
|
|
42
|
-
const epics_root = document.getElementById('epics-root');
|
|
43
|
-
/** @type {HTMLElement|null} */
|
|
44
|
-
const board_root = document.getElementById('board-root');
|
|
45
|
-
|
|
46
|
-
/** @type {HTMLElement|null} */
|
|
47
|
-
const list_mount = document.getElementById('list-panel');
|
|
48
|
-
/** @type {HTMLElement|null} */
|
|
49
|
-
const detail_mount = document.getElementById('detail-panel');
|
|
50
|
-
if (list_mount && issues_root && epics_root && board_root && detail_mount) {
|
|
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
|
-
}
|
|
113
|
-
// Load persisted filters (status/search/type) from localStorage
|
|
114
|
-
/** @type {{ status: 'all'|'open'|'in_progress'|'closed'|'ready', search: string, type: string }} */
|
|
115
|
-
let persisted_filters = { status: 'all', search: '', type: '' };
|
|
116
|
-
try {
|
|
117
|
-
const raw = window.localStorage.getItem('beads-ui.filters');
|
|
118
|
-
if (raw) {
|
|
119
|
-
const obj = JSON.parse(raw);
|
|
120
|
-
if (obj && typeof obj === 'object') {
|
|
121
|
-
const ALLOWED = ['bug', 'feature', 'task', 'epic', 'chore'];
|
|
122
|
-
let parsed_type = '';
|
|
123
|
-
if (typeof obj.type === 'string' && ALLOWED.includes(obj.type)) {
|
|
124
|
-
parsed_type = obj.type;
|
|
125
|
-
} else if (Array.isArray(obj.types)) {
|
|
126
|
-
// Backwards compatibility: pick first valid from previous array format
|
|
127
|
-
let first_valid = '';
|
|
128
|
-
for (const it of obj.types) {
|
|
129
|
-
if (ALLOWED.includes(String(it))) {
|
|
130
|
-
first_valid = /** @type {string} */ (it);
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
parsed_type = first_valid;
|
|
135
|
-
}
|
|
136
|
-
persisted_filters = {
|
|
137
|
-
status: ['all', 'open', 'in_progress', 'closed', 'ready'].includes(
|
|
138
|
-
obj.status
|
|
139
|
-
)
|
|
140
|
-
? obj.status
|
|
141
|
-
: 'all',
|
|
142
|
-
search: typeof obj.search === 'string' ? obj.search : '',
|
|
143
|
-
type: parsed_type
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
} catch {
|
|
148
|
-
// ignore parse errors
|
|
149
|
-
}
|
|
150
|
-
// Load last-view from storage
|
|
151
|
-
/** @type {'issues'|'epics'|'board'} */
|
|
152
|
-
let last_view = 'issues';
|
|
153
|
-
try {
|
|
154
|
-
const raw_view = window.localStorage.getItem('beads-ui.view');
|
|
155
|
-
if (
|
|
156
|
-
raw_view === 'issues' ||
|
|
157
|
-
raw_view === 'epics' ||
|
|
158
|
-
raw_view === 'board'
|
|
159
|
-
) {
|
|
160
|
-
last_view = raw_view;
|
|
161
|
-
}
|
|
162
|
-
} catch {
|
|
163
|
-
// ignore
|
|
164
|
-
}
|
|
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
|
-
});
|
|
188
|
-
const router = createHashRouter(store);
|
|
189
|
-
router.start();
|
|
190
|
-
/**
|
|
191
|
-
* @param {string} type
|
|
192
|
-
* @param {unknown} payload
|
|
193
|
-
*/
|
|
194
|
-
const transport = async (type, payload) => {
|
|
195
|
-
try {
|
|
196
|
-
return await client.send(/** @type {MessageType} */ (type), payload);
|
|
197
|
-
} catch {
|
|
198
|
-
return [];
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
// Top navigation (optional mount)
|
|
202
|
-
if (nav_mount) {
|
|
203
|
-
createTopNav(nav_mount, store, router);
|
|
204
|
-
}
|
|
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
|
-
|
|
242
|
-
const issues_view = createListView(
|
|
243
|
-
list_mount,
|
|
244
|
-
/** @type {any} */ (listTransport),
|
|
245
|
-
(hash) => {
|
|
246
|
-
const id = parseHash(hash);
|
|
247
|
-
if (id) {
|
|
248
|
-
router.gotoIssue(id);
|
|
249
|
-
}
|
|
250
|
-
},
|
|
251
|
-
store,
|
|
252
|
-
subscriptions,
|
|
253
|
-
sub_issue_stores
|
|
254
|
-
);
|
|
255
|
-
// Persist filter changes to localStorage
|
|
256
|
-
store.subscribe((s) => {
|
|
257
|
-
try {
|
|
258
|
-
const data = {
|
|
259
|
-
status: s.filters.status,
|
|
260
|
-
search: s.filters.search,
|
|
261
|
-
type: typeof s.filters.type === 'string' ? s.filters.type : ''
|
|
262
|
-
};
|
|
263
|
-
window.localStorage.setItem('beads-ui.filters', JSON.stringify(data));
|
|
264
|
-
} catch {
|
|
265
|
-
// ignore
|
|
266
|
-
}
|
|
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
|
-
});
|
|
279
|
-
void issues_view.load();
|
|
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
|
|
292
|
-
}
|
|
293
|
-
});
|
|
294
|
-
|
|
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;
|
|
333
|
-
store.subscribe((s) => {
|
|
334
|
-
const id = s.selected_id;
|
|
335
|
-
if (id) {
|
|
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(() => {});
|
|
361
|
-
} else {
|
|
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
|
-
}
|
|
375
|
-
}
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
// Removed: issues-changed handling. All views re-render from
|
|
379
|
-
// per-subscription stores which are updated by snapshot/upsert/delete.
|
|
380
|
-
|
|
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
|
-
}
|
|
435
|
-
|
|
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
|
-
}
|
|
475
|
-
|
|
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
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
|
|
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
|
-
}
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Manage route visibility and list subscriptions per view.
|
|
599
|
-
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
600
|
-
*/
|
|
601
|
-
const onRouteChange = (s) => {
|
|
602
|
-
if (issues_root && epics_root && board_root && detail_mount) {
|
|
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
|
|
608
|
-
}
|
|
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') {
|
|
613
|
-
void epics_view.load();
|
|
614
|
-
}
|
|
615
|
-
if (!s.selected_id && s.view === 'board') {
|
|
616
|
-
void board_view.load();
|
|
617
|
-
}
|
|
618
|
-
try {
|
|
619
|
-
window.localStorage.setItem('beads-ui.view', s.view);
|
|
620
|
-
} catch {
|
|
621
|
-
// ignore
|
|
622
|
-
}
|
|
623
|
-
};
|
|
624
|
-
store.subscribe(onRouteChange);
|
|
625
|
-
// Ensure initial state is reflected (fixes reload on #/epics)
|
|
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
|
-
});
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
656
|
-
window.addEventListener('DOMContentLoaded', () => {
|
|
657
|
-
// Initialize theme from saved preference or OS preference
|
|
658
|
-
try {
|
|
659
|
-
const saved = window.localStorage.getItem('beads-ui.theme');
|
|
660
|
-
const prefersDark =
|
|
661
|
-
window.matchMedia &&
|
|
662
|
-
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
663
|
-
const initial =
|
|
664
|
-
saved === 'dark' || saved === 'light'
|
|
665
|
-
? saved
|
|
666
|
-
: prefersDark
|
|
667
|
-
? 'dark'
|
|
668
|
-
: 'light';
|
|
669
|
-
document.documentElement.setAttribute('data-theme', initial);
|
|
670
|
-
const sw = /** @type {HTMLInputElement|null} */ (
|
|
671
|
-
document.getElementById('theme-switch')
|
|
672
|
-
);
|
|
673
|
-
if (sw) {
|
|
674
|
-
sw.checked = initial === 'dark';
|
|
675
|
-
}
|
|
676
|
-
} catch {
|
|
677
|
-
// ignore theme init errors
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Wire up theme switch in header
|
|
681
|
-
const themeSwitch = /** @type {HTMLInputElement|null} */ (
|
|
682
|
-
document.getElementById('theme-switch')
|
|
683
|
-
);
|
|
684
|
-
if (themeSwitch) {
|
|
685
|
-
themeSwitch.addEventListener('change', () => {
|
|
686
|
-
const mode = themeSwitch.checked ? 'dark' : 'light';
|
|
687
|
-
document.documentElement.setAttribute('data-theme', mode);
|
|
688
|
-
try {
|
|
689
|
-
window.localStorage.setItem('beads-ui.theme', mode);
|
|
690
|
-
} catch {
|
|
691
|
-
// ignore persistence errors
|
|
692
|
-
}
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/** @type {HTMLElement|null} */
|
|
697
|
-
const app_root = document.getElementById('app');
|
|
698
|
-
if (app_root) {
|
|
699
|
-
bootstrap(app_root);
|
|
700
|
-
}
|
|
701
|
-
});
|
|
702
|
-
}
|