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