@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,299 @@
|
|
|
1
|
+
// Dialogs the list opens: customize, new section, confirmations and the
|
|
2
|
+
// compact details drawer. Dialogs become drawers on compact.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { experimental_Icon as Icon } from "@get-bb/plugin-sdk/app";
|
|
5
|
+
import {
|
|
6
|
+
AlertDialog,
|
|
7
|
+
AlertDialogAction,
|
|
8
|
+
AlertDialogCancel,
|
|
9
|
+
AlertDialogContent,
|
|
10
|
+
AlertDialogDescription,
|
|
11
|
+
AlertDialogFooter,
|
|
12
|
+
AlertDialogHeader,
|
|
13
|
+
AlertDialogTitle,
|
|
14
|
+
} from "@/components/ui/alert-dialog";
|
|
15
|
+
import { Button } from "@/components/ui/button";
|
|
16
|
+
import {
|
|
17
|
+
Dialog,
|
|
18
|
+
DialogContent,
|
|
19
|
+
DialogDescription,
|
|
20
|
+
DialogFooter,
|
|
21
|
+
DialogHeader,
|
|
22
|
+
DialogTitle,
|
|
23
|
+
} from "@/components/ui/dialog";
|
|
24
|
+
import { Input } from "@/components/ui/input";
|
|
25
|
+
import { cn } from "@/lib/utils";
|
|
26
|
+
import { ICONS } from "../icons";
|
|
27
|
+
|
|
28
|
+
export interface CustomizeItem {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
hidden: boolean;
|
|
32
|
+
/** Pinned can't be hidden. */
|
|
33
|
+
hideable: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** "Customize list": eye toggles and reorder. */
|
|
37
|
+
export function CustomizeDialog({
|
|
38
|
+
open,
|
|
39
|
+
items,
|
|
40
|
+
onOpenChange,
|
|
41
|
+
onToggleHidden,
|
|
42
|
+
onMove,
|
|
43
|
+
}: {
|
|
44
|
+
open: boolean;
|
|
45
|
+
items: readonly CustomizeItem[];
|
|
46
|
+
onOpenChange(open: boolean): void;
|
|
47
|
+
onToggleHidden(id: string): void;
|
|
48
|
+
onMove(id: string, direction: -1 | 1): void;
|
|
49
|
+
}) {
|
|
50
|
+
return (
|
|
51
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
52
|
+
<DialogContent className="max-w-sm">
|
|
53
|
+
<DialogHeader>
|
|
54
|
+
<DialogTitle>Customize list</DialogTitle>
|
|
55
|
+
<DialogDescription>Hidden groups move into More. Their counters still show there.</DialogDescription>
|
|
56
|
+
</DialogHeader>
|
|
57
|
+
<ul className="flex flex-col gap-0.5" aria-label="Groups">
|
|
58
|
+
{items.map((item, index) => (
|
|
59
|
+
<li
|
|
60
|
+
key={item.id}
|
|
61
|
+
data-sidebar-customize-item={item.id}
|
|
62
|
+
className="flex items-center gap-1 rounded-md py-0.5 pl-2 pr-1 text-sm hover:bg-accent"
|
|
63
|
+
>
|
|
64
|
+
<span className={cn("min-w-0 flex-1 truncate", item.hidden && "text-muted-foreground")}>{item.label}</span>
|
|
65
|
+
<Button
|
|
66
|
+
variant="ghost"
|
|
67
|
+
size="icon"
|
|
68
|
+
className="size-7"
|
|
69
|
+
aria-label={`Move ${item.label} up`}
|
|
70
|
+
disabled={index === 0}
|
|
71
|
+
onClick={() => onMove(item.id, -1)}
|
|
72
|
+
>
|
|
73
|
+
<Icon name={ICONS.moveUp} aria-hidden />
|
|
74
|
+
</Button>
|
|
75
|
+
<Button
|
|
76
|
+
variant="ghost"
|
|
77
|
+
size="icon"
|
|
78
|
+
className="size-7"
|
|
79
|
+
aria-label={`Move ${item.label} down`}
|
|
80
|
+
disabled={index === items.length - 1}
|
|
81
|
+
onClick={() => onMove(item.id, 1)}
|
|
82
|
+
>
|
|
83
|
+
<Icon name={ICONS.collapse} aria-hidden />
|
|
84
|
+
</Button>
|
|
85
|
+
<Button
|
|
86
|
+
variant="ghost"
|
|
87
|
+
size="icon"
|
|
88
|
+
className="size-7"
|
|
89
|
+
aria-pressed={!item.hidden}
|
|
90
|
+
aria-label={item.hidden ? `Show ${item.label}` : `Hide ${item.label}`}
|
|
91
|
+
disabled={!item.hideable}
|
|
92
|
+
onClick={() => onToggleHidden(item.id)}
|
|
93
|
+
>
|
|
94
|
+
<Icon name={item.hidden ? ICONS.hidden : ICONS.show} aria-hidden />
|
|
95
|
+
</Button>
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
<DialogFooter>
|
|
100
|
+
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
|
101
|
+
</DialogFooter>
|
|
102
|
+
</DialogContent>
|
|
103
|
+
</Dialog>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function NewSectionDialog({
|
|
108
|
+
open,
|
|
109
|
+
onOpenChange,
|
|
110
|
+
onCreate,
|
|
111
|
+
}: {
|
|
112
|
+
open: boolean;
|
|
113
|
+
onOpenChange(open: boolean): void;
|
|
114
|
+
onCreate(name: string): Promise<void>;
|
|
115
|
+
}) {
|
|
116
|
+
const [name, setName] = useState("");
|
|
117
|
+
const [busy, setBusy] = useState(false);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (open) setName("");
|
|
120
|
+
}, [open]);
|
|
121
|
+
return (
|
|
122
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
123
|
+
<DialogContent className="max-w-sm">
|
|
124
|
+
<form
|
|
125
|
+
className="flex flex-col gap-4"
|
|
126
|
+
onSubmit={async (event) => {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
const trimmed = name.trim();
|
|
129
|
+
if (trimmed === "") return;
|
|
130
|
+
setBusy(true);
|
|
131
|
+
try {
|
|
132
|
+
await onCreate(trimmed);
|
|
133
|
+
onOpenChange(false);
|
|
134
|
+
} finally {
|
|
135
|
+
setBusy(false);
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<DialogHeader>
|
|
140
|
+
<DialogTitle>New section</DialogTitle>
|
|
141
|
+
</DialogHeader>
|
|
142
|
+
<Input autoFocus aria-label="Section name" value={name} onChange={(event) => setName(event.target.value)} />
|
|
143
|
+
<DialogFooter>
|
|
144
|
+
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|
145
|
+
Cancel
|
|
146
|
+
</Button>
|
|
147
|
+
<Button type="submit" disabled={busy || name.trim() === ""}>
|
|
148
|
+
Create
|
|
149
|
+
</Button>
|
|
150
|
+
</DialogFooter>
|
|
151
|
+
</form>
|
|
152
|
+
</DialogContent>
|
|
153
|
+
</Dialog>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function ConfirmDialog({
|
|
158
|
+
open,
|
|
159
|
+
title,
|
|
160
|
+
description,
|
|
161
|
+
confirmLabel,
|
|
162
|
+
destructive,
|
|
163
|
+
onOpenChange,
|
|
164
|
+
onConfirm,
|
|
165
|
+
}: {
|
|
166
|
+
open: boolean;
|
|
167
|
+
title: string;
|
|
168
|
+
description: string;
|
|
169
|
+
confirmLabel: string;
|
|
170
|
+
destructive?: boolean;
|
|
171
|
+
onOpenChange(open: boolean): void;
|
|
172
|
+
onConfirm(): void;
|
|
173
|
+
}) {
|
|
174
|
+
return (
|
|
175
|
+
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
|
176
|
+
<AlertDialogContent>
|
|
177
|
+
<AlertDialogHeader>
|
|
178
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
179
|
+
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
180
|
+
</AlertDialogHeader>
|
|
181
|
+
<AlertDialogFooter>
|
|
182
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
183
|
+
<AlertDialogAction
|
|
184
|
+
className={cn(destructive && "bg-destructive text-destructive-foreground hover:bg-destructive/90")}
|
|
185
|
+
onClick={onConfirm}
|
|
186
|
+
>
|
|
187
|
+
{confirmLabel}
|
|
188
|
+
</AlertDialogAction>
|
|
189
|
+
</AlertDialogFooter>
|
|
190
|
+
</AlertDialogContent>
|
|
191
|
+
</AlertDialog>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function DetailsDialog({
|
|
196
|
+
open,
|
|
197
|
+
title,
|
|
198
|
+
onOpenChange,
|
|
199
|
+
children,
|
|
200
|
+
}: {
|
|
201
|
+
open: boolean;
|
|
202
|
+
title: string;
|
|
203
|
+
onOpenChange(open: boolean): void;
|
|
204
|
+
children: React.ReactNode;
|
|
205
|
+
}) {
|
|
206
|
+
return (
|
|
207
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
208
|
+
<DialogContent className="max-w-sm">
|
|
209
|
+
<DialogHeader>
|
|
210
|
+
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
211
|
+
</DialogHeader>
|
|
212
|
+
{children}
|
|
213
|
+
</DialogContent>
|
|
214
|
+
</Dialog>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* "Move…": nest a thread under another, or move it to the top
|
|
220
|
+
* level, from the keyboard. Arrow keys pick, Enter moves.
|
|
221
|
+
*/
|
|
222
|
+
export function MoveDialog({
|
|
223
|
+
open,
|
|
224
|
+
title,
|
|
225
|
+
targets,
|
|
226
|
+
query,
|
|
227
|
+
onQueryChange,
|
|
228
|
+
onOpenChange,
|
|
229
|
+
onMove,
|
|
230
|
+
}: {
|
|
231
|
+
open: boolean;
|
|
232
|
+
title: string;
|
|
233
|
+
targets: readonly { parentThreadId: string | null; label: string; detail: string | null }[];
|
|
234
|
+
query: string;
|
|
235
|
+
onQueryChange(query: string): void;
|
|
236
|
+
onOpenChange(open: boolean): void;
|
|
237
|
+
onMove(parentThreadId: string | null): void;
|
|
238
|
+
}) {
|
|
239
|
+
const [index, setIndex] = useState(0);
|
|
240
|
+
useEffect(() => setIndex(0), [query, open]);
|
|
241
|
+
const choose = (target: (typeof targets)[number] | undefined) => {
|
|
242
|
+
if (target === undefined) return;
|
|
243
|
+
onMove(target.parentThreadId);
|
|
244
|
+
onOpenChange(false);
|
|
245
|
+
};
|
|
246
|
+
return (
|
|
247
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
248
|
+
<DialogContent className="max-w-sm">
|
|
249
|
+
<DialogHeader>
|
|
250
|
+
<DialogTitle>Move “{title}”</DialogTitle>
|
|
251
|
+
<DialogDescription>Nest it under another thread, or move it to the top level.</DialogDescription>
|
|
252
|
+
</DialogHeader>
|
|
253
|
+
<Input
|
|
254
|
+
autoFocus
|
|
255
|
+
role="combobox"
|
|
256
|
+
aria-expanded
|
|
257
|
+
aria-controls="thread-glance-move-targets"
|
|
258
|
+
aria-activedescendant={targets[index] ? `thread-glance-move-${index}` : undefined}
|
|
259
|
+
aria-label="Find a thread"
|
|
260
|
+
placeholder="Find a thread"
|
|
261
|
+
value={query}
|
|
262
|
+
onChange={(event) => onQueryChange(event.target.value)}
|
|
263
|
+
onKeyDown={(event) => {
|
|
264
|
+
if (event.key === "ArrowDown") {
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
setIndex((value) => Math.min(value + 1, targets.length - 1));
|
|
267
|
+
} else if (event.key === "ArrowUp") {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
setIndex((value) => Math.max(value - 1, 0));
|
|
270
|
+
} else if (event.key === "Enter") {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
choose(targets[index]);
|
|
273
|
+
}
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
<ul id="thread-glance-move-targets" role="listbox" aria-label="Move to" className="flex max-h-72 flex-col gap-0.5 overflow-y-auto">
|
|
277
|
+
{targets.length === 0 ? <li className="px-2 py-1.5 text-sm text-muted-foreground">No matching threads</li> : null}
|
|
278
|
+
{targets.map((target, position) => (
|
|
279
|
+
<li
|
|
280
|
+
key={target.parentThreadId ?? "top"}
|
|
281
|
+
id={`thread-glance-move-${position}`}
|
|
282
|
+
role="option"
|
|
283
|
+
aria-selected={position === index}
|
|
284
|
+
onMouseEnter={() => setIndex(position)}
|
|
285
|
+
onClick={() => choose(target)}
|
|
286
|
+
className={cn(
|
|
287
|
+
"flex cursor-pointer flex-col rounded-md px-2 py-1.5 text-sm",
|
|
288
|
+
position === index && "bg-accent",
|
|
289
|
+
)}
|
|
290
|
+
>
|
|
291
|
+
<span className="truncate">{target.label}</span>
|
|
292
|
+
{target.detail ? <span className="truncate text-xs text-muted-foreground">{target.detail}</span> : null}
|
|
293
|
+
</li>
|
|
294
|
+
))}
|
|
295
|
+
</ul>
|
|
296
|
+
</DialogContent>
|
|
297
|
+
</Dialog>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The fold rows (`N older` on a group, `N more child threads` on a family)
|
|
2
|
+
// and the environment folder row.
|
|
3
|
+
import { memo, useState } from "react";
|
|
4
|
+
import { experimental_Icon as Icon, experimental_ProviderIcon as ProviderIcon } from "@get-bb/plugin-sdk/app";
|
|
5
|
+
import type { PluginEnvironmentProvider } from "@get-bb/plugin-sdk/app";
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from "@/components/ui/dropdown-menu";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import { ICONS } from "../icons";
|
|
14
|
+
import { RowRails } from "./Rails";
|
|
15
|
+
import { olderRowText } from "../model/labels";
|
|
16
|
+
import { rowIndent } from "../model/layout";
|
|
17
|
+
import { EMPTY_LEVEL_TEXT, type EmptyRow, type EnvironmentRow, type OlderRow } from "../model/view";
|
|
18
|
+
import type { RowController } from "./controller";
|
|
19
|
+
import { FlagGlyph } from "./glyphs";
|
|
20
|
+
import { RenameEditor } from "./RenameEditor";
|
|
21
|
+
import { ROW_ICON_BUTTON } from "./ThreadRowView";
|
|
22
|
+
|
|
23
|
+
export const OlderRowView = memo(function OlderRowView({ row, controller }: { row: OlderRow; controller: RowController }) {
|
|
24
|
+
const { label, ariaLabel } = olderRowText(row);
|
|
25
|
+
// A family's fold sits where its children do, with a dots glyph in the
|
|
26
|
+
// status slot; the group's fold keeps the chevron.
|
|
27
|
+
const inFamily = row.scope !== "group";
|
|
28
|
+
return (
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
aria-expanded={row.expanded}
|
|
32
|
+
aria-label={ariaLabel}
|
|
33
|
+
onClick={() => controller.onToggleOlder(row)}
|
|
34
|
+
className={cn(
|
|
35
|
+
"relative flex w-full items-center rounded-md pr-2 text-left text-xs text-muted-foreground outline-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
36
|
+
inFamily ? "h-7 gap-1.5 max-md:pointer-coarse:h-9" : "h-6 gap-1",
|
|
37
|
+
)}
|
|
38
|
+
style={{ paddingLeft: rowIndent(row.depth, controller.nesting) + (inFamily ? 0 : 2) }}
|
|
39
|
+
>
|
|
40
|
+
<RowRails rails={row.rails} nesting={controller.nesting} />
|
|
41
|
+
{inFamily ? (
|
|
42
|
+
<span aria-hidden className="inline-flex size-4 shrink-0 items-center justify-center">
|
|
43
|
+
<Icon name={ICONS.more} className="size-3.5" />
|
|
44
|
+
</span>
|
|
45
|
+
) : (
|
|
46
|
+
<Icon name={ICONS.expand} aria-hidden className={cn("size-3 transition-transform", row.expanded && "-rotate-90")} />
|
|
47
|
+
)}
|
|
48
|
+
{label}
|
|
49
|
+
</button>
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** A plain line under an opened chip that Needs attention leaves empty; not a control. */
|
|
54
|
+
export const EmptyRowView = memo(function EmptyRowView({ row, controller }: { row: EmptyRow; controller: RowController }) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className="relative flex h-7 w-full items-center gap-1.5 pr-2 text-xs text-muted-foreground max-md:pointer-coarse:h-9"
|
|
58
|
+
style={{ paddingLeft: rowIndent(row.depth, controller.nesting) }}
|
|
59
|
+
>
|
|
60
|
+
<RowRails rails={row.rails} nesting={controller.nesting} />
|
|
61
|
+
<span aria-hidden className="size-4 shrink-0" />
|
|
62
|
+
<span>{EMPTY_LEVEL_TEXT}</span>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const EnvironmentRowView = memo(function EnvironmentRowView({
|
|
68
|
+
row,
|
|
69
|
+
controller,
|
|
70
|
+
environmentProviders,
|
|
71
|
+
}: {
|
|
72
|
+
row: EnvironmentRow;
|
|
73
|
+
controller: RowController;
|
|
74
|
+
environmentProviders: readonly PluginEnvironmentProvider[];
|
|
75
|
+
}) {
|
|
76
|
+
const [renaming, setRenaming] = useState(false);
|
|
77
|
+
const provider =
|
|
78
|
+
row.environmentProviderId === null
|
|
79
|
+
? null
|
|
80
|
+
: environmentProviders.find((candidate) => candidate.id === row.environmentProviderId) ?? null;
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className="group/row relative flex h-7 w-full items-center gap-1.5 rounded-md pr-1 text-sm text-muted-foreground hover:bg-sidebar-accent"
|
|
84
|
+
style={{ paddingLeft: rowIndent(row.depth, controller.nesting) }}
|
|
85
|
+
>
|
|
86
|
+
<RowRails rails={row.rails} nesting={controller.nesting} />
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
aria-expanded={!row.collapsed}
|
|
90
|
+
aria-label={`${row.collapsed ? "Expand" : "Collapse"} ${row.label} environment, ${row.threadIds.length} threads`}
|
|
91
|
+
onClick={() => controller.onToggleEnvironment(row.environmentId)}
|
|
92
|
+
className="absolute inset-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
93
|
+
/>
|
|
94
|
+
<span className="pointer-events-none relative inline-flex size-4 shrink-0 items-center justify-center">
|
|
95
|
+
{provider !== null ? (
|
|
96
|
+
<ProviderIcon providerKind="environment" provider={provider} className="size-3.5" />
|
|
97
|
+
) : (
|
|
98
|
+
<Icon name={row.environmentProviderId === null ? ICONS.machine : ICONS.environmentFallback} aria-hidden className="size-3.5" />
|
|
99
|
+
)}
|
|
100
|
+
</span>
|
|
101
|
+
{renaming ? (
|
|
102
|
+
<RenameEditor
|
|
103
|
+
initial={row.label}
|
|
104
|
+
label="Environment name"
|
|
105
|
+
onSave={(name) => controller.onRenameEnvironment(row.environmentId, name)}
|
|
106
|
+
onDone={() => setRenaming(false)}
|
|
107
|
+
/>
|
|
108
|
+
) : (
|
|
109
|
+
<span className="pointer-events-none relative min-w-0 flex-1 truncate">{row.label}</span>
|
|
110
|
+
)}
|
|
111
|
+
{row.flag !== null ? <FlagGlyph flag={row.flag} className="pointer-events-none relative size-3.5" /> : null}
|
|
112
|
+
<span className="pointer-events-none relative text-[11px] tabular-nums">{row.threadIds.length}</span>
|
|
113
|
+
<Icon
|
|
114
|
+
name={ICONS.expand}
|
|
115
|
+
aria-hidden
|
|
116
|
+
className={cn("pointer-events-none relative size-3 transition-transform", !row.collapsed && "rotate-90")}
|
|
117
|
+
/>
|
|
118
|
+
<DropdownMenu>
|
|
119
|
+
<DropdownMenuTrigger asChild>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
aria-label="Environment actions"
|
|
123
|
+
className={cn(ROW_ICON_BUTTON, !controller.compact && "opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100")}
|
|
124
|
+
>
|
|
125
|
+
<Icon name={ICONS.more} aria-hidden className="size-4" />
|
|
126
|
+
</button>
|
|
127
|
+
</DropdownMenuTrigger>
|
|
128
|
+
<DropdownMenuContent align="end">
|
|
129
|
+
<DropdownMenuItem
|
|
130
|
+
onSelect={() => controller.onNewThreadInEnvironment(row.environmentId, row.projectId, row.sectionId)}
|
|
131
|
+
>
|
|
132
|
+
<Icon name={ICONS.newThread} aria-hidden className="size-4" />
|
|
133
|
+
New thread in environment
|
|
134
|
+
</DropdownMenuItem>
|
|
135
|
+
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
|
136
|
+
<Icon name={ICONS.rename} aria-hidden className="size-4" />
|
|
137
|
+
Rename
|
|
138
|
+
</DropdownMenuItem>
|
|
139
|
+
<DropdownMenuItem onSelect={() => controller.onArchiveEnvironment(row.environmentId)}>
|
|
140
|
+
<Icon name={ICONS.archive} aria-hidden className="size-4" />
|
|
141
|
+
Archive
|
|
142
|
+
</DropdownMenuItem>
|
|
143
|
+
</DropdownMenuContent>
|
|
144
|
+
</DropdownMenu>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
});
|