bdtui 0.5.1 → 0.7.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/CHANGELOG.md +31 -0
- package/README.md +10 -7
- package/package.json +1 -1
- package/src/app.js +64 -33
- package/src/bd.js +14 -2
- package/src/keys.js +5 -4
- package/src/state.js +68 -42
- package/src/views/modals.js +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.7.0] - 2026-06-21
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Shift+r`: reset all narrowing filters (title search, epic scope, type) and reload fresh.
|
|
12
|
+
- The status bar now summarizes every active narrowing filter together — e.g. `"login" · epic: bd-12 · type: task — 3 results | Shift+r to reset` — instead of only the most recent one. The epic filter now shows the same result-count/reset hint that title search did.
|
|
13
|
+
- The tab bar gains a persistent `search:` indicator for the active title filter, alongside the existing `type:` and `epic:` indicators.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `r` (reload) now **keeps** active filters instead of clearing the epic filter; use `Shift+r` to clear filters. This reverses the `0.5.0` behavior where `r` cleared the epic filter.
|
|
18
|
+
- The `/` title filter is now persistent state: it survives a reload (`r`) and composes with the epic and type filters. The prompt pre-fills with the current search, and submitting an empty value clears just the title filter.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `Shift+c` (claim) and `Shift+g` (jump to bottom) now actually fire. They were registered as bare uppercase `C`/`G`, but blessed reports a shifted letter as `S-<lower>`, so the handlers were dead — claim did nothing and `Shift+g` only reached the last *loaded* page instead of revealing all rows and jumping to the true bottom.
|
|
23
|
+
- Reloading or filtering while scoped to an epic on the **All**/**Closed** tabs no longer caps the fetch at bd's default 50 rows, which could drop an epic's children from the scoped view.
|
|
24
|
+
- The title search is now tag-escaped before it reaches the status bar, so a query containing `{`/`}` no longer corrupts the status line.
|
|
25
|
+
- Reparenting with `h` no longer throws `Unexpected token '✓' … is not valid JSON`. `bd dep add`/`bd dep remove` print a human-readable line rather than JSON, so their output is no longer parsed as JSON.
|
|
26
|
+
- `h` (change parent) now **detaches every existing parent first, then attaches** the chosen one — previously it added the new parent before removing the old, so the JSON error above left the bead with two parents. It also clears *all* existing parent-child links (not just the first), repairing beads that already ended up with multiple parents.
|
|
27
|
+
|
|
28
|
+
## [0.6.0] - 2026-06-14
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- The **Closed** and **All** tabs now render the epic→children tree like the other tabs; previously they showed a flat, ungrouped list with every bead at the top level. Closed children nest under their epic (parent epics that aren't themselves closed are pulled in as the grouping anchor).
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Tree views now build from the `parent` field that `bd` embeds in each `bd list`/`bd ready` row instead of fetching one `bd dep list` per bead. The Closed/All tabs gain the tree at no extra per-bead subprocess cost (lazy "load more" pagination is unchanged), and the Ready/Blocked/In-progress tabs load with fewer `bd` calls. The Blocked tab keeps a per-row `bd dep list` fallback for the derived-blocked rows `bd blocked` returns without a `parent` field.
|
|
37
|
+
|
|
7
38
|
## [0.5.1] - 2026-06-14
|
|
8
39
|
|
|
9
40
|
### Fixed
|
package/README.md
CHANGED
|
@@ -66,18 +66,21 @@ be-12 P2 ready T └ Another child
|
|
|
66
66
|
|-----|--------|
|
|
67
67
|
| `j` / `↓` | Move down |
|
|
68
68
|
| `k` / `↑` | Move up |
|
|
69
|
-
| `g` / `
|
|
69
|
+
| `g` / `Shift+g` | Jump to top / bottom |
|
|
70
70
|
| `Enter` / `l` | Focus detail pane |
|
|
71
|
-
| `h` / `Esc` | Back to list |
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
|
|
|
71
|
+
| `h` / `Esc` | Back to list (from detail) |
|
|
72
|
+
| `Tab` / `Shift+Tab` | Next / previous status filter (blocked/ready/in_progress/closed/all) |
|
|
73
|
+
| `t` | Cycle type filter (all/epic/task/chore/bug) |
|
|
74
|
+
| `e` | Filter by epic (modal picker; `x` clears) |
|
|
75
|
+
| `/` | In-memory title filter (empty submit clears it) |
|
|
76
|
+
| `r` | Reload (keeps active filters) |
|
|
77
|
+
| `Shift+r` | Reset all filters (title / epic / type) and reload |
|
|
75
78
|
| `s` | Change status |
|
|
76
79
|
| `c` | Close with reason |
|
|
77
|
-
| `
|
|
80
|
+
| `Shift+c` | Claim (in_progress + assign self) |
|
|
78
81
|
| `o` | Reopen |
|
|
79
82
|
| `p` | Change priority |
|
|
80
|
-
| `
|
|
83
|
+
| `h` | Change parent (epic / standalone) |
|
|
81
84
|
| `y` | Yank bead ID to clipboard |
|
|
82
85
|
| `w` | Pick a workflow skill (`/executor-task`, `/executor-task-worktree`, `/executor-epic-task`, `/executor-epic-task-worktree`) and copy it with the selected task's ID. Disabled for epics. See [workflow-template](https://github.com/jsiovn/workflow-template). |
|
|
83
86
|
| `?` | Help overlay |
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import blessed from 'blessed';
|
|
2
2
|
import { execFile } from 'child_process';
|
|
3
|
-
import { state, loadList, loadDetail, applyMutation,
|
|
3
|
+
import { state, loadList, loadDetail, applyMutation, applyFilters, isPaged, ensureSelectedVisible } from './state.js';
|
|
4
4
|
import { createList, renderList } from './views/list.js';
|
|
5
5
|
import { createDetail, renderDetail } from './views/detail.js';
|
|
6
6
|
import { statusPicker, priorityPicker, textPrompt, parentPicker, skillPicker, epicPicker } from './views/modals.js';
|
|
@@ -10,6 +10,11 @@ import { bdUpdate, bdClose, bdClaim, bdReopen, bdDepAdd, bdDepRemove, bdDepListD
|
|
|
10
10
|
const FILTERS = ['blocked', 'ready', 'in_progress', 'closed', 'all'];
|
|
11
11
|
const TYPE_FILTERS = ['all', 'epic', 'task', 'chore', 'bug'];
|
|
12
12
|
|
|
13
|
+
// Neutralize blessed tag markup in free text before it lands in a tags:true
|
|
14
|
+
// box (mirrors esc() in views/detail.js). The title search is user-typed, so a
|
|
15
|
+
// query like "{bold}" would otherwise corrupt the status line.
|
|
16
|
+
const escTags = (s) => String(s ?? '').replace(/\{/g, '{open}').replace(/\}/g, '{close}');
|
|
17
|
+
|
|
13
18
|
export async function run(cwd) {
|
|
14
19
|
state.cwd = cwd;
|
|
15
20
|
|
|
@@ -38,8 +43,19 @@ export async function run(cwd) {
|
|
|
38
43
|
let debounceTimer = null;
|
|
39
44
|
|
|
40
45
|
let statusTimer = null;
|
|
46
|
+
// Describe every active narrowing filter (title search, epic scope, type) in
|
|
47
|
+
// one line with the result count and reset hint. Returns null when none are
|
|
48
|
+
// active so the caller can fall back to a plain "Ready".
|
|
49
|
+
function filterSummary() {
|
|
50
|
+
const parts = [];
|
|
51
|
+
if (state.textFilter) parts.push(`"${escTags(state.textFilter)}"`);
|
|
52
|
+
if (state.epicFilter) parts.push(`epic: ${state.epicFilter}`);
|
|
53
|
+
if (state.typeFilter !== 'all') parts.push(`type: ${state.typeFilter}`);
|
|
54
|
+
if (parts.length === 0) return null;
|
|
55
|
+
return `${parts.join(' · ')} — ${state.listOrder.length} results | Shift+r to reset`;
|
|
56
|
+
}
|
|
41
57
|
function defaultStatus() {
|
|
42
|
-
return
|
|
58
|
+
return filterSummary() || 'Ready';
|
|
43
59
|
}
|
|
44
60
|
function setStatus(msg, isError = false, transient = false) {
|
|
45
61
|
if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; }
|
|
@@ -67,8 +83,11 @@ export async function run(cwd) {
|
|
|
67
83
|
const epicInfo = state.epicFilter
|
|
68
84
|
? ` {gray-fg}│{/} {magenta-fg}epic:{/} ${state.epicFilter}`
|
|
69
85
|
: '';
|
|
86
|
+
const textInfo = state.textFilter
|
|
87
|
+
? ` {gray-fg}│{/} {green-fg}search:{/} "${escTags(state.textFilter)}"`
|
|
88
|
+
: '';
|
|
70
89
|
const info = count > 0 ? `{gray-fg} │ ${count} beads{/}` : '';
|
|
71
|
-
tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + info);
|
|
90
|
+
tabBar.setContent(tabs.join('{gray-fg}│{/}') + typeInfo + epicInfo + textInfo + info);
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
function setFocusBorder(focused) {
|
|
@@ -142,7 +161,7 @@ export async function run(cwd) {
|
|
|
142
161
|
ensureSelectedVisible();
|
|
143
162
|
if (state.selectedId) await loadDetail(state.selectedId);
|
|
144
163
|
render();
|
|
145
|
-
setStatus(
|
|
164
|
+
setStatus(defaultStatus());
|
|
146
165
|
} catch (err) {
|
|
147
166
|
setStatus(err.message, true, true);
|
|
148
167
|
}
|
|
@@ -158,7 +177,7 @@ export async function run(cwd) {
|
|
|
158
177
|
state.selectedId = state.listOrder[0] || null;
|
|
159
178
|
if (state.selectedId) await loadDetail(state.selectedId);
|
|
160
179
|
render();
|
|
161
|
-
setStatus(
|
|
180
|
+
setStatus(defaultStatus());
|
|
162
181
|
} catch (err) {
|
|
163
182
|
setStatus(err.message, true, true);
|
|
164
183
|
}
|
|
@@ -167,7 +186,7 @@ export async function run(cwd) {
|
|
|
167
186
|
async function cycleTypeFilter() {
|
|
168
187
|
const idx = TYPE_FILTERS.indexOf(state.typeFilter);
|
|
169
188
|
state.typeFilter = TYPE_FILTERS[(idx + 1) % TYPE_FILTERS.length];
|
|
170
|
-
|
|
189
|
+
applyFilters();
|
|
171
190
|
if (state.selectedId && !state.listOrder.includes(state.selectedId)) {
|
|
172
191
|
state.selectedId = state.listOrder[0] || null;
|
|
173
192
|
} else if (!state.selectedId) {
|
|
@@ -185,7 +204,7 @@ export async function run(cwd) {
|
|
|
185
204
|
return;
|
|
186
205
|
}
|
|
187
206
|
}
|
|
188
|
-
setStatus(
|
|
207
|
+
setStatus(defaultStatus());
|
|
189
208
|
}
|
|
190
209
|
|
|
191
210
|
// ── Global keys ────────────────────────────────────────────────────────────
|
|
@@ -201,11 +220,20 @@ export async function run(cwd) {
|
|
|
201
220
|
|
|
202
221
|
key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
|
|
203
222
|
|
|
223
|
+
// r reloads data while preserving every active filter; Shift+r clears all
|
|
224
|
+
// narrowing filters (title search, epic scope, type) and reloads fresh.
|
|
225
|
+
// blessed reports a shifted letter as `S-<lower>` (never bare uppercase), so
|
|
226
|
+
// the binding must be 'S-r' — 'R' would be dead. Same for S-c / S-g below.
|
|
204
227
|
key(['r'], async () => {
|
|
205
|
-
|
|
228
|
+
await refresh();
|
|
229
|
+
});
|
|
230
|
+
key(['S-r'], async () => {
|
|
231
|
+
const had = !!(state.textFilter || state.epicFilter || state.typeFilter !== 'all');
|
|
232
|
+
state.textFilter = null;
|
|
206
233
|
state.epicFilter = null;
|
|
234
|
+
state.typeFilter = 'all';
|
|
207
235
|
await refresh();
|
|
208
|
-
if (
|
|
236
|
+
if (had) setStatus('Filters reset', false, true);
|
|
209
237
|
});
|
|
210
238
|
key(['tab'], () => cycleFilter(1));
|
|
211
239
|
key(['S-tab'], () => cycleFilter(-1));
|
|
@@ -241,7 +269,7 @@ export async function run(cwd) {
|
|
|
241
269
|
return;
|
|
242
270
|
}
|
|
243
271
|
} else {
|
|
244
|
-
|
|
272
|
+
applyFilters();
|
|
245
273
|
}
|
|
246
274
|
state.selectedId = state.listOrder[0] || null;
|
|
247
275
|
if (state.selectedId) {
|
|
@@ -251,7 +279,7 @@ export async function run(cwd) {
|
|
|
251
279
|
}
|
|
252
280
|
}
|
|
253
281
|
render();
|
|
254
|
-
setStatus(
|
|
282
|
+
setStatus(defaultStatus());
|
|
255
283
|
});
|
|
256
284
|
|
|
257
285
|
key(['?'], () => {
|
|
@@ -289,10 +317,15 @@ export async function run(cwd) {
|
|
|
289
317
|
setStatus(err.message, true, true);
|
|
290
318
|
return;
|
|
291
319
|
}
|
|
292
|
-
|
|
320
|
+
// A bead should have at most one parent, but a prior bug could leave it with
|
|
321
|
+
// several parent-child deps — collect them all so we can clear every one.
|
|
322
|
+
const currentParentIds = downDeps
|
|
323
|
+
.filter((d) => d.dependency_type === 'parent-child')
|
|
324
|
+
.map((d) => d.id)
|
|
325
|
+
.filter(Boolean);
|
|
293
326
|
let newParent;
|
|
294
327
|
try {
|
|
295
|
-
newParent = await parentPicker(screen, epics, { selfId: id,
|
|
328
|
+
newParent = await parentPicker(screen, epics, { selfId: id, currentParentIds });
|
|
296
329
|
} catch (err) {
|
|
297
330
|
list.focus();
|
|
298
331
|
if (err.message !== 'cancelled') setStatus(err.message, true, true);
|
|
@@ -302,13 +335,13 @@ export async function run(cwd) {
|
|
|
302
335
|
setStatus(`Reparenting ${id}…`);
|
|
303
336
|
try {
|
|
304
337
|
await applyMutation(id, async () => {
|
|
305
|
-
|
|
338
|
+
// Detach every existing parent first so the bead is never left with two
|
|
339
|
+
// parents, then attach the chosen one (skip if it's already a parent).
|
|
340
|
+
for (const pid of currentParentIds) {
|
|
341
|
+
if (pid !== newParent) await bdDepRemove(id, pid, state.cwd);
|
|
342
|
+
}
|
|
343
|
+
if (newParent && !currentParentIds.includes(newParent)) {
|
|
306
344
|
await bdDepAdd(id, newParent, 'parent-child', state.cwd);
|
|
307
|
-
if (currentParentId && currentParentId !== newParent) {
|
|
308
|
-
await bdDepRemove(id, currentParentId, state.cwd);
|
|
309
|
-
}
|
|
310
|
-
} else if (currentParentId) {
|
|
311
|
-
await bdDepRemove(id, currentParentId, state.cwd);
|
|
312
345
|
}
|
|
313
346
|
});
|
|
314
347
|
await loadList();
|
|
@@ -331,7 +364,7 @@ export async function run(cwd) {
|
|
|
331
364
|
screen.render();
|
|
332
365
|
});
|
|
333
366
|
|
|
334
|
-
key(['
|
|
367
|
+
key(['S-g'], () => {
|
|
335
368
|
if (screen.focused !== list) return;
|
|
336
369
|
// Jump to the true bottom — in paged mode reveal every remaining batch first.
|
|
337
370
|
if (isPaged() && state.visibleCount < state.listOrder.length) {
|
|
@@ -346,22 +379,20 @@ export async function run(cwd) {
|
|
|
346
379
|
key(['/'], async () => {
|
|
347
380
|
if (screen.focused !== list) return;
|
|
348
381
|
try {
|
|
349
|
-
|
|
382
|
+
// Pre-fill with the active search so it can be edited; an empty submit
|
|
383
|
+
// clears the title filter (other filters stay put).
|
|
384
|
+
const query = await textPrompt(screen, 'Filter by title', state.textFilter || '');
|
|
350
385
|
list.focus();
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
state.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
state.visibleCount = isPaged()
|
|
358
|
-
? Math.min(state.pageSize, state.listOrder.length)
|
|
359
|
-
: state.listOrder.length;
|
|
360
|
-
state.selectedId = state.listOrder[0] || null;
|
|
386
|
+
state.textFilter = query || null;
|
|
387
|
+
applyFilters();
|
|
388
|
+
if (!state.selectedId || !state.listOrder.includes(state.selectedId)) {
|
|
389
|
+
state.selectedId = state.listOrder[0] || null;
|
|
390
|
+
}
|
|
391
|
+
ensureSelectedVisible();
|
|
361
392
|
renderList(list);
|
|
362
393
|
renderDetail(detail);
|
|
363
394
|
screen.render();
|
|
364
|
-
setStatus(
|
|
395
|
+
setStatus(defaultStatus());
|
|
365
396
|
} catch {
|
|
366
397
|
list.focus();
|
|
367
398
|
}
|
|
@@ -409,7 +440,7 @@ export async function run(cwd) {
|
|
|
409
440
|
}
|
|
410
441
|
});
|
|
411
442
|
|
|
412
|
-
key(['
|
|
443
|
+
key(['S-c'], async () => {
|
|
413
444
|
if (screen.focused !== list || !state.selectedId) return;
|
|
414
445
|
const id = state.selectedId;
|
|
415
446
|
setStatus(`Claiming ${id}…`);
|
package/src/bd.js
CHANGED
|
@@ -16,6 +16,18 @@ async function runBd(args, cwd) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Run a bd command whose stdout we don't consume (e.g. `dep add`/`dep remove`,
|
|
20
|
+
// which print a human-readable "✓ Added dependency…" line, not JSON). We can't
|
|
21
|
+
// JSON.parse that — doing so was the source of the "Unexpected token '✓'" error.
|
|
22
|
+
// Failures still surface: a non-zero exit rejects execFile and we throw stderr.
|
|
23
|
+
async function runBdVoid(args, cwd) {
|
|
24
|
+
try {
|
|
25
|
+
await execFileP('bd', args, { cwd, maxBuffer: MAX_BUFFER });
|
|
26
|
+
} catch (err) {
|
|
27
|
+
throw new Error(err.stderr?.trim() || err.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
// `bd blocked` only returns *derived*-blocked beads (open beads whose blockers
|
|
20
32
|
// aren't all closed); it omits beads whose status was explicitly set to
|
|
21
33
|
// "blocked". The Blocked tab should show both, so merge the two sources, deduped.
|
|
@@ -83,11 +95,11 @@ export const bdReopen = (id, cwd) => runBd(['reopen', id, '--json'], cwd);
|
|
|
83
95
|
export const bdDepAdd = (child, parent, type, cwd) => {
|
|
84
96
|
const args = ['dep', 'add', child, parent];
|
|
85
97
|
if (type && type !== 'blocks') args.push('--type', type);
|
|
86
|
-
return
|
|
98
|
+
return runBdVoid(args, cwd);
|
|
87
99
|
};
|
|
88
100
|
|
|
89
101
|
export const bdDepRemove = (child, parent, cwd) =>
|
|
90
|
-
|
|
102
|
+
runBdVoid(['dep', 'remove', child, parent], cwd);
|
|
91
103
|
|
|
92
104
|
// Returns all children of an epic (all statuses — bd children includes closed by default)
|
|
93
105
|
export const bdChildren = (id, cwd) => runBd(['children', id, '--json'], cwd);
|
package/src/keys.js
CHANGED
|
@@ -7,7 +7,7 @@ export const HELP_TEXT = [
|
|
|
7
7
|
' j / ↓ Move down in list',
|
|
8
8
|
' k / ↑ Move up in list',
|
|
9
9
|
' g Jump to top',
|
|
10
|
-
'
|
|
10
|
+
' Shift+g Jump to bottom',
|
|
11
11
|
' Enter / l Focus detail pane',
|
|
12
12
|
' h / Esc Back to list (from detail)',
|
|
13
13
|
'',
|
|
@@ -17,15 +17,16 @@ export const HELP_TEXT = [
|
|
|
17
17
|
' Shift+Tab Previous status filter',
|
|
18
18
|
' t Cycle type filter (all/epic/task/chore/bug)',
|
|
19
19
|
' e Filter by epic (modal picker; x clears; switches status to all)',
|
|
20
|
-
'
|
|
21
|
-
'
|
|
20
|
+
' / In-memory title filter (empty submit clears it)',
|
|
21
|
+
' r Reload (keeps active filters)',
|
|
22
|
+
' Shift+r Reset all filters (title / epic / type) and reload',
|
|
22
23
|
' (all & closed tabs load more rows as you scroll to the bottom)',
|
|
23
24
|
'',
|
|
24
25
|
' Mutations',
|
|
25
26
|
' ──────────────────────────────────────',
|
|
26
27
|
' s Change status',
|
|
27
28
|
' c Close with reason',
|
|
28
|
-
'
|
|
29
|
+
' Shift+c Claim bead (in_progress + assign self)',
|
|
29
30
|
' o Reopen',
|
|
30
31
|
' p Change priority',
|
|
31
32
|
' h Change parent (epic / standalone)',
|
package/src/state.js
CHANGED
|
@@ -15,6 +15,7 @@ export const state = {
|
|
|
15
15
|
filter: 'ready',
|
|
16
16
|
typeFilter: 'all', // 'all' | 'epic' | 'task'
|
|
17
17
|
epicFilter: null, // null or epic bead id — when set, list is scoped to that epic + its children
|
|
18
|
+
textFilter: null, // null or lowercase-matched title substring (in-memory)
|
|
18
19
|
blockedIds: new Set(), // ids that appear in `bd blocked` — derived state, not raw status
|
|
19
20
|
pageSize: 100, // lazy "load more" batch size for the flat all view
|
|
20
21
|
visibleCount: 0, // how many of listOrder are currently rendered (paged mode)
|
|
@@ -22,8 +23,8 @@ export const state = {
|
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
// The "all" and "closed" tabs (no epic filter) can hold huge numbers of beads,
|
|
25
|
-
// so they
|
|
26
|
-
//
|
|
26
|
+
// so they reveal rows lazily in pageSize batches; every other view renders its
|
|
27
|
+
// rows all at once. All tabs build the same epic→children tree (see loadList).
|
|
27
28
|
export function isPaged() {
|
|
28
29
|
return (state.filter === 'all' || state.filter === 'closed') && !state.epicFilter;
|
|
29
30
|
}
|
|
@@ -60,7 +61,11 @@ function scopeToEpic(items, epicId) {
|
|
|
60
61
|
return out;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// Apply every active narrowing filter (epic → type → text) to fullTreeItems and
|
|
65
|
+
// rebuild listOrder/treeMeta. Kept in one place so a reload (`r`) can replay the
|
|
66
|
+
// same filters and the status bar can describe them. Filters compose: an epic
|
|
67
|
+
// scope, a type filter, and a title search can all be active at once.
|
|
68
|
+
export function applyFilters() {
|
|
64
69
|
let items = state.fullTreeItems;
|
|
65
70
|
if (state.epicFilter) {
|
|
66
71
|
items = scopeToEpic(items, state.epicFilter);
|
|
@@ -71,6 +76,15 @@ export function applyTypeFilter() {
|
|
|
71
76
|
.sort((x, y) => byUpdatedDesc(state.beadsById.get(x.id), state.beadsById.get(y.id)))
|
|
72
77
|
.map((t) => ({ id: t.id, depth: 0, isLast: false }));
|
|
73
78
|
}
|
|
79
|
+
if (state.textFilter) {
|
|
80
|
+
const q = state.textFilter.toLowerCase();
|
|
81
|
+
// A title-search result set is no longer a tree, so flatten every surviving
|
|
82
|
+
// row to depth 0 — otherwise a matched child whose parent was filtered out
|
|
83
|
+
// renders as an orphan indented under a branch glyph.
|
|
84
|
+
items = items
|
|
85
|
+
.filter((t) => state.beadsById.get(t.id)?.title?.toLowerCase().includes(q))
|
|
86
|
+
.map((t) => ({ id: t.id, depth: 0, isLast: false }));
|
|
87
|
+
}
|
|
74
88
|
state.listOrder = items.map((t) => t.id);
|
|
75
89
|
state.treeMeta = new Map(items.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
|
|
76
90
|
state.visibleCount = isPaged()
|
|
@@ -80,22 +94,33 @@ export function applyTypeFilter() {
|
|
|
80
94
|
|
|
81
95
|
function buildTreeOrder(beads, parentOf) {
|
|
82
96
|
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
83
|
-
|
|
97
|
+
// A bead is a root when it has no parent present in this bead set (epics and
|
|
98
|
+
// standalone beads). The tree is intentionally two levels deep — epics on top,
|
|
99
|
+
// their direct children indented under them.
|
|
100
|
+
const isRoot = (id) => {
|
|
101
|
+
const pid = parentOf.get(id);
|
|
102
|
+
return !pid || !byId.has(pid);
|
|
103
|
+
};
|
|
84
104
|
|
|
105
|
+
const childrenOf = new Map();
|
|
106
|
+
const roots = [];
|
|
85
107
|
for (const b of beads) {
|
|
86
108
|
const pid = parentOf.get(b.id);
|
|
87
|
-
|
|
109
|
+
// Nest a bead only when its parent is itself a root. Anything else — no
|
|
110
|
+
// parent, a parent filtered out of this view, or a parent that is itself
|
|
111
|
+
// nested (deeper than two levels) — renders at the top level so no bead is
|
|
112
|
+
// ever silently dropped from the list.
|
|
113
|
+
if (pid && byId.has(pid) && isRoot(pid)) {
|
|
88
114
|
if (!childrenOf.has(pid)) childrenOf.set(pid, []);
|
|
89
115
|
childrenOf.get(pid).push(b);
|
|
116
|
+
} else {
|
|
117
|
+
roots.push(b);
|
|
90
118
|
}
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
for (const children of childrenOf.values()) {
|
|
94
122
|
children.sort(byUpdatedDesc);
|
|
95
123
|
}
|
|
96
|
-
|
|
97
|
-
const childIds = new Set([...childrenOf.values()].flat().map((b) => b.id));
|
|
98
|
-
const roots = beads.filter((b) => !childIds.has(b.id));
|
|
99
124
|
roots.sort(byUpdatedDesc);
|
|
100
125
|
|
|
101
126
|
const ordered = [];
|
|
@@ -110,50 +135,51 @@ function buildTreeOrder(beads, parentOf) {
|
|
|
110
135
|
return ordered;
|
|
111
136
|
}
|
|
112
137
|
|
|
113
|
-
// Flat load for the paged tabs ("all" / "closed"). Skips the per-bead
|
|
114
|
-
// parent-child dep fetch (which spawns one bd subprocess per bead) and the tree
|
|
115
|
-
// build, so even very large repos load in a single bd call; the UI reveals rows
|
|
116
|
-
// in pageSize batches.
|
|
117
|
-
async function loadListFlat() {
|
|
118
|
-
const [beads, blocked] = await Promise.all([
|
|
119
|
-
bdList(state.filter, state.cwd, { unlimited: true }),
|
|
120
|
-
bdList('blocked', state.cwd).catch(() => []),
|
|
121
|
-
]);
|
|
122
|
-
state.blockedIds = new Set((blocked || []).map((b) => b.id));
|
|
123
|
-
for (const b of beads) {
|
|
124
|
-
state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
|
|
125
|
-
}
|
|
126
|
-
beads.sort(byUpdatedDesc);
|
|
127
|
-
state.fullTreeItems = beads.map((b) => ({ id: b.id, depth: 0, isLast: false }));
|
|
128
|
-
applyTypeFilter();
|
|
129
|
-
return beads;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
138
|
export async function loadList() {
|
|
133
|
-
|
|
139
|
+
// The all/closed tabs lift bd's 50-row cap so the full tree is available; the
|
|
140
|
+
// renderer still reveals rows in pageSize batches (see isPaged). This is keyed
|
|
141
|
+
// off the tab itself, NOT isPaged(): an epic filter makes isPaged() false but
|
|
142
|
+
// we still need every bead fetched so scopeToEpic can find the epic's children
|
|
143
|
+
// (otherwise reloading while epic-scoped would silently drop children past
|
|
144
|
+
// row 50).
|
|
145
|
+
const unlimited = state.filter === 'all' || state.filter === 'closed';
|
|
134
146
|
const [beads, blocked] = await Promise.all([
|
|
135
|
-
bdList(state.filter, state.cwd),
|
|
147
|
+
bdList(state.filter, state.cwd, { unlimited }),
|
|
136
148
|
bdList('blocked', state.cwd).catch(() => []),
|
|
137
149
|
]);
|
|
138
150
|
state.blockedIds = new Set((blocked || []).map((b) => b.id));
|
|
139
151
|
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
140
152
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
candidates.map((b) => bdDepListDown(b.id, state.cwd))
|
|
146
|
-
);
|
|
147
|
-
|
|
153
|
+
// Parent-child relationships come straight from the `parent` field bd embeds in
|
|
154
|
+
// every list row (the target of the bead's parent-child dependency). This is
|
|
155
|
+
// the same data `bd dep list` returns, so the tree builds from the single
|
|
156
|
+
// `bd list` call above — no per-bead subprocess fan-out, even on huge repos.
|
|
148
157
|
const parentOf = new Map();
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
for (const b of beads) {
|
|
159
|
+
if (b.parent && b.parent !== b.id) parentOf.set(b.id, b.parent);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The Blocked tab merges in `bd blocked` (derived-blocked) rows, and that
|
|
163
|
+
// command — unlike `bd list` — omits the `parent` field entirely. Recover the
|
|
164
|
+
// parent for those rows with a dep-list lookup. Scoped to the blocked filter
|
|
165
|
+
// (the only parent-less source) and to non-epic rows without an embedded
|
|
166
|
+
// parent, so it stays a handful of calls on an already-small view.
|
|
167
|
+
if (state.filter === 'blocked') {
|
|
168
|
+
const needsLookup = beads.filter((b) => !('parent' in b) && b.issue_type !== 'epic');
|
|
169
|
+
const depLists = await Promise.all(
|
|
170
|
+
needsLookup.map((b) => bdDepListDown(b.id, state.cwd))
|
|
171
|
+
);
|
|
172
|
+
needsLookup.forEach((b, i) => {
|
|
173
|
+
const pc = depLists[i].find((d) => d.dependency_type === 'parent-child');
|
|
174
|
+
if (pc?.id && pc.id !== b.id) parentOf.set(b.id, pc.id);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
153
177
|
|
|
154
178
|
// Bring in parent epics that aren't already in the filtered list so the tree
|
|
155
179
|
// can root under them. Only add the epic itself, NOT its other children —
|
|
156
|
-
// that's what caused closed beads to leak into ready/open filters.
|
|
180
|
+
// that's what caused closed beads to leak into ready/open filters. Skipped for
|
|
181
|
+
// "all", which already contains every bead. The fetch is bounded by the number
|
|
182
|
+
// of distinct missing parents (≈ epic count), not the bead count.
|
|
157
183
|
if (state.filter !== 'all') {
|
|
158
184
|
const missingParents = [...new Set(parentOf.values())].filter((id) => !byId.has(id));
|
|
159
185
|
const fetched = await Promise.all(
|
|
@@ -172,7 +198,7 @@ export async function loadList() {
|
|
|
172
198
|
}
|
|
173
199
|
|
|
174
200
|
state.fullTreeItems = buildTreeOrder(beads, parentOf);
|
|
175
|
-
|
|
201
|
+
applyFilters();
|
|
176
202
|
return beads;
|
|
177
203
|
}
|
|
178
204
|
|
package/src/views/modals.js
CHANGED
|
@@ -186,10 +186,11 @@ export function skillPicker(screen, beadId) {
|
|
|
186
186
|
return listPicker(screen, 'Copy workflow command', items, 'magenta', 56, 1);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
export function parentPicker(screen, epics, { selfId,
|
|
189
|
+
export function parentPicker(screen, epics, { selfId, currentParentIds = [] } = {}) {
|
|
190
190
|
return new Promise((resolve, reject) => {
|
|
191
|
+
const exclude = new Set([selfId, ...currentParentIds]);
|
|
191
192
|
const sorted = [...epics]
|
|
192
|
-
.filter((e) =>
|
|
193
|
+
.filter((e) => !exclude.has(e.id))
|
|
193
194
|
.sort((a, b) => {
|
|
194
195
|
const ta = a.updated_at || '', tb = b.updated_at || '';
|
|
195
196
|
if (ta !== tb) return tb.localeCompare(ta);
|