beads-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { watchDb } from './watcher.js';
3
+
4
+ /** @type {{ dir: string, cb: (event: string, filename?: string) => void, w: { close: () => void } }[]} */
5
+ const watchers = [];
6
+
7
+ vi.mock('node:fs', () => {
8
+ const watch = vi.fn((dir, _opts, cb) => {
9
+ // Minimal event emitter interface for FSWatcher
10
+ const handlers = /** @type {{ close: Array<() => void> }} */ ({
11
+ close: []
12
+ });
13
+ const w = {
14
+ close: () => handlers.close.forEach((fn) => fn())
15
+ };
16
+ watchers.push({ dir, cb, w });
17
+ return /** @type {any} */ (w);
18
+ });
19
+ return { default: { watch }, watch };
20
+ });
21
+
22
+ beforeEach(() => {
23
+ watchers.length = 0;
24
+ vi.useFakeTimers();
25
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.useRealTimers();
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ describe('watchDb', () => {
34
+ test('debounces rapid change events', () => {
35
+ const calls = [];
36
+ const handle = watchDb('/repo', (p) => calls.push(p), {
37
+ debounce_ms: 100,
38
+ explicit_db: '/repo/.beads/ui.db'
39
+ });
40
+ expect(watchers.length).toBe(1);
41
+ const { cb } = watchers[0];
42
+
43
+ // Fire multiple changes in quick succession
44
+ cb('change', 'ui.db');
45
+ cb('change', 'ui.db');
46
+ cb('rename', 'ui.db');
47
+
48
+ // Nothing yet until debounce passes
49
+ expect(calls.length).toBe(0);
50
+ vi.advanceTimersByTime(99);
51
+ expect(calls.length).toBe(0);
52
+ vi.advanceTimersByTime(1);
53
+ expect(calls.length).toBe(1);
54
+
55
+ // Cleanup
56
+ handle.close();
57
+ });
58
+
59
+ test('ignores other filenames', () => {
60
+ const calls = [];
61
+ const handle = watchDb('/repo', (p) => calls.push(p), {
62
+ debounce_ms: 50,
63
+ explicit_db: '/repo/.beads/ui.db'
64
+ });
65
+ const { cb } = watchers[0];
66
+ cb('change', 'something-else.db');
67
+ vi.advanceTimersByTime(60);
68
+ expect(calls.length).toBe(0);
69
+ handle.close();
70
+ });
71
+
72
+ test('rebind attaches to new db path', () => {
73
+ const calls = [];
74
+ const handle = watchDb('/repo', (p) => calls.push(p), {
75
+ debounce_ms: 50,
76
+ explicit_db: '/repo/.beads/ui.db'
77
+ });
78
+ expect(watchers.length).toBe(1);
79
+ const first = watchers[0];
80
+
81
+ // Rebind to a different DB path
82
+ handle.rebind({ explicit_db: '/other/.beads/alt.db' });
83
+
84
+ // A new watcher is created
85
+ expect(watchers.length).toBe(2);
86
+ const second = watchers[1];
87
+
88
+ // Old watcher should ignore new file name
89
+ first.cb('change', 'ui.db');
90
+ vi.advanceTimersByTime(60);
91
+ expect(calls.length).toBe(0);
92
+
93
+ // New watcher reacts
94
+ second.cb('change', 'alt.db');
95
+ vi.advanceTimersByTime(60);
96
+ expect(calls.length).toBe(1);
97
+
98
+ handle.close();
99
+ });
100
+ });
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { runBdJson } from './bd.js';
3
+ import { handleMessage } from './ws.js';
4
+
5
+ vi.mock('./bd.js', () => ({ runBdJson: vi.fn() }));
6
+
7
+ function makeStubSocket() {
8
+ return {
9
+ sent: /** @type {string[]} */ ([]),
10
+ readyState: 1,
11
+ OPEN: 1,
12
+ /** @param {string} msg */
13
+ send(msg) {
14
+ this.sent.push(String(msg));
15
+ }
16
+ };
17
+ }
18
+
19
+ describe('ws handlers: list/show', () => {
20
+ test('list-issues forwards payload from bd', async () => {
21
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
22
+ mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [{ id: 'UI-1' }] });
23
+ const ws = makeStubSocket();
24
+ const req = {
25
+ id: 'r1',
26
+ type: 'list-issues',
27
+ payload: { filters: { status: 'open' } }
28
+ };
29
+ await handleMessage(
30
+ /** @type {any} */ (ws),
31
+ Buffer.from(JSON.stringify(req))
32
+ );
33
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
34
+ expect(obj.ok).toBe(true);
35
+ expect(Array.isArray(obj.payload)).toBe(true);
36
+ });
37
+
38
+ test('list-issues with filters.ready uses bd "ready"', async () => {
39
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
40
+ mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [{ id: 'UI-2' }] });
41
+ const ws = makeStubSocket();
42
+ const req = {
43
+ id: 'r1a',
44
+ type: 'list-issues',
45
+ payload: { filters: { ready: true } }
46
+ };
47
+ await handleMessage(
48
+ /** @type {any} */ (ws),
49
+ Buffer.from(JSON.stringify(req))
50
+ );
51
+ // Ensure we called the ready command
52
+ const call = mocked.mock.calls[mocked.mock.calls.length - 1];
53
+ expect(Array.isArray(call[0])).toBe(true);
54
+ expect(call[0][0]).toBe('ready');
55
+
56
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
57
+ expect(obj.ok).toBe(true);
58
+ expect(Array.isArray(obj.payload)).toBe(true);
59
+ });
60
+
61
+ test('show-issue returns error on missing id', async () => {
62
+ const ws = makeStubSocket();
63
+ const req = { id: 'r2', type: 'show-issue', payload: {} };
64
+ await handleMessage(
65
+ /** @type {any} */ (ws),
66
+ Buffer.from(JSON.stringify(req))
67
+ );
68
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
69
+ expect(obj.ok).toBe(false);
70
+ expect(obj.error.code).toBe('bad_request');
71
+ });
72
+
73
+ test('show-issue forwards bd JSON on success', async () => {
74
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
75
+ mocked.mockResolvedValueOnce({
76
+ code: 0,
77
+ stdoutJson: { id: 'UI-9', title: 'X' }
78
+ });
79
+ const ws = makeStubSocket();
80
+ const req = { id: 'r3', type: 'show-issue', payload: { id: 'UI-9' } };
81
+ await handleMessage(
82
+ /** @type {any} */ (ws),
83
+ Buffer.from(JSON.stringify(req))
84
+ );
85
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
86
+ expect(obj.ok).toBe(true);
87
+ expect(obj.payload.id).toBe('UI-9');
88
+ });
89
+
90
+ test('show-issue unwraps single-element arrays from bd', async () => {
91
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
92
+ mocked.mockResolvedValueOnce({
93
+ code: 0,
94
+ stdoutJson: [{ id: 'UI-9', title: 'X' }]
95
+ });
96
+ const ws = makeStubSocket();
97
+ const req = { id: 'r3a', type: 'show-issue', payload: { id: 'UI-9' } };
98
+ await handleMessage(
99
+ /** @type {any} */ (ws),
100
+ Buffer.from(JSON.stringify(req))
101
+ );
102
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
103
+ expect(obj.ok).toBe(true);
104
+ expect(obj.payload && obj.payload.id).toBe('UI-9');
105
+ });
106
+
107
+ test('show-issue returns not_found when bd returns empty array', async () => {
108
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
109
+ mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
110
+ const ws = makeStubSocket();
111
+ const req = { id: 'r3b', type: 'show-issue', payload: { id: 'X' } };
112
+ await handleMessage(
113
+ /** @type {any} */ (ws),
114
+ Buffer.from(JSON.stringify(req))
115
+ );
116
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
117
+ expect(obj.ok).toBe(false);
118
+ expect(obj.error && obj.error.code).toBe('not_found');
119
+ });
120
+
121
+ test('epic-status forwards bd epic status', async () => {
122
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
123
+ mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
124
+ const ws = makeStubSocket();
125
+ const req = { id: 're', type: /** @type {any} */ ('epic-status') };
126
+ await handleMessage(
127
+ /** @type {any} */ (ws),
128
+ Buffer.from(JSON.stringify(req))
129
+ );
130
+ const call = mocked.mock.calls[mocked.mock.calls.length - 1];
131
+ expect(Array.isArray(call[0])).toBe(true);
132
+ expect(call[0][0]).toBe('epic');
133
+ expect(call[0][1]).toBe('status');
134
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
135
+ expect(obj.ok).toBe(true);
136
+ expect(Array.isArray(obj.payload)).toBe(true);
137
+ });
138
+
139
+ test('list-issues supports limit passthrough', async () => {
140
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
141
+ mocked.mockResolvedValueOnce({ code: 0, stdoutJson: [] });
142
+ const ws = makeStubSocket();
143
+ const req = {
144
+ id: 'rlim',
145
+ type: 'list-issues',
146
+ payload: { filters: { status: 'closed', limit: 10 } }
147
+ };
148
+ await handleMessage(
149
+ /** @type {any} */ (ws),
150
+ Buffer.from(JSON.stringify(req))
151
+ );
152
+ const call = mocked.mock.calls[mocked.mock.calls.length - 1];
153
+ const args = /** @type {string[]} */ (call[0]);
154
+ expect(args.includes('--limit')).toBe(true);
155
+ expect(args.includes('10')).toBe(true);
156
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
157
+ expect(obj.ok).toBe(true);
158
+ expect(Array.isArray(obj.payload)).toBe(true);
159
+ });
160
+
161
+ test('bd error propagates as bd_error reply', async () => {
162
+ const mocked = /** @type {import('vitest').Mock} */ (runBdJson);
163
+ mocked.mockResolvedValueOnce({ code: 1, stderr: 'boom' });
164
+ const ws = makeStubSocket();
165
+ const req = { id: 'r4', type: 'list-issues', payload: {} };
166
+ await handleMessage(
167
+ /** @type {any} */ (ws),
168
+ Buffer.from(JSON.stringify(req))
169
+ );
170
+ const obj = JSON.parse(ws.sent[ws.sent.length - 1]);
171
+ expect(obj.ok).toBe(false);
172
+ expect(obj.error.code).toBe('bd_error');
173
+ });
174
+ });