beads-ui 0.1.1 → 0.2.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/CHANGES.md +27 -1
- package/README.md +39 -45
- package/app/data/providers.js +57 -26
- package/app/index.html +8 -0
- package/app/main.js +179 -33
- package/app/protocol.md +3 -4
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +170 -6
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/toast.js +35 -0
- package/app/views/board.js +347 -17
- package/app/views/detail.js +292 -92
- package/app/views/epics.js +2 -2
- package/app/views/issue-dialog.js +170 -0
- package/app/views/issue-row.js +9 -8
- package/app/views/list.js +85 -11
- package/app/views/new-issue-dialog.js +352 -0
- package/app/ws.js +30 -0
- package/docs/architecture.md +1 -1
- package/package.json +17 -1
- package/server/cli/commands.js +11 -3
- package/server/cli/index.js +35 -4
- package/server/cli/usage.js +1 -1
- package/server/watcher.js +3 -3
- package/server/ws.js +39 -19
- package/.beads/issues.jsonl +0 -107
- package/.editorconfig +0 -10
- package/.eslintrc.json +0 -36
- package/.github/workflows/ci.yml +0 -38
- package/.prettierignore +0 -5
- package/AGENTS.md +0 -85
- package/app/data/providers.test.js +0 -126
- package/app/main.board-switch.test.js +0 -94
- package/app/main.deep-link.test.js +0 -64
- package/app/main.live-updates.test.js +0 -229
- package/app/main.test.js +0 -17
- package/app/main.theme.test.js +0 -41
- package/app/main.view-sync.test.js +0 -54
- package/app/protocol.test.js +0 -57
- package/app/router.test.js +0 -34
- package/app/state.test.js +0 -21
- package/app/utils/markdown.test.js +0 -103
- package/app/utils/type-badge.test.js +0 -30
- package/app/views/board.test.js +0 -184
- package/app/views/detail.acceptance-notes.test.js +0 -67
- package/app/views/detail.assignee.test.js +0 -161
- package/app/views/detail.deps.test.js +0 -97
- package/app/views/detail.edits.test.js +0 -146
- package/app/views/detail.labels.test.js +0 -73
- package/app/views/detail.priority.test.js +0 -86
- package/app/views/detail.test.js +0 -188
- package/app/views/detail.ui47.test.js +0 -78
- package/app/views/epics.test.js +0 -283
- package/app/views/list.inline-edits.test.js +0 -84
- package/app/views/list.test.js +0 -479
- package/app/views/nav.test.js +0 -43
- package/app/ws.test.js +0 -168
- package/docs/quickstart.md +0 -142
- package/eslint.config.js +0 -59
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/prettier.config.js +0 -13
- package/server/app.test.js +0 -29
- package/server/bd.test.js +0 -93
- package/server/cli/cli.test.js +0 -109
- package/server/cli/commands.integration.test.js +0 -155
- package/server/cli/commands.unit.test.js +0 -94
- package/server/cli/open.test.js +0 -26
- package/server/db.test.js +0 -70
- package/server/protocol.test.js +0 -87
- package/server/watcher.test.js +0 -100
- package/server/ws.handlers.test.js +0 -174
- package/server/ws.labels.test.js +0 -95
- package/server/ws.mutations.test.js +0 -261
- package/server/ws.subscriptions.test.js +0 -116
- package/server/ws.test.js +0 -52
- package/test/setup-vitest.js +0 -12
- package/tsconfig.json +0 -23
- package/vitest.config.mjs +0 -14
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { ISSUE_TYPES, typeLabel } from '../utils/issue-type.js';
|
|
2
|
+
import { priority_levels } from '../utils/priority.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create and manage the New Issue dialog (native <dialog>).
|
|
6
|
+
* @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
|
|
7
|
+
* @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
|
|
8
|
+
* @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
|
|
9
|
+
* @param {{ setState: (patch: any) => void, getState: () => any }} [store]
|
|
10
|
+
* @returns {{ open: () => void, close: () => void }}
|
|
11
|
+
*/
|
|
12
|
+
export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
13
|
+
/** @type {HTMLDialogElement} */
|
|
14
|
+
const dialog = /** @type {any} */ (document.createElement('dialog'));
|
|
15
|
+
dialog.id = 'new-issue-dialog';
|
|
16
|
+
dialog.setAttribute('role', 'dialog');
|
|
17
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
18
|
+
|
|
19
|
+
dialog.innerHTML = `
|
|
20
|
+
<div class="new-issue__container" part="container">
|
|
21
|
+
<header class="new-issue__header">
|
|
22
|
+
<div class="new-issue__title">New Issue</div>
|
|
23
|
+
<button type="button" class="new-issue__close" aria-label="Close">×</button>
|
|
24
|
+
</header>
|
|
25
|
+
<div class="new-issue__body">
|
|
26
|
+
<form id="new-issue-form" class="new-issue__form">
|
|
27
|
+
<label for="new-title">Title</label>
|
|
28
|
+
<input id="new-title" name="title" type="text" required placeholder="Short summary" />
|
|
29
|
+
|
|
30
|
+
<label for="new-type">Type</label>
|
|
31
|
+
<select id="new-type" name="type" aria-label="Issue type"></select>
|
|
32
|
+
|
|
33
|
+
<label for="new-priority">Priority</label>
|
|
34
|
+
<select id="new-priority" name="priority" aria-label="Priority"></select>
|
|
35
|
+
|
|
36
|
+
<label for="new-labels">Labels</label>
|
|
37
|
+
<input id="new-labels" name="labels" type="text" placeholder="comma,separated" />
|
|
38
|
+
|
|
39
|
+
<label for="new-description">Description</label>
|
|
40
|
+
<textarea id="new-description" name="description" rows="6" placeholder="Optional markdown description"></textarea>
|
|
41
|
+
|
|
42
|
+
<div aria-live="polite" role="status" class="new-issue__error" id="new-issue-error"></div>
|
|
43
|
+
|
|
44
|
+
<div class="new-issue__actions" style="grid-column: 1 / -1">
|
|
45
|
+
<button type="button" id="btn-cancel">Cancel (Esc)</button>
|
|
46
|
+
<button type="submit" id="btn-create">Create</button>
|
|
47
|
+
</div>
|
|
48
|
+
</form>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
mount_element.appendChild(dialog);
|
|
54
|
+
|
|
55
|
+
/** @type {HTMLFormElement} */
|
|
56
|
+
const form = /** @type {any} */ (dialog.querySelector('#new-issue-form'));
|
|
57
|
+
/** @type {HTMLInputElement} */
|
|
58
|
+
const input_title = /** @type {any} */ (dialog.querySelector('#new-title'));
|
|
59
|
+
/** @type {HTMLSelectElement} */
|
|
60
|
+
const sel_type = /** @type {any} */ (dialog.querySelector('#new-type'));
|
|
61
|
+
/** @type {HTMLSelectElement} */
|
|
62
|
+
const sel_priority = /** @type {any} */ (
|
|
63
|
+
dialog.querySelector('#new-priority')
|
|
64
|
+
);
|
|
65
|
+
/** @type {HTMLInputElement} */
|
|
66
|
+
const input_labels = /** @type {any} */ (dialog.querySelector('#new-labels'));
|
|
67
|
+
/** @type {HTMLTextAreaElement} */
|
|
68
|
+
const input_description = /** @type {any} */ (
|
|
69
|
+
dialog.querySelector('#new-description')
|
|
70
|
+
);
|
|
71
|
+
/** @type {HTMLDivElement} */
|
|
72
|
+
const error_box = /** @type {any} */ (
|
|
73
|
+
dialog.querySelector('#new-issue-error')
|
|
74
|
+
);
|
|
75
|
+
/** @type {HTMLButtonElement} */
|
|
76
|
+
const btn_cancel = /** @type {any} */ (dialog.querySelector('#btn-cancel'));
|
|
77
|
+
/** @type {HTMLButtonElement} */
|
|
78
|
+
const btn_create = /** @type {any} */ (dialog.querySelector('#btn-create'));
|
|
79
|
+
/** @type {HTMLButtonElement} */
|
|
80
|
+
const btn_close = /** @type {any} */ (
|
|
81
|
+
dialog.querySelector('.new-issue__close')
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Populate selects
|
|
85
|
+
function populateSelects() {
|
|
86
|
+
sel_type.replaceChildren();
|
|
87
|
+
// Empty option to allow leaving type unspecified
|
|
88
|
+
const optEmpty = document.createElement('option');
|
|
89
|
+
optEmpty.value = '';
|
|
90
|
+
optEmpty.textContent = '— Select —';
|
|
91
|
+
sel_type.appendChild(optEmpty);
|
|
92
|
+
for (const t of ISSUE_TYPES) {
|
|
93
|
+
const o = document.createElement('option');
|
|
94
|
+
o.value = t;
|
|
95
|
+
o.textContent = typeLabel(t);
|
|
96
|
+
sel_type.appendChild(o);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
sel_priority.replaceChildren();
|
|
100
|
+
for (let i = 0; i <= 4; i += 1) {
|
|
101
|
+
const o = document.createElement('option');
|
|
102
|
+
o.value = String(i);
|
|
103
|
+
const label = priority_levels[i] || 'Medium';
|
|
104
|
+
o.textContent = `${i} – ${label}`;
|
|
105
|
+
sel_priority.appendChild(o);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
populateSelects();
|
|
109
|
+
|
|
110
|
+
function requestClose() {
|
|
111
|
+
try {
|
|
112
|
+
if (typeof dialog.close === 'function') {
|
|
113
|
+
dialog.close();
|
|
114
|
+
} else {
|
|
115
|
+
dialog.removeAttribute('open');
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
dialog.removeAttribute('open');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {boolean} is_busy
|
|
124
|
+
*/
|
|
125
|
+
function setBusy(is_busy) {
|
|
126
|
+
input_title.disabled = is_busy;
|
|
127
|
+
sel_type.disabled = is_busy;
|
|
128
|
+
sel_priority.disabled = is_busy;
|
|
129
|
+
input_labels.disabled = is_busy;
|
|
130
|
+
input_description.disabled = is_busy;
|
|
131
|
+
btn_cancel.disabled = is_busy;
|
|
132
|
+
btn_create.disabled = is_busy;
|
|
133
|
+
btn_create.textContent = is_busy ? 'Creating…' : 'Create';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clearError() {
|
|
137
|
+
error_box.textContent = '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} msg
|
|
142
|
+
*/
|
|
143
|
+
function setError(msg) {
|
|
144
|
+
error_box.textContent = msg;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function loadDefaults() {
|
|
148
|
+
try {
|
|
149
|
+
const t = window.localStorage.getItem('beads-ui.new.type');
|
|
150
|
+
if (t) {
|
|
151
|
+
sel_type.value = t;
|
|
152
|
+
} else {
|
|
153
|
+
sel_type.value = '';
|
|
154
|
+
}
|
|
155
|
+
const p = window.localStorage.getItem('beads-ui.new.priority');
|
|
156
|
+
if (p && /^\d$/.test(p)) {
|
|
157
|
+
sel_priority.value = p;
|
|
158
|
+
} else {
|
|
159
|
+
sel_priority.value = '2';
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
sel_type.value = '';
|
|
163
|
+
sel_priority.value = '2';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function saveDefaults() {
|
|
168
|
+
try {
|
|
169
|
+
const t = sel_type.value || '';
|
|
170
|
+
const p = sel_priority.value || '';
|
|
171
|
+
if (t.length > 0) {
|
|
172
|
+
window.localStorage.setItem('beads-ui.new.type', t);
|
|
173
|
+
}
|
|
174
|
+
if (p.length > 0) {
|
|
175
|
+
window.localStorage.setItem('beads-ui.new.priority', p);
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// ignore persistence errors
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract numeric suffix from an id like "UI-123"; return -1 when absent.
|
|
184
|
+
* @param {string} id
|
|
185
|
+
*/
|
|
186
|
+
function idNumeric(id) {
|
|
187
|
+
const m = /-(\d+)$/.exec(String(id || ''));
|
|
188
|
+
return m && m[1] ? Number(m[1]) : -1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Submit handler: validate, create, then open the created issue details.
|
|
193
|
+
* @returns {Promise<void>}
|
|
194
|
+
*/
|
|
195
|
+
async function createNow() {
|
|
196
|
+
clearError();
|
|
197
|
+
/** @type {string} */
|
|
198
|
+
const title = String(input_title.value || '').trim();
|
|
199
|
+
if (title.length === 0) {
|
|
200
|
+
setError('Title is required');
|
|
201
|
+
input_title.focus();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
/** @type {number} */
|
|
205
|
+
const prio = Number(sel_priority.value || '2');
|
|
206
|
+
if (!(prio >= 0 && prio <= 4)) {
|
|
207
|
+
setError('Priority must be 0..4');
|
|
208
|
+
sel_priority.focus();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
/** @type {string} */
|
|
212
|
+
const type = String(sel_type.value || '');
|
|
213
|
+
/** @type {string} */
|
|
214
|
+
const desc = String(input_description.value || '');
|
|
215
|
+
/** @type {string[]} */
|
|
216
|
+
const labels = String(input_labels.value || '')
|
|
217
|
+
.split(',')
|
|
218
|
+
.map((s) => s.trim())
|
|
219
|
+
.filter((s) => s.length > 0);
|
|
220
|
+
|
|
221
|
+
/** @type {{ title: string, type?: string, priority?: number, description?: string }} */
|
|
222
|
+
const payload = { title };
|
|
223
|
+
if (type.length > 0) {
|
|
224
|
+
payload.type = type;
|
|
225
|
+
}
|
|
226
|
+
if (String(prio).length > 0) {
|
|
227
|
+
payload.priority = prio;
|
|
228
|
+
}
|
|
229
|
+
if (desc.length > 0) {
|
|
230
|
+
payload.description = desc;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setBusy(true);
|
|
234
|
+
try {
|
|
235
|
+
await sendFn('create-issue', payload);
|
|
236
|
+
} catch {
|
|
237
|
+
setBusy(false);
|
|
238
|
+
setError('Failed to create issue');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
saveDefaults();
|
|
243
|
+
|
|
244
|
+
// Best-effort: find the created id by matching title among open issues and picking the highest numeric id
|
|
245
|
+
/** @type {any} */
|
|
246
|
+
let list = null;
|
|
247
|
+
try {
|
|
248
|
+
list = await sendFn('list-issues', {
|
|
249
|
+
filters: { status: 'open', limit: 50 }
|
|
250
|
+
});
|
|
251
|
+
} catch {
|
|
252
|
+
list = null;
|
|
253
|
+
}
|
|
254
|
+
/** @type {string} */
|
|
255
|
+
let created_id = '';
|
|
256
|
+
if (Array.isArray(list)) {
|
|
257
|
+
/** @type {any[]} */
|
|
258
|
+
const matches = list.filter((it) => String(it.title || '') === title);
|
|
259
|
+
if (matches.length > 0) {
|
|
260
|
+
/** @type {any} */
|
|
261
|
+
let best = matches[0];
|
|
262
|
+
for (const it of matches) {
|
|
263
|
+
const ai = idNumeric(best.id || '');
|
|
264
|
+
const bi = idNumeric(it.id || '');
|
|
265
|
+
if (bi > ai) {
|
|
266
|
+
best = it;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
created_id = String(best.id || '');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Apply labels if any
|
|
274
|
+
if (created_id && labels.length > 0) {
|
|
275
|
+
for (const label of labels) {
|
|
276
|
+
try {
|
|
277
|
+
await sendFn('label-add', { id: created_id, label });
|
|
278
|
+
} catch {
|
|
279
|
+
// ignore label failures
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Navigate to created issue if found
|
|
285
|
+
if (created_id) {
|
|
286
|
+
try {
|
|
287
|
+
router.gotoIssue(created_id);
|
|
288
|
+
} catch {
|
|
289
|
+
// ignore routing errors
|
|
290
|
+
}
|
|
291
|
+
// Also set state directly to ensure dialog opens even if hash routing is suppressed in tests
|
|
292
|
+
try {
|
|
293
|
+
if (store) {
|
|
294
|
+
store.setState({ selected_id: created_id });
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// ignore
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
setBusy(false);
|
|
302
|
+
requestClose();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Events
|
|
306
|
+
dialog.addEventListener('cancel', (ev) => {
|
|
307
|
+
ev.preventDefault();
|
|
308
|
+
requestClose();
|
|
309
|
+
});
|
|
310
|
+
btn_close.addEventListener('click', () => requestClose());
|
|
311
|
+
btn_cancel.addEventListener('click', () => requestClose());
|
|
312
|
+
dialog.addEventListener('keydown', (ev) => {
|
|
313
|
+
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
|
|
314
|
+
ev.preventDefault();
|
|
315
|
+
void createNow();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
form.addEventListener('submit', (ev) => {
|
|
319
|
+
ev.preventDefault();
|
|
320
|
+
void createNow();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
open() {
|
|
325
|
+
form.reset();
|
|
326
|
+
clearError();
|
|
327
|
+
loadDefaults();
|
|
328
|
+
try {
|
|
329
|
+
if (
|
|
330
|
+
'showModal' in dialog &&
|
|
331
|
+
typeof (/** @type {any} */ (dialog).showModal) === 'function'
|
|
332
|
+
) {
|
|
333
|
+
/** @type {any} */ (dialog).showModal();
|
|
334
|
+
} else {
|
|
335
|
+
dialog.setAttribute('open', '');
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
dialog.setAttribute('open', '');
|
|
339
|
+
}
|
|
340
|
+
setTimeout(() => {
|
|
341
|
+
try {
|
|
342
|
+
input_title.focus();
|
|
343
|
+
} catch {
|
|
344
|
+
// ignore
|
|
345
|
+
}
|
|
346
|
+
}, 0);
|
|
347
|
+
},
|
|
348
|
+
close() {
|
|
349
|
+
requestClose();
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
package/app/ws.js
CHANGED
|
@@ -73,12 +73,28 @@ export function createWsClient(options = {}) {
|
|
|
73
73
|
const queue = [];
|
|
74
74
|
/** @type {Map<string, Set<(payload: any) => void>>} */
|
|
75
75
|
const handlers = new Map();
|
|
76
|
+
/** @type {Set<(s: ConnectionState) => void>} */
|
|
77
|
+
const connection_handlers = new Set();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {ConnectionState} s
|
|
81
|
+
*/
|
|
82
|
+
function notifyConnection(s) {
|
|
83
|
+
for (const fn of Array.from(connection_handlers)) {
|
|
84
|
+
try {
|
|
85
|
+
fn(s);
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore listener errors
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
76
91
|
|
|
77
92
|
function scheduleReconnect() {
|
|
78
93
|
if (!should_reconnect || reconnect_timer) {
|
|
79
94
|
return;
|
|
80
95
|
}
|
|
81
96
|
state = 'reconnecting';
|
|
97
|
+
notifyConnection(state);
|
|
82
98
|
const base = Math.min(
|
|
83
99
|
backoff.maxMs || 0,
|
|
84
100
|
(backoff.initialMs || 0) * Math.pow(backoff.factor || 1, attempts)
|
|
@@ -105,6 +121,7 @@ export function createWsClient(options = {}) {
|
|
|
105
121
|
|
|
106
122
|
function onOpen() {
|
|
107
123
|
state = 'open';
|
|
124
|
+
notifyConnection(state);
|
|
108
125
|
attempts = 0;
|
|
109
126
|
// subscribe first
|
|
110
127
|
sendRaw(makeRequest('subscribe-updates', {}));
|
|
@@ -160,6 +177,7 @@ export function createWsClient(options = {}) {
|
|
|
160
177
|
|
|
161
178
|
function onClose() {
|
|
162
179
|
state = 'closed';
|
|
180
|
+
notifyConnection(state);
|
|
163
181
|
// fail all pending
|
|
164
182
|
for (const [id, p] of pending.entries()) {
|
|
165
183
|
p.reject(new Error('ws disconnected'));
|
|
@@ -177,6 +195,7 @@ export function createWsClient(options = {}) {
|
|
|
177
195
|
try {
|
|
178
196
|
ws = /** @type {any} */ (new WebSocket(url));
|
|
179
197
|
state = 'connecting';
|
|
198
|
+
notifyConnection(state);
|
|
180
199
|
const s = /** @type {any} */ (ws);
|
|
181
200
|
s.addEventListener('open', onOpen);
|
|
182
201
|
s.addEventListener('message', onMessage);
|
|
@@ -231,6 +250,17 @@ export function createWsClient(options = {}) {
|
|
|
231
250
|
set?.delete(handler);
|
|
232
251
|
};
|
|
233
252
|
},
|
|
253
|
+
/**
|
|
254
|
+
* Subscribe to connection state changes.
|
|
255
|
+
* @param {(state: ConnectionState) => void} handler
|
|
256
|
+
* @returns {() => void}
|
|
257
|
+
*/
|
|
258
|
+
onConnection(handler) {
|
|
259
|
+
connection_handlers.add(handler);
|
|
260
|
+
return () => {
|
|
261
|
+
connection_handlers.delete(handler);
|
|
262
|
+
};
|
|
263
|
+
},
|
|
234
264
|
/** Close and stop reconnecting. */
|
|
235
265
|
close() {
|
|
236
266
|
should_reconnect = false;
|
package/docs/architecture.md
CHANGED
|
@@ -147,7 +147,7 @@ Error reply
|
|
|
147
147
|
- Update status: `bd update <id> --status <open|in_progress|closed>`
|
|
148
148
|
- Update priority: `bd update <id> --priority <0..4>`
|
|
149
149
|
- Edit title: `bd update <id> --title <text>`
|
|
150
|
-
- Edit description:
|
|
150
|
+
- Edit description: `bd update <id> --description <text>`
|
|
151
151
|
- Edit acceptance: `bd update <id> --acceptance-criteria <text>`
|
|
152
152
|
- Link dependency: `bd dep add <a> <b>` (a depends on b)
|
|
153
153
|
- Unlink dependency: `bd dep remove <a> <b>`
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beads-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local‑first UI for Beads — a fast issue tracker for your coding agent.",
|
|
5
|
+
"homepage": "https://github.com/mantoni/beads-ui",
|
|
4
6
|
"type": "module",
|
|
5
7
|
"bin": {
|
|
6
8
|
"bdui": "bin/bdui.js"
|
|
@@ -44,5 +46,19 @@
|
|
|
44
46
|
"express": "^5.1.0",
|
|
45
47
|
"lit-html": "^3.3.1",
|
|
46
48
|
"ws": "^8.18.3"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"app",
|
|
52
|
+
"bin",
|
|
53
|
+
"docs",
|
|
54
|
+
"server",
|
|
55
|
+
"CHANGES.md",
|
|
56
|
+
"LICENSE",
|
|
57
|
+
"README.md",
|
|
58
|
+
"!**/*.test.js"
|
|
59
|
+
],
|
|
60
|
+
"repository": {
|
|
61
|
+
"type": "git",
|
|
62
|
+
"url": "https://github.com/mantoni/beads-ui.git"
|
|
47
63
|
}
|
|
48
64
|
}
|
package/server/cli/commands.js
CHANGED
|
@@ -19,7 +19,8 @@ import { openUrl, waitForServer } from './open.js';
|
|
|
19
19
|
* @param {{ no_open?: boolean }} [options]
|
|
20
20
|
*/
|
|
21
21
|
export async function handleStart(options) {
|
|
22
|
-
|
|
22
|
+
// Default behavior: do not open a browser unless explicitly requested.
|
|
23
|
+
const no_open = options?.no_open !== false;
|
|
23
24
|
const existing_pid = readPidFile();
|
|
24
25
|
if (existing_pid && isProcessRunning(existing_pid)) {
|
|
25
26
|
printServerUrl();
|
|
@@ -80,12 +81,19 @@ export async function handleStop() {
|
|
|
80
81
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
81
82
|
* @returns {Promise<number>} Exit code (0 on success)
|
|
82
83
|
*/
|
|
83
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Handle `restart` command: stop (ignore not-running) then start.
|
|
86
|
+
* Accepts the same options as `handleStart` and passes them through,
|
|
87
|
+
* so restart only opens a browser when `no_open` is explicitly false.
|
|
88
|
+
* @param {{ no_open?: boolean }} [options]
|
|
89
|
+
* @returns {Promise<number>}
|
|
90
|
+
*/
|
|
91
|
+
export async function handleRestart(options) {
|
|
84
92
|
const stop_code = await handleStop();
|
|
85
93
|
// 0 = stopped, 2 = not running; both are acceptable to proceed
|
|
86
94
|
if (stop_code !== 0 && stop_code !== 2) {
|
|
87
95
|
return 1;
|
|
88
96
|
}
|
|
89
|
-
const start_code = await handleStart();
|
|
97
|
+
const start_code = await handleStart(options);
|
|
90
98
|
return start_code === 0 ? 0 : 1;
|
|
91
99
|
}
|
package/server/cli/index.js
CHANGED
|
@@ -17,6 +17,10 @@ export function parseArgs(args) {
|
|
|
17
17
|
flags.push('help');
|
|
18
18
|
continue;
|
|
19
19
|
}
|
|
20
|
+
if (token === '--open') {
|
|
21
|
+
flags.push('open');
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
20
24
|
if (token === '--no-open') {
|
|
21
25
|
flags.push('no-open');
|
|
22
26
|
continue;
|
|
@@ -53,19 +57,46 @@ export async function main(args) {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
if (command === 'start') {
|
|
60
|
+
/**
|
|
61
|
+
* Default behavior: do NOT open a browser.
|
|
62
|
+
* `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
|
|
63
|
+
*/
|
|
56
64
|
/** @type {{ no_open: boolean }} */
|
|
57
65
|
const options = {
|
|
58
|
-
no_open:
|
|
59
|
-
flags.includes('no-open') ||
|
|
60
|
-
String(process.env.BDUI_NO_OPEN || '') === '1'
|
|
66
|
+
no_open: true
|
|
61
67
|
};
|
|
68
|
+
|
|
69
|
+
const has_open = flags.includes('open');
|
|
70
|
+
const has_no_open = flags.includes('no-open');
|
|
71
|
+
const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
|
|
72
|
+
|
|
73
|
+
if (has_open) {
|
|
74
|
+
options.no_open = false;
|
|
75
|
+
} else if (has_no_open) {
|
|
76
|
+
options.no_open = true;
|
|
77
|
+
} else if (env_no_open) {
|
|
78
|
+
options.no_open = true;
|
|
79
|
+
}
|
|
62
80
|
return await handleStart(options);
|
|
63
81
|
}
|
|
64
82
|
if (command === 'stop') {
|
|
65
83
|
return await handleStop();
|
|
66
84
|
}
|
|
67
85
|
if (command === 'restart') {
|
|
68
|
-
|
|
86
|
+
/** @type {{ no_open: boolean }} */
|
|
87
|
+
const options = { no_open: true };
|
|
88
|
+
const has_open = flags.includes('open');
|
|
89
|
+
const has_no_open = flags.includes('no-open');
|
|
90
|
+
const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
|
|
91
|
+
|
|
92
|
+
if (has_open) {
|
|
93
|
+
options.no_open = false;
|
|
94
|
+
} else if (has_no_open) {
|
|
95
|
+
options.no_open = true;
|
|
96
|
+
} else if (env_no_open) {
|
|
97
|
+
options.no_open = true;
|
|
98
|
+
}
|
|
99
|
+
return await handleRestart(options);
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
// Unknown command path (should not happen due to parseArgs guard)
|
package/server/cli/usage.js
CHANGED
package/server/watcher.js
CHANGED
|
@@ -6,11 +6,11 @@ import { resolveDbPath } from './db.js';
|
|
|
6
6
|
* Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
|
|
7
7
|
* The DB path is resolved following beads precedence and can be overridden via options.
|
|
8
8
|
* @param {string} root_dir - Project root directory (starting point for resolution).
|
|
9
|
-
* @param {(payload: { ts: number }) => void}
|
|
9
|
+
* @param {(payload: { ts: number }) => void} onChange - Called when changes are detected.
|
|
10
10
|
* @param {{ debounce_ms?: number, explicit_db?: string }} [options]
|
|
11
11
|
* @returns {{ close: () => void, rebind: (opts?: { root_dir?: string, explicit_db?: string }) => void, path: string }}
|
|
12
12
|
*/
|
|
13
|
-
export function watchDb(root_dir,
|
|
13
|
+
export function watchDb(root_dir, onChange, options = {}) {
|
|
14
14
|
const debounce_ms = options.debounce_ms ?? 250;
|
|
15
15
|
|
|
16
16
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
@@ -29,7 +29,7 @@ export function watchDb(root_dir, on_change, options = {}) {
|
|
|
29
29
|
clearTimeout(timer);
|
|
30
30
|
}
|
|
31
31
|
timer = setTimeout(() => {
|
|
32
|
-
|
|
32
|
+
onChange({ ts: Date.now() });
|
|
33
33
|
}, debounce_ms);
|
|
34
34
|
timer.unref?.();
|
|
35
35
|
};
|
package/server/ws.js
CHANGED
|
@@ -10,7 +10,7 @@ import { isRequest, makeError, makeOk } from './protocol.js';
|
|
|
10
10
|
/**
|
|
11
11
|
* @typedef {{
|
|
12
12
|
* subscribed: boolean,
|
|
13
|
-
* list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, limit?: number },
|
|
13
|
+
* list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, blocked?: boolean, limit?: number },
|
|
14
14
|
* show_id?: string | null
|
|
15
15
|
* }} ConnectionSubs
|
|
16
16
|
*/
|
|
@@ -74,8 +74,8 @@ export function notifyIssuesChanged(payload, options = {}) {
|
|
|
74
74
|
continue;
|
|
75
75
|
}
|
|
76
76
|
if (s.list_filters) {
|
|
77
|
-
// Ready lists are conservatively invalidated on any change
|
|
78
|
-
if (s.list_filters.ready === true) {
|
|
77
|
+
// Ready/Blocked lists are conservatively invalidated on any change
|
|
78
|
+
if (s.list_filters.ready === true || s.list_filters.blocked === true) {
|
|
79
79
|
recipients.add(ws);
|
|
80
80
|
continue;
|
|
81
81
|
}
|
|
@@ -268,6 +268,25 @@ export async function handleMessage(ws, data) {
|
|
|
268
268
|
return;
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
// When "blocked" is requested, use the dedicated bd subcommand
|
|
272
|
+
if (filters && typeof filters === 'object' && filters.blocked === true) {
|
|
273
|
+
const res = await runBdJson(['blocked', '--json']);
|
|
274
|
+
if (res.code !== 0) {
|
|
275
|
+
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
276
|
+
ws.send(JSON.stringify(err));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Remember subscription scope for this connection
|
|
280
|
+
try {
|
|
281
|
+
const s = getSubs(ws);
|
|
282
|
+
s.list_filters = { blocked: true };
|
|
283
|
+
} catch {
|
|
284
|
+
// ignore tracking errors
|
|
285
|
+
}
|
|
286
|
+
ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
271
290
|
/** @type {string[]} */
|
|
272
291
|
const args = ['list', '--json'];
|
|
273
292
|
if (filters && typeof filters === 'object') {
|
|
@@ -498,7 +517,9 @@ export async function handleMessage(ws, data) {
|
|
|
498
517
|
id.length === 0 ||
|
|
499
518
|
(field !== 'title' &&
|
|
500
519
|
field !== 'description' &&
|
|
501
|
-
field !== 'acceptance'
|
|
520
|
+
field !== 'acceptance' &&
|
|
521
|
+
field !== 'notes' &&
|
|
522
|
+
field !== 'design') ||
|
|
502
523
|
typeof value !== 'string'
|
|
503
524
|
) {
|
|
504
525
|
ws.send(
|
|
@@ -506,20 +527,7 @@ export async function handleMessage(ws, data) {
|
|
|
506
527
|
makeError(
|
|
507
528
|
req,
|
|
508
529
|
'bad_request',
|
|
509
|
-
"payload requires { id: string, field: 'title'|'description'|'acceptance', value: string }"
|
|
510
|
-
)
|
|
511
|
-
)
|
|
512
|
-
);
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
// Description updates are currently not supported by bd
|
|
516
|
-
if (field === 'description') {
|
|
517
|
-
ws.send(
|
|
518
|
-
JSON.stringify(
|
|
519
|
-
makeError(
|
|
520
|
-
req,
|
|
521
|
-
'bd_error',
|
|
522
|
-
'editing description is not supported by bd'
|
|
530
|
+
"payload requires { id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }"
|
|
523
531
|
)
|
|
524
532
|
)
|
|
525
533
|
);
|
|
@@ -527,8 +535,20 @@ export async function handleMessage(ws, data) {
|
|
|
527
535
|
}
|
|
528
536
|
// Map UI fields to bd CLI flags
|
|
529
537
|
// title → --title
|
|
538
|
+
// description → --description
|
|
530
539
|
// acceptance → --acceptance-criteria
|
|
531
|
-
|
|
540
|
+
// notes → --notes
|
|
541
|
+
// design → --design
|
|
542
|
+
const flag =
|
|
543
|
+
field === 'title'
|
|
544
|
+
? '--title'
|
|
545
|
+
: field === 'description'
|
|
546
|
+
? '--description'
|
|
547
|
+
: field === 'acceptance'
|
|
548
|
+
? '--acceptance-criteria'
|
|
549
|
+
: field === 'notes'
|
|
550
|
+
? '--notes'
|
|
551
|
+
: '--design';
|
|
532
552
|
const res = await runBd(['update', id, flag, value]);
|
|
533
553
|
if (res.code !== 0) {
|
|
534
554
|
ws.send(
|