@gperezmz/bb-plugin-thread-glance 0.2.4 → 0.3.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 +70 -78
- package/features/thread-list/components/controller.ts +5 -10
- package/features/thread-list/components/glyphs.tsx +26 -13
- 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 +28 -17
- 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 +19 -6
- 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 +200 -274
- package/features/thread-list/testing/fixtures.ts +41 -12
- package/package.json +1 -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,23 @@ 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 muted count of a parent's direct children, and its chevron. */
|
|
33
33
|
export interface Chip {
|
|
34
|
+
/** Direct children opening it shows, hidden ones left out. */
|
|
34
35
|
count: number;
|
|
35
|
-
flag: Flag | null;
|
|
36
36
|
/** The user opened the chip, so every child shows. */
|
|
37
37
|
expanded: boolean;
|
|
38
|
-
/** Harnesses among the visible children that differ from the parent's. */
|
|
39
|
-
providerIds: string[];
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
export interface ThreadRow {
|
|
@@ -50,32 +48,51 @@ export interface ThreadRow {
|
|
|
50
48
|
/** Title of the thread this one attaches to, for tooltips and labels. */
|
|
51
49
|
parentTitle: string | null;
|
|
52
50
|
chip: Chip | null;
|
|
53
|
-
/** The title is bold: the thread is unread and its family is not attended. */
|
|
54
|
-
bold: boolean;
|
|
55
51
|
/**
|
|
56
|
-
* The
|
|
57
|
-
*
|
|
58
|
-
* row says why it waits on you or failed.
|
|
52
|
+
* The row draws its harness icon: a root whose harness is not bb's
|
|
53
|
+
* default, a child whose harness is not its parent thread's.
|
|
59
54
|
*/
|
|
55
|
+
harness: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* The child dot on the status glyph: the most urgent state among the
|
|
58
|
+
* thread's descendants at any depth, or null for none.
|
|
59
|
+
*/
|
|
60
|
+
childDot: Flag | null;
|
|
61
|
+
/** The title is bold: the thread is unread. */
|
|
62
|
+
bold: boolean;
|
|
63
|
+
/** A root whose tree holds an unread thread: it offers Mark read for the whole tree. */
|
|
64
|
+
treeUnread: boolean;
|
|
65
|
+
/** The line under the title: why it waits on you or failed, in both densities. */
|
|
60
66
|
note: RowNote | null;
|
|
67
|
+
/**
|
|
68
|
+
* In Comfortable density, a row with no note whose branch is known not to
|
|
69
|
+
* be its project's default draws that branch on its second line.
|
|
70
|
+
*/
|
|
71
|
+
branchLine: string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Where the pull request badge goes: after the branch on the second line,
|
|
74
|
+
* on the title line (a root off its default branch, as before), or nowhere.
|
|
75
|
+
*/
|
|
76
|
+
pullRequest: "second-line" | "title" | null;
|
|
77
|
+
/** The machine's name, beside the age, for a thread off bb's primary machine. */
|
|
78
|
+
machine: string | null;
|
|
61
79
|
/** The title, and the chip with it, step back: see `isDimmed`. */
|
|
62
80
|
dimmed: boolean;
|
|
63
81
|
/** A hidden thread shown because it needs attention or failed. */
|
|
64
82
|
hiddenBadge: boolean;
|
|
65
|
-
/** "In project X" when the thread is outside its
|
|
83
|
+
/** "In project X" when the thread is outside its tree's group. */
|
|
66
84
|
crossGroupLabel: string | null;
|
|
67
|
-
/** A root in Needs attention: its home group's name, drawn where the age goes. */
|
|
68
|
-
homeGroupLabel: string | null;
|
|
69
85
|
projectId: string;
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
/** The fold inside an open tree: "N more child threads". */
|
|
72
89
|
export interface OlderRow {
|
|
73
90
|
type: "older";
|
|
74
91
|
key: string;
|
|
75
|
-
/**
|
|
92
|
+
/** The parent thread's id. */
|
|
76
93
|
scopeId: string;
|
|
77
|
-
/** `reveal`: an auto-reveal left `count` children hidden
|
|
78
|
-
scope: "
|
|
94
|
+
/** `reveal`: an auto-reveal left `count` children hidden. */
|
|
95
|
+
scope: "tree" | "reveal";
|
|
79
96
|
count: number;
|
|
80
97
|
expanded: boolean;
|
|
81
98
|
depth: number;
|
|
@@ -95,54 +112,46 @@ export interface EnvironmentRow {
|
|
|
95
112
|
depth: number;
|
|
96
113
|
}
|
|
97
114
|
|
|
98
|
-
/**
|
|
99
|
-
export interface
|
|
100
|
-
type: "
|
|
115
|
+
/** A group's settled fold: the faint "Settled (N)" divider at its end. */
|
|
116
|
+
export interface SettledRow {
|
|
117
|
+
type: "settled";
|
|
101
118
|
key: string;
|
|
102
|
-
|
|
103
|
-
|
|
119
|
+
groupId: string;
|
|
120
|
+
/** Settled trees in the group, the open one drawn above the divider included. */
|
|
104
121
|
count: number;
|
|
105
|
-
|
|
122
|
+
expanded: boolean;
|
|
106
123
|
}
|
|
107
124
|
|
|
108
|
-
|
|
125
|
+
/** The rows a tree and its folders draw. */
|
|
126
|
+
export type TreeRow = ThreadRow | OlderRow | EnvironmentRow;
|
|
127
|
+
|
|
128
|
+
export type Row = TreeRow | SettledRow;
|
|
109
129
|
|
|
110
130
|
export interface GroupView {
|
|
111
131
|
descriptor: GroupDescriptor;
|
|
112
132
|
counters: Counters;
|
|
113
133
|
/** What the user stored. */
|
|
114
134
|
userCollapsed: boolean;
|
|
115
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* What is drawn: user collapse, unless opening a thread in the group
|
|
137
|
+
* opened it. A collapsed group still draws its trees that need attention.
|
|
138
|
+
*/
|
|
116
139
|
collapsed: boolean;
|
|
117
140
|
hidden: boolean;
|
|
118
141
|
rows: Row[];
|
|
119
|
-
/** Every
|
|
142
|
+
/** Every tree root bucketed in the group, those behind folds included. */
|
|
120
143
|
rootIds: string[];
|
|
121
144
|
}
|
|
122
145
|
|
|
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
146
|
export interface ListView {
|
|
136
|
-
/** Null when no family is in the section. */
|
|
137
|
-
attention: AttentionView | null;
|
|
138
147
|
groups: GroupView[];
|
|
139
148
|
/** Hidden groups, shown in the More popover. */
|
|
140
149
|
more: GroupView[];
|
|
141
150
|
moreCounters: Counters;
|
|
142
151
|
/** Resolved top-level order for the mode, for header drag. */
|
|
143
152
|
order: string[];
|
|
144
|
-
/**
|
|
145
|
-
|
|
153
|
+
/** Thread trees that need attention, in every group, hidden ones included: the need-you filter's N. */
|
|
154
|
+
needYouCount: number;
|
|
146
155
|
}
|
|
147
156
|
|
|
148
157
|
export interface ViewInputs {
|
|
@@ -152,16 +161,28 @@ export interface ViewInputs {
|
|
|
152
161
|
sections: readonly PluginSidebarSection[];
|
|
153
162
|
prefs: Preferences;
|
|
154
163
|
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
164
|
targets: Targets;
|
|
165
|
+
settle: SettleInputs;
|
|
166
|
+
/** bb's default harness; null while unknown, when no root draws one. */
|
|
167
|
+
defaultProviderId: string | null;
|
|
168
|
+
/** bb's primary machine; null while unknown, when no row names its machine. */
|
|
169
|
+
primaryHostId: string | null;
|
|
170
|
+
/** Comfortable density: rows may take a branch line. */
|
|
171
|
+
comfortable: boolean;
|
|
172
|
+
/** A project's default branch: undefined while looked up, null when not found. */
|
|
173
|
+
defaultBranchOf(thread: PluginSidebarThread): string | null | undefined;
|
|
174
|
+
/**
|
|
175
|
+
* The need-you filter is on: every group, hidden ones included, draws only
|
|
176
|
+
* its trees that need attention, and a group with none is left out.
|
|
177
|
+
*/
|
|
178
|
+
needYouOnly: boolean;
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
interface Context extends ViewInputs {
|
|
161
|
-
heldRootId: string | null;
|
|
162
182
|
compare: (a: SortKey, b: SortKey) => number;
|
|
163
183
|
expandedChildren: ReadonlySet<string>;
|
|
164
184
|
expandedOlder: ReadonlySet<string>;
|
|
185
|
+
openSettledFolds: ReadonlySet<string>;
|
|
165
186
|
collapsedEnvironments: ReadonlySet<string>;
|
|
166
187
|
/** Parent ids on the path to a reveal target. */
|
|
167
188
|
revealPath: ReadonlySet<string>;
|
|
@@ -170,10 +191,6 @@ interface Context extends ViewInputs {
|
|
|
170
191
|
activePath: ReadonlySet<string>;
|
|
171
192
|
projectNames: ReadonlyMap<string, string>;
|
|
172
193
|
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
194
|
}
|
|
178
195
|
|
|
179
196
|
function titleOf(context: Context, id: string | null): string | null {
|
|
@@ -203,21 +220,45 @@ function crossGroupLabel(context: Context, info: ThreadInfo, root: ThreadInfo):
|
|
|
203
220
|
/**
|
|
204
221
|
* Whether a row's title steps back: a quiet thread does, at any depth, so
|
|
205
222
|
* brightness shows state and depth is left to the indent and smaller text.
|
|
206
|
-
* A root with children stays bright until its whole
|
|
223
|
+
* A root with children stays bright until its whole tree is quiet.
|
|
207
224
|
*/
|
|
208
225
|
function isDimmed(context: Context, info: ThreadInfo, chip: Chip | null): boolean {
|
|
209
|
-
if (info.parentId === null && chip !== null) return context.forest.
|
|
226
|
+
if (info.parentId === null && chip !== null) return context.forest.treeOf.get(info.thread.id)?.quiet ?? info.quiet;
|
|
210
227
|
return info.quiet;
|
|
211
228
|
}
|
|
212
229
|
|
|
230
|
+
|
|
231
|
+
/** A root draws its harness when it differs from bb's default; a child, when it differs from its parent thread's. */
|
|
232
|
+
function drawsHarness(context: Context, info: ThreadInfo): boolean {
|
|
233
|
+
const providerId = info.thread.providerId;
|
|
234
|
+
if (info.parentId === null) return context.defaultProviderId !== null && providerId !== context.defaultProviderId;
|
|
235
|
+
return providerId !== context.forest.infos.get(info.parentId)?.thread.providerId;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The row's second line and pull request badge, as the density and the branch allow. */
|
|
239
|
+
function lines(context: Context, info: ThreadInfo, depth: number): Pick<ThreadRow, "branchLine" | "pullRequest"> {
|
|
240
|
+
const branch = info.thread.environment?.branchName ?? null;
|
|
241
|
+
const offDefault = isOffDefaultBranch(branch, context.defaultBranchOf(info.thread));
|
|
242
|
+
if (context.comfortable && info.note === null && offDefault) return { branchLine: branch, pullRequest: "second-line" };
|
|
243
|
+
return { branchLine: null, pullRequest: depth === 0 && offDefault ? "title" : null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The machine a row names: one other than bb's primary, unless the list is grouped by machine. */
|
|
247
|
+
function machineOf(context: Context, info: ThreadInfo): string | null {
|
|
248
|
+
const host = info.thread.host;
|
|
249
|
+
if (context.prefs.organizationMode === "machine" || context.primaryHostId === null || host === null) return null;
|
|
250
|
+
return host.id === context.primaryHostId ? null : host.name || "Unknown machine";
|
|
251
|
+
}
|
|
252
|
+
|
|
213
253
|
function threadRow(
|
|
214
254
|
context: Context,
|
|
215
255
|
info: ThreadInfo,
|
|
216
256
|
root: ThreadInfo,
|
|
217
257
|
options: { depth: number; nested: boolean; chip: Chip | null },
|
|
218
258
|
): ThreadRow {
|
|
219
|
-
const needsAttention = !info.thread.isArchived && info.attentionFlags.size > 0;
|
|
220
259
|
return {
|
|
260
|
+
...lines(context, info, options.depth),
|
|
261
|
+
machine: machineOf(context, info),
|
|
221
262
|
type: "thread",
|
|
222
263
|
key: `thread:${info.thread.id}`,
|
|
223
264
|
info,
|
|
@@ -225,12 +266,14 @@ function threadRow(
|
|
|
225
266
|
nested: options.nested,
|
|
226
267
|
parentTitle: titleOf(context, info.parentId),
|
|
227
268
|
chip: options.chip,
|
|
228
|
-
|
|
229
|
-
|
|
269
|
+
harness: drawsHarness(context, info),
|
|
270
|
+
childDot: mostUrgent(subtreeOf(context, info.thread.id).dotFlags),
|
|
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,11 @@ 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 opening it would show nothing. */
|
|
378
409
|
function chipOf(context: Context, info: ThreadInfo, expanded: boolean): Chip | null {
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (!hasChildren) return null;
|
|
383
|
-
return {
|
|
384
|
-
count: subtree.visibleCount,
|
|
385
|
-
flag: mostUrgent(subtree.flags),
|
|
386
|
-
expanded,
|
|
387
|
-
providerIds: childProviders(info, subtree.descendants),
|
|
388
|
-
};
|
|
410
|
+
const count = subtreeOf(context, info.thread.id).childCount;
|
|
411
|
+
if (count === 0 && eligibleChildren(context, info.thread.id).length === 0) return null;
|
|
412
|
+
return { count, expanded };
|
|
389
413
|
}
|
|
390
414
|
|
|
391
415
|
/** What a thread and everything under it carry, for an environment folder's glyph. */
|
|
@@ -396,20 +420,20 @@ function rollupFlags(context: Context, info: ThreadInfo): Set<Flag> {
|
|
|
396
420
|
}
|
|
397
421
|
|
|
398
422
|
/** The rows under `parent`: each shown child, then its own level. */
|
|
399
|
-
function foldedLevel(context: Context,
|
|
423
|
+
function foldedLevel(context: Context, tree: ThreadTree, parent: ThreadInfo, depth: number): TreeRow[] {
|
|
400
424
|
const { shown, older } = foldedChildren(context, parent, depth);
|
|
401
425
|
const units: Unit[] = shown.map((info) => ({
|
|
402
426
|
info,
|
|
403
427
|
flags: rollupFlags(context, info),
|
|
404
428
|
rows: [
|
|
405
|
-
threadRow(context, info,
|
|
429
|
+
threadRow(context, info, tree.root, {
|
|
406
430
|
depth,
|
|
407
431
|
nested: depth > 1,
|
|
408
432
|
// Expanded only when the user expanded it: an auto-reveal shows a
|
|
409
433
|
// chevron that still expands, and a "N more child threads" row.
|
|
410
434
|
chip: chipOf(context, info, context.expandedChildren.has(info.thread.id)),
|
|
411
435
|
}),
|
|
412
|
-
...foldedLevel(context,
|
|
436
|
+
...foldedLevel(context, tree, info, depth + 1),
|
|
413
437
|
],
|
|
414
438
|
}));
|
|
415
439
|
const rows = clusterEnvironments(context, units, depth);
|
|
@@ -417,180 +441,69 @@ function foldedLevel(context: Context, family: Family, parent: ThreadInfo, depth
|
|
|
417
441
|
return rows;
|
|
418
442
|
}
|
|
419
443
|
|
|
420
|
-
function
|
|
421
|
-
const root =
|
|
444
|
+
function foldedTreeRows(context: Context, tree: ThreadTree): TreeRow[] {
|
|
445
|
+
const root = tree.root;
|
|
422
446
|
const chip = chipOf(context, root, context.expandedChildren.has(root.thread.id));
|
|
423
|
-
return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context,
|
|
447
|
+
return [threadRow(context, root, root, { depth: 0, nested: false, chip }), ...foldedLevel(context, tree, root, 1)];
|
|
424
448
|
}
|
|
425
449
|
|
|
426
|
-
function
|
|
427
|
-
return { info:
|
|
450
|
+
function treeUnit(context: Context, tree: ThreadTree): Unit {
|
|
451
|
+
return { info: tree.root, rows: foldedTreeRows(context, tree), flags: tree.flags };
|
|
428
452
|
}
|
|
429
453
|
|
|
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;
|
|
454
|
+
/** Whether a thread in the tree needs attention: it stays drawn when its group is collapsed. */
|
|
455
|
+
export function needsAttention(tree: Pick<ThreadTree, "attentionFlags">): boolean {
|
|
456
|
+
return tree.attentionFlags.size > 0;
|
|
438
457
|
}
|
|
439
458
|
|
|
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;
|
|
459
|
+
/** The need-you filter's N: thread trees that need attention, in every group. */
|
|
460
|
+
export function countNeedYou(forest: Pick<Forest, "trees">): number {
|
|
461
|
+
return forest.trees.filter(needsAttention).length;
|
|
480
462
|
}
|
|
481
463
|
|
|
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
|
-
};
|
|
464
|
+
/** The filter narrows the list only while something needs you: at 0 the full list is back. */
|
|
465
|
+
export function needYouActive(on: boolean, count: number): boolean {
|
|
466
|
+
return on && count > 0;
|
|
521
467
|
}
|
|
522
468
|
|
|
523
469
|
/**
|
|
524
|
-
* One group. `
|
|
525
|
-
*
|
|
470
|
+
* One group. `trees` is every tree bucketed in it. Collapsed, it draws only
|
|
471
|
+
* its trees that need attention; opening a thread in it opens it, as an
|
|
472
|
+
* auto-reveal did.
|
|
526
473
|
*/
|
|
527
|
-
function buildGroup(
|
|
528
|
-
context
|
|
529
|
-
|
|
530
|
-
families: Family[],
|
|
531
|
-
drawn: Family[],
|
|
532
|
-
hidden: boolean,
|
|
533
|
-
): GroupView {
|
|
534
|
-
const counters = countFamilies(families);
|
|
474
|
+
function buildGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
|
|
475
|
+
if (context.needYouOnly) return needYouGroup(context, descriptor, trees, hidden);
|
|
476
|
+
const counters = countTrees(trees);
|
|
535
477
|
const userCollapsed = isGroupCollapsed(descriptor, context.prefs);
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
);
|
|
541
|
-
const collapsed = userCollapsed && !hasTarget;
|
|
478
|
+
const activeId = context.activeThreadId;
|
|
479
|
+
const opened =
|
|
480
|
+
activeId !== null && context.targets.has(activeId) && trees.some((tree) => tree.containsActive);
|
|
481
|
+
const collapsed = userCollapsed && !opened;
|
|
542
482
|
const isPinned = descriptor.id === PINNED_GROUP_ID;
|
|
543
483
|
|
|
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
|
-
}
|
|
484
|
+
const sorted = sortTrees(context, descriptor, trees);
|
|
555
485
|
|
|
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
|
-
};
|
|
486
|
+
let rows: Row[];
|
|
487
|
+
if (collapsed) {
|
|
488
|
+
rows = clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0);
|
|
489
|
+
} else {
|
|
490
|
+
// Settled trees go behind the fold at the group's end. The open one, if
|
|
491
|
+
// settled, is drawn just above the divider, so opening it moves nothing else.
|
|
492
|
+
const settled = sorted.filter((tree) => isSettledTree(tree, context.settle));
|
|
493
|
+
const settledSet = new Set(settled);
|
|
494
|
+
const live = sorted.filter((tree) => !settledSet.has(tree));
|
|
495
|
+
const open = settled.find((tree) => tree.containsActive) ?? null;
|
|
496
|
+
rows = clusterEnvironments(context, live.map((tree) => treeUnit(context, tree)), 0);
|
|
497
|
+
if (settled.length > 0) {
|
|
498
|
+
if (open !== null) rows.push(...treeUnit(context, open).rows);
|
|
499
|
+
const expanded = context.openSettledFolds.has(descriptor.id);
|
|
500
|
+
rows.push({ type: "settled", key: `settled:${descriptor.id}`, groupId: descriptor.id, count: settled.length, expanded });
|
|
501
|
+
if (expanded) {
|
|
502
|
+
const folded = settled.filter((tree) => tree !== open).map((tree) => treeUnit(context, tree));
|
|
503
|
+
rows.push(...clusterEnvironments(context, folded, 0));
|
|
504
|
+
}
|
|
588
505
|
}
|
|
589
506
|
}
|
|
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
507
|
return {
|
|
595
508
|
descriptor,
|
|
596
509
|
counters,
|
|
@@ -598,7 +511,32 @@ function buildGroup(
|
|
|
598
511
|
collapsed,
|
|
599
512
|
hidden,
|
|
600
513
|
rows,
|
|
601
|
-
rootIds:
|
|
514
|
+
rootIds: trees.map((tree) => tree.root.thread.id),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** A group's trees in its order: Pinned's own, else the chosen sort. */
|
|
519
|
+
function sortTrees(context: Context, descriptor: GroupDescriptor, trees: readonly ThreadTree[]): ThreadTree[] {
|
|
520
|
+
if (descriptor.id === PINNED_GROUP_ID) return [...trees].sort((a, b) => comparePinned(a.root.thread, b.root.thread));
|
|
521
|
+
return [...trees].sort((a, b) =>
|
|
522
|
+
context.compare(
|
|
523
|
+
{ thread: a.root.thread, treeAttention: a.latestAttentionAt },
|
|
524
|
+
{ thread: b.root.thread, treeAttention: b.latestAttentionAt },
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** A group under the need-you filter: its header, and its trees that need attention, whatever its collapse. */
|
|
530
|
+
function needYouGroup(context: Context, descriptor: GroupDescriptor, trees: ThreadTree[], hidden: boolean): GroupView {
|
|
531
|
+
const sorted = sortTrees(context, descriptor, trees);
|
|
532
|
+
return {
|
|
533
|
+
descriptor,
|
|
534
|
+
counters: countTrees(trees),
|
|
535
|
+
userCollapsed: isGroupCollapsed(descriptor, context.prefs),
|
|
536
|
+
collapsed: false,
|
|
537
|
+
hidden,
|
|
538
|
+
rows: clusterEnvironments(context, sorted.filter(needsAttention).map((tree) => treeUnit(context, tree)), 0),
|
|
539
|
+
rootIds: trees.map((tree) => tree.root.thread.id),
|
|
602
540
|
};
|
|
603
541
|
}
|
|
604
542
|
|
|
@@ -618,40 +556,28 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
618
556
|
}
|
|
619
557
|
const context: Context = {
|
|
620
558
|
...inputs,
|
|
621
|
-
|
|
622
|
-
compare: makeComparator({
|
|
623
|
-
field: prefs.chronologicalSort,
|
|
624
|
-
direction: prefs.sortDirection,
|
|
625
|
-
workingFirst: prefs.workingFirst,
|
|
626
|
-
}),
|
|
559
|
+
compare: makeComparator({ field: prefs.chronologicalSort, direction: prefs.sortDirection }),
|
|
627
560
|
expandedChildren: new Set(prefs.expandedChildren),
|
|
628
561
|
expandedOlder: new Set(prefs.expandedOlder),
|
|
562
|
+
openSettledFolds: new Set(prefs.openSettledFolds),
|
|
629
563
|
collapsedEnvironments: new Set(prefs.collapsedEnvironments),
|
|
630
564
|
revealIds,
|
|
631
565
|
revealPath,
|
|
632
566
|
activePath,
|
|
633
567
|
projectNames: new Map(inputs.projects.map((project) => [project.id, project.name])),
|
|
634
568
|
sectionNames: new Map(inputs.sections.map((section) => [section.id, section.name])),
|
|
635
|
-
drawnInAttention: false,
|
|
636
|
-
attended: false,
|
|
637
569
|
};
|
|
638
570
|
|
|
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, {
|
|
571
|
+
const byGroup = new Map<string, ThreadTree[]>();
|
|
572
|
+
for (const tree of forest.trees) {
|
|
573
|
+
const id = groupIdForRoot(tree.root.thread, {
|
|
645
574
|
mode: prefs.organizationMode,
|
|
646
575
|
projects: inputs.projects,
|
|
647
576
|
});
|
|
648
577
|
const list = byGroup.get(id) ?? [];
|
|
649
|
-
list.push(
|
|
578
|
+
list.push(tree);
|
|
650
579
|
byGroup.set(id, list);
|
|
651
|
-
homeOf.set(family, id);
|
|
652
|
-
if (inAttention(family, context.heldRootId, openRootId)) section.push(family);
|
|
653
580
|
}
|
|
654
|
-
const inSection = new Set(section);
|
|
655
581
|
|
|
656
582
|
const alphabetical = effectiveSortField(prefs.chronologicalSort) === "alpha";
|
|
657
583
|
const entities = entityGroups(
|
|
@@ -674,16 +600,20 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
674
600
|
for (const id of order) {
|
|
675
601
|
const descriptor = descriptors.get(id);
|
|
676
602
|
if (descriptor === undefined) continue;
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
if (drawn.length === 0) {
|
|
603
|
+
const trees = byGroup.get(id) ?? [];
|
|
604
|
+
// Pinned and the loose bucket appear only when they hold threads, except
|
|
605
|
+
// the loose bucket in custom-section mode, as in bb.
|
|
606
|
+
if (trees.length === 0) {
|
|
682
607
|
if (id === PINNED_GROUP_ID) continue;
|
|
683
608
|
if (id === THREADS_GROUP_ID && prefs.organizationMode !== "chronological") continue;
|
|
684
609
|
}
|
|
685
610
|
const hidden = id !== PINNED_GROUP_ID && hiddenIds.has(id);
|
|
686
|
-
|
|
611
|
+
if (context.needYouOnly) {
|
|
612
|
+
// Under the filter, a hidden group's trees come out of More, each under its own header.
|
|
613
|
+
if (trees.some(needsAttention)) groups.push(buildGroup(context, descriptor, trees, hidden));
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const view = buildGroup(context, descriptor, trees, hidden);
|
|
687
617
|
if (hidden) {
|
|
688
618
|
more.push(view);
|
|
689
619
|
moreCounters = addCounters(moreCounters, view.counters);
|
|
@@ -692,11 +622,7 @@ export function buildListView(inputs: ViewInputs): ListView {
|
|
|
692
622
|
}
|
|
693
623
|
}
|
|
694
624
|
|
|
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 };
|
|
625
|
+
return { groups, more, moreCounters, order, needYouCount: countNeedYou(forest) };
|
|
700
626
|
}
|
|
701
627
|
|
|
702
628
|
/** Every thread row in visual order, for keyboard and windowing. */
|