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,1252 +0,0 @@
1
- // Issue Detail view implementation (lit-html based)
2
- import { html, render } from 'lit-html';
3
- import { parseView } from '../router.js';
4
- import { issueDisplayId } from '../utils/issue-id.js';
5
- import { issueHashFor } from '../utils/issue-url.js';
6
- import { renderMarkdown } from '../utils/markdown.js';
7
- import { emojiForPriority } from '../utils/priority-badge.js';
8
- import { priority_levels } from '../utils/priority.js';
9
- import { statusLabel } from '../utils/status.js';
10
- import { showToast } from '../utils/toast.js';
11
- import { createTypeBadge } from '../utils/type-badge.js';
12
-
13
- /**
14
- * @typedef {Object} Dependency
15
- * @property {string} id
16
- * @property {string} [title]
17
- * @property {string} [status]
18
- * @property {number} [priority]
19
- * @property {string} [issue_type]
20
- */
21
-
22
- /**
23
- * @typedef {Object} IssueDetail
24
- * @property {string} id
25
- * @property {string} [title]
26
- * @property {string} [description]
27
- * @property {string} [design]
28
- * @property {string} [acceptance]
29
- * @property {string} [notes]
30
- * @property {string} [status]
31
- * @property {string} [assignee]
32
- * @property {number} [priority]
33
- * @property {string[]} [labels]
34
- * @property {Dependency[]} [dependencies]
35
- * @property {Dependency[]} [dependents]
36
- */
37
-
38
- /**
39
- * @param {string} hash
40
- */
41
- function defaultNavigateFn(hash) {
42
- window.location.hash = hash;
43
- }
44
-
45
- /**
46
- * Create the Issue Detail view.
47
- *
48
- * @param {HTMLElement} mount_element - Element to render into.
49
- * @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
50
- * @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
51
- * @param {{ snapshotFor?: (client_id: string) => any[], subscribe?: (fn: () => void) => () => void }} [issue_stores] - Optional issue stores for live updates.
52
- * @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
53
- */
54
- export function createDetailView(
55
- mount_element,
56
- sendFn,
57
- navigateFn = defaultNavigateFn,
58
- issue_stores = undefined
59
- ) {
60
- /** @type {IssueDetail | null} */
61
- let current = null;
62
- /** @type {string | null} */
63
- let current_id = null;
64
- /** @type {boolean} */
65
- let pending = false;
66
- /** @type {boolean} */
67
- let edit_title = false;
68
- /** @type {boolean} */
69
- let edit_desc = false;
70
- /** @type {boolean} */
71
- let edit_design = false;
72
- /** @type {boolean} */
73
- let edit_notes = false;
74
- /** @type {boolean} */
75
- let edit_accept = false;
76
- /** @type {boolean} */
77
- let edit_assignee = false;
78
- /** @type {string} */
79
- let new_label_text = '';
80
-
81
- /** @param {string} id */
82
- function issueHref(id) {
83
- try {
84
- /** @type {'issues'|'epics'|'board'} */
85
- const view = parseView(window.location.hash || '');
86
- return issueHashFor(view, id);
87
- } catch {
88
- return issueHashFor('issues', id);
89
- }
90
- }
91
-
92
- /**
93
- * @param {string} message
94
- */
95
- function renderPlaceholder(message) {
96
- render(
97
- html`
98
- <div class="panel__body" id="detail-root">
99
- <p class="muted">${message}</p>
100
- </div>
101
- `,
102
- mount_element
103
- );
104
- }
105
-
106
- /**
107
- * Refresh current from subscription store snapshot if available.
108
- */
109
- function refreshFromStore() {
110
- if (
111
- !current_id ||
112
- !issue_stores ||
113
- typeof issue_stores.snapshotFor !== 'function'
114
- ) {
115
- return;
116
- }
117
- const arr = /** @type {IssueDetail[]} */ (
118
- issue_stores.snapshotFor(`detail:${current_id}`)
119
- );
120
- if (Array.isArray(arr) && arr.length > 0) {
121
- // First item is the issue for this subscription
122
- const found =
123
- arr.find((it) => String(it.id) === String(current_id)) || arr[0];
124
- current = /** @type {IssueDetail} */ (found);
125
- }
126
- }
127
-
128
- // Live updates: re-render when issue stores change
129
- if (issue_stores && typeof issue_stores.subscribe === 'function') {
130
- issue_stores.subscribe(() => {
131
- try {
132
- const prev_id = current && current.id ? String(current.id) : null;
133
- refreshFromStore();
134
- // Only re-render when the current entity changed or when we were loading
135
- if (!prev_id || (current && String(current.id) === prev_id)) {
136
- doRender();
137
- } else {
138
- doRender();
139
- }
140
- } catch {
141
- // ignore
142
- }
143
- });
144
- }
145
-
146
- // Handlers
147
- const onTitleSpanClick = () => {
148
- edit_title = true;
149
- doRender();
150
- };
151
- /**
152
- * @param {KeyboardEvent} ev
153
- */
154
- const onTitleKeydown = (ev) => {
155
- if (ev.key === 'Enter') {
156
- edit_title = true;
157
- doRender();
158
- } else if (ev.key === 'Escape') {
159
- edit_title = false;
160
- doRender();
161
- }
162
- };
163
- const onTitleSave = async () => {
164
- if (!current || pending) {
165
- return;
166
- }
167
- const input = /** @type {HTMLInputElement|null} */ (
168
- mount_element.querySelector('h2 input')
169
- );
170
- const prev = current.title || '';
171
- const next = input ? input.value : '';
172
- if (next === prev) {
173
- edit_title = false;
174
- doRender();
175
- return;
176
- }
177
- pending = true;
178
- if (input) {
179
- input.disabled = true;
180
- }
181
- try {
182
- const updated = await sendFn('edit-text', {
183
- id: current.id,
184
- field: 'title',
185
- value: next
186
- });
187
- if (updated && typeof updated === 'object') {
188
- current = /** @type {IssueDetail} */ (updated);
189
- edit_title = false;
190
- doRender();
191
- }
192
- } catch {
193
- current.title = prev;
194
- edit_title = false;
195
- doRender();
196
- showToast('Failed to save title', 'error');
197
- } finally {
198
- pending = false;
199
- }
200
- };
201
- const onTitleCancel = () => {
202
- edit_title = false;
203
- doRender();
204
- };
205
- // Assignee inline edit handlers
206
- const onAssigneeSpanClick = () => {
207
- edit_assignee = true;
208
- doRender();
209
- };
210
- /**
211
- * @param {KeyboardEvent} ev
212
- */
213
- const onAssigneeKeydown = (ev) => {
214
- if (ev.key === 'Enter') {
215
- ev.preventDefault();
216
- edit_assignee = true;
217
- doRender();
218
- } else if (ev.key === 'Escape') {
219
- ev.preventDefault();
220
- edit_assignee = false;
221
- doRender();
222
- }
223
- };
224
- const onAssigneeSave = async () => {
225
- if (!current || pending) {
226
- return;
227
- }
228
- const input = /** @type {HTMLInputElement|null} */ (
229
- mount_element.querySelector('#detail-root .prop.assignee input')
230
- );
231
- const prev = current?.assignee ?? '';
232
- const next = input?.value ?? '';
233
- if (next === prev) {
234
- edit_assignee = false;
235
- doRender();
236
- return;
237
- }
238
- pending = true;
239
- if (input) {
240
- input.disabled = true;
241
- }
242
- try {
243
- const updated = await sendFn('update-assignee', {
244
- id: current.id,
245
- assignee: next
246
- });
247
- if (updated && typeof updated === 'object') {
248
- current = /** @type {IssueDetail} */ (updated);
249
- edit_assignee = false;
250
- doRender();
251
- }
252
- } catch {
253
- // revert visually
254
- current.assignee = prev;
255
- edit_assignee = false;
256
- doRender();
257
- showToast('Failed to update assignee', 'error');
258
- } finally {
259
- pending = false;
260
- }
261
- };
262
- const onAssigneeCancel = () => {
263
- edit_assignee = false;
264
- doRender();
265
- };
266
-
267
- // Labels handlers
268
- /**
269
- * @param {Event} ev
270
- */
271
- const onLabelInput = (ev) => {
272
- const el = /** @type {HTMLInputElement} */ (ev.currentTarget);
273
- new_label_text = el.value || '';
274
- };
275
- /**
276
- * @param {KeyboardEvent} e
277
- */
278
- function onLabelKeydown(e) {
279
- if (e.key === 'Enter') {
280
- e.preventDefault();
281
- void onAddLabel();
282
- }
283
- }
284
- async function onAddLabel() {
285
- if (!current || pending) {
286
- return;
287
- }
288
- const text = new_label_text.trim();
289
- if (!text) {
290
- return;
291
- }
292
- pending = true;
293
- try {
294
- const updated = await sendFn('label-add', {
295
- id: current.id,
296
- label: text
297
- });
298
- if (updated && typeof updated === 'object') {
299
- current = /** @type {IssueDetail} */ (updated);
300
- new_label_text = '';
301
- doRender();
302
- }
303
- } catch {
304
- showToast('Failed to add label', 'error');
305
- } finally {
306
- pending = false;
307
- }
308
- }
309
- /**
310
- * @param {string} label
311
- */
312
- async function onRemoveLabel(label) {
313
- if (!current || pending) {
314
- return;
315
- }
316
- pending = true;
317
- try {
318
- const updated = await sendFn('label-remove', {
319
- id: current.id,
320
- label
321
- });
322
- if (updated && typeof updated === 'object') {
323
- current = /** @type {IssueDetail} */ (updated);
324
- doRender();
325
- }
326
- } catch {
327
- showToast('Failed to remove label', 'error');
328
- } finally {
329
- pending = false;
330
- }
331
- }
332
- /**
333
- * @param {Event} ev
334
- */
335
- const onStatusChange = async (ev) => {
336
- if (!current || pending) {
337
- doRender();
338
- return;
339
- }
340
- const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
341
- const prev = current.status || 'open';
342
- const next = sel.value;
343
- if (next === prev) {
344
- return;
345
- }
346
- pending = true;
347
- current.status = next;
348
- doRender();
349
- try {
350
- const updated = await sendFn('update-status', {
351
- id: current.id,
352
- status: next
353
- });
354
- if (updated && typeof updated === 'object') {
355
- current = /** @type {IssueDetail} */ (updated);
356
- doRender();
357
- }
358
- } catch {
359
- current.status = prev;
360
- doRender();
361
- showToast('Failed to update status', 'error');
362
- } finally {
363
- pending = false;
364
- }
365
- };
366
- /**
367
- * @param {Event} ev
368
- */
369
- const onPriorityChange = async (ev) => {
370
- if (!current || pending) {
371
- doRender();
372
- return;
373
- }
374
- const sel = /** @type {HTMLSelectElement} */ (ev.currentTarget);
375
- const prev = typeof current.priority === 'number' ? current.priority : 2;
376
- const next = Number(sel.value);
377
- if (next === prev) {
378
- return;
379
- }
380
- pending = true;
381
- current.priority = next;
382
- doRender();
383
- try {
384
- const updated = await sendFn('update-priority', {
385
- id: current.id,
386
- priority: next
387
- });
388
- if (updated && typeof updated === 'object') {
389
- current = /** @type {IssueDetail} */ (updated);
390
- doRender();
391
- }
392
- } catch {
393
- current.priority = prev;
394
- doRender();
395
- showToast('Failed to update priority', 'error');
396
- } finally {
397
- pending = false;
398
- }
399
- };
400
-
401
- const onDescEdit = () => {
402
- edit_desc = true;
403
- doRender();
404
- };
405
- /**
406
- * @param {KeyboardEvent} ev
407
- */
408
- const onDescKeydown = (ev) => {
409
- if (ev.key === 'Escape') {
410
- edit_desc = false;
411
- doRender();
412
- } else if (ev.key === 'Enter' && ev.ctrlKey) {
413
- const btn = /** @type {HTMLButtonElement|null} */ (
414
- mount_element.querySelector('#detail-root .editable-actions button')
415
- );
416
- if (btn) {
417
- btn.click();
418
- }
419
- }
420
- };
421
- const onDescSave = async () => {
422
- if (!current || pending) {
423
- return;
424
- }
425
- const ta = /** @type {HTMLTextAreaElement|null} */ (
426
- mount_element.querySelector('#detail-root textarea')
427
- );
428
- const prev = current.description || '';
429
- const next = ta ? ta.value : '';
430
- if (next === prev) {
431
- edit_desc = false;
432
- doRender();
433
- return;
434
- }
435
- pending = true;
436
- if (ta) {
437
- ta.disabled = true;
438
- }
439
- try {
440
- const updated = await sendFn('edit-text', {
441
- id: current.id,
442
- field: 'description',
443
- value: next
444
- });
445
- if (updated && typeof updated === 'object') {
446
- current = /** @type {IssueDetail} */ (updated);
447
- edit_desc = false;
448
- doRender();
449
- }
450
- } catch {
451
- current.description = prev;
452
- edit_desc = false;
453
- doRender();
454
- showToast('Failed to save description', 'error');
455
- } finally {
456
- pending = false;
457
- }
458
- };
459
- const onDescCancel = () => {
460
- edit_desc = false;
461
- doRender();
462
- };
463
-
464
- // Design inline edit handlers (same UX as Description)
465
- const onDesignEdit = () => {
466
- edit_design = true;
467
- doRender();
468
- try {
469
- const ta = /** @type {HTMLTextAreaElement|null} */ (
470
- mount_element.querySelector('#detail-root .design textarea')
471
- );
472
- if (ta) {
473
- ta.focus();
474
- }
475
- } catch {
476
- // ignore focus errors
477
- }
478
- };
479
- /**
480
- * @param {KeyboardEvent} ev
481
- */
482
- const onDesignKeydown = (ev) => {
483
- if (ev.key === 'Escape') {
484
- edit_design = false;
485
- doRender();
486
- } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
487
- const btn = /** @type {HTMLButtonElement|null} */ (
488
- mount_element.querySelector(
489
- '#detail-root .design .editable-actions button'
490
- )
491
- );
492
- if (btn) {
493
- btn.click();
494
- }
495
- }
496
- };
497
- const onDesignSave = async () => {
498
- if (!current || pending) {
499
- return;
500
- }
501
- const ta = /** @type {HTMLTextAreaElement|null} */ (
502
- mount_element.querySelector('#detail-root .design textarea')
503
- );
504
- const prev = current.design || '';
505
- const next = ta ? ta.value : '';
506
- if (next === prev) {
507
- edit_design = false;
508
- doRender();
509
- return;
510
- }
511
- pending = true;
512
- if (ta) {
513
- ta.disabled = true;
514
- }
515
- try {
516
- const updated = await sendFn('edit-text', {
517
- id: current.id,
518
- field: 'design',
519
- value: next
520
- });
521
- if (updated && typeof updated === 'object') {
522
- current = /** @type {IssueDetail} */ (updated);
523
- edit_design = false;
524
- doRender();
525
- }
526
- } catch {
527
- current.design = prev;
528
- edit_design = false;
529
- doRender();
530
- showToast('Failed to save design', 'error');
531
- } finally {
532
- pending = false;
533
- }
534
- };
535
- const onDesignCancel = () => {
536
- edit_design = false;
537
- doRender();
538
- };
539
-
540
- // Notes inline edit handlers
541
- const onNotesEdit = () => {
542
- edit_notes = true;
543
- doRender();
544
- };
545
- /**
546
- * @param {KeyboardEvent} ev
547
- */
548
- const onNotesKeydown = (ev) => {
549
- if (ev.key === 'Escape') {
550
- edit_notes = false;
551
- doRender();
552
- } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
553
- const btn = /** @type {HTMLButtonElement|null} */ (
554
- mount_element.querySelector(
555
- '#detail-root .notes .editable-actions button'
556
- )
557
- );
558
- if (btn) {
559
- btn.click();
560
- }
561
- }
562
- };
563
- const onNotesSave = async () => {
564
- if (!current || pending) {
565
- return;
566
- }
567
- const ta = /** @type {HTMLTextAreaElement|null} */ (
568
- mount_element.querySelector('#detail-root .notes textarea')
569
- );
570
- const prev = current.notes || '';
571
- const next = ta ? ta.value : '';
572
- if (next === prev) {
573
- edit_notes = false;
574
- doRender();
575
- return;
576
- }
577
- pending = true;
578
- if (ta) {
579
- ta.disabled = true;
580
- }
581
- try {
582
- const updated = await sendFn('edit-text', {
583
- id: current.id,
584
- field: 'notes',
585
- value: next
586
- });
587
- if (updated && typeof updated === 'object') {
588
- current = /** @type {IssueDetail} */ (updated);
589
- edit_notes = false;
590
- doRender();
591
- }
592
- } catch {
593
- current.notes = prev;
594
- edit_notes = false;
595
- doRender();
596
- showToast('Failed to save notes', 'error');
597
- } finally {
598
- pending = false;
599
- }
600
- };
601
- const onNotesCancel = () => {
602
- edit_notes = false;
603
- doRender();
604
- };
605
-
606
- const onAcceptEdit = () => {
607
- edit_accept = true;
608
- doRender();
609
- };
610
- /**
611
- * @param {KeyboardEvent} ev
612
- */
613
- const onAcceptKeydown = (ev) => {
614
- if (ev.key === 'Escape') {
615
- edit_accept = false;
616
- doRender();
617
- } else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
618
- const btn = /** @type {HTMLButtonElement|null} */ (
619
- mount_element.querySelector(
620
- '#detail-root .acceptance .editable-actions button'
621
- )
622
- );
623
- if (btn) {
624
- btn.click();
625
- }
626
- }
627
- };
628
- const onAcceptSave = async () => {
629
- if (!current || pending) {
630
- return;
631
- }
632
- const ta = /** @type {HTMLTextAreaElement|null} */ (
633
- mount_element.querySelector('#detail-root .acceptance textarea')
634
- );
635
- const prev = current.acceptance || '';
636
- const next = ta ? ta.value : '';
637
- if (next === prev) {
638
- edit_accept = false;
639
- doRender();
640
- return;
641
- }
642
- pending = true;
643
- if (ta) {
644
- ta.disabled = true;
645
- }
646
- try {
647
- const updated = await sendFn('edit-text', {
648
- id: current.id,
649
- field: 'acceptance',
650
- value: next
651
- });
652
- if (updated && typeof updated === 'object') {
653
- current = /** @type {IssueDetail} */ (updated);
654
- edit_accept = false;
655
- doRender();
656
- }
657
- } catch {
658
- current.acceptance = prev;
659
- edit_accept = false;
660
- doRender();
661
- showToast('Failed to save acceptance', 'error');
662
- } finally {
663
- pending = false;
664
- }
665
- };
666
- const onAcceptCancel = () => {
667
- edit_accept = false;
668
- doRender();
669
- };
670
-
671
- /**
672
- * @param {'Dependencies'|'Dependents'} title
673
- * @param {Dependency[]} items
674
- */
675
- function depsSection(title, items) {
676
- const test_id =
677
- title === 'Dependencies' ? 'add-dependency' : 'add-dependent';
678
- return html`
679
- <div class="props-card">
680
- <div>
681
- <div class="props-card__title">${title}</div>
682
- </div>
683
- <ul>
684
- ${!items || items.length === 0
685
- ? null
686
- : items.map((dep) => {
687
- const did = dep.id;
688
- const href = issueHref(did);
689
- return html`<li
690
- data-href=${href}
691
- @click=${() => navigateFn(href)}
692
- >
693
- ${createTypeBadge(dep.issue_type || '')}
694
- <span class="text-truncate">${dep.title || ''}</span>
695
- <button
696
- aria-label=${`Remove dependency ${issueDisplayId(did)}`}
697
- @click=${makeDepRemoveClick(did, title)}
698
- >
699
- ×
700
- </button>
701
- </li>`;
702
- })}
703
- </ul>
704
- <div class="props-card__footer">
705
- <input type="text" placeholder="Issue ID" data-testid=${test_id} />
706
- <button @click=${makeDepAddClick(items, title)}>Add</button>
707
- </div>
708
- </div>
709
- `;
710
- }
711
-
712
- /**
713
- * @param {IssueDetail} issue
714
- */
715
- function detailTemplate(issue) {
716
- const title_zone = edit_title
717
- ? html`<div class="detail-title">
718
- <h2>
719
- <input
720
- type="text"
721
- aria-label="Edit title"
722
- .value=${issue.title || ''}
723
- @keydown=${onTitleInputKeydown}
724
- />
725
- <button @click=${onTitleSave}>Save</button>
726
- <button @click=${onTitleCancel}>Cancel</button>
727
- </h2>
728
- </div>`
729
- : html`<div class="detail-title">
730
- <h2>
731
- <span
732
- class="editable"
733
- tabindex="0"
734
- role="button"
735
- aria-label="Edit title"
736
- @click=${onTitleSpanClick}
737
- @keydown=${onTitleKeydown}
738
- >${issue.title || ''}</span
739
- >
740
- </h2>
741
- </div>`;
742
-
743
- const status_select = html`<select
744
- class=${`badge-select badge--status is-${issue.status || 'open'}`}
745
- @change=${onStatusChange}
746
- .value=${issue.status || 'open'}
747
- ?disabled=${pending}
748
- >
749
- ${(() => {
750
- const cur = String(issue.status || 'open');
751
- return ['open', 'in_progress', 'closed'].map(
752
- (s) =>
753
- html`<option value=${s} ?selected=${cur === s}>
754
- ${statusLabel(s)}
755
- </option>`
756
- );
757
- })()}
758
- </select>`;
759
-
760
- const priority_select = html`<select
761
- class=${`badge-select badge--priority is-p${String(
762
- typeof issue.priority === 'number' ? issue.priority : 2
763
- )}`}
764
- @change=${onPriorityChange}
765
- .value=${String(typeof issue.priority === 'number' ? issue.priority : 2)}
766
- ?disabled=${pending}
767
- >
768
- ${(() => {
769
- const cur = String(
770
- typeof issue.priority === 'number' ? issue.priority : 2
771
- );
772
- return priority_levels.map(
773
- (p, i) =>
774
- html`<option value=${String(i)} ?selected=${cur === String(i)}>
775
- ${emojiForPriority(i)} ${p}
776
- </option>`
777
- );
778
- })()}
779
- </select>`;
780
-
781
- const desc_block = edit_desc
782
- ? html`<div class="description">
783
- <textarea
784
- @keydown=${onDescKeydown}
785
- .value=${issue.description || ''}
786
- rows="8"
787
- style="width:100%"
788
- ></textarea>
789
- <div class="editable-actions">
790
- <button @click=${onDescSave}>Save</button>
791
- <button @click=${onDescCancel}>Cancel</button>
792
- </div>
793
- </div>`
794
- : html`<div
795
- class="md editable"
796
- tabindex="0"
797
- role="button"
798
- aria-label="Edit description"
799
- @click=${onDescEdit}
800
- @keydown=${onDescEditableKeydown}
801
- >
802
- ${(() => {
803
- const text = issue.description || '';
804
- if (text.trim() === '') {
805
- return html`<div class="muted">Description</div>`;
806
- }
807
- return renderMarkdown(text);
808
- })()}
809
- </div>`;
810
-
811
- // Normalize acceptance text: prefer issue.acceptance, fallback to acceptance_criteria from bd
812
- const acceptance_text = (() => {
813
- /** @type {any} */
814
- const any_issue = issue;
815
- const raw = String(
816
- issue.acceptance || any_issue.acceptance_criteria || ''
817
- );
818
- return raw;
819
- })();
820
-
821
- const accept_block = edit_accept
822
- ? html`<div class="acceptance">
823
- ${acceptance_text.trim().length > 0
824
- ? html`<div class="props-card__title">Acceptance Criteria</div>`
825
- : ''}
826
- <textarea
827
- @keydown=${onAcceptKeydown}
828
- .value=${acceptance_text}
829
- rows="6"
830
- style="width:100%"
831
- ></textarea>
832
- <div class="editable-actions">
833
- <button @click=${onAcceptSave}>Save</button>
834
- <button @click=${onAcceptCancel}>Cancel</button>
835
- </div>
836
- </div>`
837
- : html`<div class="acceptance">
838
- ${(() => {
839
- const text = acceptance_text;
840
- const has = text.trim().length > 0;
841
- return html`${has
842
- ? html`<div class="props-card__title">Acceptance Criteria</div>`
843
- : ''}
844
- <div
845
- class="md editable"
846
- tabindex="0"
847
- role="button"
848
- aria-label="Edit acceptance criteria"
849
- @click=${onAcceptEdit}
850
- @keydown=${onAcceptEditableKeydown}
851
- >
852
- ${has
853
- ? renderMarkdown(text)
854
- : html`<div class="muted">Add acceptance criteria…</div>`}
855
- </div>`;
856
- })()}
857
- </div>`;
858
-
859
- // Notes: editable in-place similar to Description
860
- const notes_text = String(issue.notes || '');
861
- const notes_block = edit_notes
862
- ? html`<div class="notes">
863
- ${notes_text.trim().length > 0
864
- ? html`<div class="props-card__title">Notes</div>`
865
- : ''}
866
- <textarea
867
- @keydown=${onNotesKeydown}
868
- .value=${notes_text}
869
- rows="6"
870
- style="width:100%"
871
- ></textarea>
872
- <div class="editable-actions">
873
- <button @click=${onNotesSave}>Save</button>
874
- <button @click=${onNotesCancel}>Cancel</button>
875
- </div>
876
- </div>`
877
- : html`<div class="notes">
878
- ${(() => {
879
- const text = notes_text;
880
- const has = text.trim().length > 0;
881
- return html`${has
882
- ? html`<div class="props-card__title">Notes</div>`
883
- : ''}
884
- <div
885
- class="md editable"
886
- tabindex="0"
887
- role="button"
888
- aria-label="Edit notes"
889
- @click=${onNotesEdit}
890
- @keydown=${onNotesEditableKeydown}
891
- >
892
- ${has
893
- ? renderMarkdown(text)
894
- : html`<div class="muted">Add notes…</div>`}
895
- </div>`;
896
- })()}
897
- </div>`;
898
-
899
- // Labels section
900
- const labels = Array.isArray(issue.labels) ? issue.labels : [];
901
- const labels_block = html`<div class="prop labels">
902
- <div class="label">Labels</div>
903
- <div class="value">
904
- <div>
905
- ${labels.map(
906
- (l) =>
907
- html`<span class="badge" title=${l}
908
- >${l}
909
- <button
910
- class="icon-button"
911
- title="Remove label"
912
- aria-label=${'Remove label ' + l}
913
- @click=${() => onRemoveLabel(l)}
914
- style="margin-left:6px"
915
- >
916
- ×
917
- </button></span
918
- >`
919
- )}
920
- <input
921
- type="text"
922
- aria-label="Add label"
923
- placeholder="Add label"
924
- .value=${new_label_text}
925
- @input=${onLabelInput}
926
- @keydown=${onLabelKeydown}
927
- size=${Math.max(12, Math.min(28, new_label_text.length + 3))}
928
- />
929
- </div>
930
- </div>
931
- </div>`;
932
-
933
- // Design section block
934
- const design_text = String(issue.design || '');
935
- const design_block = edit_design
936
- ? html`<div class="design">
937
- ${design_text.trim().length > 0
938
- ? html`<div class="props-card__title">Design</div>`
939
- : ''}
940
- <textarea
941
- @keydown=${onDesignKeydown}
942
- .value=${design_text}
943
- rows="6"
944
- style="width:100%"
945
- ></textarea>
946
- <div class="editable-actions">
947
- <button @click=${onDesignSave}>Save</button>
948
- <button @click=${onDesignCancel}>Cancel</button>
949
- </div>
950
- </div>`
951
- : html`<div class="design">
952
- ${(() => {
953
- const text = design_text;
954
- const has = text.trim().length > 0;
955
- return html`${has
956
- ? html`<div class="props-card__title">Design</div>`
957
- : ''}
958
- <div
959
- class="md editable"
960
- tabindex="0"
961
- role="button"
962
- aria-label="Edit design"
963
- @click=${onDesignEdit}
964
- @keydown=${onDesignEditableKeydown}
965
- >
966
- ${has
967
- ? renderMarkdown(text)
968
- : html`<div class="muted">Add design…</div>`}
969
- </div>`;
970
- })()}
971
- </div>`;
972
-
973
- return html`
974
- <div class="panel__body" id="detail-root">
975
- <div style="position:relative">
976
- <div class="detail-layout">
977
- <div class="detail-main">
978
- ${title_zone} ${desc_block} ${design_block} ${notes_block}
979
- ${accept_block}
980
- </div>
981
- <div class="detail-side">
982
- <div class="props-card">
983
- <div class="props-card__title">Properties</div>
984
- <div class="prop">
985
- <div class="label">Type</div>
986
- <div class="value">
987
- ${createTypeBadge(/** @type {any} */ (issue).issue_type)}
988
- </div>
989
- </div>
990
- <div class="prop">
991
- <div class="label">Status</div>
992
- <div class="value">${status_select}</div>
993
- </div>
994
- <div class="prop">
995
- <div class="label">Priority</div>
996
- <div class="value">${priority_select}</div>
997
- </div>
998
- <div class="prop assignee">
999
- <div class="label">Assignee</div>
1000
- <div class="value">
1001
- ${edit_assignee
1002
- ? html`<input
1003
- type="text"
1004
- aria-label="Edit assignee"
1005
- .value=${/** @type {any} */ (issue).assignee || ''}
1006
- size=${Math.min(
1007
- 40,
1008
- Math.max(12, (issue.assignee || '').length + 3)
1009
- )}
1010
- @keydown=${
1011
- /** @param {KeyboardEvent} e */ (e) => {
1012
- if (e.key === 'Escape') {
1013
- e.preventDefault();
1014
- onAssigneeCancel();
1015
- } else if (e.key === 'Enter') {
1016
- e.preventDefault();
1017
- onAssigneeSave();
1018
- }
1019
- }
1020
- }
1021
- />
1022
- <button
1023
- class="btn"
1024
- style="margin-left:6px"
1025
- @click=${onAssigneeSave}
1026
- >
1027
- Save
1028
- </button>
1029
- <button
1030
- class="btn"
1031
- style="margin-left:6px"
1032
- @click=${onAssigneeCancel}
1033
- >
1034
- Cancel
1035
- </button>`
1036
- : html`${(() => {
1037
- const raw = issue.assignee || '';
1038
- const has = raw.trim().length > 0;
1039
- const text = has ? raw : 'Unassigned';
1040
- const cls = has ? 'editable' : 'editable muted';
1041
- return html`<span
1042
- class=${cls}
1043
- tabindex="0"
1044
- role="button"
1045
- aria-label="Edit assignee"
1046
- @click=${onAssigneeSpanClick}
1047
- @keydown=${onAssigneeKeydown}
1048
- >${text}</span
1049
- >`;
1050
- })()}`}
1051
- </div>
1052
- </div>
1053
- ${labels_block}
1054
- </div>
1055
- ${depsSection('Dependencies', issue.dependencies || [])}
1056
- ${depsSection('Dependents', issue.dependents || [])}
1057
- </div>
1058
- </div>
1059
- </div>
1060
- </div>
1061
- `;
1062
- }
1063
-
1064
- function doRender() {
1065
- if (!current) {
1066
- renderPlaceholder(current_id ? 'Loading…' : 'No issue selected');
1067
- return;
1068
- }
1069
- render(detailTemplate(current), mount_element);
1070
- // panel header removed for detail view; ID is shown inline with title
1071
- }
1072
-
1073
- /**
1074
- * Create a click handler for the remove button of a dependency row.
1075
- *
1076
- * @param {string} did
1077
- * @param {'Dependencies'|'Dependents'} title
1078
- * @returns {(ev: Event) => Promise<void>}
1079
- */
1080
- function makeDepRemoveClick(did, title) {
1081
- return async (ev) => {
1082
- ev.stopPropagation();
1083
- if (!current || pending) {
1084
- return;
1085
- }
1086
- pending = true;
1087
- try {
1088
- if (title === 'Dependencies') {
1089
- const updated = await sendFn('dep-remove', {
1090
- a: current.id,
1091
- b: did,
1092
- view_id: current.id
1093
- });
1094
- if (updated && typeof updated === 'object') {
1095
- current = /** @type {IssueDetail} */ (updated);
1096
- doRender();
1097
- }
1098
- } else {
1099
- const updated = await sendFn('dep-remove', {
1100
- a: did,
1101
- b: current.id,
1102
- view_id: current.id
1103
- });
1104
- if (updated && typeof updated === 'object') {
1105
- current = /** @type {IssueDetail} */ (updated);
1106
- doRender();
1107
- }
1108
- }
1109
- } catch {
1110
- // ignore
1111
- } finally {
1112
- pending = false;
1113
- }
1114
- };
1115
- }
1116
-
1117
- /**
1118
- * Create a click handler for the Add button in a dependency section.
1119
- *
1120
- * @param {Dependency[]} items
1121
- * @param {'Dependencies'|'Dependents'} title
1122
- * @returns {(ev: Event) => Promise<void>}
1123
- */
1124
- function makeDepAddClick(items, title) {
1125
- return async (ev) => {
1126
- if (!current || pending) {
1127
- return;
1128
- }
1129
- const btn = /** @type {HTMLButtonElement} */ (ev.currentTarget);
1130
- const input = /** @type {HTMLInputElement|null} */ (
1131
- btn.previousElementSibling
1132
- );
1133
- const target = input ? input.value.trim() : '';
1134
- if (!target || target === current.id) {
1135
- showToast('Enter a different issue id');
1136
- return;
1137
- }
1138
- const set = new Set((items || []).map((d) => d.id));
1139
- if (set.has(target)) {
1140
- showToast('Link already exists');
1141
- return;
1142
- }
1143
- pending = true;
1144
- if (btn) {
1145
- btn.disabled = true;
1146
- }
1147
- if (input) {
1148
- input.disabled = true;
1149
- }
1150
- try {
1151
- if (title === 'Dependencies') {
1152
- const updated = await sendFn('dep-add', {
1153
- a: current.id,
1154
- b: target,
1155
- view_id: current.id
1156
- });
1157
- if (updated && typeof updated === 'object') {
1158
- current = /** @type {IssueDetail} */ (updated);
1159
- doRender();
1160
- }
1161
- } else {
1162
- const updated = await sendFn('dep-add', {
1163
- a: target,
1164
- b: current.id,
1165
- view_id: current.id
1166
- });
1167
- if (updated && typeof updated === 'object') {
1168
- current = /** @type {IssueDetail} */ (updated);
1169
- doRender();
1170
- }
1171
- }
1172
- } catch {
1173
- showToast('Failed to add dependency', 'error');
1174
- } finally {
1175
- pending = false;
1176
- }
1177
- };
1178
- }
1179
- /**
1180
- * @param {KeyboardEvent} ev
1181
- */
1182
- function onTitleInputKeydown(ev) {
1183
- if (ev.key === 'Escape') {
1184
- edit_title = false;
1185
- doRender();
1186
- } else if (ev.key === 'Enter') {
1187
- ev.preventDefault();
1188
- onTitleSave();
1189
- }
1190
- }
1191
-
1192
- /**
1193
- * @param {KeyboardEvent} ev
1194
- */
1195
- function onDescEditableKeydown(ev) {
1196
- if (ev.key === 'Enter') {
1197
- onDescEdit();
1198
- }
1199
- }
1200
-
1201
- /**
1202
- * @param {KeyboardEvent} ev
1203
- */
1204
- function onAcceptEditableKeydown(ev) {
1205
- if (ev.key === 'Enter') {
1206
- onAcceptEdit();
1207
- }
1208
- }
1209
-
1210
- /**
1211
- * @param {KeyboardEvent} ev
1212
- */
1213
- function onNotesEditableKeydown(ev) {
1214
- if (ev.key === 'Enter') {
1215
- onNotesEdit();
1216
- }
1217
- }
1218
-
1219
- /**
1220
- * @param {KeyboardEvent} ev
1221
- */
1222
- function onDesignEditableKeydown(ev) {
1223
- if (ev.key === 'Enter') {
1224
- onDesignEdit();
1225
- }
1226
- }
1227
-
1228
- return {
1229
- async load(id) {
1230
- if (!id) {
1231
- renderPlaceholder('No issue selected');
1232
- return;
1233
- }
1234
- current_id = String(id);
1235
- // Try from store first; show placeholder while waiting for snapshot
1236
- current = null;
1237
- refreshFromStore();
1238
- if (!current) {
1239
- renderPlaceholder('Loading…');
1240
- }
1241
- // Render from current (if available) or keep placeholder until push arrives
1242
- pending = false;
1243
- doRender();
1244
- },
1245
- clear() {
1246
- renderPlaceholder('Select an issue to view details');
1247
- },
1248
- destroy() {
1249
- mount_element.replaceChildren();
1250
- }
1251
- };
1252
- }