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/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
+ }