@gperezmz/bb-plugin-thread-glance 0.1.2
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/LICENSE +21 -0
- package/README.md +20 -0
- package/THIRD_PARTY_NOTICES.md +230 -0
- package/app.tsx +12 -0
- package/components/ui/alert-dialog.tsx +142 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +83 -0
- package/components/ui/coarse-pointer-sizing.ts +66 -0
- package/components/ui/context-menu.tsx +355 -0
- package/components/ui/dialog.tsx +416 -0
- package/components/ui/dropdown-menu.tsx +640 -0
- package/components/ui/hooks/use-compact-viewport.tsx +37 -0
- package/components/ui/hooks/use-media-query.ts +70 -0
- package/components/ui/hooks/use-pointer-coarse.ts +7 -0
- package/components/ui/hover-card.tsx +36 -0
- package/components/ui/icon-extended.tsx +333 -0
- package/components/ui/icon-registry.ts +189 -0
- package/components/ui/icon.tsx +444 -0
- package/components/ui/input.tsx +30 -0
- package/components/ui/menu-item-hover.tsx +93 -0
- package/components/ui/motion.ts +4 -0
- package/components/ui/overlay-trigger.ts +84 -0
- package/components/ui/popover.tsx +223 -0
- package/components/ui/responsive-overlay.tsx +823 -0
- package/components/ui/tooltip.tsx +63 -0
- package/dist/app.css +2 -0
- package/dist/app.js +66 -0
- package/dist/app.meta.json +11 -0
- package/dist/server.js +71 -0
- package/dist/server.js.map +6 -0
- package/dist/server.meta.json +11 -0
- package/features/thread-list/components/Dialogs.tsx +299 -0
- package/features/thread-list/components/FoldRows.tsx +147 -0
- package/features/thread-list/components/GroupSection.tsx +370 -0
- package/features/thread-list/components/ProviderBadge.tsx +57 -0
- package/features/thread-list/components/PullRequestBadge.tsx +50 -0
- package/features/thread-list/components/Rails.tsx +28 -0
- package/features/thread-list/components/RenameEditor.tsx +118 -0
- package/features/thread-list/components/RowMenu.tsx +168 -0
- package/features/thread-list/components/SplitMiniMap.tsx +55 -0
- package/features/thread-list/components/ThreadDetails.tsx +138 -0
- package/features/thread-list/components/ThreadList.tsx +855 -0
- package/features/thread-list/components/ThreadRowView.tsx +631 -0
- package/features/thread-list/components/Toolbar.tsx +296 -0
- package/features/thread-list/components/controller.ts +64 -0
- package/features/thread-list/components/glyphs.tsx +129 -0
- package/features/thread-list/components/row-card.ts +127 -0
- package/features/thread-list/data/storage.ts +18 -0
- package/features/thread-list/data/useAutoExpand.ts +53 -0
- package/features/thread-list/data/useClientPreferences.ts +20 -0
- package/features/thread-list/data/useNotes.ts +41 -0
- package/features/thread-list/data/useNow.ts +19 -0
- package/features/thread-list/data/usePreferences.ts +126 -0
- package/features/thread-list/data/useScheduled.ts +43 -0
- package/features/thread-list/data/useStamps.ts +68 -0
- package/features/thread-list/icons.ts +44 -0
- package/features/thread-list/index.ts +2 -0
- package/features/thread-list/model/attention.ts +89 -0
- package/features/thread-list/model/counters.ts +67 -0
- package/features/thread-list/model/details.ts +79 -0
- package/features/thread-list/model/drag.ts +104 -0
- package/features/thread-list/model/expansion.ts +71 -0
- package/features/thread-list/model/families.ts +297 -0
- package/features/thread-list/model/groups.ts +263 -0
- package/features/thread-list/model/labels.ts +50 -0
- package/features/thread-list/model/layout.ts +61 -0
- package/features/thread-list/model/menu.ts +78 -0
- package/features/thread-list/model/move.ts +46 -0
- package/features/thread-list/model/notes.ts +52 -0
- package/features/thread-list/model/provider-mark.ts +59 -0
- package/features/thread-list/model/share.ts +101 -0
- package/features/thread-list/model/sort.ts +78 -0
- package/features/thread-list/model/state.ts +330 -0
- package/features/thread-list/model/time.ts +107 -0
- package/features/thread-list/model/toggles.ts +99 -0
- package/features/thread-list/model/view.ts +669 -0
- package/features/thread-list/model/windowing.ts +27 -0
- package/features/thread-list/testing/fixtures.ts +162 -0
- package/hooks/useBrowserDimmingModal.ts +5 -0
- package/lib/portal-scope.ts +15 -0
- package/lib/utils.ts +17 -0
- package/package.json +96 -0
- package/server/cli.ts +107 -0
- package/server/failures.ts +136 -0
- package/server/import.ts +144 -0
- package/server/notes.ts +236 -0
- package/server/preference-store.ts +96 -0
- package/server/scheduled.ts +129 -0
- package/server/serial.ts +12 -0
- package/server/stamps.ts +142 -0
- package/server/startup.ts +121 -0
- package/server.ts +126 -0
- package/shared/contract.ts +73 -0
- package/shared/preferences.ts +241 -0
- package/shared/signals.ts +71 -0
- package/skills/thread-glance/SKILL.md +69 -0
- package/tsconfig.json +36 -0
- package/types/global-jsx.d.ts +10 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
// One thread row. It draws the row the model built and reports what
|
|
2
|
+
// the user did; every decision was made before it rendered.
|
|
3
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
|
5
|
+
import {
|
|
6
|
+
experimental_Icon as Icon,
|
|
7
|
+
experimental_useSidebarThreadSplit as useThreadSplit,
|
|
8
|
+
useSidebarSplitLayout,
|
|
9
|
+
useSidebarThreadDraft,
|
|
10
|
+
useSidebarThreadRowStatus,
|
|
11
|
+
useSidebarThreadShortcut,
|
|
12
|
+
} from "@get-bb/plugin-sdk/app";
|
|
13
|
+
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
|
14
|
+
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
15
|
+
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
|
16
|
+
import { cn } from "@/lib/utils";
|
|
17
|
+
import { ICONS } from "../icons";
|
|
18
|
+
import { chipLabel, rowAriaLabel } from "../model/labels";
|
|
19
|
+
import { rowIndent, titleTreatment } from "../model/layout";
|
|
20
|
+
import { rowMenuItems } from "../model/menu";
|
|
21
|
+
import { chipTone, pluginStatusWins } from "../model/state";
|
|
22
|
+
import { trailingTime } from "../model/time";
|
|
23
|
+
import type { ThreadRow } from "../model/view";
|
|
24
|
+
import type { DraggedThread } from "../model/drag";
|
|
25
|
+
import type { RowController } from "./controller";
|
|
26
|
+
import { CHIP_TONE_CLASS, FlagGlyph, GlyphIcon, PluginStatusGlyph } from "./glyphs";
|
|
27
|
+
import { ProviderBadge } from "./ProviderBadge";
|
|
28
|
+
import { PullRequestBadge } from "./PullRequestBadge";
|
|
29
|
+
import { RenameEditor } from "./RenameEditor";
|
|
30
|
+
import { RowRails } from "./Rails";
|
|
31
|
+
import { RowContextMenuContent, RowDropdownMenuContent, type ContextMenuInput } from "./RowMenu";
|
|
32
|
+
import { SplitMiniMap, type MiniMapPane } from "./SplitMiniMap";
|
|
33
|
+
import { ThreadDetails } from "./ThreadDetails";
|
|
34
|
+
import { useRowCard } from "./row-card";
|
|
35
|
+
|
|
36
|
+
/** Two clicks on one row within this window start a rename, as in bb. */
|
|
37
|
+
const RENAME_CLICK_MS = 400;
|
|
38
|
+
const LONG_PRESS_MS = 700;
|
|
39
|
+
const LONG_PRESS_TOLERANCE = 10;
|
|
40
|
+
let lastClick: { threadId: string; at: number } | null = null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Drops the click that ends a long-press, wherever it lands, so lifting the
|
|
44
|
+
* finger never selects the menu item that opened under it.
|
|
45
|
+
*/
|
|
46
|
+
function swallowNextClick(): void {
|
|
47
|
+
const swallow = (event: Event) => {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
event.stopPropagation();
|
|
50
|
+
window.removeEventListener("click", swallow, true);
|
|
51
|
+
};
|
|
52
|
+
window.addEventListener("click", swallow, true);
|
|
53
|
+
// A long-press that ends without a click must not eat the next real tap.
|
|
54
|
+
window.addEventListener("touchend", () => setTimeout(() => window.removeEventListener("click", swallow, true), 400), {
|
|
55
|
+
capture: true,
|
|
56
|
+
once: true,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const ROW_ICON_BUTTON =
|
|
61
|
+
"pointer-events-auto relative z-10 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-state-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-state-active";
|
|
62
|
+
|
|
63
|
+
function useMiniMap(threadId: string): MiniMapPane[] | null {
|
|
64
|
+
const layout = useSidebarSplitLayout();
|
|
65
|
+
return useMemo(() => {
|
|
66
|
+
if (layout === null) return null;
|
|
67
|
+
const panes = layout.panes.map((pane) => ({
|
|
68
|
+
paneId: pane.paneId,
|
|
69
|
+
rect: pane.rect,
|
|
70
|
+
isMe: pane.threadId === threadId,
|
|
71
|
+
isFocused: pane.isFocused,
|
|
72
|
+
}));
|
|
73
|
+
return panes.some((pane) => pane.isMe) ? panes : null;
|
|
74
|
+
}, [layout, threadId]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ThreadRowViewProps {
|
|
78
|
+
row: ThreadRow;
|
|
79
|
+
controller: RowController;
|
|
80
|
+
groupId: string;
|
|
81
|
+
inPinned: boolean;
|
|
82
|
+
/** Drop feedback from the list's drag state. */
|
|
83
|
+
dropState: "valid" | "blocked" | "unchanged" | "before" | "after" | null;
|
|
84
|
+
stickyTop: number | null;
|
|
85
|
+
/** The open thread is this one. */
|
|
86
|
+
active: boolean;
|
|
87
|
+
/** Its title is being renamed. */
|
|
88
|
+
editing: boolean;
|
|
89
|
+
/** The list clock, for the trailing age and timer. */
|
|
90
|
+
now: number;
|
|
91
|
+
/** This thread's stamps, for the trailing time. */
|
|
92
|
+
startedAt: number | undefined;
|
|
93
|
+
finishedAt: number | undefined;
|
|
94
|
+
pendingAt: number | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const ThreadRowView = memo(function ThreadRowView({
|
|
98
|
+
row,
|
|
99
|
+
controller,
|
|
100
|
+
groupId,
|
|
101
|
+
inPinned,
|
|
102
|
+
dropState,
|
|
103
|
+
stickyTop,
|
|
104
|
+
active: isActive,
|
|
105
|
+
editing,
|
|
106
|
+
now,
|
|
107
|
+
startedAt,
|
|
108
|
+
finishedAt,
|
|
109
|
+
pendingAt,
|
|
110
|
+
}: ThreadRowViewProps) {
|
|
111
|
+
const { info } = row;
|
|
112
|
+
const thread = info.thread;
|
|
113
|
+
const archived = thread.archivedAt !== null || thread.isArchived;
|
|
114
|
+
const compact = controller.compact;
|
|
115
|
+
|
|
116
|
+
const shortcut = useSidebarThreadShortcut(thread.id);
|
|
117
|
+
const rowStatus = useSidebarThreadRowStatus(thread.id);
|
|
118
|
+
const { hasUnsubmittedDraft } = useSidebarThreadDraft(thread.id);
|
|
119
|
+
const split = useThreadSplit(thread.id);
|
|
120
|
+
const miniMap = useMiniMap(thread.id);
|
|
121
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
122
|
+
const [contextOpen, setContextOpen] = useState(false);
|
|
123
|
+
const card = useRowCard(isActive, !compact && !editing && !menuOpen && !contextOpen);
|
|
124
|
+
const contextInput = useRef<ContextMenuInput>({ pressed: false, keyed: false });
|
|
125
|
+
const anchor = useRef<HTMLAnchorElement>(null);
|
|
126
|
+
const press = useRef<{ x: number; y: number; timer: ReturnType<typeof setTimeout> } | null>(null);
|
|
127
|
+
|
|
128
|
+
const dragData: DraggedThread = {
|
|
129
|
+
threadId: thread.id,
|
|
130
|
+
parentThreadId: thread.parentThreadId,
|
|
131
|
+
sectionId: thread.sectionId,
|
|
132
|
+
pinned: thread.pinnedAt !== null || thread.isPinned,
|
|
133
|
+
};
|
|
134
|
+
const draggable = useDraggable({
|
|
135
|
+
id: `thread:${thread.id}:${groupId}`,
|
|
136
|
+
data: { kind: "thread", thread: dragData, groupId },
|
|
137
|
+
disabled: editing || compact,
|
|
138
|
+
});
|
|
139
|
+
const droppable = useDroppable({
|
|
140
|
+
id: `drop-thread:${thread.id}:${groupId}`,
|
|
141
|
+
data: { kind: "thread", threadId: thread.id, inPinned, groupId },
|
|
142
|
+
});
|
|
143
|
+
const setRefs = useCallback(
|
|
144
|
+
(node: HTMLDivElement | null) => {
|
|
145
|
+
draggable.setNodeRef(node);
|
|
146
|
+
droppable.setNodeRef(node);
|
|
147
|
+
},
|
|
148
|
+
[draggable.setNodeRef, droppable.setNodeRef],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const provider = controller.provider(thread.providerId);
|
|
152
|
+
const showPlugin = pluginStatusWins(info.state, rowStatus);
|
|
153
|
+
const label = rowAriaLabel(row, {
|
|
154
|
+
providerName: provider.name,
|
|
155
|
+
pluginLabel: showPlugin ? rowStatus!.label : null,
|
|
156
|
+
hasDraft: hasUnsubmittedDraft,
|
|
157
|
+
});
|
|
158
|
+
const time = archived
|
|
159
|
+
? null
|
|
160
|
+
: trailingTime(
|
|
161
|
+
thread,
|
|
162
|
+
info.flags.has("working"),
|
|
163
|
+
{
|
|
164
|
+
startedAt: startedAt === undefined ? {} : { [thread.id]: startedAt },
|
|
165
|
+
finishedAt: finishedAt === undefined ? {} : { [thread.id]: finishedAt },
|
|
166
|
+
},
|
|
167
|
+
now,
|
|
168
|
+
info.state.kind === "needs-you" ? (pendingAt ?? null) : undefined,
|
|
169
|
+
);
|
|
170
|
+
const menuItems = rowMenuItems({
|
|
171
|
+
thread,
|
|
172
|
+
unread: info.unread,
|
|
173
|
+
splitAvailable: split.isAvailable,
|
|
174
|
+
isRoot: thread.parentThreadId === null,
|
|
175
|
+
hasSections: controller.mode === "chronological" || controller.sections.length > 0,
|
|
176
|
+
compact,
|
|
177
|
+
});
|
|
178
|
+
// Rename waits for the menu to close: an open menu traps focus and would
|
|
179
|
+
// take it back from the editor, and a closing one returns it to the row.
|
|
180
|
+
const renameFromMenu = useRef(false);
|
|
181
|
+
// Set until the closing menu's focus return has been refused, whichever
|
|
182
|
+
// path started the rename: a returned focus would blur and close the editor.
|
|
183
|
+
const refuseCloseFocus = useRef(false);
|
|
184
|
+
const onMenuAction = (action: Parameters<RowController["onMenuAction"]>[0], sectionId?: string | null) => {
|
|
185
|
+
if (action === "rename") {
|
|
186
|
+
renameFromMenu.current = true;
|
|
187
|
+
refuseCloseFocus.current = true;
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
refuseCloseFocus.current = false;
|
|
190
|
+
}, 1500);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
controller.onMenuAction(action, thread, sectionId);
|
|
194
|
+
};
|
|
195
|
+
// Fallback for menus that close without a close-focus event (the compact
|
|
196
|
+
// drawer; a context menu opened from the keyboard): start once every menu
|
|
197
|
+
// has closed and its focus return has settled.
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (menuOpen || contextOpen || !renameFromMenu.current) return;
|
|
200
|
+
const timer = setTimeout(() => {
|
|
201
|
+
if (!renameFromMenu.current) return;
|
|
202
|
+
renameFromMenu.current = false;
|
|
203
|
+
controller.setEditingId(thread.id);
|
|
204
|
+
}, 60);
|
|
205
|
+
return () => clearTimeout(timer);
|
|
206
|
+
}, [menuOpen, contextOpen, controller, thread.id]);
|
|
207
|
+
const onMenuCloseAutoFocus = (event: Event) => {
|
|
208
|
+
if (!refuseCloseFocus.current) return;
|
|
209
|
+
refuseCloseFocus.current = false;
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
if (!renameFromMenu.current) return;
|
|
212
|
+
renameFromMenu.current = false;
|
|
213
|
+
controller.setEditingId(thread.id);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const defaultBranch = controller.showPullRequests && row.depth === 0 ? controller.defaultBranchOf(thread) : undefined;
|
|
217
|
+
const branch = thread.environment?.branchName ?? null;
|
|
218
|
+
const showPullRequest =
|
|
219
|
+
controller.showPullRequests && row.depth === 0 && branch !== null && defaultBranch !== undefined && branch !== defaultBranch;
|
|
220
|
+
|
|
221
|
+
const stateSlot = miniMap ? (
|
|
222
|
+
<SplitMiniMap panes={miniMap} label={`${thread.displayTitle} — open in split; ${info.state.label}`} working={info.flags.has("working")} />
|
|
223
|
+
) : showPlugin ? (
|
|
224
|
+
<PluginStatusGlyph status={rowStatus!} />
|
|
225
|
+
) : (
|
|
226
|
+
<span title={info.state.label} className="inline-flex">
|
|
227
|
+
<GlyphIcon glyph={info.state.glyph} label={info.state.label} />
|
|
228
|
+
</span>
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const onAnchorClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
232
|
+
if (editing) {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (split.isAvailable && (event.metaKey || event.ctrlKey)) {
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
controller.onMenuAction("open-in-split", thread);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
if (lastClick !== null && lastClick.threadId === thread.id && now - lastClick.at < RENAME_CLICK_MS) {
|
|
243
|
+
lastClick = null;
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
controller.setEditingId(thread.id);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
lastClick = { threadId: thread.id, at: now };
|
|
249
|
+
controller.onNavigate();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const cancelPress = () => {
|
|
253
|
+
if (press.current !== null) clearTimeout(press.current.timer);
|
|
254
|
+
press.current = null;
|
|
255
|
+
};
|
|
256
|
+
const longPressFired = useRef(false);
|
|
257
|
+
const longPress = compact
|
|
258
|
+
? {
|
|
259
|
+
onTouchStart: (event: React.TouchEvent) => {
|
|
260
|
+
const touch = event.touches[0];
|
|
261
|
+
if (touch === undefined) return;
|
|
262
|
+
cancelPress();
|
|
263
|
+
longPressFired.current = false;
|
|
264
|
+
press.current = {
|
|
265
|
+
x: touch.clientX,
|
|
266
|
+
y: touch.clientY,
|
|
267
|
+
timer: setTimeout(() => {
|
|
268
|
+
press.current = null;
|
|
269
|
+
longPressFired.current = true;
|
|
270
|
+
swallowNextClick();
|
|
271
|
+
setMenuOpen(true);
|
|
272
|
+
}, LONG_PRESS_MS),
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
onTouchMove: (event: React.TouchEvent) => {
|
|
276
|
+
const touch = event.touches[0];
|
|
277
|
+
if (press.current === null || touch === undefined) return;
|
|
278
|
+
if (Math.hypot(touch.clientX - press.current.x, touch.clientY - press.current.y) > LONG_PRESS_TOLERANCE) {
|
|
279
|
+
cancelPress();
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
onTouchEnd: (event: React.TouchEvent) => {
|
|
283
|
+
cancelPress();
|
|
284
|
+
// The finger lifts over the drawer that just opened: without this,
|
|
285
|
+
// the click that follows would select the item under it.
|
|
286
|
+
if (longPressFired.current) event.preventDefault();
|
|
287
|
+
},
|
|
288
|
+
onTouchCancel: cancelPress,
|
|
289
|
+
onContextMenu: (event: React.MouseEvent) => event.preventDefault(),
|
|
290
|
+
style: { WebkitTouchCallout: "none" } as React.CSSProperties,
|
|
291
|
+
}
|
|
292
|
+
: {};
|
|
293
|
+
|
|
294
|
+
const chip = row.chip;
|
|
295
|
+
const indent = rowIndent(row.depth, controller.nesting);
|
|
296
|
+
const title = titleTreatment(row.depth, { unread: info.unread, active: isActive });
|
|
297
|
+
const twoLines = controller.comfortable || info.note !== null;
|
|
298
|
+
// A parent keeps full colour so its children read as the quieter ones.
|
|
299
|
+
const dimmed = info.quiet && !isActive && !editing && chip === null;
|
|
300
|
+
const menuShowing = menuOpen || contextOpen;
|
|
301
|
+
// Desktop: the actions cross-fade over the harness and age, as bb's
|
|
302
|
+
// trailing slot does. Compact: "…" always shows beside them.
|
|
303
|
+
const fadeClass = compact
|
|
304
|
+
? ""
|
|
305
|
+
: menuShowing
|
|
306
|
+
? "opacity-0"
|
|
307
|
+
: "group-hover/row:opacity-0 group-focus-within/row:opacity-0";
|
|
308
|
+
|
|
309
|
+
const body = (
|
|
310
|
+
<div
|
|
311
|
+
ref={setRefs}
|
|
312
|
+
data-sidebar-rename-row=""
|
|
313
|
+
data-sidebar-nest-target={
|
|
314
|
+
dropState === "valid" || dropState === "blocked" || dropState === "unchanged" ? dropState : undefined
|
|
315
|
+
}
|
|
316
|
+
data-sidebar-reorder-placement={dropState === "before" || dropState === "after" ? dropState : undefined}
|
|
317
|
+
{...draggable.attributes}
|
|
318
|
+
{...draggable.listeners}
|
|
319
|
+
role={undefined}
|
|
320
|
+
tabIndex={undefined}
|
|
321
|
+
aria-roledescription={undefined}
|
|
322
|
+
onPointerEnter={card.rowProps.onPointerEnter}
|
|
323
|
+
onPointerMove={card.rowProps.onPointerMove}
|
|
324
|
+
onPointerDown={(event) => {
|
|
325
|
+
card.rowProps.onPointerDown();
|
|
326
|
+
if (!editing) split.splitProps.onPointerDown?.(event);
|
|
327
|
+
}}
|
|
328
|
+
onPointerLeave={card.rowProps.onPointerLeave}
|
|
329
|
+
onFocus={card.rowProps.onFocus}
|
|
330
|
+
onBlur={card.rowProps.onBlur}
|
|
331
|
+
{...longPress}
|
|
332
|
+
className={cn(
|
|
333
|
+
"group/row relative flex w-full items-center gap-1.5 rounded-md pr-1 text-sm transition-colors",
|
|
334
|
+
twoLines ? "h-11 max-md:pointer-coarse:h-12" : "h-7 max-md:pointer-coarse:h-9",
|
|
335
|
+
isActive
|
|
336
|
+
? "bg-state-active text-sidebar-foreground"
|
|
337
|
+
: "cursor-pointer text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
338
|
+
!isActive && menuShowing && "bg-sidebar-accent",
|
|
339
|
+
miniMap && !isActive && "bb-sidebar-open-in-split-row",
|
|
340
|
+
draggable.isDragging && "opacity-50",
|
|
341
|
+
dropState === "valid" && "ring-1 ring-inset ring-sidebar-ring/80 bg-sidebar-accent/45",
|
|
342
|
+
dropState === "blocked" && "ring-1 ring-inset ring-destructive/60",
|
|
343
|
+
dropState === "before" && "shadow-[inset_0_2px_0_0_var(--sidebar-ring)]",
|
|
344
|
+
dropState === "after" && "shadow-[inset_0_-2px_0_0_var(--sidebar-ring)]",
|
|
345
|
+
stickyTop !== null && "sticky z-10 bg-sidebar",
|
|
346
|
+
stickyTop !== null && isActive && "bg-state-active",
|
|
347
|
+
)}
|
|
348
|
+
style={{ paddingLeft: indent, top: stickyTop ?? undefined }}
|
|
349
|
+
>
|
|
350
|
+
<a
|
|
351
|
+
ref={anchor}
|
|
352
|
+
href={thread.href}
|
|
353
|
+
data-sidebar-thread-shortcut-target=""
|
|
354
|
+
data-sidebar-thread-id={thread.id}
|
|
355
|
+
data-sidebar-rename-anchor=""
|
|
356
|
+
aria-label={label}
|
|
357
|
+
aria-keyshortcuts={shortcut?.ariaKeyshortcuts}
|
|
358
|
+
aria-current={isActive ? "page" : undefined}
|
|
359
|
+
onClick={onAnchorClick}
|
|
360
|
+
onDoubleClick={(event) => {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
controller.setEditingId(thread.id);
|
|
363
|
+
}}
|
|
364
|
+
className="absolute inset-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
365
|
+
/>
|
|
366
|
+
<RowRails rails={row.rails} nesting={controller.nesting} />
|
|
367
|
+
<span className="pointer-events-none relative flex size-4 shrink-0 items-center justify-center">{stateSlot}</span>
|
|
368
|
+
{row.nested ? (
|
|
369
|
+
// Tight against the title, and over the row's gap, so it adds 8px.
|
|
370
|
+
<span
|
|
371
|
+
aria-hidden
|
|
372
|
+
title={`Child of ${row.parentTitle ?? "a thread"}`}
|
|
373
|
+
className={cn(
|
|
374
|
+
"pointer-events-none relative -ml-[3px] -mr-px w-1.5 shrink-0 text-center text-[10px] leading-none text-muted-foreground",
|
|
375
|
+
// On two-line rows it sits beside the title, not between the lines.
|
|
376
|
+
twoLines && "mt-[9px] self-start",
|
|
377
|
+
)}
|
|
378
|
+
>
|
|
379
|
+
↳
|
|
380
|
+
</span>
|
|
381
|
+
) : null}
|
|
382
|
+
{row.crossGroupLabel !== null ? (
|
|
383
|
+
<span
|
|
384
|
+
role="img"
|
|
385
|
+
aria-label={row.crossGroupLabel}
|
|
386
|
+
title={row.crossGroupLabel}
|
|
387
|
+
data-sidebar-thread-cross-project=""
|
|
388
|
+
className="pointer-events-none relative inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground"
|
|
389
|
+
>
|
|
390
|
+
<Icon name={ICONS.crossGroup} aria-hidden className="size-3.5" />
|
|
391
|
+
</span>
|
|
392
|
+
) : null}
|
|
393
|
+
<span className="pointer-events-none relative flex min-w-0 flex-1 flex-col justify-center">
|
|
394
|
+
{editing ? (
|
|
395
|
+
<RenameEditor
|
|
396
|
+
initial={thread.displayTitle}
|
|
397
|
+
label="Thread name"
|
|
398
|
+
onSave={(title) => controller.onRename(thread.id, title)}
|
|
399
|
+
onDone={() => {
|
|
400
|
+
controller.setEditingId(null);
|
|
401
|
+
anchor.current?.focus();
|
|
402
|
+
}}
|
|
403
|
+
/>
|
|
404
|
+
) : (
|
|
405
|
+
<span
|
|
406
|
+
title={thread.displayTitle}
|
|
407
|
+
className={cn(
|
|
408
|
+
"min-w-0 truncate",
|
|
409
|
+
info.unread ? "font-semibold" : "font-normal",
|
|
410
|
+
// Read, idle threads step back so live ones lead.
|
|
411
|
+
dimmed && !title.muted && "text-muted-foreground",
|
|
412
|
+
// Children sit a step below their parent; hover and selection bring them back.
|
|
413
|
+
title.small && "text-xs",
|
|
414
|
+
// Foreground thinned toward the sidebar: at least 4.5:1 in both themes, and below a muted root.
|
|
415
|
+
title.muted && "text-[color:color-mix(in_oklch,var(--foreground)_72%,var(--sidebar))] group-hover/row:text-foreground",
|
|
416
|
+
)}
|
|
417
|
+
>
|
|
418
|
+
{/* Plain text: mention pills lost the truncation fight. */}
|
|
419
|
+
{thread.displayTitle}
|
|
420
|
+
</span>
|
|
421
|
+
)}
|
|
422
|
+
{!editing && info.note !== null ? (
|
|
423
|
+
<span className="min-w-0 truncate text-xs leading-4 text-muted-foreground" title={`${info.note.prefix}: ${info.note.text}`}>
|
|
424
|
+
<span className={info.note.tone === "destructive" ? "text-destructive" : "text-attention"}>{info.note.prefix}:</span>{" "}
|
|
425
|
+
{info.note.text}
|
|
426
|
+
</span>
|
|
427
|
+
) : controller.comfortable && !editing ? (
|
|
428
|
+
<SecondLine row={row} multiHost={controller.multiHost} defaultBranch={controller.defaultBranchOf(thread)} />
|
|
429
|
+
) : null}
|
|
430
|
+
</span>
|
|
431
|
+
{row.hiddenBadge ? (
|
|
432
|
+
<span role="img" aria-label="Hidden thread" title="Hidden thread" className="pointer-events-none relative shrink-0 text-muted-foreground">
|
|
433
|
+
<Icon name={ICONS.hidden} aria-hidden className="size-3.5" />
|
|
434
|
+
</span>
|
|
435
|
+
) : null}
|
|
436
|
+
{showPullRequest && !editing ? (
|
|
437
|
+
<span className="pointer-events-none relative">
|
|
438
|
+
<PullRequestBadge threadId={thread.id} />
|
|
439
|
+
</span>
|
|
440
|
+
) : null}
|
|
441
|
+
{chip !== null && !editing ? (
|
|
442
|
+
<button
|
|
443
|
+
type="button"
|
|
444
|
+
aria-expanded={chip.expanded}
|
|
445
|
+
aria-label={chipLabel(thread.displayTitle, chip.count, chip.flag, chip.expanded)}
|
|
446
|
+
title={chipLabel(thread.displayTitle, chip.count, chip.flag, chip.expanded)}
|
|
447
|
+
onClick={(event) => {
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
event.stopPropagation();
|
|
450
|
+
controller.onToggleChip(row);
|
|
451
|
+
}}
|
|
452
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
453
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
454
|
+
data-tone={chipTone(chip.flag)}
|
|
455
|
+
className={cn(
|
|
456
|
+
"pointer-events-auto relative z-10 inline-flex h-5 shrink-0 items-center gap-0.5 rounded-md border px-1 text-[11px] leading-none tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
457
|
+
CHIP_TONE_CLASS[chipTone(chip.flag)],
|
|
458
|
+
)}
|
|
459
|
+
>
|
|
460
|
+
{chip.count}
|
|
461
|
+
{chip.flag !== null ? <FlagGlyph flag={chip.flag} className="size-3" /> : null}
|
|
462
|
+
{controller.harnessIcon !== "hidden"
|
|
463
|
+
? chip.providerIds.map((providerId) => (
|
|
464
|
+
<ProviderBadge key={providerId} display={controller.provider(providerId)} className="size-3 [&_*]:size-3" />
|
|
465
|
+
))
|
|
466
|
+
: null}
|
|
467
|
+
<Icon
|
|
468
|
+
name={ICONS.expand}
|
|
469
|
+
aria-hidden
|
|
470
|
+
className={cn("size-3 transition-transform duration-150", chip.expanded && "rotate-90")}
|
|
471
|
+
/>
|
|
472
|
+
</button>
|
|
473
|
+
) : null}
|
|
474
|
+
{!editing ? (
|
|
475
|
+
<span
|
|
476
|
+
className={cn(
|
|
477
|
+
"pointer-events-none relative inline-flex shrink-0 transition-opacity",
|
|
478
|
+
// The hover actions take this slot too, as bb's trailing slot does.
|
|
479
|
+
shortcut === null && fadeClass,
|
|
480
|
+
)}
|
|
481
|
+
>
|
|
482
|
+
<ProviderBadge display={provider} mode={controller.harnessIcon} />
|
|
483
|
+
</span>
|
|
484
|
+
) : null}
|
|
485
|
+
{!editing ? (
|
|
486
|
+
<span className="relative flex h-6 min-w-8 shrink-0 items-center justify-end gap-1">
|
|
487
|
+
{shortcut !== null ? (
|
|
488
|
+
<kbd aria-hidden className="pointer-events-none rounded border border-border px-1 font-sans text-[10px] leading-4 text-muted-foreground">
|
|
489
|
+
{shortcut.label}
|
|
490
|
+
</kbd>
|
|
491
|
+
) : (
|
|
492
|
+
<>
|
|
493
|
+
{time !== null ? (
|
|
494
|
+
<span
|
|
495
|
+
title={time.label}
|
|
496
|
+
aria-label={time.label}
|
|
497
|
+
className={cn(
|
|
498
|
+
"pointer-events-none text-[11px] tabular-nums transition-opacity",
|
|
499
|
+
// A running timer reads as work, an age as history.
|
|
500
|
+
time.kind === "timer" ? "font-medium text-[var(--timeline-accent)]" : "text-muted-foreground",
|
|
501
|
+
fadeClass,
|
|
502
|
+
)}
|
|
503
|
+
>
|
|
504
|
+
{time.text}
|
|
505
|
+
</span>
|
|
506
|
+
) : null}
|
|
507
|
+
<span
|
|
508
|
+
className={cn(
|
|
509
|
+
"flex items-center gap-0.5 transition-opacity",
|
|
510
|
+
compact
|
|
511
|
+
? "relative"
|
|
512
|
+
: menuShowing
|
|
513
|
+
? "absolute right-0 opacity-100"
|
|
514
|
+
: "absolute right-0 opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100",
|
|
515
|
+
)}
|
|
516
|
+
>
|
|
517
|
+
{!compact ? (
|
|
518
|
+
<button
|
|
519
|
+
type="button"
|
|
520
|
+
aria-label={archived ? "Unarchive thread" : "Archive thread"}
|
|
521
|
+
title={archived ? "Unarchive" : "Archive"}
|
|
522
|
+
className={ROW_ICON_BUTTON}
|
|
523
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
524
|
+
onClick={(event) => {
|
|
525
|
+
event.preventDefault();
|
|
526
|
+
event.stopPropagation();
|
|
527
|
+
controller.onMenuAction(archived ? "unarchive" : "archive", thread);
|
|
528
|
+
}}
|
|
529
|
+
>
|
|
530
|
+
<Icon name={archived ? ICONS.unarchive : ICONS.archive} aria-hidden className="size-4" />
|
|
531
|
+
</button>
|
|
532
|
+
) : null}
|
|
533
|
+
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
|
534
|
+
<DropdownMenuTrigger asChild>
|
|
535
|
+
<button
|
|
536
|
+
type="button"
|
|
537
|
+
aria-label="Thread actions"
|
|
538
|
+
// Phones open the menu by long-press, as bb's lists do;
|
|
539
|
+
// the button stays for keyboards and screen readers.
|
|
540
|
+
className={compact ? "sr-only" : ROW_ICON_BUTTON}
|
|
541
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
542
|
+
onClick={(event) => event.stopPropagation()}
|
|
543
|
+
>
|
|
544
|
+
<Icon name={ICONS.more} aria-hidden className="size-4" />
|
|
545
|
+
</button>
|
|
546
|
+
</DropdownMenuTrigger>
|
|
547
|
+
<RowDropdownMenuContent
|
|
548
|
+
items={menuItems}
|
|
549
|
+
sections={controller.sections}
|
|
550
|
+
currentSectionId={thread.sectionId}
|
|
551
|
+
onAction={onMenuAction}
|
|
552
|
+
onCloseAutoFocus={onMenuCloseAutoFocus}
|
|
553
|
+
/>
|
|
554
|
+
</DropdownMenu>
|
|
555
|
+
</span>
|
|
556
|
+
</>
|
|
557
|
+
)}
|
|
558
|
+
</span>
|
|
559
|
+
) : null}
|
|
560
|
+
</div>
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
if (compact) return body;
|
|
564
|
+
return (
|
|
565
|
+
<ContextMenu
|
|
566
|
+
onOpenChange={(open) => {
|
|
567
|
+
if (open) contextInput.current = { pressed: false, keyed: false };
|
|
568
|
+
setContextOpen(open);
|
|
569
|
+
}}
|
|
570
|
+
>
|
|
571
|
+
<HoverCard
|
|
572
|
+
closeDelay={100}
|
|
573
|
+
open={card.open}
|
|
574
|
+
onOpenChange={card.onOpenChange}
|
|
575
|
+
>
|
|
576
|
+
<ContextMenuTrigger asChild disabled={editing}>
|
|
577
|
+
<HoverCardTrigger asChild>{body}</HoverCardTrigger>
|
|
578
|
+
</ContextMenuTrigger>
|
|
579
|
+
{!editing && !menuOpen && !contextOpen ? (
|
|
580
|
+
<HoverCardContent side="right" align="start" collisionPadding={8} className="z-[100] w-72 p-3">
|
|
581
|
+
<ThreadDetails info={info} controller={controller} showPullRequest={showPullRequest} />
|
|
582
|
+
</HoverCardContent>
|
|
583
|
+
) : null}
|
|
584
|
+
</HoverCard>
|
|
585
|
+
<RowContextMenuContent
|
|
586
|
+
items={menuItems}
|
|
587
|
+
sections={controller.sections}
|
|
588
|
+
currentSectionId={thread.sectionId}
|
|
589
|
+
onAction={onMenuAction}
|
|
590
|
+
onCloseAutoFocus={onMenuCloseAutoFocus}
|
|
591
|
+
input={contextInput}
|
|
592
|
+
/>
|
|
593
|
+
</ContextMenu>
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
function SecondLine({
|
|
598
|
+
row,
|
|
599
|
+
multiHost,
|
|
600
|
+
defaultBranch,
|
|
601
|
+
}: {
|
|
602
|
+
row: ThreadRow;
|
|
603
|
+
multiHost: boolean;
|
|
604
|
+
defaultBranch: string | null | undefined;
|
|
605
|
+
}) {
|
|
606
|
+
const thread = row.info.thread;
|
|
607
|
+
const environment = thread.environment;
|
|
608
|
+
const parts: React.ReactNode[] = [];
|
|
609
|
+
// The branch earns the line only when it isn't the project's default:
|
|
610
|
+
// "main" on every row said nothing.
|
|
611
|
+
const branch = environment?.branchName ?? null;
|
|
612
|
+
if (branch !== null && branch !== defaultBranch) {
|
|
613
|
+
parts.push(
|
|
614
|
+
<span key="branch" className="inline-flex min-w-0 items-center gap-0.5">
|
|
615
|
+
<Icon name={environment?.isWorktree ? ICONS.worktree : ICONS.branch} aria-hidden className="size-3 shrink-0" />
|
|
616
|
+
<span className="truncate">{branch}</span>
|
|
617
|
+
{environment?.isWorktree ? <span className="sr-only"> (worktree)</span> : null}
|
|
618
|
+
</span>,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (multiHost && thread.host !== null) {
|
|
622
|
+
parts.push(
|
|
623
|
+
<span key="host" className="inline-flex shrink-0 items-center gap-0.5">
|
|
624
|
+
<Icon name={ICONS.machine} aria-hidden className="size-3" />
|
|
625
|
+
{thread.host.name}
|
|
626
|
+
</span>,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (parts.length === 0) return null;
|
|
630
|
+
return <span className="flex min-w-0 items-center gap-2 text-xs leading-4 text-muted-foreground">{parts}</span>;
|
|
631
|
+
}
|