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
@@ -1,28 +1,57 @@
1
1
  import { html, render } from 'lit-html';
2
- import { issueDisplayId } from '../utils/issue-id.js';
2
+ import { createListSelectors } from '../data/list-selectors.js';
3
+ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
3
4
  import { createIssueRowRenderer } from './issue-row.js';
4
5
 
5
6
  /**
6
- * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, updated_at?: string }} IssueLite
7
+ * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, created_at?: number, updated_at?: number }} IssueLite
7
8
  */
8
9
 
9
10
  /**
10
- * Epics view: grouped table using `bd epic status --json`. Expanding a group loads
11
- * the epic via `getIssue(id)` and then loads each dependent issue to filter out
12
- * closed items. Provides inline editing for type, title, priority, status, assignee.
11
+ * Epics view (push-only):
12
+ * - Derives epic groups from the local issues store (no RPC reads)
13
+ * - Subscribes to `tab:epics` for top-level membership
14
+ * - On expand, subscribes to `detail:{id}` (issue-detail) for the epic
15
+ * - Renders children from the epic detail's `dependents` list
16
+ * - Provides inline edits via mutations; UI re-renders on push
13
17
  * @param {HTMLElement} mount_element
14
- * @param {{ getEpicStatus: () => Promise<any[]>, getIssue: (id: string) => Promise<any>, updateIssue: (input: any) => Promise<any> }} data
18
+ * @param {{ updateIssue: (input: any) => Promise<any> }} data
15
19
  * @param {(id: string) => void} goto_issue - Navigate to issue detail.
20
+ * @param {{ subscribeList: (client_id: string, spec: { type: string, params?: Record<string, string|number|boolean> }) => Promise<() => Promise<void>>, selectors: { getIds: (client_id: string) => string[], count?: (client_id: string) => number } }} [subscriptions]
21
+ * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
16
22
  */
17
- export function createEpicsView(mount_element, data, goto_issue) {
23
+ export function createEpicsView(
24
+ mount_element,
25
+ data,
26
+ goto_issue,
27
+ subscriptions = undefined,
28
+ issue_stores = undefined
29
+ ) {
18
30
  /** @type {any[]} */
19
31
  let groups = [];
20
32
  /** @type {Set<string>} */
21
33
  const expanded = new Set();
22
- /** @type {Map<string, IssueLite[]>} */
23
- const children = new Map();
24
34
  /** @type {Set<string>} */
25
35
  const loading = new Set();
36
+ /** @type {Map<string, () => Promise<void>>} */
37
+ const epic_unsubs = new Map();
38
+ // Centralized selection helpers
39
+ const selectors = issue_stores ? createListSelectors(issue_stores) : null;
40
+ // Live re-render on pushes: recompute groups when stores change
41
+ if (selectors) {
42
+ selectors.subscribe(() => {
43
+ const had_none = groups.length === 0;
44
+ groups = buildGroupsFromSnapshot();
45
+ doRender();
46
+ // Auto-expand first epic when transitioning from empty to non-empty
47
+ if (had_none && groups.length > 0) {
48
+ const first_id = String(groups[0].epic?.id || '');
49
+ if (first_id && !expanded.has(first_id)) {
50
+ void toggle(first_id);
51
+ }
52
+ }
53
+ });
54
+ }
26
55
 
27
56
  // Shared row renderer used for children rows
28
57
  const renderRow = createIssueRowRenderer({
@@ -51,7 +80,8 @@ export function createEpicsView(mount_element, data, goto_issue) {
51
80
  const epic = g.epic || {};
52
81
  const id = String(epic.id || '');
53
82
  const is_open = expanded.has(id);
54
- const list = children.get(id) || [];
83
+ // Compose children via selectors
84
+ const list = selectors ? selectors.selectEpicChildren(id) : [];
55
85
  const is_loading = loading.has(id);
56
86
  return html`
57
87
  <div class="epic-group" data-epic-id=${id}>
@@ -62,7 +92,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
62
92
  tabindex="0"
63
93
  aria-expanded=${is_open}
64
94
  >
65
- <span class="mono">${issueDisplayId(id)}</span>
95
+ ${createIssueIdRenderer(id, { class_name: 'mono' })}
66
96
  <span class="text-truncate" style="margin-left:8px"
67
97
  >${epic.title || '(no title)'}</span
68
98
  >
@@ -84,7 +114,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
84
114
  ${is_loading
85
115
  ? html`<div class="muted">Loading…</div>`
86
116
  : list.length === 0
87
- ? html`<div class="muted">No open issues</div>`
117
+ ? html`<div class="muted">No issues found</div>`
88
118
  : html`<table class="table">
89
119
  <colgroup>
90
120
  <col style="width: 100px" />
@@ -121,24 +151,7 @@ export function createEpicsView(mount_element, data, goto_issue) {
121
151
  async function updateInline(id, patch) {
122
152
  try {
123
153
  await data.updateIssue({ id, ...patch });
124
- // Opportunistic refresh for that row
125
- const full = await data.getIssue(id);
126
- /** @type {IssueLite} */
127
- const lite = {
128
- id: full.id,
129
- title: full.title,
130
- status: full.status,
131
- priority: full.priority,
132
- issue_type: full.issue_type,
133
- assignee: full.assignee
134
- };
135
- // Replace in children map
136
- for (const arr of children.values()) {
137
- const idx = arr.findIndex((x) => x.id === id);
138
- if (idx >= 0) {
139
- arr[idx] = lite;
140
- }
141
- }
154
+ // Re-render; view will update on subsequent push
142
155
  doRender();
143
156
  } catch {
144
157
  // swallow; UI remains
@@ -151,70 +164,109 @@ export function createEpicsView(mount_element, data, goto_issue) {
151
164
  async function toggle(epic_id) {
152
165
  if (!expanded.has(epic_id)) {
153
166
  expanded.add(epic_id);
154
- // Load children if not present
155
- if (!children.has(epic_id)) {
156
- loading.add(epic_id);
157
- doRender();
167
+ loading.add(epic_id);
168
+ doRender();
169
+ // Subscribe to epic detail; children are rendered from `dependents`
170
+ if (subscriptions && typeof subscriptions.subscribeList === 'function') {
158
171
  try {
159
- const epic = await data.getIssue(epic_id);
160
- // Children for the Epics view come from dependents: issues that list
161
- // the epic as a dependency. This matches how progress is tracked.
162
- /** @type {{ id: string }[]} */
163
- const deps = Array.isArray(epic.dependents) ? epic.dependents : [];
164
- /** @type {IssueLite[]} */
165
- const list = [];
166
- for (const d of deps) {
167
- try {
168
- const full = await data.getIssue(d.id);
169
- if (full.status !== 'closed') {
170
- list.push({
171
- id: full.id,
172
- title: full.title,
173
- status: full.status,
174
- priority: full.priority,
175
- issue_type: full.issue_type,
176
- assignee: full.assignee,
177
- // include updated_at for secondary sort within same priority
178
- updated_at: /** @type {any} */ (full).updated_at
179
- });
180
- }
181
- } catch {
182
- // ignore individual failures
172
+ // Register store first to avoid dropping the initial snapshot
173
+ try {
174
+ if (issue_stores && /** @type {any} */ (issue_stores).register) {
175
+ /** @type {any} */ (issue_stores).register(`detail:${epic_id}`, {
176
+ type: 'issue-detail',
177
+ params: { id: epic_id }
178
+ });
183
179
  }
180
+ } catch {
181
+ // ignore
184
182
  }
185
- // Sort by priority then updated_at (if present)
186
- list.sort((a, b) => {
187
- const pa = a.priority ?? 2;
188
- const pb = b.priority ?? 2;
189
- if (pa !== pb) {
190
- return pa - pb;
191
- }
192
- // @ts-ignore optional updated_at if present
193
- const ua = a.updated_at || '';
194
- // @ts-ignore
195
- const ub = b.updated_at || '';
196
- return ua < ub ? 1 : ua > ub ? -1 : 0;
183
+ const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
184
+ type: 'issue-detail',
185
+ params: { id: epic_id }
197
186
  });
198
- children.set(epic_id, list);
199
- } finally {
200
- loading.delete(epic_id);
187
+ epic_unsubs.set(epic_id, u);
188
+ } catch {
189
+ // ignore subscription failures
201
190
  }
202
191
  }
192
+ // Mark as not loading after subscribe attempt; membership will stream in
193
+ loading.delete(epic_id);
203
194
  } else {
204
195
  expanded.delete(epic_id);
196
+ // Unsubscribe when collapsing
197
+ if (epic_unsubs.has(epic_id)) {
198
+ try {
199
+ const u = epic_unsubs.get(epic_id);
200
+ if (u) {
201
+ await u();
202
+ }
203
+ } catch {
204
+ // ignore
205
+ }
206
+ epic_unsubs.delete(epic_id);
207
+ try {
208
+ if (issue_stores && /** @type {any} */ (issue_stores).unregister) {
209
+ /** @type {any} */ (issue_stores).unregister(`detail:${epic_id}`);
210
+ }
211
+ } catch {
212
+ // ignore
213
+ }
214
+ }
205
215
  }
206
216
  doRender();
207
217
  }
208
218
 
219
+ /** Build groups from the current `tab:epics` snapshot. */
220
+ function buildGroupsFromSnapshot() {
221
+ /** @type {IssueLite[]} */
222
+ const epic_entities =
223
+ issue_stores && issue_stores.snapshotFor
224
+ ? /** @type {IssueLite[]} */ (
225
+ issue_stores.snapshotFor('tab:epics') || []
226
+ )
227
+ : [];
228
+ const next_groups = [];
229
+ for (const epic of epic_entities) {
230
+ const dependents = Array.isArray(/** @type {any} */ (epic).dependents)
231
+ ? /** @type {any[]} */ (/** @type {any} */ (epic).dependents)
232
+ : [];
233
+ // Prefer explicit counters when provided by server; otherwise derive
234
+ const has_total = Number.isFinite(
235
+ /** @type {any} */ (epic).total_children
236
+ );
237
+ const has_closed = Number.isFinite(
238
+ /** @type {any} */ (epic).closed_children
239
+ );
240
+ const total = has_total
241
+ ? Number(/** @type {any} */ (epic).total_children) || 0
242
+ : dependents.length;
243
+ let closed = has_closed
244
+ ? Number(/** @type {any} */ (epic).closed_children) || 0
245
+ : 0;
246
+ if (!has_closed) {
247
+ for (const d of dependents) {
248
+ if (String(d.status || '') === 'closed') {
249
+ closed++;
250
+ }
251
+ }
252
+ }
253
+ next_groups.push({
254
+ epic,
255
+ total_children: total,
256
+ closed_children: closed
257
+ });
258
+ }
259
+ return next_groups;
260
+ }
261
+
209
262
  return {
210
263
  async load() {
211
- const res = await data.getEpicStatus();
212
- groups = Array.isArray(res) ? res : [];
264
+ groups = buildGroupsFromSnapshot();
213
265
  doRender();
214
266
  // Auto-expand first epic on screen
215
267
  try {
216
268
  if (groups.length > 0) {
217
- const first_id = String((groups[0].epic && groups[0].epic.id) || '');
269
+ const first_id = String(groups[0].epic?.id || '');
218
270
  if (first_id && !expanded.has(first_id)) {
219
271
  // This will render and load children lazily
220
272
  await toggle(first_id);
@@ -0,0 +1,163 @@
1
+ // Lightweight wrapper around the native <dialog> for issue details
2
+ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
3
+
4
+ // Provides: open(id), close(), getMount()
5
+ // Ensures accessibility, backdrop click to close, and Esc handling.
6
+
7
+ /**
8
+ * @typedef {{ getState: () => { selected_id: string|null } }} Store
9
+ */
10
+
11
+ /**
12
+ * Create and manage the Issue Details dialog.
13
+ * @param {HTMLElement} mount_element - Container to attach the <dialog> to (e.g., #detail-panel)
14
+ * @param {Store} store - Read-only access to app state
15
+ * @param {() => void} onClose - Called when dialog requests close (backdrop/esc/button)
16
+ * @returns {{ open: (id: string) => void, close: () => void, getMount: () => HTMLElement }}
17
+ */
18
+ export function createIssueDialog(mount_element, store, onClose) {
19
+ const dialog = document.createElement('dialog');
20
+ dialog.id = 'issue-dialog';
21
+ dialog.setAttribute('role', 'dialog');
22
+ dialog.setAttribute('aria-modal', 'true');
23
+
24
+ // Shell: header (id + close) + body mount
25
+ dialog.innerHTML = `
26
+ <div class="issue-dialog__container" part="container">
27
+ <header class="issue-dialog__header">
28
+ <div class="issue-dialog__title">
29
+ <span class="mono" id="issue-dialog-title"></span>
30
+ </div>
31
+ <button type="button" class="issue-dialog__close" aria-label="Close">×</button>
32
+ </header>
33
+ <div class="issue-dialog__body" id="issue-dialog-body"></div>
34
+ </div>
35
+ `;
36
+
37
+ mount_element.appendChild(dialog);
38
+
39
+ const body_mount = /** @type {HTMLElement} */ (
40
+ dialog.querySelector('#issue-dialog-body')
41
+ );
42
+ const title_el = /** @type {HTMLElement} */ (
43
+ dialog.querySelector('#issue-dialog-title')
44
+ );
45
+ const btn_close = /** @type {HTMLButtonElement} */ (
46
+ dialog.querySelector('.issue-dialog__close')
47
+ );
48
+
49
+ /**
50
+ * @param {string} id
51
+ */
52
+ function setTitle(id) {
53
+ // Use copyable ID renderer but keep visible text as raw id for tests/clarity
54
+ title_el.replaceChildren();
55
+ title_el.appendChild(createIssueIdRenderer(id));
56
+ }
57
+
58
+ // Backdrop click: when clicking the dialog itself (outside container), close
59
+ dialog.addEventListener('mousedown', (ev) => {
60
+ if (ev.target === dialog) {
61
+ ev.preventDefault();
62
+ requestClose();
63
+ }
64
+ });
65
+ // Esc key produces a cancel event on <dialog>
66
+ dialog.addEventListener('cancel', (ev) => {
67
+ ev.preventDefault();
68
+ requestClose();
69
+ });
70
+ // Close button
71
+ btn_close.addEventListener('click', () => requestClose());
72
+
73
+ /** @type {HTMLElement | null} */
74
+ let last_focus = null;
75
+
76
+ function requestClose() {
77
+ try {
78
+ if (typeof dialog.close === 'function') {
79
+ dialog.close();
80
+ } else {
81
+ dialog.removeAttribute('open');
82
+ }
83
+ } catch {
84
+ dialog.removeAttribute('open');
85
+ }
86
+ try {
87
+ onClose();
88
+ } catch {
89
+ // ignore consumer errors
90
+ }
91
+ // Restore focus to the element that had focus before opening
92
+ restoreFocus();
93
+ }
94
+
95
+ /**
96
+ * @param {string} id
97
+ */
98
+ function open(id) {
99
+ // Capture currently focused element to restore after closing
100
+ try {
101
+ const ae = document.activeElement;
102
+ if (ae && ae instanceof HTMLElement) {
103
+ last_focus = ae;
104
+ } else {
105
+ last_focus = null;
106
+ }
107
+ } catch {
108
+ last_focus = null;
109
+ }
110
+ setTitle(id);
111
+ try {
112
+ if ('showModal' in dialog && typeof dialog.showModal === 'function') {
113
+ dialog.showModal();
114
+ } else {
115
+ dialog.setAttribute('open', '');
116
+ }
117
+ // Focus the dialog container for keyboard users
118
+ setTimeout(() => {
119
+ try {
120
+ btn_close.focus();
121
+ } catch {
122
+ // ignore
123
+ }
124
+ }, 0);
125
+ } catch {
126
+ // Fallback for environments without <dialog>
127
+ dialog.setAttribute('open', '');
128
+ }
129
+ }
130
+
131
+ function close() {
132
+ try {
133
+ if (typeof dialog.close === 'function') {
134
+ dialog.close();
135
+ } else {
136
+ dialog.removeAttribute('open');
137
+ }
138
+ } catch {
139
+ dialog.removeAttribute('open');
140
+ }
141
+ restoreFocus();
142
+ }
143
+
144
+ function restoreFocus() {
145
+ try {
146
+ if (last_focus && document.contains(last_focus)) {
147
+ last_focus.focus();
148
+ }
149
+ } catch {
150
+ // ignore focus errors
151
+ } finally {
152
+ last_focus = null;
153
+ }
154
+ }
155
+
156
+ return {
157
+ open,
158
+ close,
159
+ getMount() {
160
+ return body_mount;
161
+ }
162
+ };
163
+ }
@@ -1,5 +1,5 @@
1
1
  import { html } from 'lit-html';
2
- import { issueDisplayId } from '../utils/issue-id.js';
2
+ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
3
3
  import { emojiForPriority } from '../utils/priority-badge.js';
4
4
  import { priority_levels } from '../utils/priority.js';
5
5
  import { statusLabel } from '../utils/status.js';
@@ -38,7 +38,6 @@ export function createIssueRowRenderer(options) {
38
38
  * @param {string} [placeholder]
39
39
  */
40
40
  function editableText(id, key, value, placeholder = '') {
41
- /** @type {string} */
42
41
  const k = `${id}:${key}`;
43
42
  const is_edit = editing.has(k);
44
43
  if (is_edit) {
@@ -92,9 +91,9 @@ export function createIssueRowRenderer(options) {
92
91
  }
93
92
  @keydown=${
94
93
  /** @param {KeyboardEvent} e */ (e) => {
95
- e.stopPropagation();
96
94
  if (e.key === 'Enter') {
97
95
  e.preventDefault();
96
+ e.stopPropagation();
98
97
  editing.add(k);
99
98
  request_render();
100
99
  }
@@ -111,8 +110,7 @@ export function createIssueRowRenderer(options) {
111
110
  */
112
111
  function makeSelectChange(id, key) {
113
112
  return async (ev) => {
114
- /** @type {HTMLSelectElement} */
115
- const sel = /** @type {any} */ (ev.currentTarget);
113
+ const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
116
114
  const val = sel.value || '';
117
115
  /** @type {{ [k:string]: any }} */
118
116
  const patch = {};
@@ -143,14 +141,15 @@ export function createIssueRowRenderer(options) {
143
141
  const cur_prio = String(it.priority ?? 2);
144
142
  const is_selected = get_selected_id() === it.id;
145
143
  return html`<tr
144
+ role="row"
146
145
  class="${row_class} ${is_selected ? 'selected' : ''}"
147
146
  data-issue-id=${it.id}
148
147
  @click=${makeRowClick(it.id)}
149
148
  >
150
- <td class="mono">${issueDisplayId(it.id)}</td>
151
- <td>${createTypeBadge(it.issue_type)}</td>
152
- <td>${editableText(it.id, 'title', it.title || '')}</td>
153
- <td>
149
+ <td role="gridcell" class="mono">${createIssueIdRenderer(it.id)}</td>
150
+ <td role="gridcell">${createTypeBadge(it.issue_type)}</td>
151
+ <td role="gridcell">${editableText(it.id, 'title', it.title || '')}</td>
152
+ <td role="gridcell">
154
153
  <select
155
154
  class="badge-select badge--status is-${cur_status}"
156
155
  .value=${cur_status}
@@ -164,10 +163,10 @@ export function createIssueRowRenderer(options) {
164
163
  )}
165
164
  </select>
166
165
  </td>
167
- <td>
166
+ <td role="gridcell">
168
167
  ${editableText(it.id, 'assignee', it.assignee || '', 'Unassigned')}
169
168
  </td>
170
- <td>
169
+ <td role="gridcell">
171
170
  <select
172
171
  class="badge-select badge--priority ${'is-p' + cur_prio}"
173
172
  .value=${cur_prio}