beads-ui 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGES.md +9 -0
- package/app/data/list-selectors.js +5 -0
- package/app/data/providers.js +2 -0
- package/app/data/sort.js +2 -0
- package/app/data/subscription-issue-store.js +2 -2
- package/app/data/subscription-issue-stores.js +31 -5
- package/app/data/subscriptions-store.js +9 -1
- package/app/main.js +4 -0
- package/app/protocol.js +13 -3
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +4 -10
- package/app/utils/priority-badge.js +1 -0
- package/app/utils/status-badge.js +1 -0
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -0
- package/app/utils/type-badge.js +1 -0
- package/app/views/board.js +8 -6
- package/app/views/detail.js +3 -0
- package/app/views/epics.js +6 -5
- package/app/views/issue-dialog.js +1 -0
- package/app/views/issue-row.js +1 -0
- package/app/views/list.js +4 -0
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +3 -0
- package/app/ws.js +4 -1
- package/package.json +2 -3
- package/server/app.js +2 -0
- package/server/bd.js +4 -0
- package/server/cli/commands.js +4 -0
- package/server/cli/daemon.js +7 -0
- package/server/cli/index.js +2 -0
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +1 -0
- package/server/config.js +3 -2
- package/server/db.js +3 -0
- package/server/list-adapters.js +9 -3
- package/server/subscriptions.js +12 -0
- package/server/validators.js +2 -0
- package/server/watcher.js +3 -0
- package/server/ws.js +9 -0
package/CHANGES.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- [`3912ae5`](https://github.com/mantoni/beads-ui/commit/3912ae552b1cc97e61fbaaa0815ca77675c542e4)
|
|
6
|
+
Status filter intermittently not applied on Issues screen
|
|
7
|
+
- [`a160484`](https://github.com/mantoni/beads-ui/commit/a16048479d1d7d61ed4ad4e53365a5736eb053af)
|
|
8
|
+
Upgrade eslint-plugin-jsdoc and switch config
|
|
9
|
+
|
|
10
|
+
_Released by [Maximilian Antoni](https://github.com/mantoni) on 2025-10-27._
|
|
11
|
+
|
|
3
12
|
## 0.3.0
|
|
4
13
|
|
|
5
14
|
- 🍏 Rewrite data-exchange layer to push-only updates via WebSocket.
|
|
@@ -13,6 +13,7 @@ import { cmpClosedDesc, cmpPriorityThenCreated } from './sort.js';
|
|
|
13
13
|
*
|
|
14
14
|
* Source of truth is per-subscription stores providing snapshots for a given
|
|
15
15
|
* client id. Central issues store fallback has been removed.
|
|
16
|
+
*
|
|
16
17
|
* @param {{ snapshotFor?: (client_id: string) => IssueLite[], subscribe?: (fn: () => void) => () => void }} [issue_stores]
|
|
17
18
|
*/
|
|
18
19
|
export function createListSelectors(issue_stores = undefined) {
|
|
@@ -20,6 +21,7 @@ export function createListSelectors(issue_stores = undefined) {
|
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Get entities for a subscription id with Issues List sort (priority asc → created asc).
|
|
24
|
+
*
|
|
23
25
|
* @param {string} client_id
|
|
24
26
|
* @returns {IssueLite[]}
|
|
25
27
|
*/
|
|
@@ -35,6 +37,7 @@ export function createListSelectors(issue_stores = undefined) {
|
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Get entities for a Board column with column-specific sort.
|
|
40
|
+
*
|
|
38
41
|
* @param {string} client_id
|
|
39
42
|
* @param {'ready'|'blocked'|'in_progress'|'closed'} mode
|
|
40
43
|
* @returns {IssueLite[]}
|
|
@@ -58,6 +61,7 @@ export function createListSelectors(issue_stores = undefined) {
|
|
|
58
61
|
/**
|
|
59
62
|
* Get children for an epic subscribed as client id `epic:${id}`.
|
|
60
63
|
* Sorted as Issues List (priority asc → created asc).
|
|
64
|
+
*
|
|
61
65
|
* @param {string} epic_id
|
|
62
66
|
* @returns {IssueLite[]}
|
|
63
67
|
*/
|
|
@@ -79,6 +83,7 @@ export function createListSelectors(issue_stores = undefined) {
|
|
|
79
83
|
|
|
80
84
|
/**
|
|
81
85
|
* Subscribe for re-render; triggers once per issues envelope.
|
|
86
|
+
*
|
|
82
87
|
* @param {() => void} fn
|
|
83
88
|
* @returns {() => void}
|
|
84
89
|
*/
|
package/app/data/providers.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Data layer: typed wrappers around the ws transport for mutations and
|
|
6
6
|
* single-issue fetch. List reads have been removed in favor of push-only
|
|
7
7
|
* stores and selectors (see docs/adr/001-push-only-lists.md).
|
|
8
|
+
*
|
|
8
9
|
* @param {(type: MessageType, payload?: unknown) => Promise<unknown>} transport - Request/response function.
|
|
9
10
|
* @returns {{ updateIssue: (input: { id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }) => Promise<unknown> }}
|
|
10
11
|
*/
|
|
@@ -13,6 +14,7 @@ export function createDataLayer(transport) {
|
|
|
13
14
|
* Update issue fields by dispatching specific mutations.
|
|
14
15
|
* Supported fields: title, acceptance, notes, design, status, priority, assignee.
|
|
15
16
|
* Returns the updated issue on success.
|
|
17
|
+
*
|
|
16
18
|
* @param {{ id: string, title?: string, acceptance?: string, notes?: string, design?: string, status?: 'open'|'in_progress'|'closed', priority?: number, assignee?: string }} input
|
|
17
19
|
* @returns {Promise<unknown>}
|
|
18
20
|
*/
|
package/app/data/sort.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Compare by priority asc, then created_at asc, then id asc.
|
|
12
|
+
*
|
|
12
13
|
* @param {IssueLite} a
|
|
13
14
|
* @param {IssueLite} b
|
|
14
15
|
*/
|
|
@@ -30,6 +31,7 @@ export function cmpPriorityThenCreated(a, b) {
|
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Compare by closed_at desc, then id asc for stability.
|
|
34
|
+
*
|
|
33
35
|
* @param {IssueLite} a
|
|
34
36
|
* @param {IssueLite} b
|
|
35
37
|
*/
|
|
@@ -9,10 +9,9 @@ import { cmpPriorityThenCreated } from './sort.js';
|
|
|
9
9
|
* delete messages in revision order and preserves object identity per id.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
// Sort comparator is centralized in app/data/sort.js
|
|
13
|
-
|
|
14
12
|
/**
|
|
15
13
|
* Create a SubscriptionIssueStore for a given subscription id.
|
|
14
|
+
*
|
|
16
15
|
* @param {string} id
|
|
17
16
|
* @param {SubscriptionIssueStoreOptions} [options]
|
|
18
17
|
* @returns {SubscriptionIssueStore}
|
|
@@ -50,6 +49,7 @@ export function createSubscriptionIssueStore(id, options = {}) {
|
|
|
50
49
|
* - Ignore messages with revision <= last_revision (except snapshot which resets first).
|
|
51
50
|
* - Preserve object identity when updating an existing item by mutating
|
|
52
51
|
* fields in place rather than replacing the object reference.
|
|
52
|
+
*
|
|
53
53
|
* @param {{ type: 'snapshot'|'upsert'|'delete', id: string, revision: number, issues?: any[], issue?: any, issue_id?: string }} msg
|
|
54
54
|
*/
|
|
55
55
|
function applyPush(msg) {
|
|
@@ -10,9 +10,6 @@ import { subKeyOf } from './subscriptions-store.js';
|
|
|
10
10
|
* push envelopes (snapshot/upsert/delete) per subscription id and expose
|
|
11
11
|
* read-only snapshots for rendering.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
*/
|
|
16
13
|
export function createSubscriptionIssueStores() {
|
|
17
14
|
/** @type {Map<string, ReturnType<typeof createSubscriptionIssueStore>>} */
|
|
18
15
|
const stores_by_id = new Map();
|
|
@@ -36,19 +33,48 @@ export function createSubscriptionIssueStores() {
|
|
|
36
33
|
/**
|
|
37
34
|
* Ensure a store exists for client_id and attach a listener that fans out
|
|
38
35
|
* store-level updates to global listeners.
|
|
36
|
+
*
|
|
39
37
|
* @param {string} client_id
|
|
40
38
|
* @param {{ type: string, params?: Record<string, string|number|boolean> }} [spec]
|
|
41
39
|
* @param {SubscriptionIssueStoreOptions} [options]
|
|
42
40
|
*/
|
|
43
41
|
function register(client_id, spec, options) {
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
const next_key = spec ? subKeyOf(spec) : '';
|
|
43
|
+
const prev_key = key_by_id.get(client_id) || '';
|
|
44
|
+
const has_store = stores_by_id.has(client_id);
|
|
45
|
+
// If the subscription spec changed for an existing client id, replace the
|
|
46
|
+
// underlying store to reset revision state and avoid ignoring a fresh
|
|
47
|
+
// snapshot with a lower revision (different server list).
|
|
48
|
+
if (has_store && prev_key && next_key && prev_key !== next_key) {
|
|
49
|
+
const prev_store = stores_by_id.get(client_id);
|
|
50
|
+
if (prev_store) {
|
|
51
|
+
try {
|
|
52
|
+
prev_store.dispose();
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const off_prev = store_unsubs.get(client_id);
|
|
58
|
+
if (off_prev) {
|
|
59
|
+
try {
|
|
60
|
+
off_prev();
|
|
61
|
+
} catch {
|
|
62
|
+
// ignore
|
|
63
|
+
}
|
|
64
|
+
store_unsubs.delete(client_id);
|
|
65
|
+
}
|
|
66
|
+
const new_store = createSubscriptionIssueStore(client_id, options);
|
|
67
|
+
stores_by_id.set(client_id, new_store);
|
|
68
|
+
const off_new = new_store.subscribe(() => emit());
|
|
69
|
+
store_unsubs.set(client_id, off_new);
|
|
70
|
+
} else if (!has_store) {
|
|
46
71
|
const store = createSubscriptionIssueStore(client_id, options);
|
|
47
72
|
stores_by_id.set(client_id, store);
|
|
48
73
|
// Fan out per-store events to global subscribers
|
|
49
74
|
const off = store.subscribe(() => emit());
|
|
50
75
|
store_unsubs.set(client_id, off);
|
|
51
76
|
}
|
|
77
|
+
key_by_id.set(client_id, next_key);
|
|
52
78
|
return () => unregister(client_id);
|
|
53
79
|
}
|
|
54
80
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
/**
|
|
17
17
|
* Generate a stable subscription key string from a spec.
|
|
18
18
|
* Mirrors server `keyOf` implementation (sorted params, URLSearchParams).
|
|
19
|
+
*
|
|
19
20
|
* @param {SubscriptionSpec} spec
|
|
20
21
|
* @returns {string}
|
|
21
22
|
*/
|
|
@@ -38,9 +39,10 @@ export function subKeyOf(spec) {
|
|
|
38
39
|
* Create a list subscription store.
|
|
39
40
|
*
|
|
40
41
|
* Wiring:
|
|
41
|
-
*
|
|
42
|
+
* - Use `subscribeList` to register a subscription and send the request.
|
|
42
43
|
*
|
|
43
44
|
* Selectors are synchronous and return derived state by client id.
|
|
45
|
+
*
|
|
44
46
|
* @param {(type: MessageType, payload?: unknown) => Promise<unknown>} send - ws send.
|
|
45
47
|
*/
|
|
46
48
|
export function createSubscriptionStore(send) {
|
|
@@ -51,6 +53,7 @@ export function createSubscriptionStore(send) {
|
|
|
51
53
|
|
|
52
54
|
/**
|
|
53
55
|
* Apply a delta to all client ids mapped to a given key.
|
|
56
|
+
*
|
|
54
57
|
* @param {string} key
|
|
55
58
|
* @param {{ added: string[], updated: string[], removed: string[] }} delta
|
|
56
59
|
*/
|
|
@@ -91,6 +94,7 @@ export function createSubscriptionStore(send) {
|
|
|
91
94
|
* Subscribe to a list spec with a client-provided id.
|
|
92
95
|
* Returns an unsubscribe function.
|
|
93
96
|
* Creates an empty items store immediately; server will publish deltas.
|
|
97
|
+
*
|
|
94
98
|
* @param {string} client_id
|
|
95
99
|
* @param {SubscriptionSpec} spec
|
|
96
100
|
* @returns {Promise<() => Promise<void>>}
|
|
@@ -158,6 +162,7 @@ export function createSubscriptionStore(send) {
|
|
|
158
162
|
const selectors = {
|
|
159
163
|
/**
|
|
160
164
|
* Get an array of item ids for a subscription.
|
|
165
|
+
*
|
|
161
166
|
* @param {string} client_id
|
|
162
167
|
* @returns {string[]}
|
|
163
168
|
*/
|
|
@@ -170,6 +175,7 @@ export function createSubscriptionStore(send) {
|
|
|
170
175
|
},
|
|
171
176
|
/**
|
|
172
177
|
* Check if an id exists in a subscription.
|
|
178
|
+
*
|
|
173
179
|
* @param {string} client_id
|
|
174
180
|
* @param {string} id
|
|
175
181
|
* @returns {boolean}
|
|
@@ -183,6 +189,7 @@ export function createSubscriptionStore(send) {
|
|
|
183
189
|
},
|
|
184
190
|
/**
|
|
185
191
|
* Count items for a subscription.
|
|
192
|
+
*
|
|
186
193
|
* @param {string} client_id
|
|
187
194
|
* @returns {number}
|
|
188
195
|
*/
|
|
@@ -192,6 +199,7 @@ export function createSubscriptionStore(send) {
|
|
|
192
199
|
},
|
|
193
200
|
/**
|
|
194
201
|
* Return a shallow object copy `{ [id]: true }` for rendering helpers.
|
|
202
|
+
*
|
|
195
203
|
* @param {string} client_id
|
|
196
204
|
* @returns {Record<string, true>}
|
|
197
205
|
*/
|
package/app/main.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createWsClient } from './ws.js';
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Bootstrap the SPA shell with two panels.
|
|
23
|
+
*
|
|
23
24
|
* @param {HTMLElement} root_element - The container element to render into.
|
|
24
25
|
*/
|
|
25
26
|
export function bootstrap(root_element) {
|
|
@@ -415,6 +416,7 @@ export function bootstrap(root_element) {
|
|
|
415
416
|
|
|
416
417
|
/**
|
|
417
418
|
* Compute subscription spec for Issues tab based on filters.
|
|
419
|
+
*
|
|
418
420
|
* @param {{ status?: string }} filters
|
|
419
421
|
* @returns {{ type: string, params?: Record<string, string|number|boolean> }}
|
|
420
422
|
*/
|
|
@@ -437,6 +439,7 @@ export function bootstrap(root_element) {
|
|
|
437
439
|
let last_issues_spec_key = null;
|
|
438
440
|
/**
|
|
439
441
|
* Ensure only the active tab has subscriptions; clean up previous.
|
|
442
|
+
*
|
|
440
443
|
* @param {{ view: 'issues'|'epics'|'board', filters: any }} s
|
|
441
444
|
*/
|
|
442
445
|
function ensureTabSubscriptions(s) {
|
|
@@ -596,6 +599,7 @@ export function bootstrap(root_element) {
|
|
|
596
599
|
|
|
597
600
|
/**
|
|
598
601
|
* Manage route visibility and list subscriptions per view.
|
|
602
|
+
*
|
|
599
603
|
* @param {{ selected_id: string | null, view: 'issues'|'epics'|'board', filters: any }} s
|
|
600
604
|
*/
|
|
601
605
|
const onRouteChange = (s) => {
|
package/app/protocol.js
CHANGED
|
@@ -58,6 +58,7 @@ export const MESSAGE_TYPES = /** @type {const} */ ([
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* Generate a lexically sortable request id.
|
|
61
|
+
*
|
|
61
62
|
* @returns {string}
|
|
62
63
|
*/
|
|
63
64
|
export function nextId() {
|
|
@@ -68,6 +69,7 @@ export function nextId() {
|
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
71
|
* Create a request envelope.
|
|
72
|
+
*
|
|
71
73
|
* @param {MessageType} type - Message type.
|
|
72
74
|
* @param {unknown} [payload] - Message payload.
|
|
73
75
|
* @param {string} [id] - Optional id; generated if omitted.
|
|
@@ -79,6 +81,7 @@ export function makeRequest(type, payload, id = nextId()) {
|
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
83
|
* Create a successful reply envelope for a given request.
|
|
84
|
+
*
|
|
82
85
|
* @param {RequestEnvelope} req - Original request.
|
|
83
86
|
* @param {unknown} [payload] - Reply payload.
|
|
84
87
|
* @returns {ReplyEnvelope}
|
|
@@ -89,10 +92,11 @@ export function makeOk(req, payload) {
|
|
|
89
92
|
|
|
90
93
|
/**
|
|
91
94
|
* Create an error reply envelope for a given request.
|
|
95
|
+
*
|
|
92
96
|
* @param {RequestEnvelope} req - Original request.
|
|
93
|
-
* @param {string} code
|
|
94
|
-
* @param {string} message
|
|
95
|
-
* @param {unknown} [details]
|
|
97
|
+
* @param {string} code
|
|
98
|
+
* @param {string} message
|
|
99
|
+
* @param {unknown} [details]
|
|
96
100
|
* @returns {ReplyEnvelope}
|
|
97
101
|
*/
|
|
98
102
|
export function makeError(req, code, message, details) {
|
|
@@ -106,6 +110,7 @@ export function makeError(req, code, message, details) {
|
|
|
106
110
|
|
|
107
111
|
/**
|
|
108
112
|
* Check if a value is a plain object.
|
|
113
|
+
*
|
|
109
114
|
* @param {unknown} value
|
|
110
115
|
* @returns {value is Record<string, unknown>}
|
|
111
116
|
*/
|
|
@@ -115,6 +120,7 @@ function isRecord(value) {
|
|
|
115
120
|
|
|
116
121
|
/**
|
|
117
122
|
* Type guard for MessageType values.
|
|
123
|
+
*
|
|
118
124
|
* @param {unknown} value
|
|
119
125
|
* @returns {value is MessageType}
|
|
120
126
|
*/
|
|
@@ -127,6 +133,7 @@ export function isMessageType(value) {
|
|
|
127
133
|
|
|
128
134
|
/**
|
|
129
135
|
* Type guard for RequestEnvelope.
|
|
136
|
+
*
|
|
130
137
|
* @param {unknown} value
|
|
131
138
|
* @returns {value is RequestEnvelope}
|
|
132
139
|
*/
|
|
@@ -143,6 +150,7 @@ export function isRequest(value) {
|
|
|
143
150
|
|
|
144
151
|
/**
|
|
145
152
|
* Type guard for ReplyEnvelope.
|
|
153
|
+
*
|
|
146
154
|
* @param {unknown} value
|
|
147
155
|
* @returns {value is ReplyEnvelope}
|
|
148
156
|
*/
|
|
@@ -173,6 +181,7 @@ export function isReply(value) {
|
|
|
173
181
|
/**
|
|
174
182
|
* Normalize and validate an incoming JSON value as a RequestEnvelope.
|
|
175
183
|
* Throws a user-friendly error if invalid.
|
|
184
|
+
*
|
|
176
185
|
* @param {unknown} json
|
|
177
186
|
* @returns {RequestEnvelope}
|
|
178
187
|
*/
|
|
@@ -185,6 +194,7 @@ export function decodeRequest(json) {
|
|
|
185
194
|
|
|
186
195
|
/**
|
|
187
196
|
* Normalize and validate an incoming JSON value as a ReplyEnvelope.
|
|
197
|
+
*
|
|
188
198
|
* @param {unknown} json
|
|
189
199
|
* @returns {ReplyEnvelope}
|
|
190
200
|
*/
|
package/app/router.js
CHANGED
|
@@ -8,6 +8,7 @@ import { issueHashFor } from './utils/issue-url.js';
|
|
|
8
8
|
* Parse an application hash and extract the selected issue id.
|
|
9
9
|
* Supports canonical form "#/(issues|epics|board)?issue=<id>" and legacy
|
|
10
10
|
* "#/issue/<id>" which we will rewrite to the canonical form.
|
|
11
|
+
*
|
|
11
12
|
* @param {string} hash
|
|
12
13
|
* @returns {string | null}
|
|
13
14
|
*/
|
|
@@ -31,6 +32,7 @@ export function parseHash(hash) {
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Parse the current view from hash.
|
|
35
|
+
*
|
|
34
36
|
* @param {string} hash
|
|
35
37
|
* @returns {'issues'|'epics'|'board'}
|
|
36
38
|
*/
|
|
@@ -95,6 +97,7 @@ export function createHashRouter(store) {
|
|
|
95
97
|
},
|
|
96
98
|
/**
|
|
97
99
|
* Navigate to a top-level view.
|
|
100
|
+
*
|
|
98
101
|
* @param {'issues'|'epics'|'board'} view
|
|
99
102
|
*/
|
|
100
103
|
/**
|
package/app/state.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Create a simple store for application state.
|
|
31
|
+
*
|
|
31
32
|
* @param {Partial<AppState>} [initial]
|
|
32
33
|
* @returns {{ getState: () => AppState, setState: (patch: { selected_id?: string | null, filters?: Partial<Filters> }) => void, subscribe: (fn: (s: AppState) => void) => () => void }}
|
|
33
34
|
*/
|
|
@@ -71,6 +72,7 @@ export function createStore(initial = {}) {
|
|
|
71
72
|
},
|
|
72
73
|
/**
|
|
73
74
|
* Update state. Nested filters can be partial.
|
|
75
|
+
*
|
|
74
76
|
* @param {{ selected_id?: string | null, filters?: Partial<Filters>, board?: Partial<BoardState> }} patch
|
|
75
77
|
*/
|
|
76
78
|
setState(patch) {
|
|
@@ -5,6 +5,7 @@ import { issueDisplayId } from './issue-id.js';
|
|
|
5
5
|
* Looks like the current inline ID (monospace `#123`) but acts as a button
|
|
6
6
|
* that copies the full, prefixed ID (e.g., `UI-123`) when activated.
|
|
7
7
|
* Shows transient "Copied" feedback and then restores the ID.
|
|
8
|
+
*
|
|
8
9
|
* @param {string} id - Full issue id including the prefix (e.g., "UI-123").
|
|
9
10
|
* @param {{ class_name?: string, duration_ms?: number }} [opts]
|
|
10
11
|
* @returns {HTMLButtonElement}
|
|
@@ -25,7 +26,7 @@ export function createIssueIdRenderer(id, opts) {
|
|
|
25
26
|
const label = issueDisplayId(id);
|
|
26
27
|
btn.textContent = label;
|
|
27
28
|
|
|
28
|
-
/** Copy handler with feedback */
|
|
29
|
+
/** Copy handler with feedback. */
|
|
29
30
|
async function doCopy() {
|
|
30
31
|
// Prevent accidental row navigation and parent handlers
|
|
31
32
|
// (click/key handlers call this inside an event context)
|
package/app/utils/issue-id.js
CHANGED
package/app/utils/issue-type.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Known issue types in canonical order for dropdowns.
|
|
3
|
+
*
|
|
3
4
|
* @type {Array<'bug'|'feature'|'task'|'epic'|'chore'>}
|
|
4
5
|
*/
|
|
5
6
|
export const ISSUE_TYPES = ['bug', 'feature', 'task', 'epic', 'chore'];
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Return a human-friendly label for an issue type.
|
|
10
|
+
*
|
|
9
11
|
* @param {string | null | undefined} type
|
|
10
12
|
* @returns {string}
|
|
11
13
|
*/
|
package/app/utils/issue-url.js
CHANGED
package/app/utils/markdown.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import DOMPurify from 'dompurify';
|
|
2
|
-
import { html } from 'lit-html';
|
|
3
2
|
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
|
4
3
|
import { marked } from 'marked';
|
|
5
4
|
|
|
@@ -7,16 +6,11 @@ import { marked } from 'marked';
|
|
|
7
6
|
* Render Markdown safely as HTML using marked and DOMPurify.
|
|
8
7
|
* Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
|
|
9
8
|
* embedded directly in templates.
|
|
10
|
-
*
|
|
11
|
-
* @param {string}
|
|
12
|
-
* @returns {import('lit-html').TemplateResult}
|
|
9
|
+
*
|
|
10
|
+
* @param {string} markdown - Markdown source text
|
|
13
11
|
*/
|
|
14
|
-
export function renderMarkdown(
|
|
15
|
-
/** @type {string} */
|
|
16
|
-
const markdown = String(src || '');
|
|
17
|
-
/** @type {string} */
|
|
12
|
+
export function renderMarkdown(markdown) {
|
|
18
13
|
const parsed = /** @type {string} */ (marked.parse(markdown));
|
|
19
|
-
/** @type {string} */
|
|
20
14
|
const html_string = DOMPurify.sanitize(parsed);
|
|
21
|
-
return
|
|
15
|
+
return unsafeHTML(html_string);
|
|
22
16
|
}
|
package/app/utils/status.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Known status values in canonical order.
|
|
3
|
+
*
|
|
3
4
|
* @type {Array<'open'|'in_progress'|'closed'>}
|
|
4
5
|
*/
|
|
5
6
|
export const STATUSES = ['open', 'in_progress', 'closed'];
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Map canonical status to display label.
|
|
10
|
+
*
|
|
9
11
|
* @param {string | null | undefined} status
|
|
10
12
|
* @returns {string}
|
|
11
13
|
*/
|
package/app/utils/toast.js
CHANGED
package/app/utils/type-badge.js
CHANGED
package/app/views/board.js
CHANGED
|
@@ -23,8 +23,9 @@ import { createTypeBadge } from '../utils/type-badge.js';
|
|
|
23
23
|
* Push-only: derives items from per-subscription stores.
|
|
24
24
|
*
|
|
25
25
|
* Sorting rules:
|
|
26
|
-
* - Ready/Blocked/In progress: priority asc, then created_at asc
|
|
27
|
-
* - Closed: closed_at desc
|
|
26
|
+
* - Ready/Blocked/In progress: priority asc, then created_at asc.
|
|
27
|
+
* - Closed: closed_at desc.
|
|
28
|
+
*
|
|
28
29
|
* @param {HTMLElement} mount_element
|
|
29
30
|
* @param {unknown} _data - Unused (legacy param retained for call-compat)
|
|
30
31
|
* @param {(id: string) => void} gotoIssue - Navigate to issue detail.
|
|
@@ -58,6 +59,7 @@ export function createBoardView(
|
|
|
58
59
|
* Closed column filter mode.
|
|
59
60
|
* 'today' → items with closed_at since local day start
|
|
60
61
|
* '3' → last 3 days; '7' → last 7 days
|
|
62
|
+
*
|
|
61
63
|
* @type {'today'|'3'|'7'}
|
|
62
64
|
*/
|
|
63
65
|
let closed_filter_mode = 'today';
|
|
@@ -165,10 +167,10 @@ export function createBoardView(
|
|
|
165
167
|
|
|
166
168
|
/**
|
|
167
169
|
* Enhance rendered board with a11y and keyboard navigation.
|
|
168
|
-
* - Roving tabindex per column (first card tabbable)
|
|
169
|
-
* - ArrowUp/ArrowDown within column
|
|
170
|
-
* - ArrowLeft/ArrowRight to adjacent non-empty column (focus top card)
|
|
171
|
-
* - Enter/Space to open details for focused card
|
|
170
|
+
* - Roving tabindex per column (first card tabbable).
|
|
171
|
+
* - ArrowUp/ArrowDown within column.
|
|
172
|
+
* - ArrowLeft/ArrowRight to adjacent non-empty column (focus top card).
|
|
173
|
+
* - Enter/Space to open details for focused card.
|
|
172
174
|
*/
|
|
173
175
|
function postRenderEnhance() {
|
|
174
176
|
try {
|
package/app/views/detail.js
CHANGED
|
@@ -44,6 +44,7 @@ function defaultNavigateFn(hash) {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Create the Issue Detail view.
|
|
47
|
+
*
|
|
47
48
|
* @param {HTMLElement} mount_element - Element to render into.
|
|
48
49
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
49
50
|
* @param {(hash: string) => void} [navigateFn] - Navigation function; defaults to setting location.hash.
|
|
@@ -1071,6 +1072,7 @@ export function createDetailView(
|
|
|
1071
1072
|
|
|
1072
1073
|
/**
|
|
1073
1074
|
* Create a click handler for the remove button of a dependency row.
|
|
1075
|
+
*
|
|
1074
1076
|
* @param {string} did
|
|
1075
1077
|
* @param {'Dependencies'|'Dependents'} title
|
|
1076
1078
|
* @returns {(ev: Event) => Promise<void>}
|
|
@@ -1114,6 +1116,7 @@ export function createDetailView(
|
|
|
1114
1116
|
|
|
1115
1117
|
/**
|
|
1116
1118
|
* Create a click handler for the Add button in a dependency section.
|
|
1119
|
+
*
|
|
1117
1120
|
* @param {Dependency[]} items
|
|
1118
1121
|
* @param {'Dependencies'|'Dependents'} title
|
|
1119
1122
|
* @returns {(ev: Event) => Promise<void>}
|
package/app/views/epics.js
CHANGED
|
@@ -9,11 +9,12 @@ import { createIssueRowRenderer } from './issue-row.js';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Epics view (push-only):
|
|
12
|
-
* - Derives epic groups from the local issues store (no RPC reads)
|
|
13
|
-
* - Subscribes to `tab:epics` for top-level membership
|
|
14
|
-
* - On expand, subscribes to `detail:{id}` (issue-detail) for the epic
|
|
15
|
-
* - Renders children from the epic detail's `dependents` list
|
|
16
|
-
* - Provides inline edits via mutations; UI re-renders on push
|
|
12
|
+
* - Derives epic groups from the local issues store (no RPC reads).
|
|
13
|
+
* - Subscribes to `tab:epics` for top-level membership.
|
|
14
|
+
* - On expand, subscribes to `detail:{id}` (issue-detail) for the epic.
|
|
15
|
+
* - Renders children from the epic detail's `dependents` list.
|
|
16
|
+
* - Provides inline edits via mutations; UI re-renders on push.
|
|
17
|
+
*
|
|
17
18
|
* @param {HTMLElement} mount_element
|
|
18
19
|
* @param {{ updateIssue: (input: any) => Promise<any> }} data
|
|
19
20
|
* @param {(id: string) => void} goto_issue - Navigate to issue detail.
|
|
@@ -10,6 +10,7 @@ import { createIssueIdRenderer } from '../utils/issue-id-renderer.js';
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Create and manage the Issue Details dialog.
|
|
13
|
+
*
|
|
13
14
|
* @param {HTMLElement} mount_element - Container to attach the <dialog> to (e.g., #detail-panel)
|
|
14
15
|
* @param {Store} store - Read-only access to app state
|
|
15
16
|
* @param {() => void} onClose - Called when dialog requests close (backdrop/esc/button)
|
package/app/views/issue-row.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createTypeBadge } from '../utils/type-badge.js';
|
|
|
12
12
|
/**
|
|
13
13
|
* Create a reusable issue row renderer used by list and epics views.
|
|
14
14
|
* Handles inline editing for title/assignee and selects for status/priority.
|
|
15
|
+
*
|
|
15
16
|
* @param {{
|
|
16
17
|
* navigate: (id: string) => void,
|
|
17
18
|
* onUpdate: (id: string, patch: { title?: string, assignee?: string, status?: 'open'|'in_progress'|'closed', priority?: number }) => Promise<void>,
|
package/app/views/list.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createIssueRowRenderer } from './issue-row.js';
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Create the Issues List view.
|
|
18
|
+
*
|
|
18
19
|
* @param {HTMLElement} mount_element - Element to render into.
|
|
19
20
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn - RPC transport.
|
|
20
21
|
* @param {(hash: string) => void} [navigate_fn] - Navigation function (defaults to setting location.hash).
|
|
@@ -25,6 +26,7 @@ import { createIssueRowRenderer } from './issue-row.js';
|
|
|
25
26
|
*/
|
|
26
27
|
/**
|
|
27
28
|
* Create the Issues List view.
|
|
29
|
+
*
|
|
28
30
|
* @param {HTMLElement} mount_element
|
|
29
31
|
* @param {(type: string, payload?: unknown) => Promise<unknown>} sendFn
|
|
30
32
|
* @param {(hash: string) => void} [navigateFn]
|
|
@@ -104,6 +106,7 @@ export function createListView(
|
|
|
104
106
|
|
|
105
107
|
/**
|
|
106
108
|
* Event: type select change.
|
|
109
|
+
*
|
|
107
110
|
* @param {Event} ev
|
|
108
111
|
*/
|
|
109
112
|
const onTypeChange = (ev) => {
|
|
@@ -237,6 +240,7 @@ export function createListView(
|
|
|
237
240
|
|
|
238
241
|
/**
|
|
239
242
|
* Update minimal fields inline via ws mutations and refresh that row's data.
|
|
243
|
+
*
|
|
240
244
|
* @param {string} id
|
|
241
245
|
* @param {{ [k: string]: any }} patch
|
|
242
246
|
*/
|
package/app/views/nav.js
CHANGED
|
@@ -2,6 +2,7 @@ import { html, render } from 'lit-html';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Render the top navigation with three tabs and handle route changes.
|
|
5
|
+
*
|
|
5
6
|
* @param {HTMLElement} mount_element
|
|
6
7
|
* @param {{ getState: () => any, subscribe: (fn: (s: any) => void) => () => void }} store
|
|
7
8
|
* @param {{ gotoView: (v: 'issues'|'epics'|'board') => void }} router
|
|
@@ -3,6 +3,7 @@ import { priority_levels } from '../utils/priority.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Create and manage the New Issue dialog (native <dialog>).
|
|
6
|
+
*
|
|
6
7
|
* @param {HTMLElement} mount_element - Container to attach dialog (e.g., main#app)
|
|
7
8
|
* @param {(type: import('../protocol.js').MessageType, payload?: unknown) => Promise<unknown>} sendFn - Transport function
|
|
8
9
|
* @param {{ gotoIssue: (id: string) => void }} router - Router for opening details after create
|
|
@@ -184,6 +185,7 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
184
185
|
|
|
185
186
|
/**
|
|
186
187
|
* Extract numeric suffix from an id like "UI-123"; return -1 when absent.
|
|
188
|
+
*
|
|
187
189
|
* @param {string} id
|
|
188
190
|
*/
|
|
189
191
|
function idNumeric(id) {
|
|
@@ -193,6 +195,7 @@ export function createNewIssueDialog(mount_element, sendFn, router, store) {
|
|
|
193
195
|
|
|
194
196
|
/**
|
|
195
197
|
* Submit handler: validate, create, then open the created issue details.
|
|
198
|
+
*
|
|
196
199
|
* @returns {Promise<void>}
|
|
197
200
|
*/
|
|
198
201
|
async function createNow() {
|
package/app/ws.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* global Console */
|
|
2
1
|
/**
|
|
3
2
|
* @import { MessageType } from './protocol.js'
|
|
4
3
|
*/
|
|
@@ -27,6 +26,7 @@ import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
|
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Create a WebSocket client with auto-reconnect and message correlation.
|
|
29
|
+
*
|
|
30
30
|
* @param {ClientOptions} [options]
|
|
31
31
|
*/
|
|
32
32
|
export function createWsClient(options = {}) {
|
|
@@ -211,6 +211,7 @@ export function createWsClient(options = {}) {
|
|
|
211
211
|
return {
|
|
212
212
|
/**
|
|
213
213
|
* Send a request and await its correlated reply payload.
|
|
214
|
+
*
|
|
214
215
|
* @param {MessageType} type
|
|
215
216
|
* @param {unknown} [payload]
|
|
216
217
|
* @returns {Promise<any>}
|
|
@@ -233,6 +234,7 @@ export function createWsClient(options = {}) {
|
|
|
233
234
|
/**
|
|
234
235
|
* Register a handler for a server-initiated event type.
|
|
235
236
|
* Returns an unsubscribe function.
|
|
237
|
+
*
|
|
236
238
|
* @param {MessageType} type
|
|
237
239
|
* @param {(payload: any) => void} handler
|
|
238
240
|
* @returns {() => void}
|
|
@@ -249,6 +251,7 @@ export function createWsClient(options = {}) {
|
|
|
249
251
|
},
|
|
250
252
|
/**
|
|
251
253
|
* Subscribe to connection state changes.
|
|
254
|
+
*
|
|
252
255
|
* @param {(state: ConnectionState) => void} handler
|
|
253
256
|
* @returns {() => void}
|
|
254
257
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beads-ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Local‑first UI for Beads — a fast issue tracker for your coding agent.",
|
|
5
5
|
"homepage": "https://github.com/mantoni/beads-ui",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,8 @@
|
|
|
32
32
|
"@types/ws": "^8.18.1",
|
|
33
33
|
"eslint": "^9.11.0",
|
|
34
34
|
"eslint-plugin-import": "^2.29.1",
|
|
35
|
-
"eslint-plugin-jsdoc": "^
|
|
35
|
+
"eslint-plugin-jsdoc": "^61.1.9",
|
|
36
36
|
"eslint-plugin-n": "^17.9.0",
|
|
37
|
-
"eslint-plugin-promise": "^6.1.1",
|
|
38
37
|
"globals": "^16.4.0",
|
|
39
38
|
"jsdom": "^27.0.1",
|
|
40
39
|
"prettier": "^3.3.3",
|
package/server/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Create and configure the Express application.
|
|
9
|
+
*
|
|
9
10
|
* @param {{ host: string, port: number, env: string, app_dir: string, root_dir: string }} config - Server configuration.
|
|
10
11
|
* @returns {Express} Configured Express app instance.
|
|
11
12
|
*/
|
|
@@ -28,6 +29,7 @@ export function createApp(config) {
|
|
|
28
29
|
/**
|
|
29
30
|
* On-demand bundle for the browser using esbuild.
|
|
30
31
|
* Note: esbuild is loaded lazily so tests don't require it to be installed.
|
|
32
|
+
*
|
|
31
33
|
* @param {Request} _req
|
|
32
34
|
* @param {Response} res
|
|
33
35
|
*/
|
package/server/bd.js
CHANGED
|
@@ -3,6 +3,7 @@ import { resolveDbPath } from './db.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Resolve the bd executable path.
|
|
6
|
+
*
|
|
6
7
|
* @returns {string}
|
|
7
8
|
*/
|
|
8
9
|
export function getBdBin() {
|
|
@@ -16,6 +17,7 @@ export function getBdBin() {
|
|
|
16
17
|
/**
|
|
17
18
|
* Run the `bd` CLI with provided arguments.
|
|
18
19
|
* Shell is not used to avoid injection; args must be pre-split.
|
|
20
|
+
*
|
|
19
21
|
* @param {string[]} args - Arguments to pass (e.g., ["list", "--json"]).
|
|
20
22
|
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
21
23
|
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
|
@@ -88,6 +90,7 @@ export function runBd(args, options = {}) {
|
|
|
88
90
|
|
|
89
91
|
/**
|
|
90
92
|
* Run `bd` and parse JSON from stdout if exit code is 0.
|
|
93
|
+
*
|
|
91
94
|
* @param {string[]} args - Must include flags that cause JSON to be printed (e.g., `--json`).
|
|
92
95
|
* @param {{ cwd?: string, env?: Record<string, string | undefined>, timeout_ms?: number }} [options]
|
|
93
96
|
* @returns {Promise<{ code: number, stdoutJson?: unknown, stderr?: string }>}
|
|
@@ -109,6 +112,7 @@ export async function runBdJson(args, options = {}) {
|
|
|
109
112
|
|
|
110
113
|
/**
|
|
111
114
|
* Add a resolved "--db <path>" pair to args when none present.
|
|
115
|
+
*
|
|
112
116
|
* @param {string[]} args
|
|
113
117
|
* @param {string} cwd
|
|
114
118
|
* @param {Record<string, string | undefined>} env
|
package/server/cli/commands.js
CHANGED
|
@@ -13,6 +13,7 @@ import { openUrl, waitForServer } from './open.js';
|
|
|
13
13
|
* Handle `start` command. Idempotent when already running.
|
|
14
14
|
* - Spawns a detached server process, writes PID file, returns 0.
|
|
15
15
|
* - If already running (PID file present and process alive), prints URL and returns 0.
|
|
16
|
+
*
|
|
16
17
|
* @returns {Promise<number>} Exit code (0 on success)
|
|
17
18
|
*/
|
|
18
19
|
/**
|
|
@@ -52,6 +53,7 @@ export async function handleStart(options) {
|
|
|
52
53
|
* Handle `stop` command.
|
|
53
54
|
* - Sends SIGTERM and waits for exit (with SIGKILL fallback), removes PID file.
|
|
54
55
|
* - Returns 2 if not running.
|
|
56
|
+
*
|
|
55
57
|
* @returns {Promise<number>} Exit code
|
|
56
58
|
*/
|
|
57
59
|
export async function handleStop() {
|
|
@@ -78,12 +80,14 @@ export async function handleStop() {
|
|
|
78
80
|
|
|
79
81
|
/**
|
|
80
82
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
83
|
+
*
|
|
81
84
|
* @returns {Promise<number>} Exit code (0 on success)
|
|
82
85
|
*/
|
|
83
86
|
/**
|
|
84
87
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
85
88
|
* Accepts the same options as `handleStart` and passes them through,
|
|
86
89
|
* so restart only opens a browser when `no_open` is explicitly false.
|
|
90
|
+
*
|
|
87
91
|
* @param {{ no_open?: boolean }} [options]
|
|
88
92
|
* @returns {Promise<number>}
|
|
89
93
|
*/
|
package/server/cli/daemon.js
CHANGED
|
@@ -13,6 +13,7 @@ import { resolveDbPath } from '../db.js';
|
|
|
13
13
|
* Resolve the runtime directory used for PID and log files.
|
|
14
14
|
* Prefers `BDUI_RUNTIME_DIR`, then `$XDG_RUNTIME_DIR/beads-ui`,
|
|
15
15
|
* and finally `os.tmpdir()/beads-ui`.
|
|
16
|
+
*
|
|
16
17
|
* @returns {string}
|
|
17
18
|
*/
|
|
18
19
|
export function getRuntimeDir() {
|
|
@@ -31,6 +32,7 @@ export function getRuntimeDir() {
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* Ensure a directory exists with safe permissions and return its path.
|
|
35
|
+
*
|
|
34
36
|
* @param {string} dir_path
|
|
35
37
|
* @returns {string}
|
|
36
38
|
*/
|
|
@@ -61,6 +63,7 @@ export function getLogFilePath() {
|
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
65
|
* Read PID from the PID file if present.
|
|
66
|
+
*
|
|
64
67
|
* @returns {number | null}
|
|
65
68
|
*/
|
|
66
69
|
export function readPidFile() {
|
|
@@ -100,6 +103,7 @@ export function removePidFile() {
|
|
|
100
103
|
|
|
101
104
|
/**
|
|
102
105
|
* Check whether a process is running.
|
|
106
|
+
*
|
|
103
107
|
* @param {number} pid
|
|
104
108
|
* @returns {boolean}
|
|
105
109
|
*/
|
|
@@ -122,6 +126,7 @@ export function isProcessRunning(pid) {
|
|
|
122
126
|
|
|
123
127
|
/**
|
|
124
128
|
* Compute the absolute path to the server entry file.
|
|
129
|
+
*
|
|
125
130
|
* @returns {string}
|
|
126
131
|
*/
|
|
127
132
|
export function getServerEntryPath() {
|
|
@@ -134,6 +139,7 @@ export function getServerEntryPath() {
|
|
|
134
139
|
/**
|
|
135
140
|
* Spawn the server as a detached daemon, redirecting stdio to the log file.
|
|
136
141
|
* Writes the PID file upon success.
|
|
142
|
+
*
|
|
137
143
|
* @returns {{ pid: number } | null} Returns child PID on success; null on failure.
|
|
138
144
|
*/
|
|
139
145
|
export function startDaemon() {
|
|
@@ -183,6 +189,7 @@ export function startDaemon() {
|
|
|
183
189
|
|
|
184
190
|
/**
|
|
185
191
|
* Send SIGTERM then (optionally) SIGKILL to stop a process and wait for exit.
|
|
192
|
+
*
|
|
186
193
|
* @param {number} pid
|
|
187
194
|
* @param {number} timeout_ms
|
|
188
195
|
* @returns {Promise<boolean>} Resolves true if the process is gone.
|
package/server/cli/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { printUsage } from './usage.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Parse argv into a command token and flags.
|
|
6
|
+
*
|
|
6
7
|
* @param {string[]} args
|
|
7
8
|
* @returns {{ command: string | null, flags: string[] }}
|
|
8
9
|
*/
|
|
@@ -41,6 +42,7 @@ export function parseArgs(args) {
|
|
|
41
42
|
/**
|
|
42
43
|
* CLI main entry. Returns an exit code and prints usage on `--help` or errors.
|
|
43
44
|
* No side effects beyond invoking stub handlers.
|
|
45
|
+
*
|
|
44
46
|
* @param {string[]} args
|
|
45
47
|
* @returns {Promise<number>}
|
|
46
48
|
*/
|
package/server/cli/open.js
CHANGED
|
@@ -3,6 +3,7 @@ import http from 'node:http';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Compute a platform-specific command to open a URL in the default browser.
|
|
6
|
+
*
|
|
6
7
|
* @param {string} url
|
|
7
8
|
* @param {string} platform
|
|
8
9
|
* @returns {{ cmd: string, args: string[] }}
|
|
@@ -21,6 +22,7 @@ export function computeOpenCommand(url, platform) {
|
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Open the given URL in the default browser. Best-effort; resolves true on spawn success.
|
|
25
|
+
*
|
|
24
26
|
* @param {string} url
|
|
25
27
|
* @returns {Promise<boolean>}
|
|
26
28
|
*/
|
|
@@ -41,6 +43,7 @@ export async function openUrl(url) {
|
|
|
41
43
|
/**
|
|
42
44
|
* Wait until the server at the URL accepts a connection, with a brief retry.
|
|
43
45
|
* Does not throw; returns when either a connection was accepted or timeout elapsed.
|
|
46
|
+
*
|
|
44
47
|
* @param {string} url
|
|
45
48
|
* @param {number} total_timeout_ms
|
|
46
49
|
* @returns {Promise<void>}
|
package/server/cli/usage.js
CHANGED
package/server/config.js
CHANGED
|
@@ -6,8 +6,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
* Notes:
|
|
7
7
|
* - `app_dir` is resolved relative to the installed package location.
|
|
8
8
|
* - `root_dir` represents the directory where the process was invoked
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* (i.e., the current working directory) so DB resolution follows the
|
|
10
|
+
* caller's context rather than the install location.
|
|
11
|
+
*
|
|
11
12
|
* @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string, url: string }}
|
|
12
13
|
*/
|
|
13
14
|
export function getConfig() {
|
package/server/db.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
*
|
|
12
12
|
* Returns a normalized absolute path and a `source` indicator. Existence is
|
|
13
13
|
* returned via the `exists` boolean.
|
|
14
|
+
*
|
|
14
15
|
* @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
|
|
15
16
|
* @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
|
|
16
17
|
*/
|
|
@@ -48,6 +49,7 @@ export function resolveDbPath(options = {}) {
|
|
|
48
49
|
/**
|
|
49
50
|
* Find nearest .beads/*.db by walking up from start.
|
|
50
51
|
* First alphabetical .db.
|
|
52
|
+
*
|
|
51
53
|
* @param {string} start
|
|
52
54
|
* @returns {string | null}
|
|
53
55
|
*/
|
|
@@ -79,6 +81,7 @@ export function findNearestBeadsDb(start) {
|
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
83
|
* Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
|
|
84
|
+
*
|
|
82
85
|
* @param {string} p
|
|
83
86
|
* @param {string} cwd
|
|
84
87
|
*/
|
package/server/list-adapters.js
CHANGED
|
@@ -3,6 +3,7 @@ import { runBdJson } from './bd.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Build concrete `bd` CLI args for a subscription type + params.
|
|
5
5
|
* Always includes `--json` for parseable output.
|
|
6
|
+
*
|
|
6
7
|
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
7
8
|
* @returns {string[]}
|
|
8
9
|
*/
|
|
@@ -43,9 +44,10 @@ export function mapSubscriptionToBdArgs(spec) {
|
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Normalize bd list output to minimal Issue shape used by the registry.
|
|
46
|
-
* - Ensures `id` is a string
|
|
47
|
-
* - Coerces timestamps to numbers
|
|
48
|
-
* - `closed_at` defaults to null when missing or invalid
|
|
47
|
+
* - Ensures `id` is a string.
|
|
48
|
+
* - Coerces timestamps to numbers.
|
|
49
|
+
* - `closed_at` defaults to null when missing or invalid.
|
|
50
|
+
*
|
|
49
51
|
* @param {unknown} value
|
|
50
52
|
* @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
|
|
51
53
|
*/
|
|
@@ -95,6 +97,7 @@ export function normalizeIssueList(value) {
|
|
|
95
97
|
/**
|
|
96
98
|
* Execute the mapped `bd` command for a subscription spec and return normalized items.
|
|
97
99
|
* Errors do not throw; they are surfaced as a structured object.
|
|
100
|
+
*
|
|
98
101
|
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
99
102
|
* @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
|
|
100
103
|
*/
|
|
@@ -171,6 +174,7 @@ export async function fetchListForSubscription(spec) {
|
|
|
171
174
|
|
|
172
175
|
/**
|
|
173
176
|
* Create a `bad_request` error object.
|
|
177
|
+
*
|
|
174
178
|
* @param {string} message
|
|
175
179
|
*/
|
|
176
180
|
function badRequest(message) {
|
|
@@ -182,6 +186,7 @@ function badRequest(message) {
|
|
|
182
186
|
|
|
183
187
|
/**
|
|
184
188
|
* Normalize arbitrary thrown values to a structured error object.
|
|
189
|
+
*
|
|
185
190
|
* @param {unknown} err
|
|
186
191
|
* @returns {FetchListResultFailure['error']}
|
|
187
192
|
*/
|
|
@@ -199,6 +204,7 @@ function toErrorObject(err) {
|
|
|
199
204
|
/**
|
|
200
205
|
* Parse a bd timestamp string to epoch ms using Date.parse.
|
|
201
206
|
* Falls back to numeric coercion when parsing fails.
|
|
207
|
+
*
|
|
202
208
|
* @param {unknown} v
|
|
203
209
|
* @returns {number}
|
|
204
210
|
*/
|
package/server/subscriptions.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* Create a new, empty entry object.
|
|
39
|
+
*
|
|
39
40
|
* @returns {Entry}
|
|
40
41
|
*/
|
|
41
42
|
function createEntry() {
|
|
@@ -49,6 +50,7 @@ function createEntry() {
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Generate a stable subscription key string from a spec. Sorts params keys.
|
|
53
|
+
*
|
|
52
54
|
* @param {SubscriptionSpec} spec
|
|
53
55
|
* @returns {string}
|
|
54
56
|
*/
|
|
@@ -69,6 +71,7 @@ export function keyOf(spec) {
|
|
|
69
71
|
|
|
70
72
|
/**
|
|
71
73
|
* Compute a delta between previous and next item maps.
|
|
74
|
+
*
|
|
72
75
|
* @param {Map<string, ItemMeta>} prev
|
|
73
76
|
* @param {Map<string, ItemMeta>} next
|
|
74
77
|
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
|
@@ -101,6 +104,7 @@ export function computeDelta(prev, next) {
|
|
|
101
104
|
|
|
102
105
|
/**
|
|
103
106
|
* Normalize array of issue-like objects into an itemsById map.
|
|
107
|
+
*
|
|
104
108
|
* @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
|
|
105
109
|
* @returns {Map<string, ItemMeta>}
|
|
106
110
|
*/
|
|
@@ -136,6 +140,7 @@ export class SubscriptionRegistry {
|
|
|
136
140
|
|
|
137
141
|
/**
|
|
138
142
|
* Get an entry by key, or null if missing.
|
|
143
|
+
*
|
|
139
144
|
* @param {string} key
|
|
140
145
|
* @returns {Entry | null}
|
|
141
146
|
*/
|
|
@@ -145,6 +150,7 @@ export class SubscriptionRegistry {
|
|
|
145
150
|
|
|
146
151
|
/**
|
|
147
152
|
* Ensure an entry exists for a spec; returns the key and entry.
|
|
153
|
+
*
|
|
148
154
|
* @param {SubscriptionSpec} spec
|
|
149
155
|
* @returns {{ key: string, entry: Entry }}
|
|
150
156
|
*/
|
|
@@ -160,6 +166,7 @@ export class SubscriptionRegistry {
|
|
|
160
166
|
|
|
161
167
|
/**
|
|
162
168
|
* Attach a subscriber to a spec. Creates the entry if missing.
|
|
169
|
+
*
|
|
163
170
|
* @param {SubscriptionSpec} spec
|
|
164
171
|
* @param {WebSocket} ws
|
|
165
172
|
* @returns {{ key: string, subscribed: true }}
|
|
@@ -173,6 +180,7 @@ export class SubscriptionRegistry {
|
|
|
173
180
|
/**
|
|
174
181
|
* Detach a subscriber from the spec. Keeps entry even if empty; eviction
|
|
175
182
|
* is handled by `onDisconnect` sweep.
|
|
183
|
+
*
|
|
176
184
|
* @param {SubscriptionSpec} spec
|
|
177
185
|
* @param {WebSocket} ws
|
|
178
186
|
* @returns {boolean} true when the subscriber was removed
|
|
@@ -189,6 +197,7 @@ export class SubscriptionRegistry {
|
|
|
189
197
|
/**
|
|
190
198
|
* On socket disconnect, remove it from all subscriber sets and evict any
|
|
191
199
|
* entries that become empty as a result of this sweep.
|
|
200
|
+
*
|
|
192
201
|
* @param {WebSocket} ws
|
|
193
202
|
*/
|
|
194
203
|
onDisconnect(ws) {
|
|
@@ -207,6 +216,7 @@ export class SubscriptionRegistry {
|
|
|
207
216
|
|
|
208
217
|
/**
|
|
209
218
|
* Serialize a function against a key so only one runs at a time per key.
|
|
219
|
+
*
|
|
210
220
|
* @template T
|
|
211
221
|
* @param {string} key
|
|
212
222
|
* @param {() => Promise<T>} fn
|
|
@@ -243,6 +253,7 @@ export class SubscriptionRegistry {
|
|
|
243
253
|
|
|
244
254
|
/**
|
|
245
255
|
* Replace items for a key and compute the delta, storing the new map.
|
|
256
|
+
*
|
|
246
257
|
* @param {string} key
|
|
247
258
|
* @param {Map<string, ItemMeta>} next_map
|
|
248
259
|
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
|
@@ -261,6 +272,7 @@ export class SubscriptionRegistry {
|
|
|
261
272
|
|
|
262
273
|
/**
|
|
263
274
|
* Convenience: update items from an array of objects with id/updated_at/closed_at.
|
|
275
|
+
*
|
|
264
276
|
* @param {string} key
|
|
265
277
|
* @param {Array<{ id: string, updated_at: number, closed_at?: number|null }>} items
|
|
266
278
|
* @returns {{ added: string[], updated: string[], removed: string[] }}
|
package/server/validators.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Known subscription types supported by the server.
|
|
9
|
+
*
|
|
9
10
|
* @type {Set<string>}
|
|
10
11
|
*/
|
|
11
12
|
const SUBSCRIPTION_TYPES = new Set([
|
|
@@ -20,6 +21,7 @@ const SUBSCRIPTION_TYPES = new Set([
|
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Validate a subscribe-list payload and normalize to a SubscriptionSpec.
|
|
24
|
+
*
|
|
23
25
|
* @param {unknown} payload
|
|
24
26
|
* @returns {{ ok: true, id: string, spec: { type: string, params?: Record<string, string|number|boolean> } } | { ok: false, code: 'bad_request', message: string }}
|
|
25
27
|
*/
|
package/server/watcher.js
CHANGED
|
@@ -5,6 +5,7 @@ import { resolveDbPath } from './db.js';
|
|
|
5
5
|
/**
|
|
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
9
|
* @param {string} root_dir - Project root directory (starting point for resolution).
|
|
9
10
|
* @param {() => void} onChange - Called when changes are detected.
|
|
10
11
|
* @param {{ debounce_ms?: number, explicit_db?: string }} [options]
|
|
@@ -33,6 +34,7 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Attach a watcher to the directory containing the resolved DB path.
|
|
37
|
+
*
|
|
36
38
|
* @param {string} base_dir
|
|
37
39
|
* @param {string | undefined} explicit_db
|
|
38
40
|
*/
|
|
@@ -84,6 +86,7 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
84
86
|
},
|
|
85
87
|
/**
|
|
86
88
|
* Re-resolve and reattach watcher when root_dir or explicit_db changes.
|
|
89
|
+
*
|
|
87
90
|
* @param {{ root_dir?: string, explicit_db?: string }} [opts]
|
|
88
91
|
*/
|
|
89
92
|
rebind(opts = {}) {
|
package/server/ws.js
CHANGED
|
@@ -40,6 +40,7 @@ let MUTATION_GATE = null;
|
|
|
40
40
|
* suppressed during the window.
|
|
41
41
|
*
|
|
42
42
|
* Fire-and-forget; callers should not await this.
|
|
43
|
+
*
|
|
43
44
|
* @param {number} [timeout_ms]
|
|
44
45
|
*/
|
|
45
46
|
function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
@@ -95,6 +96,7 @@ function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
|
95
96
|
|
|
96
97
|
/**
|
|
97
98
|
* Collect unique active list subscription specs across all connected clients.
|
|
99
|
+
*
|
|
98
100
|
* @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
|
|
99
101
|
*/
|
|
100
102
|
function collectActiveListSpecs() {
|
|
@@ -181,6 +183,7 @@ let CURRENT_WSS = null;
|
|
|
181
183
|
|
|
182
184
|
/**
|
|
183
185
|
* Get or initialize the subscription state for a socket.
|
|
186
|
+
*
|
|
184
187
|
* @param {WebSocket} ws
|
|
185
188
|
* @returns {any}
|
|
186
189
|
*/
|
|
@@ -199,6 +202,7 @@ function ensureSubs(ws) {
|
|
|
199
202
|
|
|
200
203
|
/**
|
|
201
204
|
* Get next monotonically increasing revision for a subscription key on this connection.
|
|
205
|
+
*
|
|
202
206
|
* @param {WebSocket} ws
|
|
203
207
|
* @param {string} key
|
|
204
208
|
*/
|
|
@@ -306,6 +310,7 @@ function emitSubscriptionDelete(ws, client_id, key, issue_id) {
|
|
|
306
310
|
/**
|
|
307
311
|
* Refresh a subscription spec: fetch via adapter, apply to registry and emit
|
|
308
312
|
* per-subscription full-issue envelopes to subscribers. Serialized per key.
|
|
313
|
+
*
|
|
309
314
|
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
310
315
|
*/
|
|
311
316
|
async function refreshAndPublish(spec) {
|
|
@@ -362,6 +367,7 @@ async function refreshAndPublish(spec) {
|
|
|
362
367
|
|
|
363
368
|
/**
|
|
364
369
|
* Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
|
|
370
|
+
*
|
|
365
371
|
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
366
372
|
* @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
|
|
367
373
|
*/
|
|
@@ -387,6 +393,7 @@ function applyClosedIssuesFilter(spec, items) {
|
|
|
387
393
|
|
|
388
394
|
/**
|
|
389
395
|
* Attach a WebSocket server to an existing HTTP server.
|
|
396
|
+
*
|
|
390
397
|
* @param {Server} http_server
|
|
391
398
|
* @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number }} [options]
|
|
392
399
|
* @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void }}
|
|
@@ -451,6 +458,7 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
451
458
|
|
|
452
459
|
/**
|
|
453
460
|
* Broadcast a server-initiated event to all open clients.
|
|
461
|
+
*
|
|
454
462
|
* @param {MessageType} type
|
|
455
463
|
* @param {unknown} [payload]
|
|
456
464
|
*/
|
|
@@ -478,6 +486,7 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
478
486
|
|
|
479
487
|
/**
|
|
480
488
|
* Handle an incoming message frame and respond to the same socket.
|
|
489
|
+
*
|
|
481
490
|
* @param {WebSocket} ws
|
|
482
491
|
* @param {RawData} data
|
|
483
492
|
*/
|