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
@@ -1,280 +0,0 @@
1
- import { html, render } from 'lit-html';
2
- import { createListSelectors } from '../data/list-selectors.js';
3
- import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
4
- import { createIssueRowRenderer } from './issue-row.js';
5
-
6
- /**
7
- * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, created_at?: number, updated_at?: number }} IssueLite
8
- */
9
-
10
- /**
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
17
- * @param {HTMLElement} mount_element
18
- * @param {{ updateIssue: (input: any) => Promise<any> }} data
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]
22
- */
23
- export function createEpicsView(
24
- mount_element,
25
- data,
26
- goto_issue,
27
- subscriptions = undefined,
28
- issue_stores = undefined
29
- ) {
30
- /** @type {any[]} */
31
- let groups = [];
32
- /** @type {Set<string>} */
33
- const expanded = new Set();
34
- /** @type {Set<string>} */
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
- }
55
-
56
- // Shared row renderer used for children rows
57
- const renderRow = createIssueRowRenderer({
58
- navigate: (id) => goto_issue(id),
59
- onUpdate: updateInline,
60
- requestRender: doRender,
61
- getSelectedId: () => null,
62
- row_class: 'epic-row'
63
- });
64
-
65
- function doRender() {
66
- render(template(), mount_element);
67
- }
68
-
69
- function template() {
70
- if (!groups.length) {
71
- return html`<div class="panel__header muted">No epics found.</div>`;
72
- }
73
- return html`${groups.map((g) => groupTemplate(g))}`;
74
- }
75
-
76
- /**
77
- * @param {any} g
78
- */
79
- function groupTemplate(g) {
80
- const epic = g.epic || {};
81
- const id = String(epic.id || '');
82
- const is_open = expanded.has(id);
83
- // Compose children via selectors
84
- const list = selectors ? selectors.selectEpicChildren(id) : [];
85
- const is_loading = loading.has(id);
86
- return html`
87
- <div class="epic-group" data-epic-id=${id}>
88
- <div
89
- class="epic-header"
90
- @click=${() => toggle(id)}
91
- role="button"
92
- tabindex="0"
93
- aria-expanded=${is_open}
94
- >
95
- ${createIssueIdRenderer(id, { class_name: 'mono' })}
96
- <span class="text-truncate" style="margin-left:8px"
97
- >${epic.title || '(no title)'}</span
98
- >
99
- <span
100
- class="epic-progress"
101
- style="margin-left:auto; display:flex; align-items:center; gap:8px;"
102
- >
103
- <progress
104
- value=${Number(g.closed_children || 0)}
105
- max=${Math.max(1, Number(g.total_children || 0))}
106
- ></progress>
107
- <span class="muted mono"
108
- >${g.closed_children}/${g.total_children}</span
109
- >
110
- </span>
111
- </div>
112
- ${is_open
113
- ? html`<div class="epic-children">
114
- ${is_loading
115
- ? html`<div class="muted">Loading…</div>`
116
- : list.length === 0
117
- ? html`<div class="muted">No issues found</div>`
118
- : html`<table class="table">
119
- <colgroup>
120
- <col style="width: 100px" />
121
- <col style="width: 120px" />
122
- <col />
123
- <col style="width: 120px" />
124
- <col style="width: 160px" />
125
- <col style="width: 130px" />
126
- </colgroup>
127
- <thead>
128
- <tr>
129
- <th>ID</th>
130
- <th>Type</th>
131
- <th>Title</th>
132
- <th>Status</th>
133
- <th>Assignee</th>
134
- <th>Priority</th>
135
- </tr>
136
- </thead>
137
- <tbody>
138
- ${list.map((it) => renderRow(it))}
139
- </tbody>
140
- </table>`}
141
- </div>`
142
- : null}
143
- </div>
144
- `;
145
- }
146
-
147
- /**
148
- * @param {string} id
149
- * @param {{ [k: string]: any }} patch
150
- */
151
- async function updateInline(id, patch) {
152
- try {
153
- await data.updateIssue({ id, ...patch });
154
- // Re-render; view will update on subsequent push
155
- doRender();
156
- } catch {
157
- // swallow; UI remains
158
- }
159
- }
160
-
161
- /**
162
- * @param {string} epic_id
163
- */
164
- async function toggle(epic_id) {
165
- if (!expanded.has(epic_id)) {
166
- expanded.add(epic_id);
167
- loading.add(epic_id);
168
- doRender();
169
- // Subscribe to epic detail; children are rendered from `dependents`
170
- if (subscriptions && typeof subscriptions.subscribeList === 'function') {
171
- try {
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
- });
179
- }
180
- } catch {
181
- // ignore
182
- }
183
- const u = await subscriptions.subscribeList(`detail:${epic_id}`, {
184
- type: 'issue-detail',
185
- params: { id: epic_id }
186
- });
187
- epic_unsubs.set(epic_id, u);
188
- } catch {
189
- // ignore subscription failures
190
- }
191
- }
192
- // Mark as not loading after subscribe attempt; membership will stream in
193
- loading.delete(epic_id);
194
- } else {
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
- }
215
- }
216
- doRender();
217
- }
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
-
262
- return {
263
- async load() {
264
- groups = buildGroupsFromSnapshot();
265
- doRender();
266
- // Auto-expand first epic on screen
267
- try {
268
- if (groups.length > 0) {
269
- const first_id = String(groups[0].epic?.id || '');
270
- if (first_id && !expanded.has(first_id)) {
271
- // This will render and load children lazily
272
- await toggle(first_id);
273
- }
274
- }
275
- } catch {
276
- // ignore auto-expand failures
277
- }
278
- }
279
- };
280
- }
@@ -1,163 +0,0 @@
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,190 +0,0 @@
1
- import { html } from 'lit-html';
2
- import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
3
- import { emojiForPriority } from '../utils/priority-badge.js';
4
- import { priority_levels } from '../utils/priority.js';
5
- import { statusLabel } from '../utils/status.js';
6
- import { createTypeBadge } from '../utils/type-badge.js';
7
-
8
- /**
9
- * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string }} IssueRowData
10
- */
11
-
12
- /**
13
- * Create a reusable issue row renderer used by list and epics views.
14
- * Handles inline editing for title/assignee and selects for status/priority.
15
- * @param {{
16
- * navigate: (id: string) => void,
17
- * onUpdate: (id: string, patch: { title?: string, assignee?: string, status?: 'open'|'in_progress'|'closed', priority?: number }) => Promise<void>,
18
- * requestRender: () => void,
19
- * getSelectedId?: () => string | null,
20
- * row_class?: string
21
- * }} options
22
- * @returns {(it: IssueRowData) => import('lit-html').TemplateResult<1>}
23
- */
24
- export function createIssueRowRenderer(options) {
25
- const navigate = options.navigate;
26
- const on_update = options.onUpdate;
27
- const request_render = options.requestRender;
28
- const get_selected_id = options.getSelectedId || (() => null);
29
- const row_class = options.row_class || 'issue-row';
30
-
31
- /** @type {Set<string>} */
32
- const editing = new Set();
33
-
34
- /**
35
- * @param {string} id
36
- * @param {'title'|'assignee'} key
37
- * @param {string} value
38
- * @param {string} [placeholder]
39
- */
40
- function editableText(id, key, value, placeholder = '') {
41
- const k = `${id}:${key}`;
42
- const is_edit = editing.has(k);
43
- if (is_edit) {
44
- return html`<span>
45
- <input
46
- type="text"
47
- .value=${value}
48
- class="inline-edit"
49
- @keydown=${
50
- /** @param {KeyboardEvent} e */ async (e) => {
51
- if (e.key === 'Escape') {
52
- editing.delete(k);
53
- request_render();
54
- } else if (e.key === 'Enter') {
55
- const el = /** @type {HTMLInputElement} */ (e.currentTarget);
56
- const next = el.value || '';
57
- if (next !== value) {
58
- await on_update(id, { [key]: next });
59
- }
60
- editing.delete(k);
61
- request_render();
62
- }
63
- }
64
- }
65
- @blur=${
66
- /** @param {Event} ev */ async (ev) => {
67
- const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
68
- const next = el.value || '';
69
- if (next !== value) {
70
- await on_update(id, { [key]: next });
71
- }
72
- editing.delete(k);
73
- request_render();
74
- }
75
- }
76
- autofocus
77
- />
78
- </span>`;
79
- }
80
- return html`<span
81
- class="editable text-truncate ${value ? '' : 'muted'}"
82
- tabindex="0"
83
- role="button"
84
- @click=${
85
- /** @param {MouseEvent} e */ (e) => {
86
- e.stopPropagation();
87
- e.preventDefault();
88
- editing.add(k);
89
- request_render();
90
- }
91
- }
92
- @keydown=${
93
- /** @param {KeyboardEvent} e */ (e) => {
94
- if (e.key === 'Enter') {
95
- e.preventDefault();
96
- e.stopPropagation();
97
- editing.add(k);
98
- request_render();
99
- }
100
- }
101
- }
102
- >${value || placeholder}</span
103
- >`;
104
- }
105
-
106
- /**
107
- * @param {string} id
108
- * @param {'priority'|'status'} key
109
- * @returns {(ev: Event) => Promise<void>}
110
- */
111
- function makeSelectChange(id, key) {
112
- return async (ev) => {
113
- const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
114
- const val = sel.value || '';
115
- /** @type {{ [k:string]: any }} */
116
- const patch = {};
117
- patch[key] = key === 'priority' ? Number(val) : val;
118
- await on_update(id, patch);
119
- };
120
- }
121
-
122
- /**
123
- * @param {string} id
124
- * @returns {(ev: Event) => void}
125
- */
126
- function makeRowClick(id) {
127
- return (ev) => {
128
- const el = /** @type {HTMLElement|null} */ (ev.target);
129
- if (el && (el.tagName === 'INPUT' || el.tagName === 'SELECT')) {
130
- return;
131
- }
132
- navigate(id);
133
- };
134
- }
135
-
136
- /**
137
- * @param {IssueRowData} it
138
- */
139
- function rowTemplate(it) {
140
- const cur_status = String(it.status || 'open');
141
- const cur_prio = String(it.priority ?? 2);
142
- const is_selected = get_selected_id() === it.id;
143
- return html`<tr
144
- role="row"
145
- class="${row_class} ${is_selected ? 'selected' : ''}"
146
- data-issue-id=${it.id}
147
- @click=${makeRowClick(it.id)}
148
- >
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">
153
- <select
154
- class="badge-select badge--status is-${cur_status}"
155
- .value=${cur_status}
156
- @change=${makeSelectChange(it.id, 'status')}
157
- >
158
- ${['open', 'in_progress', 'closed'].map(
159
- (s) =>
160
- html`<option value=${s} ?selected=${cur_status === s}>
161
- ${statusLabel(s)}
162
- </option>`
163
- )}
164
- </select>
165
- </td>
166
- <td role="gridcell">
167
- ${editableText(it.id, 'assignee', it.assignee || '', 'Unassigned')}
168
- </td>
169
- <td role="gridcell">
170
- <select
171
- class="badge-select badge--priority ${'is-p' + cur_prio}"
172
- .value=${cur_prio}
173
- @change=${makeSelectChange(it.id, 'priority')}
174
- >
175
- ${priority_levels.map(
176
- (p, i) =>
177
- html`<option
178
- value=${String(i)}
179
- ?selected=${cur_prio === String(i)}
180
- >
181
- ${emojiForPriority(i)} ${p}
182
- </option>`
183
- )}
184
- </select>
185
- </td>
186
- </tr>`;
187
- }
188
-
189
- return rowTemplate;
190
- }