bdtui 0.7.2 → 0.8.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/CHANGELOG.md +189 -165
- package/README.md +117 -95
- package/bin/bdtui.js +35 -35
- package/package.json +48 -48
- package/src/app.js +617 -586
- package/src/bd.js +130 -130
- package/src/keys.js +100 -98
- package/src/state.js +218 -218
- package/src/views/detail.js +231 -218
- package/src/views/list.js +99 -99
- package/src/views/modals.js +269 -255
package/src/state.js
CHANGED
|
@@ -1,218 +1,218 @@
|
|
|
1
|
-
import { bdList, bdShow, bdDeps, bdDepListDown } from './bd.js';
|
|
2
|
-
|
|
3
|
-
function byUpdatedDesc(a, b) {
|
|
4
|
-
const ta = a?.updated_at || '', tb = b?.updated_at || '';
|
|
5
|
-
if (ta !== tb) return tb.localeCompare(ta);
|
|
6
|
-
return (b?.id || '').localeCompare(a?.id || '');
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const state = {
|
|
10
|
-
beadsById: new Map(),
|
|
11
|
-
fullTreeItems: [], // unfiltered ordered tree items (before typeFilter)
|
|
12
|
-
listOrder: [],
|
|
13
|
-
treeMeta: new Map(), // id -> { depth: 0|1, isLast: bool }
|
|
14
|
-
selectedId: null,
|
|
15
|
-
filter: 'ready',
|
|
16
|
-
typeFilter: 'all', // 'all' | 'epic' | 'task'
|
|
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)
|
|
19
|
-
blockedIds: new Set(), // ids that appear in `bd blocked` — derived state, not raw status
|
|
20
|
-
pageSize: 100, // lazy "load more" batch size for the flat all view
|
|
21
|
-
visibleCount: 0, // how many of listOrder are currently rendered (paged mode)
|
|
22
|
-
cwd: process.cwd(),
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// The "all" and "closed" tabs (no epic filter) can hold huge numbers of beads,
|
|
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).
|
|
28
|
-
export function isPaged() {
|
|
29
|
-
return (state.filter === 'all' || state.filter === 'closed') && !state.epicFilter;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Paged mode only renders the first visibleCount rows. After a reload that keeps
|
|
33
|
-
// a deep selection, grow the window (to its batch boundary) so the selected bead
|
|
34
|
-
// stays rendered — otherwise the cursor and state.selectedId desync and mutation
|
|
35
|
-
// keys would act on an off-screen bead. Call AFTER selectedId is finalized.
|
|
36
|
-
export function ensureSelectedVisible() {
|
|
37
|
-
if (!isPaged() || !state.selectedId) return;
|
|
38
|
-
const idx = state.listOrder.indexOf(state.selectedId);
|
|
39
|
-
if (idx < state.visibleCount) return;
|
|
40
|
-
state.visibleCount = Math.min(
|
|
41
|
-
state.listOrder.length,
|
|
42
|
-
Math.ceil((idx + 1) / state.pageSize) * state.pageSize,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function scopeToEpic(items, epicId) {
|
|
47
|
-
const out = [];
|
|
48
|
-
let inside = false;
|
|
49
|
-
for (const it of items) {
|
|
50
|
-
if (it.depth === 0) {
|
|
51
|
-
inside = it.id === epicId;
|
|
52
|
-
if (inside) out.push(it);
|
|
53
|
-
} else if (inside) {
|
|
54
|
-
out.push(it);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
// Fix isLast on the last child after scoping (cheap, optional polish).
|
|
58
|
-
for (let i = out.length - 1; i >= 0; i--) {
|
|
59
|
-
if (out[i].depth > 0) { out[i] = { ...out[i], isLast: true }; break; }
|
|
60
|
-
}
|
|
61
|
-
return out;
|
|
62
|
-
}
|
|
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() {
|
|
69
|
-
let items = state.fullTreeItems;
|
|
70
|
-
if (state.epicFilter) {
|
|
71
|
-
items = scopeToEpic(items, state.epicFilter);
|
|
72
|
-
}
|
|
73
|
-
if (state.typeFilter !== 'all') {
|
|
74
|
-
items = items
|
|
75
|
-
.filter((t) => state.beadsById.get(t.id)?.issue_type === state.typeFilter)
|
|
76
|
-
.sort((x, y) => byUpdatedDesc(state.beadsById.get(x.id), state.beadsById.get(y.id)))
|
|
77
|
-
.map((t) => ({ id: t.id, depth: 0, isLast: false }));
|
|
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
|
-
}
|
|
88
|
-
state.listOrder = items.map((t) => t.id);
|
|
89
|
-
state.treeMeta = new Map(items.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
|
|
90
|
-
state.visibleCount = isPaged()
|
|
91
|
-
? Math.min(state.pageSize, state.listOrder.length)
|
|
92
|
-
: state.listOrder.length;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function buildTreeOrder(beads, parentOf) {
|
|
96
|
-
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
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
|
-
};
|
|
104
|
-
|
|
105
|
-
const childrenOf = new Map();
|
|
106
|
-
const roots = [];
|
|
107
|
-
for (const b of beads) {
|
|
108
|
-
const pid = parentOf.get(b.id);
|
|
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)) {
|
|
114
|
-
if (!childrenOf.has(pid)) childrenOf.set(pid, []);
|
|
115
|
-
childrenOf.get(pid).push(b);
|
|
116
|
-
} else {
|
|
117
|
-
roots.push(b);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
for (const children of childrenOf.values()) {
|
|
122
|
-
children.sort(byUpdatedDesc);
|
|
123
|
-
}
|
|
124
|
-
roots.sort(byUpdatedDesc);
|
|
125
|
-
|
|
126
|
-
const ordered = [];
|
|
127
|
-
for (const root of roots) {
|
|
128
|
-
ordered.push({ id: root.id, depth: 0, isLast: false });
|
|
129
|
-
const children = childrenOf.get(root.id) || [];
|
|
130
|
-
children.forEach((child, i) => {
|
|
131
|
-
ordered.push({ id: child.id, depth: 1, isLast: i === children.length - 1 });
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return ordered;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export async function loadList() {
|
|
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';
|
|
146
|
-
const [beads, blocked] = await Promise.all([
|
|
147
|
-
bdList(state.filter, state.cwd, { unlimited }),
|
|
148
|
-
bdList('blocked', state.cwd).catch(() => []),
|
|
149
|
-
]);
|
|
150
|
-
state.blockedIds = new Set((blocked || []).map((b) => b.id));
|
|
151
|
-
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
152
|
-
|
|
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.
|
|
157
|
-
const parentOf = new Map();
|
|
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
|
-
}
|
|
177
|
-
|
|
178
|
-
// Bring in parent epics that aren't already in the filtered list so the tree
|
|
179
|
-
// can root under them. Only add the epic itself, NOT its other children —
|
|
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.
|
|
183
|
-
if (state.filter !== 'all') {
|
|
184
|
-
const missingParents = [...new Set(parentOf.values())].filter((id) => !byId.has(id));
|
|
185
|
-
const fetched = await Promise.all(
|
|
186
|
-
missingParents.map((id) => bdShow(id, state.cwd).catch(() => null))
|
|
187
|
-
);
|
|
188
|
-
for (const p of fetched) {
|
|
189
|
-
if (p && p.id && !byId.has(p.id)) {
|
|
190
|
-
beads.push(p);
|
|
191
|
-
byId.set(p.id, p);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
for (const b of beads) {
|
|
197
|
-
state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
state.fullTreeItems = buildTreeOrder(beads, parentOf);
|
|
201
|
-
applyFilters();
|
|
202
|
-
return beads;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export async function loadDetail(id) {
|
|
206
|
-
const [bead, deps] = await Promise.all([
|
|
207
|
-
bdShow(id, state.cwd),
|
|
208
|
-
bdDeps(id, state.cwd),
|
|
209
|
-
]);
|
|
210
|
-
const merged = { ...bead, depsDown: deps.down, depsUp: deps.up };
|
|
211
|
-
state.beadsById.set(id, merged);
|
|
212
|
-
return merged;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export async function applyMutation(id, mutatorFn) {
|
|
216
|
-
await mutatorFn();
|
|
217
|
-
return loadDetail(id);
|
|
218
|
-
}
|
|
1
|
+
import { bdList, bdShow, bdDeps, bdDepListDown } from './bd.js';
|
|
2
|
+
|
|
3
|
+
function byUpdatedDesc(a, b) {
|
|
4
|
+
const ta = a?.updated_at || '', tb = b?.updated_at || '';
|
|
5
|
+
if (ta !== tb) return tb.localeCompare(ta);
|
|
6
|
+
return (b?.id || '').localeCompare(a?.id || '');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const state = {
|
|
10
|
+
beadsById: new Map(),
|
|
11
|
+
fullTreeItems: [], // unfiltered ordered tree items (before typeFilter)
|
|
12
|
+
listOrder: [],
|
|
13
|
+
treeMeta: new Map(), // id -> { depth: 0|1, isLast: bool }
|
|
14
|
+
selectedId: null,
|
|
15
|
+
filter: 'ready',
|
|
16
|
+
typeFilter: 'all', // 'all' | 'epic' | 'task'
|
|
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)
|
|
19
|
+
blockedIds: new Set(), // ids that appear in `bd blocked` — derived state, not raw status
|
|
20
|
+
pageSize: 100, // lazy "load more" batch size for the flat all view
|
|
21
|
+
visibleCount: 0, // how many of listOrder are currently rendered (paged mode)
|
|
22
|
+
cwd: process.cwd(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The "all" and "closed" tabs (no epic filter) can hold huge numbers of beads,
|
|
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).
|
|
28
|
+
export function isPaged() {
|
|
29
|
+
return (state.filter === 'all' || state.filter === 'closed') && !state.epicFilter;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Paged mode only renders the first visibleCount rows. After a reload that keeps
|
|
33
|
+
// a deep selection, grow the window (to its batch boundary) so the selected bead
|
|
34
|
+
// stays rendered — otherwise the cursor and state.selectedId desync and mutation
|
|
35
|
+
// keys would act on an off-screen bead. Call AFTER selectedId is finalized.
|
|
36
|
+
export function ensureSelectedVisible() {
|
|
37
|
+
if (!isPaged() || !state.selectedId) return;
|
|
38
|
+
const idx = state.listOrder.indexOf(state.selectedId);
|
|
39
|
+
if (idx < state.visibleCount) return;
|
|
40
|
+
state.visibleCount = Math.min(
|
|
41
|
+
state.listOrder.length,
|
|
42
|
+
Math.ceil((idx + 1) / state.pageSize) * state.pageSize,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scopeToEpic(items, epicId) {
|
|
47
|
+
const out = [];
|
|
48
|
+
let inside = false;
|
|
49
|
+
for (const it of items) {
|
|
50
|
+
if (it.depth === 0) {
|
|
51
|
+
inside = it.id === epicId;
|
|
52
|
+
if (inside) out.push(it);
|
|
53
|
+
} else if (inside) {
|
|
54
|
+
out.push(it);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Fix isLast on the last child after scoping (cheap, optional polish).
|
|
58
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
59
|
+
if (out[i].depth > 0) { out[i] = { ...out[i], isLast: true }; break; }
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
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() {
|
|
69
|
+
let items = state.fullTreeItems;
|
|
70
|
+
if (state.epicFilter) {
|
|
71
|
+
items = scopeToEpic(items, state.epicFilter);
|
|
72
|
+
}
|
|
73
|
+
if (state.typeFilter !== 'all') {
|
|
74
|
+
items = items
|
|
75
|
+
.filter((t) => state.beadsById.get(t.id)?.issue_type === state.typeFilter)
|
|
76
|
+
.sort((x, y) => byUpdatedDesc(state.beadsById.get(x.id), state.beadsById.get(y.id)))
|
|
77
|
+
.map((t) => ({ id: t.id, depth: 0, isLast: false }));
|
|
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
|
+
}
|
|
88
|
+
state.listOrder = items.map((t) => t.id);
|
|
89
|
+
state.treeMeta = new Map(items.map((t) => [t.id, { depth: t.depth, isLast: t.isLast }]));
|
|
90
|
+
state.visibleCount = isPaged()
|
|
91
|
+
? Math.min(state.pageSize, state.listOrder.length)
|
|
92
|
+
: state.listOrder.length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildTreeOrder(beads, parentOf) {
|
|
96
|
+
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
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
|
+
};
|
|
104
|
+
|
|
105
|
+
const childrenOf = new Map();
|
|
106
|
+
const roots = [];
|
|
107
|
+
for (const b of beads) {
|
|
108
|
+
const pid = parentOf.get(b.id);
|
|
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)) {
|
|
114
|
+
if (!childrenOf.has(pid)) childrenOf.set(pid, []);
|
|
115
|
+
childrenOf.get(pid).push(b);
|
|
116
|
+
} else {
|
|
117
|
+
roots.push(b);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const children of childrenOf.values()) {
|
|
122
|
+
children.sort(byUpdatedDesc);
|
|
123
|
+
}
|
|
124
|
+
roots.sort(byUpdatedDesc);
|
|
125
|
+
|
|
126
|
+
const ordered = [];
|
|
127
|
+
for (const root of roots) {
|
|
128
|
+
ordered.push({ id: root.id, depth: 0, isLast: false });
|
|
129
|
+
const children = childrenOf.get(root.id) || [];
|
|
130
|
+
children.forEach((child, i) => {
|
|
131
|
+
ordered.push({ id: child.id, depth: 1, isLast: i === children.length - 1 });
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return ordered;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function loadList() {
|
|
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';
|
|
146
|
+
const [beads, blocked] = await Promise.all([
|
|
147
|
+
bdList(state.filter, state.cwd, { unlimited }),
|
|
148
|
+
bdList('blocked', state.cwd).catch(() => []),
|
|
149
|
+
]);
|
|
150
|
+
state.blockedIds = new Set((blocked || []).map((b) => b.id));
|
|
151
|
+
const byId = new Map(beads.map((b) => [b.id, b]));
|
|
152
|
+
|
|
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.
|
|
157
|
+
const parentOf = new Map();
|
|
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
|
+
}
|
|
177
|
+
|
|
178
|
+
// Bring in parent epics that aren't already in the filtered list so the tree
|
|
179
|
+
// can root under them. Only add the epic itself, NOT its other children —
|
|
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.
|
|
183
|
+
if (state.filter !== 'all') {
|
|
184
|
+
const missingParents = [...new Set(parentOf.values())].filter((id) => !byId.has(id));
|
|
185
|
+
const fetched = await Promise.all(
|
|
186
|
+
missingParents.map((id) => bdShow(id, state.cwd).catch(() => null))
|
|
187
|
+
);
|
|
188
|
+
for (const p of fetched) {
|
|
189
|
+
if (p && p.id && !byId.has(p.id)) {
|
|
190
|
+
beads.push(p);
|
|
191
|
+
byId.set(p.id, p);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const b of beads) {
|
|
197
|
+
state.beadsById.set(b.id, { ...state.beadsById.get(b.id), ...b });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
state.fullTreeItems = buildTreeOrder(beads, parentOf);
|
|
201
|
+
applyFilters();
|
|
202
|
+
return beads;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function loadDetail(id) {
|
|
206
|
+
const [bead, deps] = await Promise.all([
|
|
207
|
+
bdShow(id, state.cwd),
|
|
208
|
+
bdDeps(id, state.cwd),
|
|
209
|
+
]);
|
|
210
|
+
const merged = { ...bead, depsDown: deps.down, depsUp: deps.up };
|
|
211
|
+
state.beadsById.set(id, merged);
|
|
212
|
+
return merged;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function applyMutation(id, mutatorFn) {
|
|
216
|
+
await mutatorFn();
|
|
217
|
+
return loadDetail(id);
|
|
218
|
+
}
|