@gperezmz/bb-plugin-thread-glance 0.2.3 → 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 +2 -1
- 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 +8 -50
- 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/{Toolbar.tsx → SettingsPanel.tsx} +26 -57
- package/features/thread-list/components/ThreadDetails.tsx +3 -3
- package/features/thread-list/components/ThreadList.tsx +116 -84
- 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 +39 -14
- package/features/thread-list/data/same-window.ts +34 -0
- package/features/thread-list/data/useAutoExpand.ts +1 -1
- package/features/thread-list/data/useClientPreferences.ts +15 -7
- package/features/thread-list/data/useDefaultBranches.ts +35 -0
- package/features/thread-list/data/usePreferences.ts +16 -7
- 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 -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/layout.ts +5 -2
- 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 +2 -18
- 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 +25 -9
- 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,9 +6,9 @@ import type { PluginSidebarSection, PluginSidebarThread } from "@get-bb/plugin-s
|
|
|
6
6
|
import type { Stamps } from "@/shared/contract";
|
|
7
7
|
import type { HarnessIcon, OrganizationMode } from "@/shared/preferences";
|
|
8
8
|
import type { ThreadNotes } from "@/shared/contract";
|
|
9
|
-
import type {
|
|
9
|
+
import type { ThreadTree } from "../model/trees";
|
|
10
10
|
import type { RowMenuAction } from "../model/menu";
|
|
11
|
-
import type { OlderRow, ThreadRow } from "../model/view";
|
|
11
|
+
import type { OlderRow, SettledRow, ThreadRow } from "../model/view";
|
|
12
12
|
import type { ProviderDisplay } from "./ProviderBadge";
|
|
13
13
|
|
|
14
14
|
export interface ModelInfo {
|
|
@@ -21,11 +21,7 @@ export interface RowController {
|
|
|
21
21
|
compact: boolean;
|
|
22
22
|
comfortable: boolean;
|
|
23
23
|
setEditingId(id: string | null): void;
|
|
24
|
-
showPullRequests: boolean;
|
|
25
24
|
harnessIcon: HarnessIcon;
|
|
26
|
-
/** Undefined while unknown; the lookup starts on first ask. */
|
|
27
|
-
defaultBranchOf(thread: PluginSidebarThread): string | null | undefined;
|
|
28
|
-
multiHost: boolean;
|
|
29
25
|
/** One object per harness, the same one on every call. */
|
|
30
26
|
provider(providerId: string): ProviderDisplay;
|
|
31
27
|
sections: readonly PluginSidebarSection[];
|
|
@@ -33,8 +29,7 @@ export interface RowController {
|
|
|
33
29
|
onNavigate(): void;
|
|
34
30
|
onToggleChip(row: ThreadRow): void;
|
|
35
31
|
onToggleOlder(row: OlderRow): void;
|
|
36
|
-
|
|
37
|
-
onOpenChildren(rootId: string): void;
|
|
32
|
+
onToggleSettled(row: SettledRow): void;
|
|
38
33
|
onToggleEnvironment(environmentId: string): void;
|
|
39
34
|
onMenuAction(action: RowMenuAction, thread: PluginSidebarThread, sectionId?: string | null): void;
|
|
40
35
|
onRename(threadId: string, title: string): Promise<void>;
|
|
@@ -54,12 +49,12 @@ export interface ListLive {
|
|
|
54
49
|
now: number;
|
|
55
50
|
stamps: Stamps;
|
|
56
51
|
notes: Readonly<Record<string, ThreadNotes>>;
|
|
57
|
-
|
|
52
|
+
treeOf(threadId: string): ThreadTree | undefined;
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
export const ListLiveContext = createContext<ListLive>({
|
|
61
56
|
now: 0,
|
|
62
57
|
stamps: { startedAt: {}, finishedAt: {}, pendingAt: {}, seenAt: {} },
|
|
63
58
|
notes: {},
|
|
64
|
-
|
|
59
|
+
treeOf: () => undefined,
|
|
65
60
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// State glyphs,
|
|
1
|
+
// State glyphs, the child dot and header counters. They draw what the model
|
|
2
2
|
// decided; tone and animation come from host token classes only.
|
|
3
3
|
import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
|
|
4
4
|
import type { PluginSidebarThreadRowStatus } from "@get-bb/plugin-sdk/app";
|
|
5
5
|
import { cn } from "@/lib/utils";
|
|
6
6
|
import type { Counters } from "../model/counters";
|
|
7
7
|
import type { RowNote } from "../model/notes";
|
|
8
|
-
import { FLAG_GLYPHS, type
|
|
8
|
+
import { dotTone, FLAG_GLYPHS, type DotTone, type Flag, type Glyph, type Tone } from "../model/state";
|
|
9
9
|
|
|
10
10
|
export const TONE_CLASS: Record<Tone, string> = {
|
|
11
11
|
attention: "text-attention",
|
|
@@ -18,19 +18,32 @@ export const TONE_CLASS: Record<Tone, string> = {
|
|
|
18
18
|
none: "",
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
// The
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"border-[color:color-mix(in_oklch,var(--destructive-text)_45%,transparent)] bg-[color-mix(in_oklch,var(--destructive-text)_10%,transparent)] text-[color:color-mix(in_oklch,var(--destructive-text),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--destructive-text)_20%,transparent)]",
|
|
29
|
-
working:
|
|
30
|
-
"border-[color:color-mix(in_oklch,var(--timeline-accent)_45%,transparent)] bg-[color-mix(in_oklch,var(--timeline-accent)_10%,transparent)] text-[color:color-mix(in_oklch,var(--timeline-accent),var(--foreground)_55%)] hover:bg-[color-mix(in_oklch,var(--timeline-accent)_20%,transparent)]",
|
|
31
|
-
neutral: "border-border/70 text-muted-foreground hover:bg-state-hover hover:text-foreground",
|
|
21
|
+
// The child dot's colours: the tone of the state it stands for, and the
|
|
22
|
+
// unread dot's accent for unread.
|
|
23
|
+
const DOT_TONE_CLASS: Record<DotTone, string> = {
|
|
24
|
+
attention: "bg-attention",
|
|
25
|
+
destructive: "bg-destructive",
|
|
26
|
+
working: "bg-[var(--timeline-accent)]",
|
|
27
|
+
unread: "bg-[var(--timeline-accent)]",
|
|
32
28
|
};
|
|
33
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The child dot on a parent's status glyph, at its lower right corner. A
|
|
32
|
+
* ring of the sidebar's colour keeps it apart from the glyph under it.
|
|
33
|
+
*/
|
|
34
|
+
export function ChildDot({ flag }: { flag: Flag }) {
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
aria-hidden
|
|
38
|
+
data-child-dot={flag}
|
|
39
|
+
className={cn(
|
|
40
|
+
"pointer-events-none absolute -right-0.5 -bottom-0.5 size-[7px] rounded-full ring-[1.5px] ring-sidebar",
|
|
41
|
+
DOT_TONE_CLASS[dotTone(flag)],
|
|
42
|
+
)}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
const SHINE = "animate-shine-icon motion-reduce:animate-none";
|
|
35
48
|
const SPIN = "animate-spin motion-reduce:animate-none";
|
|
36
49
|
|
|
@@ -47,6 +60,18 @@ export function UnreadDot({ label, className }: { label?: string; className?: st
|
|
|
47
60
|
);
|
|
48
61
|
}
|
|
49
62
|
|
|
63
|
+
// The Idle state's ring: uncoloured, smaller than the column and thinned, so
|
|
64
|
+
// it reads as status yet stays fainter than the unread dot and the draft
|
|
65
|
+
// pencil. Screen readers skip it, so an Idle row announces what it did before.
|
|
66
|
+
export function IdleRing({ className }: { className?: string }) {
|
|
67
|
+
return (
|
|
68
|
+
<span
|
|
69
|
+
aria-hidden
|
|
70
|
+
className={cn("inline-block size-2.5 shrink-0 rounded-full border-[1.5px] border-current opacity-50", className)}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
export function GlyphIcon({
|
|
51
76
|
glyph,
|
|
52
77
|
label,
|
|
@@ -56,7 +81,7 @@ export function GlyphIcon({
|
|
|
56
81
|
label?: string;
|
|
57
82
|
className?: string;
|
|
58
83
|
}) {
|
|
59
|
-
if (glyph.icon ===
|
|
84
|
+
if (glyph.icon === "ring") return <IdleRing className={TONE_CLASS[glyph.tone]} />;
|
|
60
85
|
if (glyph.icon === "dot") return <UnreadDot label={label} />;
|
|
61
86
|
return (
|
|
62
87
|
<Icon
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The list and the settings panel mount apart, each with its own copy of the
|
|
2
|
+
// preferences. A change made in one reaches the other here, in the same
|
|
3
|
+
// window, at once; the server and localStorage stay what other windows read.
|
|
4
|
+
import { useCallback, useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
type Apply<Patch> = (patch: Patch) => void;
|
|
7
|
+
|
|
8
|
+
export function sameWindow<Patch>(): Set<Apply<Patch>> {
|
|
9
|
+
return new Set();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Joins this copy to `copies` for as long as it is mounted.
|
|
14
|
+
*
|
|
15
|
+
* Returns the call that hands a patch this copy made to every other copy.
|
|
16
|
+
*/
|
|
17
|
+
export function useSameWindow<State extends object>(
|
|
18
|
+
copies: Set<Apply<Partial<State>>>,
|
|
19
|
+
setState: (update: (current: State) => State) => void,
|
|
20
|
+
): Apply<Partial<State>> {
|
|
21
|
+
const apply = useCallback((patch: Partial<State>) => setState((current) => ({ ...current, ...patch })), [setState]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
copies.add(apply);
|
|
24
|
+
return () => {
|
|
25
|
+
copies.delete(apply);
|
|
26
|
+
};
|
|
27
|
+
}, [copies, apply]);
|
|
28
|
+
return useCallback(
|
|
29
|
+
(patch: Partial<State>) => {
|
|
30
|
+
for (const other of copies) if (other !== apply) other(patch);
|
|
31
|
+
},
|
|
32
|
+
[copies, apply],
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Transient auto-expansion. Transitions are diffed against the previous
|
|
2
2
|
// render, as bb does; nothing here is persisted.
|
|
3
3
|
import { useCallback, useRef, useState } from "react";
|
|
4
|
-
import type { Forest } from "../model/
|
|
4
|
+
import type { Forest } from "../model/trees";
|
|
5
5
|
import {
|
|
6
6
|
detectTransitions,
|
|
7
7
|
mergeTargets,
|
|
@@ -5,16 +5,24 @@ import {
|
|
|
5
5
|
parseClientPreferences,
|
|
6
6
|
type ClientPreferences,
|
|
7
7
|
} from "@/shared/preferences";
|
|
8
|
+
import { sameWindow, useSameWindow } from "./same-window";
|
|
8
9
|
import { readJson, writeJson } from "./storage";
|
|
9
10
|
|
|
11
|
+
const copies = sameWindow<Partial<ClientPreferences>>();
|
|
12
|
+
|
|
10
13
|
export function useClientPreferences(): [ClientPreferences, (patch: Partial<ClientPreferences>) => void] {
|
|
11
14
|
const [value, setValue] = useState(() => parseClientPreferences(readJson(CLIENT_PREFERENCES_STORAGE_KEY)));
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const tellOthers = useSameWindow(copies, setValue);
|
|
16
|
+
const update = useCallback(
|
|
17
|
+
(patch: Partial<ClientPreferences>) => {
|
|
18
|
+
setValue((current) => {
|
|
19
|
+
const next = { ...current, ...patch };
|
|
20
|
+
writeJson(CLIENT_PREFERENCES_STORAGE_KEY, next);
|
|
21
|
+
return next;
|
|
22
|
+
});
|
|
23
|
+
tellOthers(patch);
|
|
24
|
+
},
|
|
25
|
+
[tellOthers],
|
|
26
|
+
);
|
|
19
27
|
return [value, update];
|
|
20
28
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Each project's default branch, looked up once per list through the
|
|
2
|
+
// machine of the project's default source.
|
|
3
|
+
//
|
|
4
|
+
// Debt: the answers are kept in component state, with no refresh, because
|
|
5
|
+
// the SDK offers no cached query for a project's branches. It clears when
|
|
6
|
+
// the SDK does, or when a default branch changing mid-session matters.
|
|
7
|
+
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useSdk } from "@get-bb/plugin-sdk/app";
|
|
9
|
+
import { defaultSourceHostId } from "../model/branches";
|
|
10
|
+
|
|
11
|
+
/** Project id → default branch; undefined while unknown, null when the lookup found none. */
|
|
12
|
+
export type DefaultBranches = ReadonlyMap<string, string | null>;
|
|
13
|
+
|
|
14
|
+
export function useDefaultBranches(projectIds: readonly string[]): DefaultBranches {
|
|
15
|
+
const sdk = useSdk();
|
|
16
|
+
const [branches, setBranches] = useState<DefaultBranches>(() => new Map());
|
|
17
|
+
const requested = useRef(new Set<string>());
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
for (const projectId of projectIds) {
|
|
20
|
+
if (requested.current.has(projectId)) continue;
|
|
21
|
+
requested.current.add(projectId);
|
|
22
|
+
const settle = (branch: string | null) => setBranches((current) => new Map(current).set(projectId, branch));
|
|
23
|
+
sdk.projects
|
|
24
|
+
.get({ projectId })
|
|
25
|
+
.then(async (project) => {
|
|
26
|
+
const hostId = defaultSourceHostId(project.sources);
|
|
27
|
+
if (hostId === null) return null;
|
|
28
|
+
const result = await sdk.projects.branches({ projectId, hostId, limit: "1" });
|
|
29
|
+
return result.defaultBranch ?? null;
|
|
30
|
+
})
|
|
31
|
+
.then(settle, () => settle(null));
|
|
32
|
+
}
|
|
33
|
+
}, [projectIds, sdk]);
|
|
34
|
+
return branches;
|
|
35
|
+
}
|
|
@@ -14,10 +14,16 @@ import {
|
|
|
14
14
|
type PreferenceKey,
|
|
15
15
|
type Preferences,
|
|
16
16
|
} from "@/shared/preferences";
|
|
17
|
+
import { sameWindow, useSameWindow } from "./same-window";
|
|
17
18
|
import { readJson, writeJson } from "./storage";
|
|
18
19
|
|
|
19
20
|
const WRITE_DEBOUNCE_MS = 150;
|
|
20
21
|
|
|
22
|
+
const copies = sameWindow<Partial<Preferences>>();
|
|
23
|
+
// Writes not yet sent, whichever copy made them: a reload or an echo in any
|
|
24
|
+
// copy must not put back the value one of them replaced.
|
|
25
|
+
const pending = new Map<PreferenceKey, unknown>();
|
|
26
|
+
|
|
21
27
|
export interface PreferencesState {
|
|
22
28
|
prefs: Preferences;
|
|
23
29
|
/** The server's values have arrived (or failed; the mirror stands in). */
|
|
@@ -31,7 +37,6 @@ export function usePreferences(): PreferencesState {
|
|
|
31
37
|
coercePreferences(readJson(PREFERENCES_MIRROR_STORAGE_KEY)),
|
|
32
38
|
);
|
|
33
39
|
const [hydrated, setHydrated] = useState(false);
|
|
34
|
-
const pending = useRef(new Map<PreferenceKey, unknown>());
|
|
35
40
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
36
41
|
const connection = useRealtimeConnectionState();
|
|
37
42
|
const wasConnected = useRef(false);
|
|
@@ -41,7 +46,7 @@ export function usePreferences(): PreferencesState {
|
|
|
41
46
|
const { preferences } = await rpc.call("listPreferences", null);
|
|
42
47
|
const next = coercePreferences(preferences);
|
|
43
48
|
// Keys with a write in flight keep the local value.
|
|
44
|
-
for (const [key, value] of pending
|
|
49
|
+
for (const [key, value] of pending) (next as Record<string, unknown>)[key] = value;
|
|
45
50
|
setPrefs(next);
|
|
46
51
|
writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
|
|
47
52
|
} finally {
|
|
@@ -73,7 +78,7 @@ export function usePreferences(): PreferencesState {
|
|
|
73
78
|
|
|
74
79
|
useRealtime(CHANNELS.preferences, (payload) => {
|
|
75
80
|
const signal = payload as PreferenceSignal;
|
|
76
|
-
if (!isPreferenceKey(signal?.key) || pending.
|
|
81
|
+
if (!isPreferenceKey(signal?.key) || pending.has(signal.key)) return;
|
|
77
82
|
const parsed = parsePreference(signal.key, signal.value);
|
|
78
83
|
if (!parsed.success) return;
|
|
79
84
|
setPrefs((current) => {
|
|
@@ -84,8 +89,8 @@ export function usePreferences(): PreferencesState {
|
|
|
84
89
|
});
|
|
85
90
|
|
|
86
91
|
const flush = useCallback(() => {
|
|
87
|
-
const writes = [...pending
|
|
88
|
-
pending.
|
|
92
|
+
const writes = [...pending];
|
|
93
|
+
pending.clear();
|
|
89
94
|
for (const [key, value] of writes) {
|
|
90
95
|
rpc.call("setPreference", { key, value }).catch((error: unknown) => {
|
|
91
96
|
toast.error(`Couldn't save the ${key} setting`, {
|
|
@@ -103,6 +108,9 @@ export function usePreferences(): PreferencesState {
|
|
|
103
108
|
[flush],
|
|
104
109
|
);
|
|
105
110
|
|
|
111
|
+
// Another copy's change arrives already mirrored and on its way to the server.
|
|
112
|
+
const tellOthers = useSameWindow(copies, setPrefs);
|
|
113
|
+
|
|
106
114
|
const update = useCallback(
|
|
107
115
|
(patch: Partial<Preferences>) => {
|
|
108
116
|
setPrefs((current) => {
|
|
@@ -110,8 +118,9 @@ export function usePreferences(): PreferencesState {
|
|
|
110
118
|
writeJson(PREFERENCES_MIRROR_STORAGE_KEY, next);
|
|
111
119
|
return next;
|
|
112
120
|
});
|
|
121
|
+
tellOthers(patch);
|
|
113
122
|
for (const [key, value] of Object.entries(patch)) {
|
|
114
|
-
if (isPreferenceKey(key)) pending.
|
|
123
|
+
if (isPreferenceKey(key)) pending.set(key, value);
|
|
115
124
|
}
|
|
116
125
|
if (timer.current !== null) clearTimeout(timer.current);
|
|
117
126
|
timer.current = setTimeout(() => {
|
|
@@ -119,7 +128,7 @@ export function usePreferences(): PreferencesState {
|
|
|
119
128
|
flush();
|
|
120
129
|
}, WRITE_DEBOUNCE_MS);
|
|
121
130
|
},
|
|
122
|
-
[flush],
|
|
131
|
+
[flush, tellOthers],
|
|
123
132
|
);
|
|
124
133
|
|
|
125
134
|
return { prefs, hydrated, update };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The pull request lookups that answered, by thread id. Answers arriving in
|
|
2
|
+
// one burst (a list's worth of lookups on load) land in one state update.
|
|
3
|
+
//
|
|
4
|
+
// Debt: this copies server state the host's per-row pull request hook owns,
|
|
5
|
+
// because settling reads every thread's pull request at once and the SDK
|
|
6
|
+
// offers no query over many threads. It clears when the SDK does.
|
|
7
|
+
import { useCallback, useRef, useState } from "react";
|
|
8
|
+
import type { PullRequestState } from "../model/settled";
|
|
9
|
+
|
|
10
|
+
export type PullRequestAnswers = ReadonlyMap<string, PullRequestState | null>;
|
|
11
|
+
|
|
12
|
+
export function usePullRequestAnswers(): [PullRequestAnswers, (threadId: string, state: PullRequestState | null) => void] {
|
|
13
|
+
const [answers, setAnswers] = useState<PullRequestAnswers>(() => new Map());
|
|
14
|
+
const pending = useRef(new Map<string, PullRequestState | null>());
|
|
15
|
+
const scheduled = useRef(false);
|
|
16
|
+
const onAnswer = useCallback((threadId: string, state: PullRequestState | null) => {
|
|
17
|
+
pending.current.set(threadId, state);
|
|
18
|
+
if (scheduled.current) return;
|
|
19
|
+
scheduled.current = true;
|
|
20
|
+
setTimeout(() => {
|
|
21
|
+
scheduled.current = false;
|
|
22
|
+
const batch = pending.current;
|
|
23
|
+
pending.current = new Map();
|
|
24
|
+
setAnswers((current) => {
|
|
25
|
+
let next: Map<string, PullRequestState | null> | null = null;
|
|
26
|
+
for (const [id, value] of batch) {
|
|
27
|
+
if (current.has(id) && current.get(id) === value) continue;
|
|
28
|
+
next ??= new Map(current);
|
|
29
|
+
next.set(id, value);
|
|
30
|
+
}
|
|
31
|
+
return next ?? current;
|
|
32
|
+
});
|
|
33
|
+
}, 0);
|
|
34
|
+
}, []);
|
|
35
|
+
return [answers, onAnswer];
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// What Thread Glance reads of bb's own configuration: its default harness
|
|
2
|
+
// and its primary machine. Asked once per list; null until answered.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSdk } from "@get-bb/plugin-sdk/app";
|
|
5
|
+
|
|
6
|
+
export interface SystemFacts {
|
|
7
|
+
/** The harness a new thread starts with, when bb says. */
|
|
8
|
+
defaultProviderId: string | null;
|
|
9
|
+
/** The machine bb itself runs on, when one is connected. */
|
|
10
|
+
primaryHostId: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const UNKNOWN_SYSTEM: SystemFacts = { defaultProviderId: null, primaryHostId: null };
|
|
14
|
+
|
|
15
|
+
interface SystemConfig {
|
|
16
|
+
primaryHostId: string | null;
|
|
17
|
+
generalSettings: { defaultProviderId: string | null };
|
|
18
|
+
serverAccess: { defaultProviderId: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The facts in `system.config()`'s answer. The default harness is the one
|
|
23
|
+
* the user chose in bb's settings, else the one bb falls back to.
|
|
24
|
+
*/
|
|
25
|
+
export function readSystemFacts(config: SystemConfig): SystemFacts {
|
|
26
|
+
return {
|
|
27
|
+
defaultProviderId: config.generalSettings.defaultProviderId ?? config.serverAccess.defaultProviderId ?? null,
|
|
28
|
+
primaryHostId: config.primaryHostId,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useSystemFacts(): SystemFacts {
|
|
33
|
+
const sdk = useSdk();
|
|
34
|
+
const [facts, setFacts] = useState<SystemFacts>(UNKNOWN_SYSTEM);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
// Called inside the chain, so a host that throws for an area it lacks
|
|
38
|
+
// lands in the rejection handler and leaves the facts unknown.
|
|
39
|
+
Promise.resolve()
|
|
40
|
+
.then(() => sdk.system.config())
|
|
41
|
+
.then(
|
|
42
|
+
(config) => {
|
|
43
|
+
if (!cancelled) setFacts(readSystemFacts(config));
|
|
44
|
+
},
|
|
45
|
+
() => undefined,
|
|
46
|
+
);
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, [sdk]);
|
|
51
|
+
return facts;
|
|
52
|
+
}
|
|
@@ -24,8 +24,8 @@ export const ICONS = {
|
|
|
24
24
|
moveUp: "ChevronUp",
|
|
25
25
|
newThread: "Plus",
|
|
26
26
|
newSection: "SectionAdd",
|
|
27
|
-
settings: "Settings",
|
|
28
27
|
customize: "SlidersHorizontal",
|
|
28
|
+
settings: "SlidersHorizontal",
|
|
29
29
|
check: "Check",
|
|
30
30
|
pullRequest: "GitPullRequest",
|
|
31
31
|
branch: "GitBranch",
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// What needs attention, thread by thread.
|
|
2
|
-
// counters, the
|
|
1
|
+
// What needs attention, thread by thread. Collapsed groups, the header
|
|
2
|
+
// counters, the need-you filter and auto-reveal all read the set this
|
|
3
3
|
// builds. Pure.
|
|
4
4
|
import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app";
|
|
5
5
|
import type { ChildAttention } from "@/shared/preferences";
|
|
6
|
-
import type { Family, Forest } from "./families";
|
|
7
6
|
import { type Flag, type StateKind, type ThreadState } from "./state";
|
|
8
7
|
|
|
9
8
|
/** The flags that count for a root thread. Working is not one. */
|
|
@@ -88,66 +87,3 @@ export function revealsOn(attention: ReadonlySet<Flag>, isRoot: boolean): boolea
|
|
|
88
87
|
if (attention.has("waits-on-you") || attention.has("unread-failed")) return true;
|
|
89
88
|
return !isRoot && attention.has("queue-failed");
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
/** What the section reads of a family. */
|
|
93
|
-
export type SectionFamily = Pick<Family, "root" | "attentionFlags" | "latestAttentionAt">;
|
|
94
|
-
|
|
95
|
-
/** What Needs attention remembers between renders: the family it holds, and the family that was open. */
|
|
96
|
-
export interface AttentionHold {
|
|
97
|
-
/**
|
|
98
|
-
* The family held in the section, if any, as it was when opened. The section
|
|
99
|
-
* orders it by this, so nothing that happens inside it moves it.
|
|
100
|
-
*/
|
|
101
|
-
held: SectionFamily | null;
|
|
102
|
-
/** The root of the family the open thread belonged to at the last render, if any. */
|
|
103
|
-
openRootId: string | null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export const NO_HOLD: AttentionHold = { held: null, openRootId: null };
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Whether a family is in the Needs attention section: it is the held family, or one
|
|
110
|
-
* of its threads needs attention and none of them is open. Nothing moves while you
|
|
111
|
-
* are inside a family, so the open family only enters by being judged when
|
|
112
|
-
* opened (see `holdAttention`).
|
|
113
|
-
*/
|
|
114
|
-
export function inAttention(family: SectionFamily, heldRootId: string | null, openRootId: string | null): boolean {
|
|
115
|
-
const rootId = family.root.thread.id;
|
|
116
|
-
if (rootId === heldRootId) return true;
|
|
117
|
-
return family.attentionFlags.size > 0 && rootId !== openRootId;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Whether a family is attended: held in the section with nothing in it needing
|
|
122
|
-
* attention any more. It keeps its place and draws and counts as in its home group.
|
|
123
|
-
*/
|
|
124
|
-
export function isAttended(family: SectionFamily, heldRootId: string | null): boolean {
|
|
125
|
-
return family.root.thread.id === heldRootId && family.attentionFlags.size === 0;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The hold after a render. A family is judged when a thread in it is opened
|
|
130
|
-
* from outside it: it is held if something in it needs attention then, and it stays
|
|
131
|
-
* held while one of its threads is open, after nothing in it needs attention any
|
|
132
|
-
* more, until none of its threads is open. An attended family whose open thread
|
|
133
|
-
* is archived counts as closed. A family that does not need attention
|
|
134
|
-
* when opened is not pulled in later, whatever happens in it; nor is one
|
|
135
|
-
* that never needed attention. Opening another thread of the open family judges
|
|
136
|
-
* nothing again.
|
|
137
|
-
*/
|
|
138
|
-
export function holdAttention(
|
|
139
|
-
previous: AttentionHold,
|
|
140
|
-
forest: Pick<Forest, "infos" | "familyOf">,
|
|
141
|
-
activeThreadId: string | null,
|
|
142
|
-
): AttentionHold {
|
|
143
|
-
if (activeThreadId === null) return NO_HOLD;
|
|
144
|
-
const openFamily = forest.familyOf.get(activeThreadId);
|
|
145
|
-
if (openFamily === undefined) return NO_HOLD;
|
|
146
|
-
const openRootId = openFamily.root.thread.id;
|
|
147
|
-
if (openRootId === previous.openRootId) {
|
|
148
|
-
const archived = forest.infos.get(activeThreadId)?.thread.isArchived ?? false;
|
|
149
|
-
const held = previous.held?.root.thread.id === openRootId && !(archived && openFamily.attentionFlags.size === 0);
|
|
150
|
-
return held ? previous : { held: null, openRootId };
|
|
151
|
-
}
|
|
152
|
-
return { held: openFamily.attentionFlags.size > 0 ? openFamily : null, openRootId };
|
|
153
|
-
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The project's default branch, and whether a thread's branch is another
|
|
2
|
+
// one. Pure.
|
|
3
|
+
|
|
4
|
+
/** A project source as `projects.get` returns it, narrowed to what the lookup reads. */
|
|
5
|
+
export interface ProjectSourceRef {
|
|
6
|
+
hostId: string;
|
|
7
|
+
isDefault: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The machine to ask for a project's default branch: its default source's,
|
|
12
|
+
* else its first source's. Null when the project has no source, as a
|
|
13
|
+
* personal project may not. A thread's own machine may have no source for
|
|
14
|
+
* the project, and bb answers 404 there.
|
|
15
|
+
*/
|
|
16
|
+
export function defaultSourceHostId(sources: readonly ProjectSourceRef[]): string | null {
|
|
17
|
+
return (sources.find((source) => source.isDefault) ?? sources[0])?.hostId ?? null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a thread's branch is known to differ from its project's default:
|
|
22
|
+
* `defaultBranch` is undefined while the lookup runs and null when it found
|
|
23
|
+
* none, and in both cases the branch is not known to differ.
|
|
24
|
+
*/
|
|
25
|
+
export function isOffDefaultBranch(branch: string | null, defaultBranch: string | null | undefined): branch is string {
|
|
26
|
+
return branch !== null && typeof defaultBranch === "string" && branch !== defaultBranch;
|
|
27
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Group header counters. Pure.
|
|
2
|
-
import type {
|
|
2
|
+
import type { ThreadTree } from "./trees";
|
|
3
3
|
|
|
4
4
|
export interface Counters {
|
|
5
5
|
waitsOnYou: number;
|
|
@@ -13,16 +13,16 @@ export interface Counters {
|
|
|
13
13
|
export const EMPTY_COUNTERS: Counters = { waitsOnYou: 0, failed: 0, offline: 0, working: 0, unread: 0 };
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Counts the threads in the
|
|
16
|
+
* Counts the threads in the trees. A thread counts what its Needs attention
|
|
17
17
|
* flags say, so a child adds to waits-on-you, failed, offline and unread only
|
|
18
18
|
* as "Needs attention counts every child" lets it; working counts every thread that
|
|
19
19
|
* runs. Hidden threads carry only waits-on-you and unread-failed flags
|
|
20
20
|
* already; archived threads never count.
|
|
21
21
|
*/
|
|
22
|
-
export function
|
|
22
|
+
export function countTrees(trees: readonly ThreadTree[]): Counters {
|
|
23
23
|
const counters = { ...EMPTY_COUNTERS };
|
|
24
|
-
for (const
|
|
25
|
-
for (const info of [
|
|
24
|
+
for (const tree of trees) {
|
|
25
|
+
for (const info of [tree.root, ...tree.descendants]) {
|
|
26
26
|
if (info.thread.isArchived) continue;
|
|
27
27
|
const flags = info.attentionFlags;
|
|
28
28
|
if (flags.has("waits-on-you")) counters.waitsOnYou += 1;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Hover card and details drawer content. Pure.
|
|
2
|
-
import type {
|
|
2
|
+
import type { ThreadTree, ThreadInfo } from "./trees";
|
|
3
3
|
import type { StateKind } from "./state";
|
|
4
4
|
|
|
5
5
|
const SUMMARY_ORDER: readonly [StateKind, string][] = [
|
|
@@ -29,13 +29,13 @@ export function childSummary(descendants: readonly ThreadInfo[]): string | null
|
|
|
29
29
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Descendants of `id` inside its
|
|
33
|
-
export function descendantsOf(
|
|
34
|
-
if (
|
|
35
|
-
if (
|
|
32
|
+
/** Descendants of `id` inside its tree, for the card of any row. */
|
|
33
|
+
export function descendantsOf(tree: ThreadTree | undefined, id: string): ThreadInfo[] {
|
|
34
|
+
if (tree === undefined) return [];
|
|
35
|
+
if (tree.root.thread.id === id) return tree.descendants;
|
|
36
36
|
const result: ThreadInfo[] = [];
|
|
37
37
|
const within = new Set([id]);
|
|
38
|
-
for (const info of
|
|
38
|
+
for (const info of tree.descendants) {
|
|
39
39
|
if (info.parentId !== null && within.has(info.parentId)) {
|
|
40
40
|
within.add(info.thread.id);
|
|
41
41
|
result.push(info);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
// Auto-expansion: transitions found by diffing snapshots, and the
|
|
2
2
|
// transient targets they leave behind. Pure.
|
|
3
3
|
import { revealsOn } from "./attention";
|
|
4
|
-
import type { Forest } from "./
|
|
4
|
+
import type { Forest } from "./trees";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* `reveal` targets open
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* `reveal` targets open the parent chips on their path. `open` targets (a
|
|
8
|
+
* thread that newly became unread) open no chip, so finished children don't
|
|
9
|
+
* expand their parent's. The open thread's target, of either kind, also
|
|
10
|
+
* opens its collapsed group. No target opens a settled fold.
|
|
11
11
|
*/
|
|
12
12
|
export type TargetKind = "reveal" | "open";
|
|
13
13
|
export type Targets = ReadonlyMap<string, TargetKind>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Top-level groups: which group a
|
|
1
|
+
// Top-level groups: which group a tree lands in, and the order
|
|
2
2
|
// groups appear in. Pure.
|
|
3
3
|
import type {
|
|
4
4
|
PluginSidebarProject,
|
|
@@ -43,7 +43,7 @@ export function personalProject(
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* The group a
|
|
46
|
+
* The group a tree root belongs to. Pinned roots go to Pinned. A root
|
|
47
47
|
* whose project, section or machine isn't known goes to the loose Threads
|
|
48
48
|
* bucket, except an unknown section, which gets a placeholder group as in
|
|
49
49
|
* bb.
|