@gperezmz/bb-plugin-thread-glance 0.2.4 → 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/README.md +1 -1
- package/app.tsx +3 -11
- package/dist/app.css +1 -1
- package/dist/app.js +31 -31
- package/dist/app.meta.json +1 -1
- package/dist/server.js +10 -10
- package/dist/server.js.map +3 -3
- package/dist/server.meta.json +1 -1
- package/features/thread-list/components/FoldRows.tsx +21 -30
- package/features/thread-list/components/GroupSection.tsx +7 -45
- package/features/thread-list/components/ListHeader.tsx +79 -0
- package/features/thread-list/components/ProviderBadge.tsx +0 -1
- package/features/thread-list/components/PullRequestProbes.tsx +27 -0
- package/features/thread-list/components/SettingsPanel.tsx +20 -34
- package/features/thread-list/components/ThreadDetails.tsx +3 -3
- package/features/thread-list/components/ThreadList.tsx +117 -83
- package/features/thread-list/components/ThreadRowView.tsx +203 -180
- package/features/thread-list/components/controller.ts +5 -10
- package/features/thread-list/components/glyphs.tsx +10 -16
- package/features/thread-list/data/useAutoExpand.ts +1 -1
- package/features/thread-list/data/useDefaultBranches.ts +35 -0
- package/features/thread-list/data/usePullRequestAnswers.ts +36 -0
- package/features/thread-list/data/useSystemFacts.ts +52 -0
- package/features/thread-list/icons.ts +1 -0
- package/features/thread-list/index.ts +0 -1
- package/features/thread-list/model/attention.ts +2 -66
- package/features/thread-list/model/branches.ts +27 -0
- package/features/thread-list/model/counters.ts +5 -5
- package/features/thread-list/model/details.ts +6 -6
- package/features/thread-list/model/expansion.ts +5 -5
- package/features/thread-list/model/groups.ts +2 -2
- package/features/thread-list/model/labels.ts +43 -18
- package/features/thread-list/model/menu.ts +1 -0
- package/features/thread-list/model/move.ts +1 -1
- package/features/thread-list/model/notes.ts +2 -28
- package/features/thread-list/model/settings.ts +1 -17
- package/features/thread-list/model/settled.ts +122 -0
- package/features/thread-list/model/share.ts +1 -7
- package/features/thread-list/model/sort.ts +5 -16
- package/features/thread-list/model/state.ts +17 -16
- package/features/thread-list/model/time.ts +7 -0
- package/features/thread-list/model/toggles.ts +35 -24
- package/features/thread-list/model/{families.ts → trees.ts} +40 -28
- package/features/thread-list/model/view.ts +202 -274
- package/features/thread-list/testing/browser.css +13 -0
- package/features/thread-list/testing/fixtures.ts +41 -12
- package/package.json +5 -1
- package/server/cli.ts +1 -1
- package/server/import.ts +36 -6
- package/server/preference-store.ts +2 -1
- package/server.ts +1 -1
- package/shared/preferences.ts +38 -27
- package/skills/thread-glance/SKILL.md +11 -12
- package/features/thread-list/data/useAttentionHold.ts +0 -19
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
PluginSidebarThread,
|
|
7
7
|
} from "@get-bb/plugin-sdk/app";
|
|
8
8
|
import type { Preferences } from "@/shared/preferences";
|
|
9
|
-
import { ancestorsOf, type
|
|
9
|
+
import { ancestorsOf, type ThreadTree, type Forest, type Subtree, type ThreadInfo } from "./trees";
|
|
10
10
|
import {
|
|
11
11
|
builtinGroup,
|
|
12
12
|
entityGroups,
|
|
@@ -18,25 +18,29 @@ import {
|
|
|
18
18
|
THREADS_GROUP_ID,
|
|
19
19
|
type GroupDescriptor,
|
|
20
20
|
} from "./groups";
|
|
21
|
-
import { addCounters,
|
|
21
|
+
import { addCounters, countTrees, EMPTY_COUNTERS, type Counters } from "./counters";
|
|
22
22
|
import { comparePinned, effectiveSortField, makeComparator, type SortKey } from "./sort";
|
|
23
|
-
import {
|
|
23
|
+
import { isSettledTree, type SettleInputs } from "./settled";
|
|
24
|
+
import { isOffDefaultBranch } from "./branches";
|
|
24
25
|
import { mostUrgent, type Flag } from "./state";
|
|
25
26
|
import type { Targets } from "./expansion";
|
|
26
27
|
import type { RowNote } from "./notes";
|
|
27
28
|
|
|
28
|
-
/** How many
|
|
29
|
-
export const KEEP_QUIET = 5;
|
|
30
|
-
/** How many quiet children stay in an expanded family. */
|
|
29
|
+
/** How many quiet children stay in an expanded tree. */
|
|
31
30
|
export const KEEP_QUIET_CHILDREN = 3;
|
|
32
31
|
|
|
32
|
+
/** The children chip: the count of a parent's direct children and its chevron, after the state of its descendants. */
|
|
33
33
|
export interface Chip {
|
|
34
|
+
/** Direct children opening it shows, hidden ones left out; 0 draws no number. */
|
|
34
35
|
count: number;
|
|
35
|
-
flag: Flag | null;
|
|
36
36
|
/** The user opened the chip, so every child shows. */
|
|
37
37
|
expanded: boolean;
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The most urgent state among the descendants at any depth, open or
|
|
40
|
+
* collapsed, or null for none: its glyph leads the chip and colours it, and
|
|
41
|
+
* keeps that colour through dimming and hover.
|
|
42
|
+
*/
|
|
43
|
+
flag: Flag | null;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
export interface ThreadRow {
|
|
@@ -50,32 +54,46 @@ export interface ThreadRow {
|
|
|
50
54
|
/** Title of the thread this one attaches to, for tooltips and labels. */
|
|
51
55
|
parentTitle: string | null;
|
|
52
56
|
chip: Chip | null;
|
|
53
|
-
/** The title is bold: the thread is unread and its family is not attended. */
|
|
54
|
-
bold: boolean;
|
|
55
57
|
/**
|
|
56
|
-
* The
|
|
57
|
-
*
|
|
58
|
-
* row says why it waits on you or failed.
|
|
58
|
+
* The row draws its harness icon: a root whose harness is not bb's
|
|
59
|
+
* default, a child whose harness is not its parent thread's.
|
|
59
60
|
*/
|
|
61
|
+
harness: boolean;
|
|
62
|
+
/** The title is bold: the thread is unread. */
|
|
63
|
+
bold: boolean;
|
|
64
|
+
/** A root whose tree holds an unread thread: it offers Mark read for the whole tree. */
|
|
65
|
+
treeUnread: boolean;
|
|
66
|
+
/** The line under the title: why it waits on you or failed, in both densities. */
|
|
60
67
|
note: RowNote | null;
|
|
68
|
+
/**
|
|
69
|
+
* In Comfortable density, a row with no note whose branch is known not to
|
|
70
|
+
* be its project's default draws that branch on its second line.
|
|
71
|
+
*/
|
|
72
|
+
branchLine: string | null;
|
|
73
|
+
/**
|
|
74
|
+
* Where the pull request badge goes: after the branch on the second line,
|
|
75
|
+
* on the title line (a root off its default branch, as before), or nowhere.
|
|
76
|
+
*/
|
|
77
|
+
pullRequest: "second-line" | "title" | null;
|
|
78
|
+
/** The machine's name, beside the age, for a thread off bb's primary machine. */
|
|
79
|
+
machine: string | null;
|
|
61
80
|
/** The title, and the chip with it, step back: see `isDimmed`. */
|
|
62
81
|
dimmed: boolean;
|
|
63
82
|
/** A hidden thread shown because it needs attention or failed. */
|
|
64
83
|
hiddenBadge: boolean;
|
|
65
|
-
/** "In project X" when the thread is outside its
|
|
84
|
+
/** "In project X" when the thread is outside its tree's group. */
|
|
66
85
|
crossGroupLabel: string | null;
|
|
67
|
-
/** A root in Needs attention: its home group's name, drawn where the age goes. */
|
|
68
|
-
homeGroupLabel: string | null;
|
|
69
86
|
projectId: string;
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
/** The fold inside an open tree: "N more child threads". */
|
|
72
90
|
export interface OlderRow {
|
|
73
91
|
type: "older";
|
|
74
92
|
key: string;
|
|
75
|
-
/**
|
|
93
|
+
/** The parent thread's id. */
|
|
76
94
|
scopeId: string;
|
|
77
|
-
/** `reveal`: an auto-reveal left `count` children hidden
|
|
78
|
-
scope: "
|
|
95
|
+
/** `reveal`: an auto-reveal left `count` children hidden. */
|
|
96
|
+
scope: "tree" | "reveal";
|
|
79
97
|
count: number;
|
|
80
98
|
expanded: boolean;
|
|
81
99
|
depth: number;
|
|
@@ -95,54 +113,46 @@ export interface EnvironmentRow {
|
|
|
95
113
|
depth: number;
|
|
96
114
|
}
|
|
97
115
|
|
|
98
|
-
/**
|
|
99
|
-
export interface
|
|
100
|
-
type: "
|
|
116
|
+
/** A group's settled fold: the faint "Settled (N)" divider at its end. */
|
|
117
|
+
export interface SettledRow {
|
|
118
|
+
type: "settled";
|
|
101
119
|
key: string;
|
|
102
|
-
|
|
103
|
-
|
|
120
|
+
groupId: string;
|
|
121
|
+
/** Settled trees in the group, the open one drawn above the divider included. */
|
|
104
122
|
count: number;
|
|
105
|
-
|
|
123
|
+
expanded: boolean;
|
|
106
124
|
}
|
|
107
125
|
|
|
108
|
-
|
|
126
|
+
/** The rows a tree and its folders draw. */
|
|
127
|
+
export type TreeRow = ThreadRow | OlderRow | EnvironmentRow;
|
|
128
|
+
|
|
129
|
+
export type Row = TreeRow | SettledRow;
|
|
109
130
|
|
|
110
131
|
export interface GroupView {
|
|
111
132
|
descriptor: GroupDescriptor;
|
|
112
133
|
counters: Counters;
|
|
113
134
|
/** What the user stored. */
|
|
114
135
|
userCollapsed: boolean;
|
|
115
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* What is drawn: user collapse, unless opening a thread in the group
|
|
138
|
+
* opened it. A collapsed group still draws its trees that need attention.
|
|
139
|
+
*/
|
|
116
140
|
collapsed: boolean;
|
|
117
141
|
hidden: boolean;
|
|
118
142
|
rows: Row[];
|
|
119
|
-
/** Every
|
|
143
|
+
/** Every tree root bucketed in the group, those behind folds included. */
|
|
120
144
|
rootIds: string[];
|
|
121
145
|
}
|
|
122
146
|
|
|
123
|
-
/** The id Needs attention's rows are drawn under; no group has it. */
|
|
124
|
-
export const ATTENTION_GROUP_ID = "attention";
|
|
125
|
-
|
|
126
|
-
/** The Needs attention section: every family with a thread that needs attention, or held there while one of its threads is open. */
|
|
127
|
-
export interface AttentionView {
|
|
128
|
-
/** How many families the section holds, attended ones left out, for its header. */
|
|
129
|
-
familyCount: number;
|
|
130
|
-
rows: Row[];
|
|
131
|
-
/** The home group of each thread drawn here, by thread id: a drop on its row acts there. */
|
|
132
|
-
homeGroupIds: Record<string, string>;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
147
|
export interface ListView {
|
|
136
|
-
/** Null when no family is in the section. */
|
|
137
|
-
attention: AttentionView | null;
|
|
138
148
|
groups: GroupView[];
|
|
139
149
|
/** Hidden groups, shown in the More popover. */
|
|
140
150
|
more: GroupView[];
|
|
141
151
|
moreCounters: Counters;
|
|
142
152
|
/** Resolved top-level order for the mode, for header drag. */
|
|
143
153
|
order: string[];
|
|
144
|
-
/**
|
|
145
|
-
|
|
154
|
+
/** Thread trees that need attention, in every group, hidden ones included: the need-you filter's N. */
|
|
155
|
+
needYouCount: number;
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
export interface ViewInputs {
|
|
@@ -152,16 +162,28 @@ export interface ViewInputs {
|
|
|
152
162
|
sections: readonly PluginSidebarSection[];
|
|
153
163
|
prefs: Preferences;
|
|
154
164
|
activeThreadId: string | null;
|
|
155
|
-
/** The family Needs attention keeps while one of its threads is open, as it was when opened: the section orders it by this. */
|
|
156
|
-
held: SectionFamily | null;
|
|
157
165
|
targets: Targets;
|
|
166
|
+
settle: SettleInputs;
|
|
167
|
+
/** bb's default harness; null while unknown, when no root draws one. */
|
|
168
|
+
defaultProviderId: string | null;
|
|
169
|
+
/** bb's primary machine; null while unknown, when no row names its machine. */
|
|
170
|
+
primaryHostId: string | null;
|
|
171
|
+
/** Comfortable density: rows may take a branch line. */
|
|
172
|
+
comfortable: boolean;
|
|
173
|
+
/** A project's default branch: undefined while looked up, null when not found. */
|
|
174
|
+
defaultBranchOf(thread: PluginSidebarThread): string | null | undefined;
|
|
175
|
+
/**
|
|
176
|
+
* The need-you filter is on: every group, hidden ones included, draws only
|
|
177
|
+
* its trees that need attention, and a group with none is left out.
|
|
178
|
+
*/
|
|
179
|
+
needYouOnly: boolean;
|
|
158
180
|
}
|
|
159
181
|
|
|
160
182
|
interface Context extends ViewInputs {
|
|
161
|
-
heldRootId: string | null;
|
|
162
183
|
compare: (a: SortKey, b: SortKey) => number;
|
|
163
184
|
expandedChildren: ReadonlySet<string>;
|
|
164
185
|
expandedOlder: ReadonlySet<string>;
|
|
186
|
+
openSettledFolds: ReadonlySet<string>;
|
|
165
187
|
collapsedEnvironments: ReadonlySet<string>;
|
|
166
188
|
/** Parent ids on the path to a reveal target. */
|
|
167
189
|
revealPath: ReadonlySet<string>;
|
|
@@ -170,10 +192,6 @@ interface Context extends ViewInputs {
|
|
|
170
192
|
activePath: ReadonlySet<string>;
|
|
171
193
|
projectNames: ReadonlyMap<string, string>;
|
|
172
194
|
sectionNames: ReadonlyMap<string, string>;
|
|
173
|
-
/** The rows are drawn in Needs attention. */
|
|
174
|
-
drawnInAttention: boolean;
|
|
175
|
-
/** The rows are an attended family's: they draw as in its home group, with no bold title or why line. */
|
|
176
|
-
attended: boolean;
|
|
177
195
|
}
|
|
178
196
|
|
|
179
197
|
function titleOf(context: Context, id: string | null): string | null {
|
|
@@ -203,21 +221,45 @@ function crossGroupLabel(context: Context, info: ThreadInfo, root: ThreadInfo):
|
|
|
203
221
|
/**
|
|
204
222
|
* Whether a row's title steps back: a quiet thread does, at any depth, so
|
|
205
223
|
* brightness shows state and depth is left to the indent and smaller text.
|
|
206
|
-
* A root with children stays bright until its whole
|
|
224
|
+
* A root with children stays bright until its whole tree is quiet.
|
|
207
225
|
*/
|
|
208
226
|
function isDimmed(context: Context, info: ThreadInfo, chip: Chip | null): boolean {
|
|
209
|
-
if (info.parentId === null && chip !== null) return context.forest.
|
|
227
|
+
if (info.parentId === null && chip !== null) return context.forest.treeOf.get(info.thread.id)?.quiet ?? info.quiet;
|
|
210
228
|
return info.quiet;
|
|
211
229
|
}
|
|
212
230
|
|
|
231
|
+
|
|
232
|
+
/** A root draws its harness when it differs from bb's default; a child, when it differs from its parent thread's. */
|
|
233
|
+
function drawsHarness(context: Context, info: ThreadInfo): boolean {
|
|
234
|
+
const providerId = info.thread.providerId;
|
|
235
|
+
if (info.parentId === null) return context.defaultProviderId !== null && providerId !== context.defaultProviderId;
|
|
236
|
+
return providerId !== context.forest.infos.get(info.parentId)?.thread.providerId;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The row's second line and pull request badge, as the density and the branch allow. */
|
|
240
|
+
function lines(context: Context, info: ThreadInfo, depth: number): Pick<ThreadRow, "branchLine" | "pullRequest"> {
|
|
241
|
+
const branch = info.thread.environment?.branchName ?? null;
|
|
242
|
+
const offDefault = isOffDefaultBranch(branch, context.defaultBranchOf(info.thread));
|
|
243
|
+
if (context.comfortable && info.note === null && offDefault) return { branchLine: branch, pullRequest: "second-line" };
|
|
244
|
+
return { branchLine: null, pullRequest: depth === 0 && offDefault ? "title" : null };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The machine a row names: one other than bb's primary, unless the list is grouped by machine. */
|
|
248
|
+
function machineOf(context: Context, info: ThreadInfo): string | null {
|
|
249
|
+
const host = info.thread.host;
|
|
250
|
+
if (context.prefs.organizationMode === "machine" || context.primaryHostId === null || host === null) return null;
|
|
251
|
+
return host.id === context.primaryHostId ? null : host.name || "Unknown machine";
|
|
252
|
+
}
|
|
253
|
+
|
|
213
254
|
function threadRow(
|
|
214
255
|
context: Context,
|
|
215
256
|
info: ThreadInfo,
|
|
216
257
|
root: ThreadInfo,
|
|
217
258
|
options: { depth: number; nested: boolean; chip: Chip | null },
|
|
218
259
|
): ThreadRow {
|
|
219
|
-
const needsAttention = !info.thread.isArchived && info.attentionFlags.size > 0;
|
|
220
260
|
return {
|
|
261
|
+
...lines(context, info, options.depth),
|
|
262
|
+
machine: machineOf(context, info),
|
|
221
263
|
type: "thread",
|
|
222
264
|
key: `thread:${info.thread.id}`,
|
|
223
265
|
info,
|
|
@@ -225,12 +267,13 @@ function threadRow(
|
|
|
225
267
|
nested: options.nested,
|
|
226
268
|
parentTitle: titleOf(context, info.parentId),
|
|
227
269
|
chip: options.chip,
|
|
228
|
-
|
|
229
|
-
|
|
270
|
+
harness: drawsHarness(context, info),
|
|
271
|
+
bold: info.unread,
|
|
272
|
+
treeUnread: info === root && [root, ...(context.forest.treeOf.get(root.thread.id)?.descendants ?? [])].some((info) => info.unread),
|
|
273
|
+
note: info.note,
|
|
230
274
|
dimmed: isDimmed(context, info, options.chip),
|
|
231
275
|
hiddenBadge: info.thread.isHidden,
|
|
232
276
|
crossGroupLabel: crossGroupLabel(context, info, root),
|
|
233
|
-
homeGroupLabel: null,
|
|
234
277
|
projectId: info.thread.projectId,
|
|
235
278
|
};
|
|
236
279
|
}
|
|
@@ -261,7 +304,7 @@ function foldedChildren(
|
|
|
261
304
|
const kids = eligibleChildren(context, parentId);
|
|
262
305
|
|
|
263
306
|
const reveals = (kid: ThreadInfo) => context.revealIds.has(kid.thread.id) || context.revealPath.has(kid.thread.id);
|
|
264
|
-
const olderRow = (scope: "
|
|
307
|
+
const olderRow = (scope: "tree" | "reveal", count: number, expanded: boolean): OlderRow | null =>
|
|
265
308
|
count > 0
|
|
266
309
|
? {
|
|
267
310
|
type: "older",
|
|
@@ -298,7 +341,7 @@ function foldedChildren(
|
|
|
298
341
|
// Open, the fold still counts what it holds, so its row can close it again.
|
|
299
342
|
const folded = kids.filter((kid) => !kept(kid)).length;
|
|
300
343
|
const shown = olderExpanded ? kids : kids.filter(kept);
|
|
301
|
-
return { shown, older: olderRow("
|
|
344
|
+
return { shown, older: olderRow("tree", folded, olderExpanded) };
|
|
302
345
|
}
|
|
303
346
|
|
|
304
347
|
function environmentLabel(thread: PluginSidebarThread): string {
|
|
@@ -310,13 +353,13 @@ function environmentLabel(thread: PluginSidebarThread): string {
|
|
|
310
353
|
return "Environment";
|
|
311
354
|
}
|
|
312
355
|
|
|
313
|
-
type Unit = { info: ThreadInfo; rows:
|
|
356
|
+
type Unit = { info: ThreadInfo; rows: TreeRow[]; flags: ReadonlySet<Flag> };
|
|
314
357
|
|
|
315
358
|
/**
|
|
316
359
|
* Folds sibling units that share a worktree environment (2 or more) into a
|
|
317
360
|
* folder row. Off unless the environment grouping preference is on.
|
|
318
361
|
*/
|
|
319
|
-
function clusterEnvironments(context: Context, units: Unit[], depth: number):
|
|
362
|
+
function clusterEnvironments(context: Context, units: Unit[], depth: number): TreeRow[] {
|
|
320
363
|
if (!context.prefs.environmentGrouping) return units.flatMap((unit) => unit.rows);
|
|
321
364
|
const counts = new Map<string, number>();
|
|
322
365
|
for (const unit of units) {
|
|
@@ -325,7 +368,7 @@ function clusterEnvironments(context: Context, units: Unit[], depth: number): Ro
|
|
|
325
368
|
counts.set(environment.id, (counts.get(environment.id) ?? 0) + 1);
|
|
326
369
|
}
|
|
327
370
|
}
|
|
328
|
-
const rows:
|
|
371
|
+
const rows: TreeRow[] = [];
|
|
329
372
|
const emitted = new Set<string>();
|
|
330
373
|
for (const unit of units) {
|
|
331
374
|
const environment = unit.info.thread.environment;
|
|
@@ -362,30 +405,13 @@ function clusterEnvironments(context: Context, units: Unit[], depth: number): Ro
|
|
|
362
405
|
return rows;
|
|
363
406
|
}
|
|
364
407
|
|
|
365
|
-
/**
|
|
366
|
-
function childProviders(parent: ThreadInfo, descendants: readonly ThreadInfo[]): string[] {
|
|
367
|
-
const ids: string[] = [];
|
|
368
|
-
for (const info of descendants) {
|
|
369
|
-
const id = info.thread.providerId;
|
|
370
|
-
if (info.thread.isHidden || id === parent.thread.providerId || ids.includes(id)) continue;
|
|
371
|
-
ids.push(id);
|
|
372
|
-
if (ids.length === 3) break;
|
|
373
|
-
}
|
|
374
|
-
return ids;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/** The chip of a parent: its children's count, most urgent flag and harnesses. */
|
|
408
|
+
/** The children chip of a parent, or null when it would show nothing. */
|
|
378
409
|
function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
|
|
379
|
-
const
|
|
380
|
-
const
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
return {
|
|
384
|
-
count: subtree.visibleCount,
|
|
385
|
-
flag: mostUrgent(subtree.flags),
|
|
386
|
-
expanded,
|
|
387
|
-
providerIds: childProviders(info, subtree.descendants),
|
|
388
|
-
};
|
|
410
|
+
const { childCount: count, chipFlags } = subtreeOf(context, info.thread.id);
|
|
411
|
+
const flag = mostUrgent(chipFlags);
|
|
412
|
+
// A hidden child's failure that needs no attention has no row, yet the chip still shows it.
|
|
413
|
+
if (count === 0 && flag === null && eligibleChildren(context, info.thread.id).length === 0) return null;
|
|
414
|
+
return { count, expanded, flag };
|
|
389
415
|
}
|
|
390
416
|
|
|
391
417
|
/** What a thread and everything under it carry, for an environment folder's glyph. */
|
|
@@ -396,20 +422,20 @@ function rollupFlags(context: Context, info: ThreadInfo): Set<Flag> {
|
|
|
396
422
|
}
|
|
397
423
|
|
|
398
424
|
/** The rows under `parent`: each shown child, then its own level. */
|
|
399
|
-
function foldedLevel(context: Context,
|
|
425
|
+
function foldedLevel(context: Context, tree: ThreadTree, parent: ThreadInfo, depth: number): TreeRow[] {
|
|
400
426
|
const { shown, older } = foldedChildren(context, parent, depth);
|
|
401
427
|
const units: Unit[] = shown.map((info) => ({
|
|
402
428
|
info,
|
|
403
429
|
flags: rollupFlags(context, info),
|
|
404
430
|
rows: [
|
|
405
|
-
threadRow(context, info,
|
|
431
|
+
threadRow(context, info, tree.root, {
|
|
406
432
|
depth,
|
|
407
433
|
nested: depth > 1,
|
|
408
434
|
// Expanded only when the user expanded it: an auto-reveal shows a
|
|
409
435
|
// chevron that still expands, and a "N more child threads" row.
|
|
410
436
|
chip: chipOf(context, info, context.expandedChildren.has(info.thread.id)),
|
|
411
437
|
}),
|
|
412
|
-
...foldedLevel(context,
|
|
438
|
+
...foldedLevel(context, tree, info, depth + 1),
|
|
413
439
|
],
|
|
414
440
|
}));
|
|
415
441
|
const rows = clusterEnvironments(context, units, depth);
|
|
@@ -417,180 +443,69 @@ function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth
|
|
|
417
443
|
return rows;
|
|
418
444
|
}
|
|
419
445
|
|
|
420
|
-
function
|
|
421
|
-
const root =
|
|
446
|
+
function foldedTreeRows(context: Context, tree: ThreadTree): TreeRow[] {
|
|
447
|
+
const root = tree.root;
|
|
422
448
|
const chip = chipOf(context, root, context.expandedChildren.has(root.thread.id));
|
|
423
|
-
return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context,
|
|
449
|
+
return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, tree, root, 1)];
|
|
424
450
|
}
|
|
425
451
|
|
|
426
|
-
function
|
|
427
|
-
return { info:
|
|
452
|
+
function treeUnit(context: Context, tree: ThreadTree): Unit {
|
|
453
|
+
return { info: tree.root, rows: foldedTreeRows(context, tree), flags: tree.flags };
|
|
428
454
|
}
|
|
429
455
|
|
|
430
|
-
/**
|
|
431
|
-
export function
|
|
432
|
-
|
|
433
|
-
if (flags.has("waits-on-you")) return 0;
|
|
434
|
-
if (flags.has("unread-failed") || flags.has("queue-failed")) return 1;
|
|
435
|
-
if (flags.has("offline")) return 2;
|
|
436
|
-
if (flags.has("unread")) return 3;
|
|
437
|
-
return 4;
|
|
456
|
+
/** Whether a thread in the tree needs attention: it stays drawn when its group is collapsed. */
|
|
457
|
+
export function needsAttention(tree: Pick<ThreadTree, "attentionFlags">): boolean {
|
|
458
|
+
return tree.attentionFlags.size > 0;
|
|
438
459
|
}
|
|
439
460
|
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
* it draws, with one "+N more" line for the child threads left out; opened
|
|
444
|
-
* with the root's chip, it draws as in its home group and keeps the path.
|
|
445
|
-
*/
|
|
446
|
-
function attentionFamilyRows(groupContext: Context, family: Family, homeGroupLabel: string): Row[] {
|
|
447
|
-
const context: Context = {
|
|
448
|
-
...groupContext,
|
|
449
|
-
drawnInAttention: true,
|
|
450
|
-
attended: isAttended(family, groupContext.heldRootId),
|
|
451
|
-
};
|
|
452
|
-
const root = family.root;
|
|
453
|
-
const onPath = new Set<string>([root.thread.id]);
|
|
454
|
-
for (const info of family.descendants) {
|
|
455
|
-
const attention = !info.thread.isArchived && info.attentionFlags.size > 0;
|
|
456
|
-
if (!attention && !info.isActive) continue;
|
|
457
|
-
onPath.add(info.thread.id);
|
|
458
|
-
for (const ancestor of ancestorsOf(info.thread.id, context.forest.infos, root.thread.id)) onPath.add(ancestor);
|
|
459
|
-
}
|
|
460
|
-
const expanded = context.expandedChildren.has(root.thread.id);
|
|
461
|
-
if (expanded) {
|
|
462
|
-
// The path is kept the way a reveal target's is.
|
|
463
|
-
const kept: Context = { ...context, revealIds: new Set([...context.revealIds, ...onPath]) };
|
|
464
|
-
const [rootRow, ...rest] = foldedFamilyRows(kept, family);
|
|
465
|
-
return [{ ...(rootRow as ThreadRow), homeGroupLabel }, ...rest];
|
|
466
|
-
}
|
|
467
|
-
const chip = chipOf(context, root, false);
|
|
468
|
-
const rows: Row[] = [{ ...threadRow(context, root, root, { depth: 0, nested: false, chip }), homeGroupLabel }];
|
|
469
|
-
const walk = (parentId: string, depth: number) => {
|
|
470
|
-
for (const id of context.forest.children.get(parentId) ?? []) {
|
|
471
|
-
if (!onPath.has(id)) continue;
|
|
472
|
-
rows.push(threadRow(context, context.forest.infos.get(id)!, root, { depth, nested: depth > 1, chip: null }));
|
|
473
|
-
walk(id, depth + 1);
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
walk(root.thread.id, 1);
|
|
477
|
-
const left = family.descendants.filter((info) => !info.thread.isHidden && !onPath.has(info.thread.id)).length;
|
|
478
|
-
if (left > 0) rows.push({ type: "left-out", key: `left-out:${root.thread.id}`, scopeId: root.thread.id, count: left, depth: 1 });
|
|
479
|
-
return rows;
|
|
461
|
+
/** The need-you filter's N: thread trees that need attention, in every group. */
|
|
462
|
+
export function countNeedYou(forest: Pick<Forest, "trees">): number {
|
|
463
|
+
return forest.trees.filter(needsAttention).length;
|
|
480
464
|
}
|
|
481
465
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
homeGroupOf: (family: Family) => GroupDescriptor,
|
|
486
|
-
): AttentionView | null {
|
|
487
|
-
if (families.length === 0) return null;
|
|
488
|
-
// Most urgent first, then the chosen field in its natural direction: the
|
|
489
|
-
// sort direction orders the groups only. The held family sorts as it was
|
|
490
|
-
// when opened, so nothing inside it moves it.
|
|
491
|
-
const compare = makeComparator({
|
|
492
|
-
field: context.prefs.chronologicalSort,
|
|
493
|
-
direction: "default",
|
|
494
|
-
workingFirst: context.prefs.workingFirst,
|
|
495
|
-
});
|
|
496
|
-
const slotOf = (family: Family): SectionFamily =>
|
|
497
|
-
context.held !== null && family.root.thread.id === context.heldRootId ? context.held : family;
|
|
498
|
-
const sorted = [...families].sort((familyA, familyB) => {
|
|
499
|
-
const a = slotOf(familyA);
|
|
500
|
-
const b = slotOf(familyB);
|
|
501
|
-
return (
|
|
502
|
-
urgency(a) - urgency(b) ||
|
|
503
|
-
compare(
|
|
504
|
-
{ thread: a.root.thread, familyAttention: a.latestAttentionAt },
|
|
505
|
-
{ thread: b.root.thread, familyAttention: b.latestAttentionAt },
|
|
506
|
-
)
|
|
507
|
-
);
|
|
508
|
-
});
|
|
509
|
-
const homeGroupIds: Record<string, string> = {};
|
|
510
|
-
const rows = sorted.flatMap((family) => {
|
|
511
|
-
const home = homeGroupOf(family);
|
|
512
|
-
const familyRows = attentionFamilyRows(context, family, home.label);
|
|
513
|
-
for (const row of familyRows) if (row.type === "thread") homeGroupIds[row.info.thread.id] = home.id;
|
|
514
|
-
return familyRows;
|
|
515
|
-
});
|
|
516
|
-
return {
|
|
517
|
-
familyCount: sorted.filter((family) => !isAttended(family, context.heldRootId)).length,
|
|
518
|
-
rows,
|
|
519
|
-
homeGroupIds,
|
|
520
|
-
};
|
|
466
|
+
/** The filter narrows the list only while something needs you: at 0 the full list is back. */
|
|
467
|
+
export function needYouActive(on: boolean, count: number): boolean {
|
|
468
|
+
return on && count > 0;
|
|
521
469
|
}
|
|
522
470
|
|
|
523
471
|
/**
|
|
524
|
-
* One group. `
|
|
525
|
-
*
|
|
472
|
+
* One group. `trees` is every tree bucketed in it. Collapsed, it draws only
|
|
473
|
+
* its trees that need attention; opening a thread in it opens it, as an
|
|
474
|
+
* auto-reveal did.
|
|
526
475
|
*/
|
|
527
|
-
function buildGroup(
|
|
528
|
-
context
|
|
529
|
-
|
|
530
|
-
families: Family[],
|
|
531
|
-
drawn: Family[],
|
|
532
|
-
hidden: boolean,
|
|
533
|
-
): GroupView {
|
|
534
|
-
const counters = countFamilies(families);
|
|
476
|
+
function buildGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
|
|
477
|
+
if (context.needYouOnly) return needYouGroup(context, descriptor, trees, hidden);
|
|
478
|
+
const counters = countTrees(trees);
|
|
535
479
|
const userCollapsed = isGroupCollapsed(descriptor, context.prefs);
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
);
|
|
541
|
-
const collapsed = userCollapsed && !hasTarget;
|
|
480
|
+
const activeId = context.activeThreadId;
|
|
481
|
+
const opened =
|
|
482
|
+
activeId !== null && context.targets.has(activeId) && trees.some((tree) => tree.containsActive);
|
|
483
|
+
const collapsed = userCollapsed && !opened;
|
|
542
484
|
const isPinned = descriptor.id === PINNED_GROUP_ID;
|
|
543
485
|
|
|
544
|
-
const sorted =
|
|
545
|
-
if (isPinned) {
|
|
546
|
-
sorted.sort((a, b) => comparePinned(a.root.thread, b.root.thread));
|
|
547
|
-
} else {
|
|
548
|
-
sorted.sort((a, b) =>
|
|
549
|
-
context.compare(
|
|
550
|
-
{ thread: a.root.thread, familyAttention: a.latestAttentionAt },
|
|
551
|
-
{ thread: b.root.thread, familyAttention: b.latestAttentionAt },
|
|
552
|
-
),
|
|
553
|
-
);
|
|
554
|
-
}
|
|
486
|
+
const sorted = sortTrees(context, descriptor, trees);
|
|
555
487
|
|
|
556
|
-
let
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
family.descendants.some((info) => context.targets.has(info.thread.id)),
|
|
576
|
-
);
|
|
577
|
-
const folded = new Set(foldedFamilies);
|
|
578
|
-
if (!opened) visible = sorted.filter((family) => !folded.has(family));
|
|
579
|
-
older = {
|
|
580
|
-
type: "older",
|
|
581
|
-
key: `older:group:${descriptor.id}`,
|
|
582
|
-
scopeId: descriptor.id,
|
|
583
|
-
scope: "group",
|
|
584
|
-
count: foldedFamilies.length,
|
|
585
|
-
expanded: opened,
|
|
586
|
-
depth: 0,
|
|
587
|
-
};
|
|
488
|
+
let rows: Row[];
|
|
489
|
+
if (collapsed) {
|
|
490
|
+
rows = clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0);
|
|
491
|
+
} else {
|
|
492
|
+
// Settled trees go behind the fold at the group's end. The open one, if
|
|
493
|
+
// settled, is drawn just above the divider, so opening it moves nothing else.
|
|
494
|
+
const settled = sorted.filter((tree) => isSettledTree(tree, context.settle));
|
|
495
|
+
const settledSet = new Set(settled);
|
|
496
|
+
const live = sorted.filter((tree) => !settledSet.has(tree));
|
|
497
|
+
const open = settled.find((tree) => tree.containsActive) ?? null;
|
|
498
|
+
rows = clusterEnvironments(context, live.map((tree) => treeUnit(context, tree)), 0);
|
|
499
|
+
if (settled.length > 0) {
|
|
500
|
+
if (open !== null) rows.push(...treeUnit(context, open).rows);
|
|
501
|
+
const expanded = context.openSettledFolds.has(descriptor.id);
|
|
502
|
+
rows.push({ type: "settled", key: `settled:${descriptor.id}`, groupId: descriptor.id, count: settled.length, expanded });
|
|
503
|
+
if (expanded) {
|
|
504
|
+
const folded = settled.filter((tree) => tree !== open).map((tree) => treeUnit(context, tree));
|
|
505
|
+
rows.push(...clusterEnvironments(context, folded, 0));
|
|
506
|
+
}
|
|
588
507
|
}
|
|
589
508
|
}
|
|
590
|
-
|
|
591
|
-
const units = visible.map((family) => familyUnit(context, family));
|
|
592
|
-
const rows = clusterEnvironments(context, units, 0);
|
|
593
|
-
if (older !== null) rows.push(older);
|
|
594
509
|
return {
|
|
595
510
|
descriptor,
|
|
596
511
|
counters,
|
|
@@ -598,7 +513,32 @@ function buildGroup(
|
|
|
598
513
|
collapsed,
|
|
599
514
|
hidden,
|
|
600
515
|
rows,
|
|
601
|
-
rootIds:
|
|
516
|
+
rootIds: trees.map((tree) => tree.root.thread.id),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** A group's trees in its order: Pinned's own, else the chosen sort. */
|
|
521
|
+
function sortTrees(context: Context, descriptor: GroupDescriptor, trees: readonly ThreadTree[]): ThreadTree[] {
|
|
522
|
+
if (descriptor.id === PINNED_GROUP_ID) return [...trees].sort((a, b) => comparePinned(a.root.thread, b.root.thread));
|
|
523
|
+
return [...trees].sort((a, b) =>
|
|
524
|
+
context.compare(
|
|
525
|
+
{ thread: a.root.thread, treeAttention: a.latestAttentionAt },
|
|
526
|
+
{ thread: b.root.thread, treeAttention: b.latestAttentionAt },
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** A group under the need-you filter: its header, and its trees that need attention, whatever its collapse. */
|
|
532
|
+
function needYouGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
|
|
533
|
+
const sorted = sortTrees(context, descriptor, trees);
|
|
534
|
+
return {
|
|
535
|
+
descriptor,
|
|
536
|
+
counters: countTrees(trees),
|
|
537
|
+
userCollapsed: isGroupCollapsed(descriptor, context.prefs),
|
|
538
|
+
collapsed: false,
|
|
539
|
+
hidden,
|
|
540
|
+
rows: clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0),
|
|
541
|
+
rootIds: trees.map((tree) => tree.root.thread.id),
|
|
602
542
|
};
|
|
603
543
|
}
|
|
604
544
|
|
|
@@ -618,40 +558,28 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
618
558
|
}
|
|
619
559
|
const context: Context = {
|
|
620
560
|
...inputs,
|
|
621
|
-
|
|
622
|
-
compare: makeComparator({
|
|
623
|
-
field: prefs.chronologicalSort,
|
|
624
|
-
direction: prefs.sortDirection,
|
|
625
|
-
workingFirst: prefs.workingFirst,
|
|
626
|
-
}),
|
|
561
|
+
compare: makeComparator({ field: prefs.chronologicalSort, direction: prefs.sortDirection }),
|
|
627
562
|
expandedChildren: new Set(prefs.expandedChildren),
|
|
628
563
|
expandedOlder: new Set(prefs.expandedOlder),
|
|
564
|
+
openSettledFolds: new Set(prefs.openSettledFolds),
|
|
629
565
|
collapsedEnvironments: new Set(prefs.collapsedEnvironments),
|
|
630
566
|
revealIds,
|
|
631
567
|
revealPath,
|
|
632
568
|
activePath,
|
|
633
569
|
projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
|
|
634
570
|
sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
|
|
635
|
-
drawnInAttention: false,
|
|
636
|
-
attended: false,
|
|
637
571
|
};
|
|
638
572
|
|
|
639
|
-
const byGroup = new Map<string,
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
const openRootId = inputs.activeThreadId === null ? null : (forest.familyOf.get(inputs.activeThreadId)?.root.thread.id ?? null);
|
|
643
|
-
for (const family of forest.families) {
|
|
644
|
-
const id = groupIdForRoot(family.root.thread, {
|
|
573
|
+
const byGroup = new Map<string, ThreadTree[]>();
|
|
574
|
+
for (const tree of forest.trees) {
|
|
575
|
+
const id = groupIdForRoot(tree.root.thread, {
|
|
645
576
|
mode: prefs.organizationMode,
|
|
646
577
|
projects: inputs.projects,
|
|
647
578
|
});
|
|
648
579
|
const list = byGroup.get(id) ?? [];
|
|
649
|
-
list.push(
|
|
580
|
+
list.push(tree);
|
|
650
581
|
byGroup.set(id, list);
|
|
651
|
-
homeOf.set(family, id);
|
|
652
|
-
if (inAttention(family, context.heldRootId, openRootId)) section.push(family);
|
|
653
582
|
}
|
|
654
|
-
const inSection = new Set(section);
|
|
655
583
|
|
|
656
584
|
const alphabetical = effectiveSortField(prefs.chronologicalSort) === "alpha";
|
|
657
585
|
const entities = entityGroups(
|
|
@@ -674,16 +602,20 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
674
602
|
for (const id of order) {
|
|
675
603
|
const descriptor = descriptors.get(id);
|
|
676
604
|
if (descriptor === undefined) continue;
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
if (drawn.length === 0) {
|
|
605
|
+
const trees = byGroup.get(id) ?? [];
|
|
606
|
+
// Pinned and the loose bucket appear only when they hold threads, except
|
|
607
|
+
// the loose bucket in custom-section mode, as in bb.
|
|
608
|
+
if (trees.length === 0) {
|
|
682
609
|
if (id === PINNED_GROUP_ID) continue;
|
|
683
610
|
if (id === THREADS_GROUP_ID && prefs.organizationMode !== "chronological") continue;
|
|
684
611
|
}
|
|
685
612
|
const hidden = id !== PINNED_GROUP_ID && hiddenIds.has(id);
|
|
686
|
-
|
|
613
|
+
if (context.needYouOnly) {
|
|
614
|
+
// Under the filter, a hidden group's trees come out of More, each under its own header.
|
|
615
|
+
if (trees.some(needsAttention)) groups.push(buildGroup(context, descriptor, trees, hidden));
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const view = buildGroup(context, descriptor, trees, hidden);
|
|
687
619
|
if (hidden) {
|
|
688
620
|
more.push(view);
|
|
689
621
|
moreCounters = addCounters(moreCounters, view.counters);
|
|
@@ -692,11 +624,7 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
692
624
|
}
|
|
693
625
|
}
|
|
694
626
|
|
|
695
|
-
|
|
696
|
-
for (const thread of inputs.threads) if (thread.host !== null) hosts.add(thread.host.id);
|
|
697
|
-
// Every root's group id has a descriptor: entity groups include unknown sections and every host.
|
|
698
|
-
const attention = buildAttention(context, section, (family) => descriptors.get(homeOf.get(family)!)!);
|
|
699
|
-
return { attention, groups, more, moreCounters, order, multiHost: hosts.size > 1 };
|
|
627
|
+
return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest) };
|
|
700
628
|
}
|
|
701
629
|
|
|
702
630
|
/** Every thread row in visual order, for keyboard and windowing. */
|