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.
- package/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
// Issue Detail view implementation (lit-html based)
|
|
2
|
+
import { html, render } from 'lit-html';
|
|
3
|
+
import { issueDisplayId } from '../utils/issue-id.js';
|
|
4
|
+
import { renderMarkdown } from '../utils/markdown.js';
|
|
5
|
+
import { emojiForPriority } from '../utils/priority-badge.js';
|
|
6
|
+
import { priority_levels } from '../utils/priority.js';
|
|
7
|
+
import { statusLabel } from '../utils/status.js';
|
|
8
|
+
import { createTypeBadge } from '../utils/type-badge.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} Dependency
|
|
12
|
+
* @property {string} id
|
|
13
|
+
* @property {string} [title]
|
|
14
|
+
* @property {string} [status]
|
|
15
|
+
* @property {number} [priority]
|
|
16
|
+
* @property {string} [issue_type]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} IssueDetail
|
|
21
|
+
* @property {string} id
|
|
22
|
+
* @property {string} [title]
|
|
23
|
+
* @property {string} [description]
|
|
24
|
+
* @property {string} [acceptance]
|
|
25
|
+
* @property {string} [notes]
|
|
26
|
+
* @property {string} [status]
|
|
27
|
+
* @property {number} [priority]
|
|
28
|
+
* @property {string[]} [labels]
|
|
29
|
+
* @property {Dependency[]} [dependencies]
|
|
30
|
+
* @property {Dependency[]} [dependents]
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} hash
|
|
35
|
+
*/
|
|
36
|
+
function defaultNavigateFn(hash) {
|
|
37
|
+
window.location.hash = hash;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create the Issue Detail view.
|
|
42
|
+
* @param {HTMLElement} mount_element - Element to render into.
|
|
43
|
+
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
44
|
+
* @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
|
|
45
|
+
* @returns {{ load: (id: string) => Promise<void>, clear: () => void, destroy: () => void }} View API.
|
|
46
|
+
*/
|
|
47
|
+
export function createDetailView(
|
|
48
|
+
mount_element,
|
|
49
|
+
sendFn,
|
|
50
|
+
navigateFn = defaultNavigateFn
|
|
51
|
+
) {
|
|
52
|
+
/** @type {IssueDetail | null} */
|
|
53
|
+
let current = null;
|
|
54
|
+
/** @type {boolean} */
|
|
55
|
+
let pending = false;
|
|
56
|
+
/** @type {boolean} */
|
|
57
|
+
let edit_title = false;
|
|
58
|
+
/** @type {boolean} */
|
|
59
|
+
let edit_desc = false;
|
|
60
|
+
/** @type {boolean} */
|
|
61
|
+
let edit_accept = false;
|
|
62
|
+
/** @type {boolean} */
|
|
63
|
+
let edit_assignee = false;
|
|
64
|
+
/** @type {string} */
|
|
65
|
+
let new_label_text = '';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Show a transient toast message.
|
|
69
|
+
* @param {string} text
|
|
70
|
+
*/
|
|
71
|
+
function showToast(text) {
|
|
72
|
+
/** @type {HTMLDivElement} */
|
|
73
|
+
const toast = document.createElement('div');
|
|
74
|
+
toast.className = 'toast';
|
|
75
|
+
toast.textContent = text;
|
|
76
|
+
toast.style.position = 'absolute';
|
|
77
|
+
toast.style.right = '12px';
|
|
78
|
+
toast.style.bottom = '12px';
|
|
79
|
+
toast.style.background = 'rgba(0,0,0,0.8)';
|
|
80
|
+
toast.style.color = '#fff';
|
|
81
|
+
toast.style.padding = '8px 10px';
|
|
82
|
+
toast.style.borderRadius = '4px';
|
|
83
|
+
toast.style.fontSize = '12px';
|
|
84
|
+
mount_element.appendChild(toast);
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
try {
|
|
87
|
+
toast.remove();
|
|
88
|
+
} catch {
|
|
89
|
+
/* ignore */
|
|
90
|
+
}
|
|
91
|
+
}, 2800);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {string} id */
|
|
95
|
+
function issueHref(id) {
|
|
96
|
+
return `#/issue/${id}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} message
|
|
101
|
+
*/
|
|
102
|
+
function renderPlaceholder(message) {
|
|
103
|
+
render(
|
|
104
|
+
html`
|
|
105
|
+
<div class="panel__body" id="detail-root">
|
|
106
|
+
<p class="muted">${message}</p>
|
|
107
|
+
</div>
|
|
108
|
+
`,
|
|
109
|
+
mount_element
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Handlers
|
|
114
|
+
const onTitleSpanClick = () => {
|
|
115
|
+
edit_title = true;
|
|
116
|
+
doRender();
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* @param {KeyboardEvent} ev
|
|
120
|
+
*/
|
|
121
|
+
const onTitleKeydown = (ev) => {
|
|
122
|
+
if (ev.key === 'Enter') {
|
|
123
|
+
edit_title = true;
|
|
124
|
+
doRender();
|
|
125
|
+
} else if (ev.key === 'Escape') {
|
|
126
|
+
edit_title = false;
|
|
127
|
+
doRender();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const onTitleSave = async () => {
|
|
131
|
+
if (!current || pending) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
/** @type {HTMLInputElement|null} */
|
|
135
|
+
const input = /** @type {any} */ (mount_element.querySelector('h2 input'));
|
|
136
|
+
const prev = current.title || '';
|
|
137
|
+
const next = input ? input.value : '';
|
|
138
|
+
if (next === prev) {
|
|
139
|
+
edit_title = false;
|
|
140
|
+
doRender();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
pending = true;
|
|
144
|
+
if (input) {
|
|
145
|
+
input.disabled = true;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const updated = await sendFn('edit-text', {
|
|
149
|
+
id: current.id,
|
|
150
|
+
field: 'title',
|
|
151
|
+
value: next
|
|
152
|
+
});
|
|
153
|
+
if (updated && typeof updated === 'object') {
|
|
154
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
155
|
+
edit_title = false;
|
|
156
|
+
doRender();
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
current.title = prev;
|
|
160
|
+
edit_title = false;
|
|
161
|
+
doRender();
|
|
162
|
+
showToast('Failed to save title');
|
|
163
|
+
} finally {
|
|
164
|
+
pending = false;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const onTitleCancel = () => {
|
|
168
|
+
edit_title = false;
|
|
169
|
+
doRender();
|
|
170
|
+
};
|
|
171
|
+
// Assignee inline edit handlers
|
|
172
|
+
const onAssigneeSpanClick = () => {
|
|
173
|
+
edit_assignee = true;
|
|
174
|
+
doRender();
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* @param {KeyboardEvent} ev
|
|
178
|
+
*/
|
|
179
|
+
const onAssigneeKeydown = (ev) => {
|
|
180
|
+
if (ev.key === 'Enter') {
|
|
181
|
+
ev.preventDefault();
|
|
182
|
+
edit_assignee = true;
|
|
183
|
+
doRender();
|
|
184
|
+
} else if (ev.key === 'Escape') {
|
|
185
|
+
ev.preventDefault();
|
|
186
|
+
edit_assignee = false;
|
|
187
|
+
doRender();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const onAssigneeSave = async () => {
|
|
191
|
+
if (!current || pending) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
/** @type {HTMLInputElement|null} */
|
|
195
|
+
const input = /** @type {any} */ (
|
|
196
|
+
mount_element.querySelector('#detail-root .prop.assignee input')
|
|
197
|
+
);
|
|
198
|
+
const prev = String(
|
|
199
|
+
(current && /** @type {any} */ (current).assignee) || ''
|
|
200
|
+
);
|
|
201
|
+
const next = input ? String(input.value || '') : '';
|
|
202
|
+
if (next === prev) {
|
|
203
|
+
edit_assignee = false;
|
|
204
|
+
doRender();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
pending = true;
|
|
208
|
+
if (input) {
|
|
209
|
+
input.disabled = true;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const updated = await sendFn('update-assignee', {
|
|
213
|
+
id: current.id,
|
|
214
|
+
assignee: next
|
|
215
|
+
});
|
|
216
|
+
if (updated && typeof updated === 'object') {
|
|
217
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
218
|
+
edit_assignee = false;
|
|
219
|
+
doRender();
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// revert visually
|
|
223
|
+
/** @type {any} */ (current).assignee = prev;
|
|
224
|
+
edit_assignee = false;
|
|
225
|
+
doRender();
|
|
226
|
+
showToast('Failed to update assignee');
|
|
227
|
+
} finally {
|
|
228
|
+
pending = false;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const onAssigneeCancel = () => {
|
|
232
|
+
edit_assignee = false;
|
|
233
|
+
doRender();
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Labels handlers
|
|
237
|
+
/**
|
|
238
|
+
* @param {Event} ev
|
|
239
|
+
*/
|
|
240
|
+
const onLabelInput = (ev) => {
|
|
241
|
+
/** @type {HTMLInputElement} */
|
|
242
|
+
const el = /** @type {any} */ (ev.currentTarget);
|
|
243
|
+
new_label_text = el.value || '';
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* @param {KeyboardEvent} e
|
|
247
|
+
*/
|
|
248
|
+
function onLabelKeydown(e) {
|
|
249
|
+
if (e.key === 'Enter') {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
void onAddLabel();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function onAddLabel() {
|
|
255
|
+
if (!current || pending) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const text = new_label_text.trim();
|
|
259
|
+
if (!text) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
pending = true;
|
|
263
|
+
try {
|
|
264
|
+
const updated = await sendFn('label-add', {
|
|
265
|
+
id: current.id,
|
|
266
|
+
label: text
|
|
267
|
+
});
|
|
268
|
+
if (updated && typeof updated === 'object') {
|
|
269
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
270
|
+
new_label_text = '';
|
|
271
|
+
doRender();
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
showToast('Failed to add label');
|
|
275
|
+
} finally {
|
|
276
|
+
pending = false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* @param {string} label
|
|
281
|
+
*/
|
|
282
|
+
async function onRemoveLabel(label) {
|
|
283
|
+
if (!current || pending) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
pending = true;
|
|
287
|
+
try {
|
|
288
|
+
const updated = await sendFn('label-remove', {
|
|
289
|
+
id: current.id,
|
|
290
|
+
label
|
|
291
|
+
});
|
|
292
|
+
if (updated && typeof updated === 'object') {
|
|
293
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
294
|
+
doRender();
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
showToast('Failed to remove label');
|
|
298
|
+
} finally {
|
|
299
|
+
pending = false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* @param {Event} ev
|
|
304
|
+
*/
|
|
305
|
+
const onStatusChange = async (ev) => {
|
|
306
|
+
if (!current || pending) {
|
|
307
|
+
doRender();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
/** @type {HTMLSelectElement} */
|
|
311
|
+
const sel = /** @type {any} */ (ev.currentTarget);
|
|
312
|
+
const prev = current.status || 'open';
|
|
313
|
+
const next = sel.value;
|
|
314
|
+
if (next === prev) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
pending = true;
|
|
318
|
+
current.status = next;
|
|
319
|
+
doRender();
|
|
320
|
+
try {
|
|
321
|
+
const updated = await sendFn('update-status', {
|
|
322
|
+
id: current.id,
|
|
323
|
+
status: next
|
|
324
|
+
});
|
|
325
|
+
if (updated && typeof updated === 'object') {
|
|
326
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
327
|
+
doRender();
|
|
328
|
+
}
|
|
329
|
+
} catch {
|
|
330
|
+
current.status = prev;
|
|
331
|
+
doRender();
|
|
332
|
+
showToast('Failed to update status');
|
|
333
|
+
} finally {
|
|
334
|
+
pending = false;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* @param {Event} ev
|
|
339
|
+
*/
|
|
340
|
+
const onPriorityChange = async (ev) => {
|
|
341
|
+
if (!current || pending) {
|
|
342
|
+
doRender();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
/** @type {HTMLSelectElement} */
|
|
346
|
+
const sel = /** @type {any} */ (ev.currentTarget);
|
|
347
|
+
const prev = typeof current.priority === 'number' ? current.priority : 2;
|
|
348
|
+
const next = Number(sel.value);
|
|
349
|
+
if (next === prev) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
pending = true;
|
|
353
|
+
current.priority = next;
|
|
354
|
+
doRender();
|
|
355
|
+
try {
|
|
356
|
+
const updated = await sendFn('update-priority', {
|
|
357
|
+
id: current.id,
|
|
358
|
+
priority: next
|
|
359
|
+
});
|
|
360
|
+
if (updated && typeof updated === 'object') {
|
|
361
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
362
|
+
doRender();
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
current.priority = prev;
|
|
366
|
+
doRender();
|
|
367
|
+
showToast('Failed to update priority');
|
|
368
|
+
} finally {
|
|
369
|
+
pending = false;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const onDescEdit = () => {
|
|
374
|
+
edit_desc = true;
|
|
375
|
+
doRender();
|
|
376
|
+
};
|
|
377
|
+
/**
|
|
378
|
+
* @param {KeyboardEvent} ev
|
|
379
|
+
*/
|
|
380
|
+
const onDescKeydown = (ev) => {
|
|
381
|
+
if (ev.key === 'Escape') {
|
|
382
|
+
edit_desc = false;
|
|
383
|
+
doRender();
|
|
384
|
+
} else if (
|
|
385
|
+
ev.key === 'Enter' &&
|
|
386
|
+
/** @type {KeyboardEvent} */ (ev).ctrlKey
|
|
387
|
+
) {
|
|
388
|
+
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
389
|
+
mount_element.querySelector('#detail-root .editable-actions button')
|
|
390
|
+
);
|
|
391
|
+
if (btn) {
|
|
392
|
+
btn.click();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const onDescSave = async () => {
|
|
397
|
+
if (!current || pending) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
/** @type {HTMLTextAreaElement|null} */
|
|
401
|
+
const ta = /** @type {any} */ (
|
|
402
|
+
mount_element.querySelector('#detail-root textarea')
|
|
403
|
+
);
|
|
404
|
+
const prev = current.description || '';
|
|
405
|
+
const next = ta ? ta.value : '';
|
|
406
|
+
if (next === prev) {
|
|
407
|
+
edit_desc = false;
|
|
408
|
+
doRender();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
pending = true;
|
|
412
|
+
if (ta) {
|
|
413
|
+
ta.disabled = true;
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
const updated = await sendFn('edit-text', {
|
|
417
|
+
id: current.id,
|
|
418
|
+
field: 'description',
|
|
419
|
+
value: next
|
|
420
|
+
});
|
|
421
|
+
if (updated && typeof updated === 'object') {
|
|
422
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
423
|
+
edit_desc = false;
|
|
424
|
+
doRender();
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
current.description = prev;
|
|
428
|
+
edit_desc = false;
|
|
429
|
+
doRender();
|
|
430
|
+
showToast('Failed to save description');
|
|
431
|
+
} finally {
|
|
432
|
+
pending = false;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const onDescCancel = () => {
|
|
436
|
+
edit_desc = false;
|
|
437
|
+
doRender();
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const onAcceptEdit = () => {
|
|
441
|
+
edit_accept = true;
|
|
442
|
+
doRender();
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* @param {KeyboardEvent} ev
|
|
446
|
+
*/
|
|
447
|
+
const onAcceptKeydown = (ev) => {
|
|
448
|
+
if (ev.key === 'Escape') {
|
|
449
|
+
edit_accept = false;
|
|
450
|
+
doRender();
|
|
451
|
+
} else if (
|
|
452
|
+
ev.key === 'Enter' &&
|
|
453
|
+
/** @type {KeyboardEvent} */ (ev).ctrlKey
|
|
454
|
+
) {
|
|
455
|
+
const btn = /** @type {HTMLButtonElement|null} */ (
|
|
456
|
+
mount_element.querySelector(
|
|
457
|
+
'#detail-root .acceptance .editable-actions button'
|
|
458
|
+
)
|
|
459
|
+
);
|
|
460
|
+
if (btn) {
|
|
461
|
+
btn.click();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const onAcceptSave = async () => {
|
|
466
|
+
if (!current || pending) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
/** @type {HTMLTextAreaElement|null} */
|
|
470
|
+
const ta = /** @type {any} */ (
|
|
471
|
+
mount_element.querySelector('#detail-root .acceptance textarea')
|
|
472
|
+
);
|
|
473
|
+
const prev = current.acceptance || '';
|
|
474
|
+
const next = ta ? ta.value : '';
|
|
475
|
+
if (next === prev) {
|
|
476
|
+
edit_accept = false;
|
|
477
|
+
doRender();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
pending = true;
|
|
481
|
+
if (ta) {
|
|
482
|
+
ta.disabled = true;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const updated = await sendFn('edit-text', {
|
|
486
|
+
id: current.id,
|
|
487
|
+
field: 'acceptance',
|
|
488
|
+
value: next
|
|
489
|
+
});
|
|
490
|
+
if (updated && typeof updated === 'object') {
|
|
491
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
492
|
+
edit_accept = false;
|
|
493
|
+
doRender();
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
current.acceptance = prev;
|
|
497
|
+
edit_accept = false;
|
|
498
|
+
doRender();
|
|
499
|
+
showToast('Failed to save acceptance');
|
|
500
|
+
} finally {
|
|
501
|
+
pending = false;
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
const onAcceptCancel = () => {
|
|
505
|
+
edit_accept = false;
|
|
506
|
+
doRender();
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* @param {'Dependencies'|'Dependents'} title
|
|
511
|
+
* @param {Dependency[]} items
|
|
512
|
+
*/
|
|
513
|
+
function depsSection(title, items) {
|
|
514
|
+
const test_id =
|
|
515
|
+
title === 'Dependencies' ? 'add-dependency' : 'add-dependent';
|
|
516
|
+
return html`
|
|
517
|
+
<div class="props-card">
|
|
518
|
+
<div>
|
|
519
|
+
<div class="props-card__title">${title}</div>
|
|
520
|
+
</div>
|
|
521
|
+
<ul>
|
|
522
|
+
${!items || items.length === 0
|
|
523
|
+
? null
|
|
524
|
+
: items.map((dep) => {
|
|
525
|
+
const did = dep.id;
|
|
526
|
+
const href = issueHref(did);
|
|
527
|
+
return html` <li
|
|
528
|
+
style="display:grid;grid-template-columns:auto auto 1fr auto;gap:6px;align-items:center;padding:2px 0;cursor:pointer;"
|
|
529
|
+
@click=${() => navigateFn(href)}
|
|
530
|
+
>
|
|
531
|
+
<a href=${href} @click=${makeDepLinkClick(href)}
|
|
532
|
+
>${issueDisplayId(did)}</a
|
|
533
|
+
>
|
|
534
|
+
${createTypeBadge(dep.issue_type || '')}
|
|
535
|
+
<span class="text-truncate">${dep.title || ''}</span>
|
|
536
|
+
<button
|
|
537
|
+
aria-label=${`Remove dependency ${issueDisplayId(did)}`}
|
|
538
|
+
@click=${makeDepRemoveClick(did, title)}
|
|
539
|
+
>
|
|
540
|
+
×
|
|
541
|
+
</button>
|
|
542
|
+
</li>`;
|
|
543
|
+
})}
|
|
544
|
+
</ul>
|
|
545
|
+
<div class="props-card__footer">
|
|
546
|
+
<input type="text" placeholder="Issue ID" data-testid=${test_id} />
|
|
547
|
+
<button @click=${makeDepAddClick(items, title)}>Add</button>
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* @param {IssueDetail} issue
|
|
555
|
+
*/
|
|
556
|
+
function detailTemplate(issue) {
|
|
557
|
+
const title_zone = edit_title
|
|
558
|
+
? html`<div class="detail-title">
|
|
559
|
+
<h2>
|
|
560
|
+
<input
|
|
561
|
+
type="text"
|
|
562
|
+
aria-label="Edit title"
|
|
563
|
+
.value=${issue.title || ''}
|
|
564
|
+
@keydown=${onTitleInputKeydown}
|
|
565
|
+
style="width:100%;font-size:inherit;line-height:inherit;"
|
|
566
|
+
/>
|
|
567
|
+
<button style="margin-left:6px" @click=${onTitleSave}>Save</button>
|
|
568
|
+
<button style="margin-left:6px" @click=${onTitleCancel}>
|
|
569
|
+
Cancel
|
|
570
|
+
</button>
|
|
571
|
+
</h2>
|
|
572
|
+
<span class="mono detail-id">${issueDisplayId(issue.id)}</span>
|
|
573
|
+
</div>`
|
|
574
|
+
: html`<div class="detail-title">
|
|
575
|
+
<h2>
|
|
576
|
+
<span
|
|
577
|
+
class="editable"
|
|
578
|
+
tabindex="0"
|
|
579
|
+
role="button"
|
|
580
|
+
aria-label="Edit title"
|
|
581
|
+
@click=${onTitleSpanClick}
|
|
582
|
+
@keydown=${onTitleKeydown}
|
|
583
|
+
>${issue.title || ''}</span
|
|
584
|
+
>
|
|
585
|
+
</h2>
|
|
586
|
+
<span class="mono detail-id">${issueDisplayId(issue.id)}</span>
|
|
587
|
+
</div>`;
|
|
588
|
+
|
|
589
|
+
const status_select = html`<select
|
|
590
|
+
class=${`badge-select badge--status is-${issue.status || 'open'}`}
|
|
591
|
+
@change=${onStatusChange}
|
|
592
|
+
.value=${issue.status || 'open'}
|
|
593
|
+
?disabled=${pending}
|
|
594
|
+
>
|
|
595
|
+
${(() => {
|
|
596
|
+
const cur = String(issue.status || 'open');
|
|
597
|
+
return ['open', 'in_progress', 'closed'].map(
|
|
598
|
+
(s) =>
|
|
599
|
+
html`<option value=${s} ?selected=${cur === s}>
|
|
600
|
+
${statusLabel(s)}
|
|
601
|
+
</option>`
|
|
602
|
+
);
|
|
603
|
+
})()}
|
|
604
|
+
</select>`;
|
|
605
|
+
|
|
606
|
+
const priority_select = html`<select
|
|
607
|
+
class=${`badge-select badge--priority is-p${String(
|
|
608
|
+
typeof issue.priority === 'number' ? issue.priority : 2
|
|
609
|
+
)}`}
|
|
610
|
+
@change=${onPriorityChange}
|
|
611
|
+
.value=${String(typeof issue.priority === 'number' ? issue.priority : 2)}
|
|
612
|
+
?disabled=${pending}
|
|
613
|
+
>
|
|
614
|
+
${(() => {
|
|
615
|
+
const cur = String(
|
|
616
|
+
typeof issue.priority === 'number' ? issue.priority : 2
|
|
617
|
+
);
|
|
618
|
+
return priority_levels.map(
|
|
619
|
+
(p, i) =>
|
|
620
|
+
html`<option value=${String(i)} ?selected=${cur === String(i)}>
|
|
621
|
+
${emojiForPriority(i)} ${p}
|
|
622
|
+
</option>`
|
|
623
|
+
);
|
|
624
|
+
})()}
|
|
625
|
+
</select>`;
|
|
626
|
+
|
|
627
|
+
const desc_block = edit_desc
|
|
628
|
+
? html`<div class="description">
|
|
629
|
+
<textarea
|
|
630
|
+
@keydown=${onDescKeydown}
|
|
631
|
+
.value=${issue.description || ''}
|
|
632
|
+
rows="8"
|
|
633
|
+
style="width:100%"
|
|
634
|
+
></textarea>
|
|
635
|
+
<div class="editable-actions">
|
|
636
|
+
<button @click=${onDescSave}>Save</button>
|
|
637
|
+
<button @click=${onDescCancel}>Cancel</button>
|
|
638
|
+
</div>
|
|
639
|
+
</div>`
|
|
640
|
+
: html`<div
|
|
641
|
+
class="md editable"
|
|
642
|
+
tabindex="0"
|
|
643
|
+
role="button"
|
|
644
|
+
aria-label="Edit description"
|
|
645
|
+
@click=${onDescEdit}
|
|
646
|
+
@keydown=${onDescEditableKeydown}
|
|
647
|
+
>
|
|
648
|
+
${(() => {
|
|
649
|
+
const text = issue.description || '';
|
|
650
|
+
if (text.trim() === '') {
|
|
651
|
+
return html`<div class="muted">Description</div>`;
|
|
652
|
+
}
|
|
653
|
+
return renderMarkdown(text);
|
|
654
|
+
})()}
|
|
655
|
+
</div>`;
|
|
656
|
+
|
|
657
|
+
// Normalize acceptance text: prefer issue.acceptance, fallback to acceptance_criteria from bd
|
|
658
|
+
const acceptance_text = (() => {
|
|
659
|
+
/** @type {any} */
|
|
660
|
+
const any_issue = issue;
|
|
661
|
+
const raw = String(
|
|
662
|
+
issue.acceptance || any_issue.acceptance_criteria || ''
|
|
663
|
+
);
|
|
664
|
+
return raw;
|
|
665
|
+
})();
|
|
666
|
+
|
|
667
|
+
const accept_block = edit_accept
|
|
668
|
+
? html`<div class="acceptance">
|
|
669
|
+
${acceptance_text.trim().length > 0
|
|
670
|
+
? html`<div class="props-card__title">Acceptance</div>`
|
|
671
|
+
: ''}
|
|
672
|
+
<textarea
|
|
673
|
+
@keydown=${onAcceptKeydown}
|
|
674
|
+
.value=${acceptance_text}
|
|
675
|
+
rows="6"
|
|
676
|
+
style="width:100%"
|
|
677
|
+
></textarea>
|
|
678
|
+
<div class="editable-actions">
|
|
679
|
+
<button @click=${onAcceptSave}>Save</button>
|
|
680
|
+
<button @click=${onAcceptCancel}>Cancel</button>
|
|
681
|
+
</div>
|
|
682
|
+
</div>`
|
|
683
|
+
: html`<div class="acceptance">
|
|
684
|
+
${acceptance_text.trim().length > 0
|
|
685
|
+
? html`<div class="props-card__title">Acceptance</div>
|
|
686
|
+
<div
|
|
687
|
+
class="md editable"
|
|
688
|
+
tabindex="0"
|
|
689
|
+
role="button"
|
|
690
|
+
aria-label="Edit acceptance"
|
|
691
|
+
@click=${onAcceptEdit}
|
|
692
|
+
@keydown=${onAcceptEditableKeydown}
|
|
693
|
+
>
|
|
694
|
+
${renderMarkdown(acceptance_text)}
|
|
695
|
+
</div>`
|
|
696
|
+
: ''}
|
|
697
|
+
</div>`;
|
|
698
|
+
|
|
699
|
+
// Notes (read-only): show heading only if there is content
|
|
700
|
+
const notes_text = String(issue.notes || '');
|
|
701
|
+
const notes_block = html`<div class="notes">
|
|
702
|
+
${notes_text.trim().length > 0
|
|
703
|
+
? html`<div class="props-card__title">Notes</div>
|
|
704
|
+
<div class="md">${renderMarkdown(notes_text)}</div>`
|
|
705
|
+
: ''}
|
|
706
|
+
</div>`;
|
|
707
|
+
|
|
708
|
+
// Labels section
|
|
709
|
+
const labels = Array.isArray(issue.labels) ? issue.labels : [];
|
|
710
|
+
const labels_block = html`<div class="prop labels">
|
|
711
|
+
<div class="label">Labels</div>
|
|
712
|
+
<div class="value">
|
|
713
|
+
<div>
|
|
714
|
+
${labels.map(
|
|
715
|
+
(l) =>
|
|
716
|
+
html`<span class="badge" title=${l}
|
|
717
|
+
>${l}
|
|
718
|
+
<button
|
|
719
|
+
class="icon-button"
|
|
720
|
+
title="Remove label"
|
|
721
|
+
aria-label=${'Remove label ' + l}
|
|
722
|
+
@click=${() => onRemoveLabel(l)}
|
|
723
|
+
style="margin-left:6px"
|
|
724
|
+
>
|
|
725
|
+
×
|
|
726
|
+
</button></span
|
|
727
|
+
>`
|
|
728
|
+
)}
|
|
729
|
+
<input
|
|
730
|
+
type="text"
|
|
731
|
+
aria-label="Add label"
|
|
732
|
+
placeholder="Add label…"
|
|
733
|
+
.value=${new_label_text}
|
|
734
|
+
@input=${onLabelInput}
|
|
735
|
+
@keydown=${onLabelKeydown}
|
|
736
|
+
size=${Math.max(12, Math.min(28, new_label_text.length + 3))}
|
|
737
|
+
/>
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
</div>`;
|
|
741
|
+
|
|
742
|
+
return html`
|
|
743
|
+
<div class="panel__body" id="detail-root">
|
|
744
|
+
<div style="position:relative">
|
|
745
|
+
<div class="detail-layout">
|
|
746
|
+
<div class="detail-main">
|
|
747
|
+
${title_zone} ${desc_block} ${notes_block} ${accept_block}
|
|
748
|
+
</div>
|
|
749
|
+
<div class="detail-side">
|
|
750
|
+
<div class="props-card">
|
|
751
|
+
<div class="props-card__title">Properties</div>
|
|
752
|
+
<div class="prop">
|
|
753
|
+
<div class="label">Type</div>
|
|
754
|
+
<div class="value">
|
|
755
|
+
${createTypeBadge(/** @type {any} */ (issue).issue_type)}
|
|
756
|
+
</div>
|
|
757
|
+
</div>
|
|
758
|
+
<div class="prop">
|
|
759
|
+
<div class="label">Status</div>
|
|
760
|
+
<div class="value">${status_select}</div>
|
|
761
|
+
</div>
|
|
762
|
+
<div class="prop">
|
|
763
|
+
<div class="label">Priority</div>
|
|
764
|
+
<div class="value">${priority_select}</div>
|
|
765
|
+
</div>
|
|
766
|
+
<div class="prop assignee">
|
|
767
|
+
<div class="label">Assignee</div>
|
|
768
|
+
<div class="value">
|
|
769
|
+
${edit_assignee
|
|
770
|
+
? html`<input
|
|
771
|
+
type="text"
|
|
772
|
+
aria-label="Edit assignee"
|
|
773
|
+
.value=${/** @type {any} */ (issue).assignee || ''}
|
|
774
|
+
size=${Math.min(
|
|
775
|
+
40,
|
|
776
|
+
Math.max(
|
|
777
|
+
12,
|
|
778
|
+
String(
|
|
779
|
+
/** @type {any} */ (issue).assignee || ''
|
|
780
|
+
).length + 3
|
|
781
|
+
)
|
|
782
|
+
)}
|
|
783
|
+
@keydown=${
|
|
784
|
+
/** @param {KeyboardEvent} e */ (e) => {
|
|
785
|
+
if (e.key === 'Escape') {
|
|
786
|
+
e.preventDefault();
|
|
787
|
+
onAssigneeCancel();
|
|
788
|
+
} else if (e.key === 'Enter') {
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
onAssigneeSave();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/>
|
|
795
|
+
<button
|
|
796
|
+
class="btn"
|
|
797
|
+
style="margin-left:6px"
|
|
798
|
+
@click=${onAssigneeSave}
|
|
799
|
+
>
|
|
800
|
+
Save
|
|
801
|
+
</button>
|
|
802
|
+
<button
|
|
803
|
+
class="btn"
|
|
804
|
+
style="margin-left:6px"
|
|
805
|
+
@click=${onAssigneeCancel}
|
|
806
|
+
>
|
|
807
|
+
Cancel
|
|
808
|
+
</button>`
|
|
809
|
+
: html`${(() => {
|
|
810
|
+
const raw = String(
|
|
811
|
+
/** @type {any} */ (issue).assignee || ''
|
|
812
|
+
);
|
|
813
|
+
const has = raw.trim().length > 0;
|
|
814
|
+
const text = has ? raw : 'Unassigned';
|
|
815
|
+
const cls = has ? 'editable' : 'editable muted';
|
|
816
|
+
return html`<span
|
|
817
|
+
class=${cls}
|
|
818
|
+
tabindex="0"
|
|
819
|
+
role="button"
|
|
820
|
+
aria-label="Edit assignee"
|
|
821
|
+
@click=${onAssigneeSpanClick}
|
|
822
|
+
@keydown=${onAssigneeKeydown}
|
|
823
|
+
>${text}</span
|
|
824
|
+
>`;
|
|
825
|
+
})()}`}
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
${labels_block}
|
|
829
|
+
</div>
|
|
830
|
+
${depsSection('Dependencies', issue.dependencies || [])}
|
|
831
|
+
${depsSection('Dependents', issue.dependents || [])}
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function doRender() {
|
|
840
|
+
if (!current) {
|
|
841
|
+
renderPlaceholder('No issue selected');
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
render(detailTemplate(current), mount_element);
|
|
845
|
+
// panel header removed for detail view; ID is shown inline with title
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Create an anchor click handler for dependency links.
|
|
850
|
+
* @param {string} href
|
|
851
|
+
* @returns {(ev: Event) => void}
|
|
852
|
+
*/
|
|
853
|
+
function makeDepLinkClick(href) {
|
|
854
|
+
return (ev) => {
|
|
855
|
+
ev.preventDefault();
|
|
856
|
+
/** @type {Event} */
|
|
857
|
+
const e = ev;
|
|
858
|
+
// stop bubbling to the li row click
|
|
859
|
+
e.stopPropagation();
|
|
860
|
+
navigateFn(href);
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Create a click handler for the remove button of a dependency row.
|
|
866
|
+
* @param {string} did
|
|
867
|
+
* @param {'Dependencies'|'Dependents'} title
|
|
868
|
+
* @returns {(ev: Event) => Promise<void>}
|
|
869
|
+
*/
|
|
870
|
+
function makeDepRemoveClick(did, title) {
|
|
871
|
+
return async (ev) => {
|
|
872
|
+
/** @type {Event} */
|
|
873
|
+
const e = ev;
|
|
874
|
+
e.stopPropagation();
|
|
875
|
+
if (!current || pending) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
pending = true;
|
|
879
|
+
try {
|
|
880
|
+
if (title === 'Dependencies') {
|
|
881
|
+
const updated = await sendFn('dep-remove', {
|
|
882
|
+
a: current.id,
|
|
883
|
+
b: did,
|
|
884
|
+
view_id: current.id
|
|
885
|
+
});
|
|
886
|
+
if (updated && typeof updated === 'object') {
|
|
887
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
888
|
+
doRender();
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
const updated = await sendFn('dep-remove', {
|
|
892
|
+
a: did,
|
|
893
|
+
b: current.id,
|
|
894
|
+
view_id: current.id
|
|
895
|
+
});
|
|
896
|
+
if (updated && typeof updated === 'object') {
|
|
897
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
898
|
+
doRender();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
} catch {
|
|
902
|
+
// ignore
|
|
903
|
+
} finally {
|
|
904
|
+
pending = false;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Create a click handler for the Add button in a dependency section.
|
|
911
|
+
* @param {Dependency[]} items
|
|
912
|
+
* @param {'Dependencies'|'Dependents'} title
|
|
913
|
+
* @returns {(ev: Event) => Promise<void>}
|
|
914
|
+
*/
|
|
915
|
+
function makeDepAddClick(items, title) {
|
|
916
|
+
return async (ev) => {
|
|
917
|
+
if (!current || pending) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
/** @type {HTMLButtonElement} */
|
|
921
|
+
const btn = /** @type {any} */ (ev.currentTarget);
|
|
922
|
+
/** @type {HTMLInputElement|null} */
|
|
923
|
+
const input = /** @type {any} */ (btn.previousElementSibling);
|
|
924
|
+
const target = input ? input.value.trim() : '';
|
|
925
|
+
if (!target || target === current.id) {
|
|
926
|
+
showToast('Enter a different issue id');
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const set = new Set((items || []).map((d) => d.id));
|
|
930
|
+
if (set.has(target)) {
|
|
931
|
+
showToast('Link already exists');
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
pending = true;
|
|
935
|
+
if (btn) {
|
|
936
|
+
btn.disabled = true;
|
|
937
|
+
}
|
|
938
|
+
if (input) {
|
|
939
|
+
input.disabled = true;
|
|
940
|
+
}
|
|
941
|
+
try {
|
|
942
|
+
if (title === 'Dependencies') {
|
|
943
|
+
const updated = await sendFn('dep-add', {
|
|
944
|
+
a: current.id,
|
|
945
|
+
b: target,
|
|
946
|
+
view_id: current.id
|
|
947
|
+
});
|
|
948
|
+
if (updated && typeof updated === 'object') {
|
|
949
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
950
|
+
doRender();
|
|
951
|
+
}
|
|
952
|
+
} else {
|
|
953
|
+
const updated = await sendFn('dep-add', {
|
|
954
|
+
a: target,
|
|
955
|
+
b: current.id,
|
|
956
|
+
view_id: current.id
|
|
957
|
+
});
|
|
958
|
+
if (updated && typeof updated === 'object') {
|
|
959
|
+
current = /** @type {IssueDetail} */ (updated);
|
|
960
|
+
doRender();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
} catch {
|
|
964
|
+
showToast('Failed to add dependency');
|
|
965
|
+
} finally {
|
|
966
|
+
pending = false;
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* @param {KeyboardEvent} ev
|
|
972
|
+
*/
|
|
973
|
+
function onTitleInputKeydown(ev) {
|
|
974
|
+
if (ev.key === 'Escape') {
|
|
975
|
+
edit_title = false;
|
|
976
|
+
doRender();
|
|
977
|
+
} else if (ev.key === 'Enter') {
|
|
978
|
+
ev.preventDefault();
|
|
979
|
+
onTitleSave();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* @param {KeyboardEvent} ev
|
|
985
|
+
*/
|
|
986
|
+
function onDescEditableKeydown(ev) {
|
|
987
|
+
if (ev.key === 'Enter') {
|
|
988
|
+
onDescEdit();
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* @param {KeyboardEvent} ev
|
|
994
|
+
*/
|
|
995
|
+
function onAcceptEditableKeydown(ev) {
|
|
996
|
+
if (ev.key === 'Enter') {
|
|
997
|
+
onAcceptEdit();
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return {
|
|
1002
|
+
async load(id) {
|
|
1003
|
+
if (!id) {
|
|
1004
|
+
renderPlaceholder('No issue selected');
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
/** @type {unknown} */
|
|
1008
|
+
let result;
|
|
1009
|
+
try {
|
|
1010
|
+
result = await sendFn('show-issue', { id });
|
|
1011
|
+
} catch {
|
|
1012
|
+
result = null;
|
|
1013
|
+
}
|
|
1014
|
+
if (!result || typeof result !== 'object') {
|
|
1015
|
+
renderPlaceholder('Issue not found');
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
const issue = /** @type {IssueDetail} */ (result);
|
|
1019
|
+
// Some backends may normalize ID casing (e.g., UI-1 vs ui-1).
|
|
1020
|
+
// Treat IDs case-insensitively to avoid false negatives on deep links.
|
|
1021
|
+
if (
|
|
1022
|
+
!issue ||
|
|
1023
|
+
String(issue.id || '').toLowerCase() !== String(id || '').toLowerCase()
|
|
1024
|
+
) {
|
|
1025
|
+
renderPlaceholder('Issue not found');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
current = issue;
|
|
1029
|
+
pending = false;
|
|
1030
|
+
doRender();
|
|
1031
|
+
},
|
|
1032
|
+
clear() {
|
|
1033
|
+
renderPlaceholder('Select an issue to view details');
|
|
1034
|
+
},
|
|
1035
|
+
destroy() {
|
|
1036
|
+
mount_element.replaceChildren();
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
}
|