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