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