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.
Files changed (54) hide show
  1. package/CHANGES.md +29 -2
  2. package/README.md +39 -45
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +25 -127
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/index.html +8 -0
  10. package/app/main.js +483 -61
  11. package/app/protocol.js +10 -14
  12. package/app/protocol.md +21 -19
  13. package/app/router.js +45 -9
  14. package/app/state.js +27 -11
  15. package/app/styles.css +373 -184
  16. package/app/utils/issue-id-renderer.js +71 -0
  17. package/app/utils/issue-url.js +9 -0
  18. package/app/utils/markdown.js +15 -194
  19. package/app/utils/priority-badge.js +0 -2
  20. package/app/utils/status-badge.js +0 -1
  21. package/app/utils/toast.js +34 -0
  22. package/app/utils/type-badge.js +0 -3
  23. package/app/views/board.js +439 -87
  24. package/app/views/detail.js +364 -154
  25. package/app/views/epics.js +128 -76
  26. package/app/views/issue-dialog.js +163 -0
  27. package/app/views/issue-row.js +10 -11
  28. package/app/views/list.js +164 -93
  29. package/app/views/new-issue-dialog.js +345 -0
  30. package/app/ws.js +36 -9
  31. package/bin/bdui.js +1 -1
  32. package/docs/adr/001-push-only-lists.md +134 -0
  33. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  34. package/docs/architecture.md +35 -85
  35. package/docs/data-exchange-subscription-plan.md +198 -0
  36. package/docs/db-watching.md +2 -1
  37. package/docs/migration-v2.md +54 -0
  38. package/docs/protocol/issues-push-v2.md +179 -0
  39. package/docs/subscription-issue-store.md +112 -0
  40. package/package.json +11 -3
  41. package/server/bd.js +0 -2
  42. package/server/cli/commands.js +12 -5
  43. package/server/cli/daemon.js +12 -5
  44. package/server/cli/index.js +34 -5
  45. package/server/cli/usage.js +2 -2
  46. package/server/config.js +12 -6
  47. package/server/db.js +0 -1
  48. package/server/index.js +9 -5
  49. package/server/list-adapters.js +218 -0
  50. package/server/subscriptions.js +277 -0
  51. package/server/validators.js +111 -0
  52. package/server/watcher.js +6 -9
  53. package/server/ws.js +466 -227
  54. package/docs/quickstart.md +0 -142
package/app/views/list.js CHANGED
@@ -1,6 +1,8 @@
1
- /* global NodeListOf */
2
1
  import { html, render } from 'lit-html';
2
+ import { createListSelectors } from '../data/list-selectors.js';
3
+ import { cmpClosedDesc } from '../data/sort.js';
3
4
  import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
5
+ import { issueHashFor } from '../utils/issue-url.js';
4
6
  // issueDisplayId not used directly in this file; rendered in shared row
5
7
  import { statusLabel } from '../utils/status.js';
6
8
  import { createIssueRowRenderer } from './issue-row.js';
@@ -8,7 +10,7 @@ import { createIssueRowRenderer } from './issue-row.js';
8
10
  // List view implementation; requires a transport send function.
9
11
 
10
12
  /**
11
- * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
13
+ * @typedef {{ id: string, title?: string, status?: 'closed'|'open'|'in_progress', priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
12
14
  */
13
15
 
14
16
  /**
@@ -17,9 +19,30 @@ import { createIssueRowRenderer } from './issue-row.js';
17
19
  * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
18
20
  * @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
19
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]
20
24
  * @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
21
25
  */
22
- export function createListView(mount_element, sendFn, navigate_fn, store) {
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);
23
46
  /** @type {string} */
24
47
  let status_filter = 'all';
25
48
  /** @type {string} */
@@ -35,8 +58,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
35
58
  // Shared row renderer (used in template below)
36
59
  const row_renderer = createIssueRowRenderer({
37
60
  navigate: (id) => {
38
- const nav = navigate_fn || ((h) => (window.location.hash = h));
39
- nav(`#/issue/${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));
40
65
  },
41
66
  onUpdate: updateInline,
42
67
  requestRender: doRender,
@@ -51,12 +76,11 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
51
76
  * @param {Event} ev
52
77
  */
53
78
  const onStatusChange = async (ev) => {
54
- /** @type {HTMLSelectElement} */
55
- const sel = /** @type {any} */ (ev.currentTarget);
79
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
56
80
  status_filter = sel.value;
57
81
  if (store) {
58
82
  store.setState({
59
- filters: { status: /** @type {any} */ (status_filter) }
83
+ filters: { status: status_filter }
60
84
  });
61
85
  }
62
86
  // Always reload on status changes
@@ -70,8 +94,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
70
94
  * @param {Event} ev
71
95
  */
72
96
  const onSearchInput = (ev) => {
73
- /** @type {HTMLInputElement} */
74
- const input = /** @type {any} */ (ev.currentTarget);
97
+ const input = /** @type {HTMLInputElement} */ (ev.currentTarget);
75
98
  search_text = input.value;
76
99
  if (store) {
77
100
  store.setState({ filters: { search: search_text } });
@@ -84,8 +107,7 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
84
107
  * @param {Event} ev
85
108
  */
86
109
  const onTypeChange = (ev) => {
87
- /** @type {HTMLSelectElement} */
88
- const sel = /** @type {any} */ (ev.currentTarget);
110
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
89
111
  type_filter = sel.value || '';
90
112
  if (store) {
91
113
  store.setState({ filters: { type: type_filter } });
@@ -103,12 +125,13 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
103
125
  }
104
126
  }
105
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;
106
130
 
107
131
  /**
108
132
  * Build lit-html template for the list view.
109
133
  */
110
134
  function template() {
111
- /** @type {Issue[]} */
112
135
  let filtered = issues_cache;
113
136
  if (status_filter !== 'all' && status_filter !== 'ready') {
114
137
  filtered = filtered.filter(
@@ -128,6 +151,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
128
151
  (it) => String(it.issue_type || '') === String(type_filter)
129
152
  );
130
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
+ }
131
158
 
132
159
  return html`
133
160
  <div class="panel__header">
@@ -164,7 +191,12 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
164
191
  <div class="muted" style="padding:10px 12px;">No issues</div>
165
192
  </div>`
166
193
  : html`<div class="issues-block">
167
- <table class="table">
194
+ <table
195
+ class="table"
196
+ role="grid"
197
+ aria-rowcount=${String(filtered.length)}
198
+ aria-colcount="6"
199
+ >
168
200
  <colgroup>
169
201
  <col style="width: 100px" />
170
202
  <col style="width: 120px" />
@@ -174,16 +206,16 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
174
206
  <col style="width: 130px" />
175
207
  </colgroup>
176
208
  <thead>
177
- <tr>
178
- <th>ID</th>
179
- <th>Type</th>
180
- <th>Title</th>
181
- <th>Status</th>
182
- <th>Assignee</th>
183
- <th>Priority</th>
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>
184
216
  </tr>
185
217
  </thead>
186
- <tbody>
218
+ <tbody role="rowgroup">
187
219
  ${filtered.map((it) => row_renderer(it))}
188
220
  </tbody>
189
221
  </table>
@@ -204,40 +236,55 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
204
236
  // no separate ready checkbox when using select option
205
237
 
206
238
  /**
207
- * Load issues from backend and re-render.
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.
208
265
  */
209
266
  async function load() {
210
267
  // Preserve scroll position to avoid jarring jumps on live refresh
211
- /** @type {HTMLElement|null} */
212
- const beforeEl = /** @type {any} */ (
268
+ const beforeEl = /** @type {HTMLElement|null} */ (
213
269
  mount_element.querySelector('#list-root')
214
270
  );
215
271
  const prevScroll = beforeEl ? beforeEl.scrollTop : 0;
216
- /** @type {any} */
217
- const filters = {};
218
- if (status_filter !== 'all' && status_filter !== 'ready') {
219
- filters.status = status_filter;
220
- }
221
- if (status_filter === 'ready') {
222
- filters.ready = true;
223
- }
224
- /** @type {unknown} */
225
- let result;
272
+ // Compose items from subscriptions membership and issues store entities
226
273
  try {
227
- result = await sendFn('list-issues', { filters });
274
+ if (selectors) {
275
+ issues_cache = /** @type {Issue[]} */ (
276
+ selectors.selectIssuesFor('tab:issues')
277
+ );
278
+ } else {
279
+ issues_cache = [];
280
+ }
228
281
  } catch {
229
- result = [];
230
- }
231
- if (!Array.isArray(result)) {
232
282
  issues_cache = [];
233
- } else {
234
- issues_cache = /** @type {Issue[]} */ (result);
235
283
  }
236
284
  doRender();
237
285
  // Restore scroll position if possible
238
286
  try {
239
- /** @type {HTMLElement|null} */
240
- const afterEl = /** @type {any} */ (
287
+ const afterEl = /** @type {HTMLElement|null} */ (
241
288
  mount_element.querySelector('#list-root')
242
289
  );
243
290
  if (afterEl && prevScroll > 0) {
@@ -251,14 +298,64 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
251
298
  // Keyboard navigation
252
299
  mount_element.tabIndex = 0;
253
300
  mount_element.addEventListener('keydown', (ev) => {
254
- /** @type {HTMLTableSectionElement|null} */
255
- const tbody = /** @type {any} */ (
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} */ (
256
356
  mount_element.querySelector('#list-root tbody')
257
357
  );
258
- /** @type {NodeListOf<HTMLTableRowElement>} */
259
- const items = tbody
260
- ? tbody.querySelectorAll('tr')
261
- : /** @type {any} */ ([]);
358
+ const items = tbody ? tbody.querySelectorAll('tr') : [];
262
359
  if (items.length === 0) {
263
360
  return;
264
361
  }
@@ -298,8 +395,10 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
298
395
  const current = items[idx];
299
396
  const id = current ? current.getAttribute('data-issue-id') : '';
300
397
  if (id) {
301
- const nav = navigate_fn || ((h) => (window.location.hash = h));
302
- nav(`#/issue/${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));
303
402
  }
304
403
  }
305
404
  });
@@ -338,6 +437,20 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
338
437
  });
339
438
  }
340
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
+
341
454
  return {
342
455
  load,
343
456
  destroy() {
@@ -348,46 +461,4 @@ export function createListView(mount_element, sendFn, navigate_fn, store) {
348
461
  }
349
462
  }
350
463
  };
351
-
352
- /**
353
- * Update minimal fields inline via ws mutations and refresh that row's data.
354
- * @param {string} id
355
- * @param {{ [k: string]: any }} patch
356
- */
357
- async function updateInline(id, patch) {
358
- try {
359
- // Dispatch specific mutations based on provided keys
360
- if (typeof patch.title === 'string') {
361
- await sendFn('edit-text', { id, field: 'title', value: patch.title });
362
- }
363
- if (typeof patch.assignee === 'string') {
364
- await sendFn('update-assignee', { id, assignee: patch.assignee });
365
- }
366
- if (typeof patch.status === 'string') {
367
- await sendFn('update-status', { id, status: patch.status });
368
- }
369
- if (typeof patch.priority === 'number') {
370
- await sendFn('update-priority', { id, priority: patch.priority });
371
- }
372
- // Refresh the item from backend
373
- /** @type {any} */
374
- const full = await sendFn('show-issue', { id });
375
- // Replace in cache
376
- const idx = issues_cache.findIndex((x) => x.id === id);
377
- if (idx >= 0 && full && typeof full === 'object') {
378
- issues_cache[idx] = /** @type {Issue} */ ({
379
- id: full.id,
380
- title: full.title,
381
- status: full.status,
382
- priority: full.priority,
383
- issue_type: full.issue_type,
384
- assignee: full.assignee,
385
- labels: Array.isArray(full.labels) ? full.labels : []
386
- });
387
- }
388
- doRender();
389
- } catch {
390
- // ignore failures; UI state remains as-is
391
- }
392
- }
393
464
  }