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
package/app/views/list.js DELETED
@@ -1,468 +0,0 @@
1
- import { html, render } from 'lit-html';
2
- import { createListSelectors } from '../data/list-selectors.js';
3
- import { cmpClosedDesc } from '../data/sort.js';
4
- import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
5
- import { issueHashFor } from '../utils/issue-url.js';
6
- // issueDisplayId not used directly in this file; rendered in shared row
7
- import { statusLabel } from '../utils/status.js';
8
- import { createIssueRowRenderer } from './issue-row.js';
9
-
10
- // List view implementation; requires a transport send function.
11
-
12
- /**
13
- * @typedef {{ id: string, title?: string, status?: 'closed'|'open'|'in_progress', priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
14
- */
15
-
16
- /**
17
- * Create the Issues List view.
18
- *
19
- * @param {HTMLElement} mount_element - Element to render into.
20
- * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
21
- * @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
22
- * @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
23
- * @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
24
- * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issueStores]
25
- * @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
26
- */
27
- /**
28
- * Create the Issues List view.
29
- *
30
- * @param {HTMLElement} mount_element
31
- * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn
32
- * @param {(hash: string) => void} [navigateFn]
33
- * @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store]
34
- * @param {{ selectors: { getIds: (client_id: string) => string[] } }} [_subscriptions]
35
- * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
36
- * @returns {{ load: () => Promise<void>, destroy: () => void }}
37
- */
38
- export function createListView(
39
- mount_element,
40
- sendFn,
41
- navigateFn,
42
- store,
43
- _subscriptions = undefined,
44
- issue_stores = undefined
45
- ) {
46
- // Touch unused param to satisfy lint rules without impacting behavior
47
- /** @type {any} */ (void _subscriptions);
48
- /** @type {string} */
49
- let status_filter = 'all';
50
- /** @type {string} */
51
- let search_text = '';
52
- /** @type {Issue[]} */
53
- let issues_cache = [];
54
- /** @type {string} */
55
- let type_filter = '';
56
- /** @type {string | null} */
57
- let selected_id = store ? store.getState().selected_id : null;
58
- /** @type {null | (() => void)} */
59
- let unsubscribe = null;
60
- // Shared row renderer (used in template below)
61
- const row_renderer = createIssueRowRenderer({
62
- navigate: (id) => {
63
- const nav = navigateFn || ((h) => (window.location.hash = h));
64
- /** @type {'issues'|'epics'|'board'} */
65
- const view = store ? store.getState().view : 'issues';
66
- nav(issueHashFor(view, id));
67
- },
68
- onUpdate: updateInline,
69
- requestRender: doRender,
70
- getSelectedId: () => selected_id,
71
- row_class: 'issue-row'
72
- });
73
-
74
- /**
75
- * Event: select status change.
76
- */
77
- /**
78
- * @param {Event} ev
79
- */
80
- const onStatusChange = async (ev) => {
81
- const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
82
- status_filter = sel.value;
83
- if (store) {
84
- store.setState({
85
- filters: { status: status_filter }
86
- });
87
- }
88
- // Always reload on status changes
89
- await load();
90
- };
91
-
92
- /**
93
- * Event: search input.
94
- */
95
- /**
96
- * @param {Event} ev
97
- */
98
- const onSearchInput = (ev) => {
99
- const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
100
- search_text = input.value;
101
- if (store) {
102
- store.setState({ filters: { search: search_text } });
103
- }
104
- doRender();
105
- };
106
-
107
- /**
108
- * Event: type select change.
109
- *
110
- * @param {Event} ev
111
- */
112
- const onTypeChange = (ev) => {
113
- const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
114
- type_filter = sel.value || '';
115
- if (store) {
116
- store.setState({ filters: { type: type_filter } });
117
- }
118
- doRender();
119
- };
120
-
121
- // Initialize filters from store on first render so reload applies persisted state
122
- if (store) {
123
- const s = store.getState();
124
- if (s && s.filters && typeof s.filters === 'object') {
125
- status_filter = s.filters.status || 'all';
126
- search_text = s.filters.search || '';
127
- type_filter = typeof s.filters.type === 'string' ? s.filters.type : '';
128
- }
129
- }
130
- // Initial values are reflected via bound `.value` in the template
131
- // Compose helpers: centralize membership + entity selection + sorting
132
- const selectors = issue_stores ? createListSelectors(issue_stores) : null;
133
-
134
- /**
135
- * Build lit-html template for the list view.
136
- */
137
- function template() {
138
- let filtered = issues_cache;
139
- if (status_filter !== 'all' && status_filter !== 'ready') {
140
- filtered = filtered.filter(
141
- (it) => String(it.status || '') === status_filter
142
- );
143
- }
144
- if (search_text) {
145
- const needle = search_text.toLowerCase();
146
- filtered = filtered.filter((it) => {
147
- const a = String(it.id).toLowerCase();
148
- const b = String(it.title || '').toLowerCase();
149
- return a.includes(needle) || b.includes(needle);
150
- });
151
- }
152
- if (type_filter) {
153
- filtered = filtered.filter(
154
- (it) => String(it.issue_type || '') === String(type_filter)
155
- );
156
- }
157
- // Sorting: closed list is a special case → sort by closed_at desc only
158
- if (status_filter === 'closed') {
159
- filtered = filtered.slice().sort(cmpClosedDesc);
160
- }
161
-
162
- return html`
163
- <div class="panel__header">
164
- <select @change=${onStatusChange} .value=${status_filter}>
165
- <option value="all">All</option>
166
- <option value="ready">Ready</option>
167
- <option value="open">${statusLabel('open')}</option>
168
- <option value="in_progress">${statusLabel('in_progress')}</option>
169
- <option value="closed">${statusLabel('closed')}</option>
170
- </select>
171
- <select
172
- @change=${onTypeChange}
173
- .value=${type_filter}
174
- aria-label="Filter by type"
175
- >
176
- <option value="">All types</option>
177
- ${ISSUE_TYPES.map(
178
- (t) =>
179
- html`<option value=${t} ?selected=${type_filter === t}>
180
- ${typeLabel(t)}
181
- </option>`
182
- )}
183
- </select>
184
- <input
185
- type="search"
186
- placeholder="Search…"
187
- @input=${onSearchInput}
188
- .value=${search_text}
189
- />
190
- </div>
191
- <div class="panel__body" id="list-root">
192
- ${filtered.length === 0
193
- ? html`<div class="issues-block">
194
- <div class="muted" style="padding:10px 12px;">No issues</div>
195
- </div>`
196
- : html`<div class="issues-block">
197
- <table
198
- class="table"
199
- role="grid"
200
- aria-rowcount=${String(filtered.length)}
201
- aria-colcount="6"
202
- >
203
- <colgroup>
204
- <col style="width: 100px" />
205
- <col style="width: 120px" />
206
- <col />
207
- <col style="width: 120px" />
208
- <col style="width: 160px" />
209
- <col style="width: 130px" />
210
- </colgroup>
211
- <thead>
212
- <tr role="row">
213
- <th role="columnheader">ID</th>
214
- <th role="columnheader">Type</th>
215
- <th role="columnheader">Title</th>
216
- <th role="columnheader">Status</th>
217
- <th role="columnheader">Assignee</th>
218
- <th role="columnheader">Priority</th>
219
- </tr>
220
- </thead>
221
- <tbody role="rowgroup">
222
- ${filtered.map((it) => row_renderer(it))}
223
- </tbody>
224
- </table>
225
- </div>`}
226
- </div>
227
- `;
228
- }
229
-
230
- /**
231
- * Render the current issues_cache with filters applied.
232
- */
233
- function doRender() {
234
- render(template(), mount_element);
235
- }
236
-
237
- // Initial render (header + body shell with current state)
238
- doRender();
239
- // no separate ready checkbox when using select option
240
-
241
- /**
242
- * Update minimal fields inline via ws mutations and refresh that row's data.
243
- *
244
- * @param {string} id
245
- * @param {{ [k: string]: any }} patch
246
- */
247
- async function updateInline(id, patch) {
248
- try {
249
- // Dispatch specific mutations based on provided keys
250
- if (typeof patch.title === 'string') {
251
- await sendFn('edit-text', { id, field: 'title', value: patch.title });
252
- }
253
- if (typeof patch.assignee === 'string') {
254
- await sendFn('update-assignee', { id, assignee: patch.assignee });
255
- }
256
- if (typeof patch.status === 'string') {
257
- await sendFn('update-status', { id, status: patch.status });
258
- }
259
- if (typeof patch.priority === 'number') {
260
- await sendFn('update-priority', { id, priority: patch.priority });
261
- }
262
- } catch {
263
- // ignore failures; UI state remains as-is
264
- }
265
- }
266
-
267
- /**
268
- * Load issues from local push stores and re-render.
269
- */
270
- async function load() {
271
- // Preserve scroll position to avoid jarring jumps on live refresh
272
- const beforeEl = /** @type {HTMLElement|null} */ (
273
- mount_element.querySelector('#list-root')
274
- );
275
- const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
276
- // Compose items from subscriptions membership and issues store entities
277
- try {
278
- if (selectors) {
279
- issues_cache = /** @type {Issue[]} */ (
280
- selectors.selectIssuesFor('tab:issues')
281
- );
282
- } else {
283
- issues_cache = [];
284
- }
285
- } catch {
286
- issues_cache = [];
287
- }
288
- doRender();
289
- // Restore scroll position if possible
290
- try {
291
- const afterEl = /** @type {HTMLElement|null} */ (
292
- mount_element.querySelector('#list-root')
293
- );
294
- if (afterEl && prevScroll > 0) {
295
- afterEl.scrollTop = prevScroll;
296
- }
297
- } catch {
298
- // ignore
299
- }
300
- }
301
-
302
- // Keyboard navigation
303
- mount_element.tabIndex = 0;
304
- mount_element.addEventListener('keydown', (ev) => {
305
- // Grid cell Up/Down navigation when focus is inside the table and not within
306
- // an editable control (input/textarea/select). Preserves column position.
307
- if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
308
- const tgt = /** @type {HTMLElement} */ (ev.target);
309
- const table =
310
- tgt && typeof tgt.closest === 'function'
311
- ? tgt.closest('#list-root table.table')
312
- : null;
313
- if (table) {
314
- // Do not intercept when inside native editable controls
315
- const in_editable = Boolean(
316
- tgt &&
317
- typeof tgt.closest === 'function' &&
318
- (tgt.closest('input') ||
319
- tgt.closest('textarea') ||
320
- tgt.closest('select'))
321
- );
322
- if (!in_editable) {
323
- const cell =
324
- tgt && typeof tgt.closest === 'function' ? tgt.closest('td') : null;
325
- if (cell && cell.parentElement) {
326
- const row = /** @type {HTMLTableRowElement} */ (cell.parentElement);
327
- const tbody = /** @type {HTMLTableSectionElement|null} */ (
328
- row.parentElement
329
- );
330
- if (tbody && tbody.querySelectorAll) {
331
- const rows = Array.from(tbody.querySelectorAll('tr'));
332
- const row_idx = Math.max(0, rows.indexOf(row));
333
- const col_idx = cell.cellIndex || 0;
334
- const next_idx =
335
- ev.key === 'ArrowDown'
336
- ? Math.min(row_idx + 1, rows.length - 1)
337
- : Math.max(row_idx - 1, 0);
338
- const next_row = rows[next_idx];
339
- const next_cell =
340
- next_row && next_row.cells ? next_row.cells[col_idx] : null;
341
- if (next_cell) {
342
- const focusable = /** @type {HTMLElement|null} */ (
343
- next_cell.querySelector(
344
- 'button:not([disabled]), [tabindex]:not([tabindex="-1"]), a[href], select:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled])'
345
- )
346
- );
347
- if (focusable && typeof focusable.focus === 'function') {
348
- ev.preventDefault();
349
- focusable.focus();
350
- return;
351
- }
352
- }
353
- }
354
- }
355
- }
356
- }
357
- }
358
-
359
- const tbody = /** @type {HTMLTableSectionElement|null} */ (
360
- mount_element.querySelector('#list-root tbody')
361
- );
362
- const items = tbody ? tbody.querySelectorAll('tr') : [];
363
- if (items.length === 0) {
364
- return;
365
- }
366
- let idx = 0;
367
- if (selected_id) {
368
- const arr = Array.from(items);
369
- idx = arr.findIndex((el) => {
370
- const did = el.getAttribute('data-issue-id') || '';
371
- return did === selected_id;
372
- });
373
- if (idx < 0) {
374
- idx = 0;
375
- }
376
- }
377
- if (ev.key === 'ArrowDown') {
378
- ev.preventDefault();
379
- const next = items[Math.min(idx + 1, items.length - 1)];
380
- const next_id = next ? next.getAttribute('data-issue-id') : '';
381
- const set = next_id ? next_id : null;
382
- if (store && set) {
383
- store.setState({ selected_id: set });
384
- }
385
- selected_id = set;
386
- doRender();
387
- } else if (ev.key === 'ArrowUp') {
388
- ev.preventDefault();
389
- const prev = items[Math.max(idx - 1, 0)];
390
- const prev_id = prev ? prev.getAttribute('data-issue-id') : '';
391
- const set = prev_id ? prev_id : null;
392
- if (store && set) {
393
- store.setState({ selected_id: set });
394
- }
395
- selected_id = set;
396
- doRender();
397
- } else if (ev.key === 'Enter') {
398
- ev.preventDefault();
399
- const current = items[idx];
400
- const id = current ? current.getAttribute('data-issue-id') : '';
401
- if (id) {
402
- const nav = navigateFn || ((h) => (window.location.hash = h));
403
- /** @type {'issues'|'epics'|'board'} */
404
- const view = store ? store.getState().view : 'issues';
405
- nav(issueHashFor(view, id));
406
- }
407
- }
408
- });
409
-
410
- // Keep selection in sync with store
411
- if (store) {
412
- unsubscribe = store.subscribe((s) => {
413
- if (s.selected_id !== selected_id) {
414
- selected_id = s.selected_id;
415
- doRender();
416
- }
417
- if (s.filters && typeof s.filters === 'object') {
418
- const next_status = s.filters.status;
419
- const next_search = s.filters.search || '';
420
- const next_type =
421
- typeof s.filters.type === 'string' ? s.filters.type : '';
422
- let needs_render = false;
423
- if (next_status !== status_filter) {
424
- status_filter = next_status;
425
- // Reload on any status scope change to keep cache correct
426
- void load();
427
- return;
428
- }
429
- if (next_search !== search_text) {
430
- search_text = next_search;
431
- needs_render = true;
432
- }
433
- if (next_type !== type_filter) {
434
- type_filter = next_type;
435
- needs_render = true;
436
- }
437
- if (needs_render) {
438
- doRender();
439
- }
440
- }
441
- });
442
- }
443
-
444
- // Live updates: recompose and re-render when issue stores change
445
- if (selectors) {
446
- selectors.subscribe(() => {
447
- try {
448
- issues_cache = /** @type {Issue[]} */ (
449
- selectors.selectIssuesFor('tab:issues')
450
- );
451
- doRender();
452
- } catch {
453
- // ignore
454
- }
455
- });
456
- }
457
-
458
- return {
459
- load,
460
- destroy() {
461
- mount_element.replaceChildren();
462
- if (unsubscribe) {
463
- unsubscribe();
464
- unsubscribe = null;
465
- }
466
- }
467
- };
468
- }
package/app/views/nav.js DELETED
@@ -1,68 +0,0 @@
1
- import { html, render } from 'lit-html';
2
-
3
- /**
4
- * Render the top navigation with three tabs and handle route changes.
5
- *
6
- * @param {HTMLElement} mount_element
7
- * @param {{ getState: () => any, subscribe: (fn: (s: any) => void) => () => void }} store
8
- * @param {{ gotoView: (v: 'issues'|'epics'|'board') => void }} router
9
- */
10
- export function createTopNav(mount_element, store, router) {
11
- /** @type {(() => void) | null} */
12
- let unsubscribe = null;
13
-
14
- /**
15
- * @param {'issues'|'epics'|'board'} view
16
- * @returns {(ev: MouseEvent) => void}
17
- */
18
- function onClick(view) {
19
- return (ev) => {
20
- ev.preventDefault();
21
- router.gotoView(view);
22
- };
23
- }
24
-
25
- function template() {
26
- const s = store.getState();
27
- const active = s.view || 'issues';
28
- return html`
29
- <nav class="header-nav" aria-label="Primary">
30
- <a
31
- href="#/issues"
32
- class="tab ${active === 'issues' ? 'active' : ''}"
33
- @click=${onClick('issues')}
34
- >Issues</a
35
- >
36
- <a
37
- href="#/epics"
38
- class="tab ${active === 'epics' ? 'active' : ''}"
39
- @click=${onClick('epics')}
40
- >Epics</a
41
- >
42
- <a
43
- href="#/board"
44
- class="tab ${active === 'board' ? 'active' : ''}"
45
- @click=${onClick('board')}
46
- >Board</a
47
- >
48
- </nav>
49
- `;
50
- }
51
-
52
- function doRender() {
53
- render(template(), mount_element);
54
- }
55
-
56
- doRender();
57
- unsubscribe = store.subscribe(() => doRender());
58
-
59
- return {
60
- destroy() {
61
- if (unsubscribe) {
62
- unsubscribe();
63
- unsubscribe = null;
64
- }
65
- render(html``, mount_element);
66
- }
67
- };
68
- }