beads-ui 0.1.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 (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
@@ -0,0 +1,393 @@
1
+ /* global NodeListOf */
2
+ import { html, render } from 'lit-html';
3
+ import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
4
+ // issueDisplayId not used directly in this file; rendered in shared row
5
+ import { statusLabel } from '../utils/status.js';
6
+ import { createIssueRowRenderer } from './issue-row.js';
7
+
8
+ // List view implementation; requires a transport send function.
9
+
10
+ /**
11
+ * @typedef {{ id: string, title?: string, status?: string, priority?: number, issue_type?: string, assignee?: string, labels?: string[] }} Issue
12
+ */
13
+
14
+ /**
15
+ * Create the Issues List view.
16
+ * @param {HTMLElement} mount_element - Element to render into.
17
+ * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
18
+ * @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
19
+ * @param {{ getState: () => any, setState: (patch: any) => void, subscribe: (fn: (s:any)=>void)=>()=>void }} [store] - Optional state store.
20
+ * @returns {{ load: () => Promise<void>, destroy: () => void }} View API.
21
+ */
22
+ export function createListView(mount_element, sendFn, navigate_fn, store) {
23
+ /** @type {string} */
24
+ let status_filter = 'all';
25
+ /** @type {string} */
26
+ let search_text = '';
27
+ /** @type {Issue[]} */
28
+ let issues_cache = [];
29
+ /** @type {string} */
30
+ let type_filter = '';
31
+ /** @type {string | null} */
32
+ let selected_id = store ? store.getState().selected_id : null;
33
+ /** @type {null | (() => void)} */
34
+ let unsubscribe = null;
35
+ // Shared row renderer (used in template below)
36
+ const row_renderer = createIssueRowRenderer({
37
+ navigate: (id) => {
38
+ const nav = navigate_fn || ((h) => (window.location.hash = h));
39
+ nav(`#/issue/${id}`);
40
+ },
41
+ onUpdate: updateInline,
42
+ requestRender: doRender,
43
+ getSelectedId: () => selected_id,
44
+ row_class: 'issue-row'
45
+ });
46
+
47
+ /**
48
+ * Event: select status change.
49
+ */
50
+ /**
51
+ * @param {Event} ev
52
+ */
53
+ const onStatusChange = async (ev) => {
54
+ /** @type {HTMLSelectElement} */
55
+ const sel = /** @type {any} */ (ev.currentTarget);
56
+ status_filter = sel.value;
57
+ if (store) {
58
+ store.setState({
59
+ filters: { status: /** @type {any} */ (status_filter) }
60
+ });
61
+ }
62
+ // Always reload on status changes
63
+ await load();
64
+ };
65
+
66
+ /**
67
+ * Event: search input.
68
+ */
69
+ /**
70
+ * @param {Event} ev
71
+ */
72
+ const onSearchInput = (ev) => {
73
+ /** @type {HTMLInputElement} */
74
+ const input = /** @type {any} */ (ev.currentTarget);
75
+ search_text = input.value;
76
+ if (store) {
77
+ store.setState({ filters: { search: search_text } });
78
+ }
79
+ doRender();
80
+ };
81
+
82
+ /**
83
+ * Event: type select change.
84
+ * @param {Event} ev
85
+ */
86
+ const onTypeChange = (ev) => {
87
+ /** @type {HTMLSelectElement} */
88
+ const sel = /** @type {any} */ (ev.currentTarget);
89
+ type_filter = sel.value || '';
90
+ if (store) {
91
+ store.setState({ filters: { type: type_filter } });
92
+ }
93
+ doRender();
94
+ };
95
+
96
+ // Initialize filters from store on first render so reload applies persisted state
97
+ if (store) {
98
+ const s = store.getState();
99
+ if (s && s.filters && typeof s.filters === 'object') {
100
+ status_filter = s.filters.status || 'all';
101
+ search_text = s.filters.search || '';
102
+ type_filter = typeof s.filters.type === 'string' ? s.filters.type : '';
103
+ }
104
+ }
105
+ // Initial values are reflected via bound `.value` in the template
106
+
107
+ /**
108
+ * Build lit-html template for the list view.
109
+ */
110
+ function template() {
111
+ /** @type {Issue[]} */
112
+ let filtered = issues_cache;
113
+ if (status_filter !== 'all' && status_filter !== 'ready') {
114
+ filtered = filtered.filter(
115
+ (it) => String(it.status || '') === status_filter
116
+ );
117
+ }
118
+ if (search_text) {
119
+ const needle = search_text.toLowerCase();
120
+ filtered = filtered.filter((it) => {
121
+ const a = String(it.id).toLowerCase();
122
+ const b = String(it.title || '').toLowerCase();
123
+ return a.includes(needle) || b.includes(needle);
124
+ });
125
+ }
126
+ if (type_filter) {
127
+ filtered = filtered.filter(
128
+ (it) => String(it.issue_type || '') === String(type_filter)
129
+ );
130
+ }
131
+
132
+ return html`
133
+ <div class="panel__header">
134
+ <select @change=${onStatusChange} .value=${status_filter}>
135
+ <option value="all">All</option>
136
+ <option value="ready">Ready</option>
137
+ <option value="open">${statusLabel('open')}</option>
138
+ <option value="in_progress">${statusLabel('in_progress')}</option>
139
+ <option value="closed">${statusLabel('closed')}</option>
140
+ </select>
141
+ <select
142
+ @change=${onTypeChange}
143
+ .value=${type_filter}
144
+ aria-label="Filter by type"
145
+ >
146
+ <option value="">All types</option>
147
+ ${ISSUE_TYPES.map(
148
+ (t) =>
149
+ html`<option value=${t} ?selected=${type_filter === t}>
150
+ ${typeLabel(t)}
151
+ </option>`
152
+ )}
153
+ </select>
154
+ <input
155
+ type="search"
156
+ placeholder="Search…"
157
+ @input=${onSearchInput}
158
+ .value=${search_text}
159
+ />
160
+ </div>
161
+ <div class="panel__body" id="list-root">
162
+ ${filtered.length === 0
163
+ ? html`<div class="issues-block">
164
+ <div class="muted" style="padding:10px 12px;">No issues</div>
165
+ </div>`
166
+ : html`<div class="issues-block">
167
+ <table class="table">
168
+ <colgroup>
169
+ <col style="width: 100px" />
170
+ <col style="width: 120px" />
171
+ <col />
172
+ <col style="width: 120px" />
173
+ <col style="width: 160px" />
174
+ <col style="width: 130px" />
175
+ </colgroup>
176
+ <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>
184
+ </tr>
185
+ </thead>
186
+ <tbody>
187
+ ${filtered.map((it) => row_renderer(it))}
188
+ </tbody>
189
+ </table>
190
+ </div>`}
191
+ </div>
192
+ `;
193
+ }
194
+
195
+ /**
196
+ * Render the current issues_cache with filters applied.
197
+ */
198
+ function doRender() {
199
+ render(template(), mount_element);
200
+ }
201
+
202
+ // Initial render (header + body shell with current state)
203
+ doRender();
204
+ // no separate ready checkbox when using select option
205
+
206
+ /**
207
+ * Load issues from backend and re-render.
208
+ */
209
+ async function load() {
210
+ // Preserve scroll position to avoid jarring jumps on live refresh
211
+ /** @type {HTMLElement|null} */
212
+ const beforeEl = /** @type {any} */ (
213
+ mount_element.querySelector('#list-root')
214
+ );
215
+ 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;
226
+ try {
227
+ result = await sendFn('list-issues', { filters });
228
+ } catch {
229
+ result = [];
230
+ }
231
+ if (!Array.isArray(result)) {
232
+ issues_cache = [];
233
+ } else {
234
+ issues_cache = /** @type {Issue[]} */ (result);
235
+ }
236
+ doRender();
237
+ // Restore scroll position if possible
238
+ try {
239
+ /** @type {HTMLElement|null} */
240
+ const afterEl = /** @type {any} */ (
241
+ mount_element.querySelector('#list-root')
242
+ );
243
+ if (afterEl && prevScroll > 0) {
244
+ afterEl.scrollTop = prevScroll;
245
+ }
246
+ } catch {
247
+ // ignore
248
+ }
249
+ }
250
+
251
+ // Keyboard navigation
252
+ mount_element.tabIndex = 0;
253
+ mount_element.addEventListener('keydown', (ev) => {
254
+ /** @type {HTMLTableSectionElement|null} */
255
+ const tbody = /** @type {any} */ (
256
+ mount_element.querySelector('#list-root tbody')
257
+ );
258
+ /** @type {NodeListOf<HTMLTableRowElement>} */
259
+ const items = tbody
260
+ ? tbody.querySelectorAll('tr')
261
+ : /** @type {any} */ ([]);
262
+ if (items.length === 0) {
263
+ return;
264
+ }
265
+ let idx = 0;
266
+ if (selected_id) {
267
+ const arr = Array.from(items);
268
+ idx = arr.findIndex((el) => {
269
+ const did = el.getAttribute('data-issue-id') || '';
270
+ return did === selected_id;
271
+ });
272
+ if (idx < 0) {
273
+ idx = 0;
274
+ }
275
+ }
276
+ if (ev.key === 'ArrowDown') {
277
+ ev.preventDefault();
278
+ const next = items[Math.min(idx + 1, items.length - 1)];
279
+ const next_id = next ? next.getAttribute('data-issue-id') : '';
280
+ const set = next_id ? next_id : null;
281
+ if (store && set) {
282
+ store.setState({ selected_id: set });
283
+ }
284
+ selected_id = set;
285
+ doRender();
286
+ } else if (ev.key === 'ArrowUp') {
287
+ ev.preventDefault();
288
+ const prev = items[Math.max(idx - 1, 0)];
289
+ const prev_id = prev ? prev.getAttribute('data-issue-id') : '';
290
+ const set = prev_id ? prev_id : null;
291
+ if (store && set) {
292
+ store.setState({ selected_id: set });
293
+ }
294
+ selected_id = set;
295
+ doRender();
296
+ } else if (ev.key === 'Enter') {
297
+ ev.preventDefault();
298
+ const current = items[idx];
299
+ const id = current ? current.getAttribute('data-issue-id') : '';
300
+ if (id) {
301
+ const nav = navigate_fn || ((h) => (window.location.hash = h));
302
+ nav(`#/issue/${id}`);
303
+ }
304
+ }
305
+ });
306
+
307
+ // Keep selection in sync with store
308
+ if (store) {
309
+ unsubscribe = store.subscribe((s) => {
310
+ if (s.selected_id !== selected_id) {
311
+ selected_id = s.selected_id;
312
+ doRender();
313
+ }
314
+ if (s.filters && typeof s.filters === 'object') {
315
+ const next_status = s.filters.status;
316
+ const next_search = s.filters.search || '';
317
+ const next_type =
318
+ typeof s.filters.type === 'string' ? s.filters.type : '';
319
+ let needs_render = false;
320
+ if (next_status !== status_filter) {
321
+ status_filter = next_status;
322
+ // Reload on any status scope change to keep cache correct
323
+ void load();
324
+ return;
325
+ }
326
+ if (next_search !== search_text) {
327
+ search_text = next_search;
328
+ needs_render = true;
329
+ }
330
+ if (next_type !== type_filter) {
331
+ type_filter = next_type;
332
+ needs_render = true;
333
+ }
334
+ if (needs_render) {
335
+ doRender();
336
+ }
337
+ }
338
+ });
339
+ }
340
+
341
+ return {
342
+ load,
343
+ destroy() {
344
+ mount_element.replaceChildren();
345
+ if (unsubscribe) {
346
+ unsubscribe();
347
+ unsubscribe = null;
348
+ }
349
+ }
350
+ };
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
+ }