beads-ui 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGES.md +24 -0
  2. package/README.md +15 -6
  3. package/app/main.bundle.js +617 -0
  4. package/app/main.bundle.js.map +7 -0
  5. package/bin/bdui.js +2 -1
  6. package/package.json +27 -14
  7. package/server/app.js +38 -36
  8. package/server/bd.js +2 -2
  9. package/server/cli/commands.js +8 -8
  10. package/server/cli/daemon.js +13 -5
  11. package/server/cli/index.js +17 -31
  12. package/server/cli/usage.js +3 -2
  13. package/server/db.js +6 -6
  14. package/server/index.js +10 -4
  15. package/server/logging.js +23 -0
  16. package/server/watcher.js +7 -5
  17. package/server/ws.js +23 -11
  18. package/app/data/list-selectors.js +0 -103
  19. package/app/data/providers.js +0 -78
  20. package/app/data/sort.js +0 -47
  21. package/app/data/subscription-issue-store.js +0 -161
  22. package/app/data/subscription-issue-stores.js +0 -128
  23. package/app/data/subscriptions-store.js +0 -227
  24. package/app/main.js +0 -706
  25. package/app/protocol.md +0 -66
  26. package/app/router.js +0 -117
  27. package/app/state.js +0 -105
  28. package/app/utils/issue-id-renderer.js +0 -72
  29. package/app/utils/issue-id.js +0 -11
  30. package/app/utils/issue-type.js +0 -29
  31. package/app/utils/issue-url.js +0 -10
  32. package/app/utils/markdown.js +0 -16
  33. package/app/utils/priority-badge.js +0 -48
  34. package/app/utils/priority.js +0 -1
  35. package/app/utils/status-badge.js +0 -33
  36. package/app/utils/status.js +0 -25
  37. package/app/utils/toast.js +0 -35
  38. package/app/utils/type-badge.js +0 -34
  39. package/app/views/board.js +0 -537
  40. package/app/views/detail.js +0 -1252
  41. package/app/views/epics.js +0 -281
  42. package/app/views/issue-dialog.js +0 -164
  43. package/app/views/issue-row.js +0 -191
  44. package/app/views/list.js +0 -468
  45. package/app/views/nav.js +0 -68
  46. package/app/views/new-issue-dialog.js +0 -348
  47. package/app/ws.js +0 -282
  48. package/docs/adr/001-push-only-lists.md +0 -134
  49. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
  50. package/docs/architecture.md +0 -194
  51. package/docs/data-exchange-subscription-plan.md +0 -198
  52. package/docs/db-watching.md +0 -30
  53. package/docs/migration-v2.md +0 -54
  54. package/docs/protocol/issues-push-v2.md +0 -179
  55. package/docs/subscription-issue-store.md +0 -112
  56. package/server/protocol.js +0 -3
@@ -1,348 +0,0 @@
1
- import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
2
- import { priority_levels } from '../utils/priority.js';
3
-
4
- /**
5
- * Create and manage the New Issue dialog (native <dialog>).
6
- *
7
- * @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
8
- * @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
9
- * @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
10
- * @param {{ setState: (patch: any) => void, getState: () => any }} [store]
11
- * @returns {{ open: () => void, close: () => void }}
12
- */
13
- export function createNewIssueDialog(mount_element, sendFn, router, store) {
14
- const dialog = /** @type {HTMLDialogElement} */ (
15
- document.createElement('dialog')
16
- );
17
- dialog.id = 'new-issue-dialog';
18
- dialog.setAttribute('role', 'dialog');
19
- dialog.setAttribute('aria-modal', 'true');
20
-
21
- dialog.innerHTML = `
22
- <div class="new-issue__container" part="container">
23
- <header class="new-issue__header">
24
- <div class="new-issue__title">New Issue</div>
25
- <button type="button" class="new-issue__close" aria-label="Close">×</button>
26
- </header>
27
- <div class="new-issue__body">
28
- <form id="new-issue-form" class="new-issue__form">
29
- <label for="new-title">Title</label>
30
- <input id="new-title" name="title" type="text" required placeholder="Short summary" />
31
-
32
- <label for="new-type">Type</label>
33
- <select id="new-type" name="type" aria-label="Issue type"></select>
34
-
35
- <label for="new-priority">Priority</label>
36
- <select id="new-priority" name="priority" aria-label="Priority"></select>
37
-
38
- <label for="new-labels">Labels</label>
39
- <input id="new-labels" name="labels" type="text" placeholder="comma,separated" />
40
-
41
- <label for="new-description">Description</label>
42
- <textarea id="new-description" name="description" rows="6" placeholder="Optional markdown description"></textarea>
43
-
44
- <div aria-live="polite" role="status" class="new-issue__error" id="new-issue-error"></div>
45
-
46
- <div class="new-issue__actions" style="grid-column: 1 / -1">
47
- <button type="button" id="btn-cancel">Cancel (Esc)</button>
48
- <button type="submit" id="btn-create">Create</button>
49
- </div>
50
- </form>
51
- </div>
52
- </div>
53
- `;
54
-
55
- mount_element.appendChild(dialog);
56
-
57
- const form = /** @type {HTMLFormElement} */ (
58
- dialog.querySelector('#new-issue-form')
59
- );
60
- const input_title = /** @type {HTMLInputElement} */ (
61
- dialog.querySelector('#new-title')
62
- );
63
- const sel_type = /** @type {HTMLSelectElement} */ (
64
- dialog.querySelector('#new-type')
65
- );
66
- const sel_priority = /** @type {HTMLSelectElement} */ (
67
- dialog.querySelector('#new-priority')
68
- );
69
- const input_labels = /** @type {HTMLInputElement} */ (
70
- dialog.querySelector('#new-labels')
71
- );
72
- const input_description = /** @type {HTMLTextAreaElement} */ (
73
- dialog.querySelector('#new-description')
74
- );
75
- const error_box = /** @type {HTMLDivElement} */ (
76
- dialog.querySelector('#new-issue-error')
77
- );
78
- const btn_cancel = /** @type {HTMLButtonElement} */ (
79
- dialog.querySelector('#btn-cancel')
80
- );
81
- const btn_create = /** @type {HTMLButtonElement} */ (
82
- dialog.querySelector('#btn-create')
83
- );
84
- const btn_close = /** @type {HTMLButtonElement} */ (
85
- dialog.querySelector('.new-issue__close')
86
- );
87
-
88
- // Populate selects
89
- function populateSelects() {
90
- sel_type.replaceChildren();
91
- // Empty option to allow leaving type unspecified
92
- const optEmpty = document.createElement('option');
93
- optEmpty.value = '';
94
- optEmpty.textContent = '— Select —';
95
- sel_type.appendChild(optEmpty);
96
- for (const t of ISSUE_TYPES) {
97
- const o = document.createElement('option');
98
- o.value = t;
99
- o.textContent = typeLabel(t);
100
- sel_type.appendChild(o);
101
- }
102
-
103
- sel_priority.replaceChildren();
104
- for (let i = 0; i <= 4; i += 1) {
105
- const o = document.createElement('option');
106
- o.value = String(i);
107
- const label = priority_levels[i] || 'Medium';
108
- o.textContent = `${i} – ${label}`;
109
- sel_priority.appendChild(o);
110
- }
111
- }
112
- populateSelects();
113
-
114
- function requestClose() {
115
- try {
116
- if (typeof dialog.close === 'function') {
117
- dialog.close();
118
- } else {
119
- dialog.removeAttribute('open');
120
- }
121
- } catch {
122
- dialog.removeAttribute('open');
123
- }
124
- }
125
-
126
- /**
127
- * @param {boolean} is_busy
128
- */
129
- function setBusy(is_busy) {
130
- input_title.disabled = is_busy;
131
- sel_type.disabled = is_busy;
132
- sel_priority.disabled = is_busy;
133
- input_labels.disabled = is_busy;
134
- input_description.disabled = is_busy;
135
- btn_cancel.disabled = is_busy;
136
- btn_create.disabled = is_busy;
137
- btn_create.textContent = is_busy ? 'Creating…' : 'Create';
138
- }
139
-
140
- function clearError() {
141
- error_box.textContent = '';
142
- }
143
-
144
- /**
145
- * @param {string} msg
146
- */
147
- function setError(msg) {
148
- error_box.textContent = msg;
149
- }
150
-
151
- function loadDefaults() {
152
- try {
153
- const t = window.localStorage.getItem('beads-ui.new.type');
154
- if (t) {
155
- sel_type.value = t;
156
- } else {
157
- sel_type.value = '';
158
- }
159
- const p = window.localStorage.getItem('beads-ui.new.priority');
160
- if (p && /^\d$/.test(p)) {
161
- sel_priority.value = p;
162
- } else {
163
- sel_priority.value = '2';
164
- }
165
- } catch {
166
- sel_type.value = '';
167
- sel_priority.value = '2';
168
- }
169
- }
170
-
171
- function saveDefaults() {
172
- try {
173
- const t = sel_type.value || '';
174
- const p = sel_priority.value || '';
175
- if (t.length > 0) {
176
- window.localStorage.setItem('beads-ui.new.type', t);
177
- }
178
- if (p.length > 0) {
179
- window.localStorage.setItem('beads-ui.new.priority', p);
180
- }
181
- } catch {
182
- // ignore persistence errors
183
- }
184
- }
185
-
186
- /**
187
- * Extract numeric suffix from an id like "UI-123"; return -1 when absent.
188
- *
189
- * @param {string} id
190
- */
191
- function idNumeric(id) {
192
- const m = /-(\d+)$/.exec(String(id || ''));
193
- return m && m[1] ? Number(m[1]) : -1;
194
- }
195
-
196
- /**
197
- * Submit handler: validate, create, then open the created issue details.
198
- *
199
- * @returns {Promise<void>}
200
- */
201
- async function createNow() {
202
- clearError();
203
- const title = String(input_title.value || '').trim();
204
- if (title.length === 0) {
205
- setError('Title is required');
206
- input_title.focus();
207
- return;
208
- }
209
- const prio = Number(sel_priority.value || '2');
210
- if (!(prio >= 0 && prio <= 4)) {
211
- setError('Priority must be 0..4');
212
- sel_priority.focus();
213
- return;
214
- }
215
- const type = String(sel_type.value || '');
216
- const desc = String(input_description.value || '');
217
- const labels = String(input_labels.value || '')
218
- .split(',')
219
- .map((s) => s.trim())
220
- .filter((s) => s.length > 0);
221
-
222
- /** @type {{ title: string, type?: string, priority?: number, description?: string }} */
223
- const payload = { title };
224
- if (type.length > 0) {
225
- payload.type = type;
226
- }
227
- if (String(prio).length > 0) {
228
- payload.priority = prio;
229
- }
230
- if (desc.length > 0) {
231
- payload.description = desc;
232
- }
233
-
234
- setBusy(true);
235
- try {
236
- await sendFn('create-issue', payload);
237
- } catch {
238
- setBusy(false);
239
- setError('Failed to create issue');
240
- return;
241
- }
242
-
243
- saveDefaults();
244
-
245
- // Best-effort: find the created id by matching title among open issues and picking the highest numeric id
246
- /** @type {any} */
247
- let list = null;
248
- try {
249
- list = await sendFn('list-issues', {
250
- filters: { status: 'open', limit: 50 }
251
- });
252
- } catch {
253
- list = null;
254
- }
255
- let created_id = '';
256
- if (Array.isArray(list)) {
257
- const matches = list.filter((it) => String(it.title || '') === title);
258
- if (matches.length > 0) {
259
- /** @type {any} */
260
- let best = matches[0];
261
- for (const it of matches) {
262
- const ai = idNumeric(best.id || '');
263
- const bi = idNumeric(it.id || '');
264
- if (bi > ai) {
265
- best = it;
266
- }
267
- }
268
- created_id = String(best.id || '');
269
- }
270
- }
271
-
272
- // Apply labels if any
273
- if (created_id && labels.length > 0) {
274
- for (const label of labels) {
275
- try {
276
- await sendFn('label-add', { id: created_id, label });
277
- } catch {
278
- // ignore label failures
279
- }
280
- }
281
- }
282
-
283
- // Navigate to created issue if found
284
- if (created_id) {
285
- try {
286
- router.gotoIssue(created_id);
287
- } catch {
288
- // ignore routing errors
289
- }
290
- // Also set state directly to ensure dialog opens even if hash routing is suppressed in tests
291
- try {
292
- if (store) {
293
- store.setState({ selected_id: created_id });
294
- }
295
- } catch {
296
- // ignore
297
- }
298
- }
299
-
300
- setBusy(false);
301
- requestClose();
302
- }
303
-
304
- // Events
305
- dialog.addEventListener('cancel', (ev) => {
306
- ev.preventDefault();
307
- requestClose();
308
- });
309
- btn_close.addEventListener('click', () => requestClose());
310
- btn_cancel.addEventListener('click', () => requestClose());
311
- dialog.addEventListener('keydown', (ev) => {
312
- if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
313
- ev.preventDefault();
314
- void createNow();
315
- }
316
- });
317
- form.addEventListener('submit', (ev) => {
318
- ev.preventDefault();
319
- void createNow();
320
- });
321
-
322
- return {
323
- open() {
324
- form.reset();
325
- clearError();
326
- loadDefaults();
327
- try {
328
- if ('showModal' in dialog && typeof dialog.showModal === 'function') {
329
- dialog.showModal();
330
- } else {
331
- dialog.setAttribute('open', '');
332
- }
333
- } catch {
334
- dialog.setAttribute('open', '');
335
- }
336
- setTimeout(() => {
337
- try {
338
- input_title.focus();
339
- } catch {
340
- // ignore
341
- }
342
- }, 0);
343
- },
344
- close() {
345
- requestClose();
346
- }
347
- };
348
- }
package/app/ws.js DELETED
@@ -1,282 +0,0 @@
1
- /**
2
- * @import { MessageType } from './protocol.js'
3
- */
4
- /**
5
- * Persistent WebSocket client with reconnect, request/response correlation,
6
- * and simple event dispatching.
7
- *
8
- * Usage:
9
- * const ws = createWsClient();
10
- * const data = await ws.send('list-issues', { filters: {} });
11
- * const off = ws.on('snapshot', (payload) => { <push event> });
12
- */
13
- import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
14
-
15
- /**
16
- * @typedef {'connecting'|'open'|'closed'|'reconnecting'} ConnectionState
17
- */
18
-
19
- /**
20
- * @typedef {{ initialMs?: number, maxMs?: number, factor?: number, jitterRatio?: number }} BackoffOptions
21
- */
22
-
23
- /**
24
- * @typedef {{ url?: string, backoff?: BackoffOptions, logger?: Console }} ClientOptions
25
- */
26
-
27
- /**
28
- * Create a WebSocket client with auto-reconnect and message correlation.
29
- *
30
- * @param {ClientOptions} [options]
31
- */
32
- export function createWsClient(options = {}) {
33
- /** @type {Console} */
34
- const logger = options.logger || console;
35
-
36
- /** @type {BackoffOptions} */
37
- const backoff = {
38
- initialMs: options.backoff?.initialMs ?? 1000,
39
- maxMs: options.backoff?.maxMs ?? 30000,
40
- factor: options.backoff?.factor ?? 2,
41
- jitterRatio: options.backoff?.jitterRatio ?? 0.2
42
- };
43
-
44
- /** @type {() => string} */
45
- const resolveUrl = () => {
46
- if (options.url && options.url.length > 0) {
47
- return options.url;
48
- }
49
- if (typeof location !== 'undefined') {
50
- return (
51
- (location.protocol === 'https:' ? 'wss://' : 'ws://') +
52
- location.host +
53
- '/ws'
54
- );
55
- }
56
- return 'ws://localhost/ws';
57
- };
58
-
59
- /** @type {WebSocket | null} */
60
- let ws = null;
61
- /** @type {ConnectionState} */
62
- let state = 'closed';
63
- /** @type {number} */
64
- let attempts = 0;
65
- /** @type {ReturnType<typeof setTimeout> | null} */
66
- let reconnect_timer = null;
67
- /** @type {boolean} */
68
- let should_reconnect = true;
69
-
70
- /** @type {Map<string, { resolve: (v: any) => void, reject: (e: any) => void, type: string }>} */
71
- const pending = new Map();
72
- /** @type {Array<ReturnType<typeof makeRequest>>} */
73
- const queue = [];
74
- /** @type {Map<string, Set<(payload: any) => void>>} */
75
- const handlers = new Map();
76
- /** @type {Set<(s: ConnectionState) => void>} */
77
- const connection_handlers = new Set();
78
-
79
- /**
80
- * @param {ConnectionState} s
81
- */
82
- function notifyConnection(s) {
83
- for (const fn of Array.from(connection_handlers)) {
84
- try {
85
- fn(s);
86
- } catch {
87
- // ignore listener errors
88
- }
89
- }
90
- }
91
-
92
- function scheduleReconnect() {
93
- if (!should_reconnect || reconnect_timer) {
94
- return;
95
- }
96
- state = 'reconnecting';
97
- notifyConnection(state);
98
- const base = Math.min(
99
- backoff.maxMs || 0,
100
- (backoff.initialMs || 0) * Math.pow(backoff.factor || 1, attempts)
101
- );
102
- const jitter = (backoff.jitterRatio || 0) * base;
103
- const delay = Math.max(
104
- 0,
105
- Math.round(base + (Math.random() * 2 - 1) * jitter)
106
- );
107
- reconnect_timer = setTimeout(() => {
108
- reconnect_timer = null;
109
- connect();
110
- }, delay);
111
- }
112
-
113
- /** @param {ReturnType<typeof makeRequest>} req */
114
- function sendRaw(req) {
115
- try {
116
- ws?.send(JSON.stringify(req));
117
- } catch (err) {
118
- logger.error('ws send failed', err);
119
- }
120
- }
121
-
122
- function onOpen() {
123
- state = 'open';
124
- notifyConnection(state);
125
- attempts = 0;
126
- // flush queue
127
- while (queue.length) {
128
- const req = queue.shift();
129
- if (req) {
130
- sendRaw(req);
131
- }
132
- }
133
- }
134
-
135
- /** @param {MessageEvent} ev */
136
- function onMessage(ev) {
137
- /** @type {any} */
138
- let msg;
139
- try {
140
- msg = JSON.parse(String(ev.data));
141
- } catch {
142
- logger.warn('ws received non-JSON message');
143
- return;
144
- }
145
- if (!msg || typeof msg.id !== 'string' || typeof msg.type !== 'string') {
146
- logger.warn('ws received invalid envelope');
147
- return;
148
- }
149
-
150
- if (pending.has(msg.id)) {
151
- const entry = pending.get(msg.id);
152
- pending.delete(msg.id);
153
- if (msg.ok) {
154
- entry?.resolve(msg.payload);
155
- } else {
156
- entry?.reject(msg.error || new Error('ws error'));
157
- }
158
- return;
159
- }
160
-
161
- // Treat as server-initiated event
162
- const set = handlers.get(msg.type);
163
- if (set && set.size > 0) {
164
- for (const fn of Array.from(set)) {
165
- try {
166
- fn(msg.payload);
167
- } catch (err) {
168
- logger.error('ws event handler error', err);
169
- }
170
- }
171
- } else {
172
- logger.warn(`ws received unhandled message type: ${msg.type}`);
173
- }
174
- }
175
-
176
- function onClose() {
177
- state = 'closed';
178
- notifyConnection(state);
179
- // fail all pending
180
- for (const [id, p] of pending.entries()) {
181
- p.reject(new Error('ws disconnected'));
182
- pending.delete(id);
183
- }
184
- attempts += 1;
185
- scheduleReconnect();
186
- }
187
-
188
- function connect() {
189
- if (!should_reconnect) {
190
- return;
191
- }
192
- const url = resolveUrl();
193
- try {
194
- ws = new WebSocket(url);
195
- state = 'connecting';
196
- notifyConnection(state);
197
- ws.addEventListener('open', onOpen);
198
- ws.addEventListener('message', onMessage);
199
- ws.addEventListener('error', () => {
200
- // let close handler handle reconnect
201
- });
202
- ws.addEventListener('close', onClose);
203
- } catch (err) {
204
- logger.error('ws connect failed', err);
205
- scheduleReconnect();
206
- }
207
- }
208
-
209
- connect();
210
-
211
- return {
212
- /**
213
- * Send a request and await its correlated reply payload.
214
- *
215
- * @param {MessageType} type
216
- * @param {unknown} [payload]
217
- * @returns {Promise<any>}
218
- */
219
- send(type, payload) {
220
- if (!MESSAGE_TYPES.includes(type)) {
221
- return Promise.reject(new Error(`unknown message type: ${type}`));
222
- }
223
- const id = nextId();
224
- const req = makeRequest(type, payload, id);
225
- return new Promise((resolve, reject) => {
226
- pending.set(id, { resolve, reject, type });
227
- if (ws && ws.readyState === ws.OPEN) {
228
- sendRaw(req);
229
- } else {
230
- queue.push(req);
231
- }
232
- });
233
- },
234
- /**
235
- * Register a handler for a server-initiated event type.
236
- * Returns an unsubscribe function.
237
- *
238
- * @param {MessageType} type
239
- * @param {(payload: any) => void} handler
240
- * @returns {() => void}
241
- */
242
- on(type, handler) {
243
- if (!handlers.has(type)) {
244
- handlers.set(type, new Set());
245
- }
246
- const set = handlers.get(type);
247
- set?.add(handler);
248
- return () => {
249
- set?.delete(handler);
250
- };
251
- },
252
- /**
253
- * Subscribe to connection state changes.
254
- *
255
- * @param {(state: ConnectionState) => void} handler
256
- * @returns {() => void}
257
- */
258
- onConnection(handler) {
259
- connection_handlers.add(handler);
260
- return () => {
261
- connection_handlers.delete(handler);
262
- };
263
- },
264
- /** Close and stop reconnecting. */
265
- close() {
266
- should_reconnect = false;
267
- if (reconnect_timer) {
268
- clearTimeout(reconnect_timer);
269
- reconnect_timer = null;
270
- }
271
- try {
272
- ws?.close();
273
- } catch {
274
- /* ignore */
275
- }
276
- },
277
- /** For diagnostics in tests or UI. */
278
- getState() {
279
- return state;
280
- }
281
- };
282
- }