beads-ui 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGES.md +26 -0
- package/README.md +15 -6
- package/app/main.bundle.js +617 -0
- package/app/main.bundle.js.map +7 -0
- package/bin/bdui.js +2 -1
- package/package.json +27 -16
- package/server/app.js +39 -35
- package/server/bd.js +6 -2
- package/server/cli/commands.js +12 -8
- package/server/cli/daemon.js +20 -5
- package/server/cli/index.js +19 -31
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +4 -2
- package/server/config.js +3 -2
- package/server/db.js +9 -6
- package/server/index.js +10 -4
- package/server/list-adapters.js +9 -3
- package/server/logging.js +23 -0
- package/server/subscriptions.js +12 -0
- package/server/validators.js +2 -0
- package/server/watcher.js +10 -5
- package/server/ws.js +31 -10
- package/app/data/list-selectors.js +0 -98
- package/app/data/providers.js +0 -76
- package/app/data/sort.js +0 -45
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -102
- package/app/data/subscriptions-store.js +0 -219
- package/app/main.js +0 -702
- package/app/protocol.js +0 -196
- package/app/protocol.md +0 -66
- package/app/router.js +0 -114
- package/app/state.js +0 -103
- package/app/utils/issue-id-renderer.js +0 -71
- package/app/utils/issue-id.js +0 -10
- package/app/utils/issue-type.js +0 -27
- package/app/utils/issue-url.js +0 -9
- package/app/utils/markdown.js +0 -22
- package/app/utils/priority-badge.js +0 -47
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -32
- package/app/utils/status.js +0 -23
- package/app/utils/toast.js +0 -34
- package/app/utils/type-badge.js +0 -33
- package/app/views/board.js +0 -535
- package/app/views/detail.js +0 -1249
- package/app/views/epics.js +0 -280
- package/app/views/issue-dialog.js +0 -163
- package/app/views/issue-row.js +0 -190
- package/app/views/list.js +0 -464
- package/app/views/nav.js +0 -67
- package/app/views/new-issue-dialog.js +0 -345
- package/app/ws.js +0 -279
- package/docs/adr/001-push-only-lists.md +0 -134
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
- package/docs/architecture.md +0 -194
- package/docs/data-exchange-subscription-plan.md +0 -198
- package/docs/db-watching.md +0 -30
- package/docs/migration-v2.md +0 -54
- package/docs/protocol/issues-push-v2.md +0 -179
- package/docs/subscription-issue-store.md +0 -112
package/app/protocol.js
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Protocol definitions for beads-ui WebSocket communication.
|
|
3
|
-
*
|
|
4
|
-
* Conventions
|
|
5
|
-
* - All messages are JSON objects.
|
|
6
|
-
* - Client → Server uses RequestEnvelope.
|
|
7
|
-
* - Server → Client uses ReplyEnvelope.
|
|
8
|
-
* - Every request is correlated by `id` in replies.
|
|
9
|
-
* - Server can also send unsolicited events (e.g., subscription `snapshot`).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** @typedef {'list-issues'|'update-status'|'edit-text'|'update-priority'|'create-issue'|'list-ready'|'dep-add'|'dep-remove'|'epic-status'|'update-assignee'|'label-add'|'label-remove'|'subscribe-list'|'unsubscribe-list'|'snapshot'|'upsert'|'delete'} MessageType */
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* @typedef {Object} RequestEnvelope
|
|
16
|
-
* @property {string} id - Unique id to correlate request/response.
|
|
17
|
-
* @property {MessageType} type - Message type.
|
|
18
|
-
* @property {unknown} [payload] - Message payload.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @typedef {Object} ErrorObject
|
|
23
|
-
* @property {string} code - Stable error code.
|
|
24
|
-
* @property {string} message - Human-readable message.
|
|
25
|
-
* @property {unknown} [details] - Optional extra info for debugging.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* @typedef {Object} ReplyEnvelope
|
|
30
|
-
* @property {string} id - Correlates to the originating request.
|
|
31
|
-
* @property {boolean} ok - True when request succeeded; false on error.
|
|
32
|
-
* @property {MessageType} type - Echoes request type (or event type).
|
|
33
|
-
* @property {unknown} [payload] - Response payload.
|
|
34
|
-
* @property {ErrorObject} [error] - Present when ok=false.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
/** @type {MessageType[]} */
|
|
38
|
-
export const MESSAGE_TYPES = /** @type {const} */ ([
|
|
39
|
-
'list-issues',
|
|
40
|
-
'update-status',
|
|
41
|
-
'edit-text',
|
|
42
|
-
'update-priority',
|
|
43
|
-
'create-issue',
|
|
44
|
-
'list-ready',
|
|
45
|
-
'dep-add',
|
|
46
|
-
'dep-remove',
|
|
47
|
-
'epic-status',
|
|
48
|
-
'update-assignee',
|
|
49
|
-
'label-add',
|
|
50
|
-
'label-remove',
|
|
51
|
-
'subscribe-list',
|
|
52
|
-
'unsubscribe-list',
|
|
53
|
-
// vNext per-subscription full-issue push events
|
|
54
|
-
'snapshot',
|
|
55
|
-
'upsert',
|
|
56
|
-
'delete'
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Generate a lexically sortable request id.
|
|
61
|
-
* @returns {string}
|
|
62
|
-
*/
|
|
63
|
-
export function nextId() {
|
|
64
|
-
const now = Date.now().toString(36);
|
|
65
|
-
const rand = Math.random().toString(36).slice(2, 8);
|
|
66
|
-
return `${now}-${rand}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Create a request envelope.
|
|
71
|
-
* @param {MessageType} type - Message type.
|
|
72
|
-
* @param {unknown} [payload] - Message payload.
|
|
73
|
-
* @param {string} [id] - Optional id; generated if omitted.
|
|
74
|
-
* @returns {RequestEnvelope}
|
|
75
|
-
*/
|
|
76
|
-
export function makeRequest(type, payload, id = nextId()) {
|
|
77
|
-
return { id, type, payload };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Create a successful reply envelope for a given request.
|
|
82
|
-
* @param {RequestEnvelope} req - Original request.
|
|
83
|
-
* @param {unknown} [payload] - Reply payload.
|
|
84
|
-
* @returns {ReplyEnvelope}
|
|
85
|
-
*/
|
|
86
|
-
export function makeOk(req, payload) {
|
|
87
|
-
return { id: req.id, ok: true, type: req.type, payload };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Create an error reply envelope for a given request.
|
|
92
|
-
* @param {RequestEnvelope} req - Original request.
|
|
93
|
-
* @param {string} code - Error code.
|
|
94
|
-
* @param {string} message - Error message.
|
|
95
|
-
* @param {unknown} [details] - Extra details.
|
|
96
|
-
* @returns {ReplyEnvelope}
|
|
97
|
-
*/
|
|
98
|
-
export function makeError(req, code, message, details) {
|
|
99
|
-
return {
|
|
100
|
-
id: req.id,
|
|
101
|
-
ok: false,
|
|
102
|
-
type: req.type,
|
|
103
|
-
error: { code, message, details }
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Check if a value is a plain object.
|
|
109
|
-
* @param {unknown} value
|
|
110
|
-
* @returns {value is Record<string, unknown>}
|
|
111
|
-
*/
|
|
112
|
-
function isRecord(value) {
|
|
113
|
-
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Type guard for MessageType values.
|
|
118
|
-
* @param {unknown} value
|
|
119
|
-
* @returns {value is MessageType}
|
|
120
|
-
*/
|
|
121
|
-
export function isMessageType(value) {
|
|
122
|
-
return (
|
|
123
|
-
typeof value === 'string' &&
|
|
124
|
-
MESSAGE_TYPES.includes(/** @type {MessageType} */ (value))
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Type guard for RequestEnvelope.
|
|
130
|
-
* @param {unknown} value
|
|
131
|
-
* @returns {value is RequestEnvelope}
|
|
132
|
-
*/
|
|
133
|
-
export function isRequest(value) {
|
|
134
|
-
if (!isRecord(value)) {
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
return (
|
|
138
|
-
typeof value.id === 'string' &&
|
|
139
|
-
typeof value.type === 'string' &&
|
|
140
|
-
(value.payload === undefined || 'payload' in value)
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Type guard for ReplyEnvelope.
|
|
146
|
-
* @param {unknown} value
|
|
147
|
-
* @returns {value is ReplyEnvelope}
|
|
148
|
-
*/
|
|
149
|
-
export function isReply(value) {
|
|
150
|
-
if (!isRecord(value)) {
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
if (
|
|
154
|
-
typeof value.id !== 'string' ||
|
|
155
|
-
typeof value.ok !== 'boolean' ||
|
|
156
|
-
!isMessageType(value.type)
|
|
157
|
-
) {
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
if (value.ok === false) {
|
|
161
|
-
const err = value.error;
|
|
162
|
-
if (
|
|
163
|
-
!isRecord(err) ||
|
|
164
|
-
typeof err.code !== 'string' ||
|
|
165
|
-
typeof err.message !== 'string'
|
|
166
|
-
) {
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Normalize and validate an incoming JSON value as a RequestEnvelope.
|
|
175
|
-
* Throws a user-friendly error if invalid.
|
|
176
|
-
* @param {unknown} json
|
|
177
|
-
* @returns {RequestEnvelope}
|
|
178
|
-
*/
|
|
179
|
-
export function decodeRequest(json) {
|
|
180
|
-
if (!isRequest(json)) {
|
|
181
|
-
throw new Error('Invalid request envelope');
|
|
182
|
-
}
|
|
183
|
-
return json;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Normalize and validate an incoming JSON value as a ReplyEnvelope.
|
|
188
|
-
* @param {unknown} json
|
|
189
|
-
* @returns {ReplyEnvelope}
|
|
190
|
-
*/
|
|
191
|
-
export function decodeReply(json) {
|
|
192
|
-
if (!isReply(json)) {
|
|
193
|
-
throw new Error('Invalid reply envelope');
|
|
194
|
-
}
|
|
195
|
-
return json;
|
|
196
|
-
}
|
package/app/protocol.md
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# beads-ui WebSocket Protocol (v2.0.0)
|
|
2
|
-
|
|
3
|
-
Note (2025-10-26)
|
|
4
|
-
|
|
5
|
-
- The server no longer implements legacy read RPCs `list-issues` and
|
|
6
|
-
`epic-status`. Clients must use the push-only protocol described in
|
|
7
|
-
`docs/protocol/issues-push-v2.md` (subscribe-list with per-subscription events
|
|
8
|
-
snapshot/upsert/delete). The shapes below are retained for historical
|
|
9
|
-
reference of v1.
|
|
10
|
-
|
|
11
|
-
This document defines the JSON messages exchanged between the browser client and
|
|
12
|
-
the local server.
|
|
13
|
-
|
|
14
|
-
- Transport: single WebSocket connection
|
|
15
|
-
- Encoding: JSON text frames
|
|
16
|
-
- Correlation: all request/response pairs share the same `id`
|
|
17
|
-
|
|
18
|
-
## Envelope Shapes
|
|
19
|
-
|
|
20
|
-
- RequestEnvelope: `{ id: string, type: string, payload?: any }`
|
|
21
|
-
- ReplyEnvelope:
|
|
22
|
-
`{ id: string, ok: boolean, type: string, payload?: any, error?: { code: string, message: string, details?: any } }`
|
|
23
|
-
|
|
24
|
-
Server may send unsolicited events (e.g., subscription
|
|
25
|
-
`snapshot`/`upsert`/`delete`) using the ReplyEnvelope shape with `ok: true` and
|
|
26
|
-
a generated `id`.
|
|
27
|
-
|
|
28
|
-
## Message Types
|
|
29
|
-
|
|
30
|
-
- Removed in v2: `list-issues` (use subscriptions + push stores)
|
|
31
|
-
- `update-status` payload:
|
|
32
|
-
`{ id: string, status: 'open'|'in_progress'|'closed' }`
|
|
33
|
-
- `edit-text` payload:
|
|
34
|
-
`{ id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }`
|
|
35
|
-
- `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
|
|
36
|
-
- `create-issue` payload:
|
|
37
|
-
`{ title: string, type?: 'bug'|'feature'|'task'|'epic'|'chore', priority?: 0|1|2|3|4, description?: string }`
|
|
38
|
-
- `list-ready` payload: `{}`
|
|
39
|
-
- Removed in v2: `subscribe-updates` and the `issues-changed` event. All list
|
|
40
|
-
and detail updates flow via per-subscription push envelopes
|
|
41
|
-
(`snapshot`/`upsert`/`delete`).
|
|
42
|
-
- `dep-add` payload: `{ a: string, b: string, view_id?: string }` where `a`
|
|
43
|
-
depends on `b` (i.e., `a` is blocked by `b`). Reply payload is the updated
|
|
44
|
-
issue for `view_id` (or `a` when omitted).
|
|
45
|
-
- `dep-remove` payload: `{ a: string, b: string, view_id?: string }` removing
|
|
46
|
-
the `a` depends on `b` link. Reply payload is the updated issue for `view_id`
|
|
47
|
-
(or `a`).
|
|
48
|
-
|
|
49
|
-
## Mapping to `bd` CLI
|
|
50
|
-
|
|
51
|
-
- Removed in v2: `list-issues` → use subscriptions and push
|
|
52
|
-
(`docs/protocol/issues-push-v2.md`)
|
|
53
|
-
- `update-status` → `bd update <id> --status <status>`
|
|
54
|
-
- `edit-text` → `bd update <id> --title <t>` or `--description <d>` or
|
|
55
|
-
`--acceptance-criteria <a>` or `--notes <n>` or `--design <z>`
|
|
56
|
-
- `update-priority` → `bd update <id> --priority <n>`
|
|
57
|
-
- `create-issue` → `bd create "title" -t <type> -p <prio> -d "desc"`
|
|
58
|
-
- `list-ready` → `bd ready --json`
|
|
59
|
-
|
|
60
|
-
## Errors
|
|
61
|
-
|
|
62
|
-
Errors follow the shape `{ code, message, details? }`. Common codes:
|
|
63
|
-
|
|
64
|
-
- `bad_request` – malformed payload or unknown type
|
|
65
|
-
- `not_found` – entity not found (e.g., issue id)
|
|
66
|
-
- `bd_error` – underlying `bd` command failed
|
package/app/router.js
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { issueHashFor } from './utils/issue-url.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Hash-based router for tabs (issues/epics/board) and deep-linked issue ids.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Parse an application hash and extract the selected issue id.
|
|
9
|
-
* Supports canonical form "#/(issues|epics|board)?issue=<id>" and legacy
|
|
10
|
-
* "#/issue/<id>" which we will rewrite to the canonical form.
|
|
11
|
-
* @param {string} hash
|
|
12
|
-
* @returns {string | null}
|
|
13
|
-
*/
|
|
14
|
-
export function parseHash(hash) {
|
|
15
|
-
const h = String(hash || '');
|
|
16
|
-
// Extract the fragment sans leading '#'
|
|
17
|
-
const frag = h.startsWith('#') ? h.slice(1) : h;
|
|
18
|
-
const qIndex = frag.indexOf('?');
|
|
19
|
-
const query = qIndex >= 0 ? frag.slice(qIndex + 1) : '';
|
|
20
|
-
if (query) {
|
|
21
|
-
const params = new URLSearchParams(query);
|
|
22
|
-
const id = params.get('issue');
|
|
23
|
-
if (id) {
|
|
24
|
-
return decodeURIComponent(id);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
// Legacy pattern: #/issue/<id>
|
|
28
|
-
const m = /^\/issue\/([^\s?#]+)/.exec(frag);
|
|
29
|
-
return m && m[1] ? decodeURIComponent(m[1]) : null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Parse the current view from hash.
|
|
34
|
-
* @param {string} hash
|
|
35
|
-
* @returns {'issues'|'epics'|'board'}
|
|
36
|
-
*/
|
|
37
|
-
export function parseView(hash) {
|
|
38
|
-
const h = String(hash || '');
|
|
39
|
-
if (/^#\/epics(\b|\/|$)/.test(h)) {
|
|
40
|
-
return 'epics';
|
|
41
|
-
}
|
|
42
|
-
if (/^#\/board(\b|\/|$)/.test(h)) {
|
|
43
|
-
return 'board';
|
|
44
|
-
}
|
|
45
|
-
// Default to issues (also covers #/issues and unknown/empty)
|
|
46
|
-
return 'issues';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @param {{ getState: () => any, setState: (patch: any) => void }} store
|
|
51
|
-
*/
|
|
52
|
-
export function createHashRouter(store) {
|
|
53
|
-
/** @type {(ev?: HashChangeEvent) => any} */
|
|
54
|
-
const onHashChange = () => {
|
|
55
|
-
const hash = window.location.hash || '';
|
|
56
|
-
// Rewrite legacy #/issue/<id> to canonical #/issues?issue=<id>
|
|
57
|
-
const legacyMatch = /^#\/issue\/([^\s?#]+)/.exec(hash);
|
|
58
|
-
if (legacyMatch && legacyMatch[1]) {
|
|
59
|
-
const id = decodeURIComponent(legacyMatch[1]);
|
|
60
|
-
// Update state immediately for consumers expecting sync selection
|
|
61
|
-
store.setState({ selected_id: id, view: 'issues' });
|
|
62
|
-
const next = `#/issues?issue=${encodeURIComponent(id)}`;
|
|
63
|
-
if (window.location.hash !== next) {
|
|
64
|
-
window.location.hash = next;
|
|
65
|
-
return; // will trigger handler again
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const id = parseHash(hash);
|
|
69
|
-
const view = parseView(hash);
|
|
70
|
-
store.setState({ selected_id: id, view });
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
start() {
|
|
75
|
-
window.addEventListener('hashchange', onHashChange);
|
|
76
|
-
onHashChange();
|
|
77
|
-
},
|
|
78
|
-
stop() {
|
|
79
|
-
window.removeEventListener('hashchange', onHashChange);
|
|
80
|
-
},
|
|
81
|
-
/**
|
|
82
|
-
* @param {string} id
|
|
83
|
-
*/
|
|
84
|
-
gotoIssue(id) {
|
|
85
|
-
// Keep current view in hash and append issue param via helper
|
|
86
|
-
const s = store.getState ? store.getState() : { view: 'issues' };
|
|
87
|
-
const view = s.view || 'issues';
|
|
88
|
-
const next = issueHashFor(view, id);
|
|
89
|
-
if (window.location.hash !== next) {
|
|
90
|
-
window.location.hash = next;
|
|
91
|
-
} else {
|
|
92
|
-
// Force state update even if hash is the same
|
|
93
|
-
store.setState({ selected_id: id, view });
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
/**
|
|
97
|
-
* Navigate to a top-level view.
|
|
98
|
-
* @param {'issues'|'epics'|'board'} view
|
|
99
|
-
*/
|
|
100
|
-
/**
|
|
101
|
-
* @param {'issues'|'epics'|'board'} view
|
|
102
|
-
*/
|
|
103
|
-
gotoView(view) {
|
|
104
|
-
const s = store.getState ? store.getState() : { selected_id: null };
|
|
105
|
-
const id = s.selected_id;
|
|
106
|
-
const next = id ? issueHashFor(view, id) : `#/${view}`;
|
|
107
|
-
if (window.location.hash !== next) {
|
|
108
|
-
window.location.hash = next;
|
|
109
|
-
} else {
|
|
110
|
-
store.setState({ view, selected_id: null });
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
}
|
package/app/state.js
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal app state store with subscription.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @typedef {'all'|'open'|'in_progress'|'closed'|'ready'} StatusFilter
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {{ status: StatusFilter, search: string, type: string }} Filters
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @typedef {'issues'|'epics'|'board'} ViewName
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @typedef {'today'|'3'|'7'} ClosedFilter
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @typedef {{ closed_filter: ClosedFilter }} BoardState
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @typedef {{ selected_id: string | null, view: ViewName, filters: Filters, board: BoardState }} AppState
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Create a simple store for application state.
|
|
31
|
-
* @param {Partial<AppState>} [initial]
|
|
32
|
-
* @returns {{ getState: () => AppState, setState: (patch: { selected_id?: string | null, filters?: Partial<Filters> }) => void, subscribe: (fn: (s: AppState) => void) => () => void }}
|
|
33
|
-
*/
|
|
34
|
-
export function createStore(initial = {}) {
|
|
35
|
-
/** @type {AppState} */
|
|
36
|
-
let state = {
|
|
37
|
-
selected_id: initial.selected_id ?? null,
|
|
38
|
-
view: initial.view ?? 'issues',
|
|
39
|
-
filters: {
|
|
40
|
-
status: initial.filters?.status ?? 'all',
|
|
41
|
-
search: initial.filters?.search ?? '',
|
|
42
|
-
type:
|
|
43
|
-
typeof initial.filters?.type === 'string' ? initial.filters?.type : ''
|
|
44
|
-
},
|
|
45
|
-
board: {
|
|
46
|
-
closed_filter:
|
|
47
|
-
initial.board?.closed_filter === '3' ||
|
|
48
|
-
initial.board?.closed_filter === '7' ||
|
|
49
|
-
initial.board?.closed_filter === 'today'
|
|
50
|
-
? initial.board?.closed_filter
|
|
51
|
-
: 'today'
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/** @type {Set<(s: AppState) => void>} */
|
|
56
|
-
const subs = new Set();
|
|
57
|
-
|
|
58
|
-
function emit() {
|
|
59
|
-
for (const fn of Array.from(subs)) {
|
|
60
|
-
try {
|
|
61
|
-
fn(state);
|
|
62
|
-
} catch {
|
|
63
|
-
// ignore
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return {
|
|
69
|
-
getState() {
|
|
70
|
-
return state;
|
|
71
|
-
},
|
|
72
|
-
/**
|
|
73
|
-
* Update state. Nested filters can be partial.
|
|
74
|
-
* @param {{ selected_id?: string | null, filters?: Partial<Filters>, board?: Partial<BoardState> }} patch
|
|
75
|
-
*/
|
|
76
|
-
setState(patch) {
|
|
77
|
-
/** @type {AppState} */
|
|
78
|
-
const next = {
|
|
79
|
-
...state,
|
|
80
|
-
...patch,
|
|
81
|
-
filters: { ...state.filters, ...(patch.filters || {}) },
|
|
82
|
-
board: { ...state.board, ...(patch.board || {}) }
|
|
83
|
-
};
|
|
84
|
-
// Avoid emitting if nothing changed (shallow compare)
|
|
85
|
-
if (
|
|
86
|
-
next.selected_id === state.selected_id &&
|
|
87
|
-
next.view === state.view &&
|
|
88
|
-
next.filters.status === state.filters.status &&
|
|
89
|
-
next.filters.search === state.filters.search &&
|
|
90
|
-
next.filters.type === state.filters.type &&
|
|
91
|
-
next.board.closed_filter === state.board.closed_filter
|
|
92
|
-
) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
state = next;
|
|
96
|
-
emit();
|
|
97
|
-
},
|
|
98
|
-
subscribe(fn) {
|
|
99
|
-
subs.add(fn);
|
|
100
|
-
return () => subs.delete(fn);
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { issueDisplayId } from './issue-id.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Create a reusable, copy-to-clipboard issue ID renderer.
|
|
5
|
-
* Looks like the current inline ID (monospace `#123`) but acts as a button
|
|
6
|
-
* that copies the full, prefixed ID (e.g., `UI-123`) when activated.
|
|
7
|
-
* Shows transient "Copied" feedback and then restores the ID.
|
|
8
|
-
* @param {string} id - Full issue id including the prefix (e.g., "UI-123").
|
|
9
|
-
* @param {{ class_name?: string, duration_ms?: number }} [opts]
|
|
10
|
-
* @returns {HTMLButtonElement}
|
|
11
|
-
*/
|
|
12
|
-
export function createIssueIdRenderer(id, opts) {
|
|
13
|
-
/** @type {number} */
|
|
14
|
-
const duration =
|
|
15
|
-
typeof opts?.duration_ms === 'number' ? opts.duration_ms : 1200;
|
|
16
|
-
/** @type {HTMLButtonElement} */
|
|
17
|
-
const btn = document.createElement('button');
|
|
18
|
-
// Visual: match inline ID look; keep it neutral and text-like
|
|
19
|
-
btn.className =
|
|
20
|
-
(opts?.class_name ? opts.class_name + ' ' : '') + 'mono id-copy';
|
|
21
|
-
btn.type = 'button';
|
|
22
|
-
btn.setAttribute('aria-live', 'polite');
|
|
23
|
-
btn.setAttribute('title', 'Copy issue ID');
|
|
24
|
-
btn.setAttribute('aria-label', `Copy issue ID ${id}`);
|
|
25
|
-
const label = issueDisplayId(id);
|
|
26
|
-
btn.textContent = label;
|
|
27
|
-
|
|
28
|
-
/** Copy handler with feedback */
|
|
29
|
-
async function doCopy() {
|
|
30
|
-
// Prevent accidental row navigation and parent handlers
|
|
31
|
-
// (click/key handlers call this inside an event context)
|
|
32
|
-
try {
|
|
33
|
-
if (
|
|
34
|
-
navigator.clipboard &&
|
|
35
|
-
typeof navigator.clipboard.writeText === 'function'
|
|
36
|
-
) {
|
|
37
|
-
await navigator.clipboard.writeText(String(id));
|
|
38
|
-
}
|
|
39
|
-
const prev = btn.textContent || label;
|
|
40
|
-
btn.textContent = 'Copied';
|
|
41
|
-
// Keep accessible label consistent with feedback
|
|
42
|
-
const oldAria = btn.getAttribute('aria-label') || '';
|
|
43
|
-
btn.setAttribute('aria-label', 'Copied');
|
|
44
|
-
setTimeout(
|
|
45
|
-
() => {
|
|
46
|
-
btn.textContent = prev;
|
|
47
|
-
btn.setAttribute('aria-label', oldAria);
|
|
48
|
-
},
|
|
49
|
-
Math.max(80, duration)
|
|
50
|
-
);
|
|
51
|
-
} catch {
|
|
52
|
-
// On failure, leave text as-is; no throw to avoid disruptive UX
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
btn.addEventListener('click', (ev) => {
|
|
57
|
-
ev.preventDefault();
|
|
58
|
-
ev.stopPropagation();
|
|
59
|
-
void doCopy();
|
|
60
|
-
});
|
|
61
|
-
btn.addEventListener('keydown', (ev) => {
|
|
62
|
-
// Ensure keyboard activation works even in non-interactive test envs
|
|
63
|
-
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
64
|
-
ev.preventDefault();
|
|
65
|
-
ev.stopPropagation();
|
|
66
|
-
void doCopy();
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
return btn;
|
|
71
|
-
}
|
package/app/utils/issue-id.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Format a beads issue id as a user-facing display string `#${n}`.
|
|
3
|
-
* Extracts the trailing numeric portion of the id and prefixes with '#'.
|
|
4
|
-
* @param {string | null | undefined} id
|
|
5
|
-
* @returns {string}
|
|
6
|
-
*/
|
|
7
|
-
export function issueDisplayId(id) {
|
|
8
|
-
const m = String(id || '').match(/(\d+)$/);
|
|
9
|
-
return m ? `#${m[1]}` : '#';
|
|
10
|
-
}
|
package/app/utils/issue-type.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Known issue types in canonical order for dropdowns.
|
|
3
|
-
* @type {Array<'bug'|'feature'|'task'|'epic'|'chore'>}
|
|
4
|
-
*/
|
|
5
|
-
export const ISSUE_TYPES = ['bug', 'feature', 'task', 'epic', 'chore'];
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Return a human-friendly label for an issue type.
|
|
9
|
-
* @param {string | null | undefined} type
|
|
10
|
-
* @returns {string}
|
|
11
|
-
*/
|
|
12
|
-
export function typeLabel(type) {
|
|
13
|
-
switch ((type || '').toString().toLowerCase()) {
|
|
14
|
-
case 'bug':
|
|
15
|
-
return 'Bug';
|
|
16
|
-
case 'feature':
|
|
17
|
-
return 'Feature';
|
|
18
|
-
case 'task':
|
|
19
|
-
return 'Task';
|
|
20
|
-
case 'epic':
|
|
21
|
-
return 'Epic';
|
|
22
|
-
case 'chore':
|
|
23
|
-
return 'Chore';
|
|
24
|
-
default:
|
|
25
|
-
return '';
|
|
26
|
-
}
|
|
27
|
-
}
|
package/app/utils/issue-url.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build a canonical issue hash that retains the view.
|
|
3
|
-
* @param {'issues'|'epics'|'board'} view
|
|
4
|
-
* @param {string} id
|
|
5
|
-
*/
|
|
6
|
-
export function issueHashFor(view, id) {
|
|
7
|
-
const v = view === 'epics' || view === 'board' ? view : 'issues';
|
|
8
|
-
return `#/${v}?issue=${encodeURIComponent(id)}`;
|
|
9
|
-
}
|
package/app/utils/markdown.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import DOMPurify from 'dompurify';
|
|
2
|
-
import { html } from 'lit-html';
|
|
3
|
-
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
|
|
4
|
-
import { marked } from 'marked';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Render Markdown safely as HTML using marked and DOMPurify.
|
|
8
|
-
* Returns a lit-html TemplateResult via the unsafeHTML directive so it can be
|
|
9
|
-
* embedded directly in templates.
|
|
10
|
-
* @function renderMarkdown
|
|
11
|
-
* @param {string} src Markdown source text
|
|
12
|
-
* @returns {import('lit-html').TemplateResult}
|
|
13
|
-
*/
|
|
14
|
-
export function renderMarkdown(src) {
|
|
15
|
-
/** @type {string} */
|
|
16
|
-
const markdown = String(src || '');
|
|
17
|
-
/** @type {string} */
|
|
18
|
-
const parsed = /** @type {string} */ (marked.parse(markdown));
|
|
19
|
-
/** @type {string} */
|
|
20
|
-
const html_string = DOMPurify.sanitize(parsed);
|
|
21
|
-
return html`${unsafeHTML(html_string)}`;
|
|
22
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { priority_levels } from './priority.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Create a colored badge for a priority value (0..4).
|
|
5
|
-
* @param {number | null | undefined} priority
|
|
6
|
-
* @returns {HTMLSpanElement}
|
|
7
|
-
*/
|
|
8
|
-
export function createPriorityBadge(priority) {
|
|
9
|
-
const p = typeof priority === 'number' ? priority : 2;
|
|
10
|
-
const el = document.createElement('span');
|
|
11
|
-
el.className = 'priority-badge';
|
|
12
|
-
el.classList.add(`is-p${Math.max(0, Math.min(4, p))}`);
|
|
13
|
-
el.setAttribute('role', 'img');
|
|
14
|
-
const label = labelForPriority(p);
|
|
15
|
-
el.setAttribute('title', label);
|
|
16
|
-
el.setAttribute('aria-label', `Priority: ${label}`);
|
|
17
|
-
el.textContent = emojiForPriority(p) + ' ' + label;
|
|
18
|
-
return el;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @param {number} p
|
|
23
|
-
*/
|
|
24
|
-
function labelForPriority(p) {
|
|
25
|
-
const i = Math.max(0, Math.min(4, p));
|
|
26
|
-
return priority_levels[i] || 'Medium';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* @param {number} p
|
|
31
|
-
*/
|
|
32
|
-
export function emojiForPriority(p) {
|
|
33
|
-
switch (p) {
|
|
34
|
-
case 0:
|
|
35
|
-
return '🔥';
|
|
36
|
-
case 1:
|
|
37
|
-
return '⚡️';
|
|
38
|
-
case 2:
|
|
39
|
-
return '🔧';
|
|
40
|
-
case 3:
|
|
41
|
-
return '🪶';
|
|
42
|
-
case 4:
|
|
43
|
-
return '💤';
|
|
44
|
-
default:
|
|
45
|
-
return '🔧';
|
|
46
|
-
}
|
|
47
|
-
}
|
package/app/utils/priority.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const priority_levels = ['Critical', 'High', 'Medium', 'Low', 'Backlog'];
|