paseo-plugin-machine-status 0.1.1

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.
@@ -0,0 +1,494 @@
1
+ import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import { useMutation, useQuery } from "@tanstack/react-query";
3
+ import React, { useRef, useState } from "react";
4
+ import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
5
+ import {
6
+ controlContainer,
7
+ controlEngine,
8
+ pruneDocker,
9
+ readContainerLogs,
10
+ readDocker,
11
+ removeImage,
12
+ type DockerContainer,
13
+ type DockerImage,
14
+ } from "../shared/docker";
15
+ import { formatBytes } from "../shared/format";
16
+ import { contextMenuTriggers, useContextMenu, type ContextMenuItem } from "./menu";
17
+ import { Card, MetricRow, useChromeStyles, type Chrome } from "./ui";
18
+ import { paletteFor, STATUS, type VizPalette } from "../shared/viz";
19
+
20
+ export function DockerTab({ theme, layout, host }: PluginSurfaceProps) {
21
+ const chrome: Chrome = { theme, compact: layout.compact };
22
+ const styles = useChromeStyles(chrome);
23
+ const palette = paletteFor(theme.colors.surface0);
24
+ const read = useRpc(readDocker);
25
+ const control = useRpc(controlContainer);
26
+ const prune = useRpc(pruneDocker);
27
+ const engineControl = useRpc(controlEngine);
28
+ const logs = useRpc(readContainerLogs);
29
+ const dropImage = useRpc(removeImage);
30
+ const openMenu = useContextMenu();
31
+ const anchor = useRef({ x: 0, y: 0 });
32
+ const [activeId, setActiveId] = useState<string | null>(null);
33
+ const [engineStarting, setEngineStarting] = useState(false);
34
+
35
+ const docker = useQuery({
36
+ queryKey: ["machine-status", "docker", host.id],
37
+ queryFn: () => read({}),
38
+ // While the engine is coming up, poll fast enough that the panel notices;
39
+ // Docker Desktop takes tens of seconds to answer after being launched.
40
+ refetchInterval: activeId !== null ? false : engineStarting ? 3_000 : 10_000,
41
+ });
42
+
43
+ const act = useMutation({
44
+ mutationFn: (input: { id: string; action: "start" | "stop" | "restart" | "remove" }) =>
45
+ control(input),
46
+ onSettled: () => {
47
+ setActiveId(null);
48
+ void docker.refetch();
49
+ },
50
+ });
51
+
52
+ const clean = useMutation({
53
+ mutationFn: (target: "containers" | "images") => prune({ target }),
54
+ onSettled: () => void docker.refetch(),
55
+ });
56
+
57
+ const engine = useMutation({
58
+ mutationFn: (action: "start" | "quit") => engineControl({ action }),
59
+ onSuccess: (_result, action) => setEngineStarting(action === "start"),
60
+ onSettled: () => void docker.refetch(),
61
+ });
62
+
63
+ const viewLogs = useMutation({
64
+ mutationFn: (container: DockerContainer) =>
65
+ logs({ id: container.id, lines: 200 }).then((result) => ({ ...result, name: container.name })),
66
+ });
67
+
68
+ const imageAction = useMutation({
69
+ mutationFn: (id: string) => dropImage({ id }),
70
+ onSettled: () => void docker.refetch(),
71
+ });
72
+
73
+ const data = docker.data;
74
+ const containers = data?.containers ?? [];
75
+ const running = containers.filter((container) => container.running);
76
+
77
+ const confirm = (container: DockerContainer, action: "stop" | "restart" | "remove") => {
78
+ const wording: Record<typeof action, string> = {
79
+ stop: `Stop ${container.name}? Anything connecting to it loses the connection.`,
80
+ restart: `Restart ${container.name}? It is unavailable while it comes back.`,
81
+ remove: `Remove ${container.name}? The container is gone; its volumes stay.`,
82
+ };
83
+ openMenu({
84
+ ...anchor.current,
85
+ title: wording[action],
86
+ items: [
87
+ {
88
+ id: "confirm",
89
+ label: `Confirm — ${action}`,
90
+ tone: "danger",
91
+ onSelect: () => act.mutate({ id: container.id, action }),
92
+ },
93
+ { id: "cancel", label: "Cancel", onSelect: () => setActiveId(null) },
94
+ ],
95
+ });
96
+ };
97
+
98
+ const menuFor = (container: DockerContainer): ContextMenuItem[] => {
99
+ const actions: ContextMenuItem[] = [
100
+ { id: "logs", label: "View logs", onSelect: () => viewLogs.mutate(container) },
101
+ ];
102
+ for (const port of container.ports.slice(0, 3)) {
103
+ actions.push({
104
+ id: `open-${port}`,
105
+ label: `Open http://127.0.0.1:${port}`,
106
+ onSelect: () => void Linking.openURL(`http://127.0.0.1:${port}`).catch(() => undefined),
107
+ });
108
+ }
109
+ if (container.running) {
110
+ actions.push(
111
+ { id: "restart", label: "Restart…", onSelect: () => confirm(container, "restart") },
112
+ { id: "stop", label: "Stop…", tone: "danger", onSelect: () => confirm(container, "stop") },
113
+ );
114
+ } else {
115
+ actions.push(
116
+ { id: "start", label: "Start", onSelect: () => act.mutate({ id: container.id, action: "start" }) },
117
+ { id: "remove", label: "Remove…", tone: "danger", onSelect: () => confirm(container, "remove") },
118
+ );
119
+ }
120
+ return actions;
121
+ };
122
+
123
+ const open = (container: DockerContainer, x: number, y: number) => {
124
+ anchor.current = { x, y };
125
+ setActiveId(container.id);
126
+ act.reset();
127
+ openMenu({ x, y, title: `${container.name} · ${container.state}`, items: menuFor(container) });
128
+ };
129
+
130
+ const openImageMenu = (image: DockerImage, x: number, y: number) => {
131
+ imageAction.reset();
132
+ openMenu({
133
+ x,
134
+ y,
135
+ title: image.dangling ? "Untagged image" : image.reference,
136
+ items: [
137
+ {
138
+ id: "remove",
139
+ label: "Remove…",
140
+ tone: "danger",
141
+ onSelect: () =>
142
+ openMenu({
143
+ x,
144
+ y,
145
+ title: `Remove ${image.reference}? Anything using it must pull again.`,
146
+ items: [
147
+ {
148
+ id: "go",
149
+ label: `Confirm — free ${formatBytes(image.sizeBytes)}`,
150
+ tone: "danger",
151
+ onSelect: () => imageAction.mutate(image.id),
152
+ },
153
+ { id: "cancel", label: "Cancel", onSelect: () => undefined },
154
+ ],
155
+ }),
156
+ },
157
+ ],
158
+ });
159
+ };
160
+
161
+ const confirmPrune = (target: "containers" | "images", x: number, y: number) => {
162
+ openMenu({
163
+ x,
164
+ y,
165
+ title:
166
+ target === "containers"
167
+ ? "Remove every stopped container?"
168
+ : "Remove dangling images? Tagged images stay.",
169
+ items: [
170
+ { id: "go", label: "Confirm — remove", tone: "danger", onSelect: () => clean.mutate(target) },
171
+ { id: "cancel", label: "Cancel", onSelect: () => undefined },
172
+ ],
173
+ });
174
+ };
175
+
176
+ return (
177
+ <ScrollView style={styles.screen} contentContainerStyle={styles.content}>
178
+ {data ? (
179
+ <Card
180
+ chrome={chrome}
181
+ title="Engine"
182
+ subtitle={data.engine.version ? `v${data.engine.version}` : undefined}
183
+ >
184
+ <View style={styles.row}>
185
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
186
+ <View
187
+ style={{
188
+ width: 8,
189
+ height: 8,
190
+ borderRadius: 4,
191
+ backgroundColor:
192
+ data.engine.state === "running"
193
+ ? STATUS.good
194
+ : data.engine.state === "stopped"
195
+ ? STATUS.warning
196
+ : palette.neutral,
197
+ }}
198
+ />
199
+ {/* The dot repeats the word; it never carries the state alone. */}
200
+ <Text style={styles.value}>
201
+ {data.engine.state === "running"
202
+ ? `${data.engine.app ?? "Docker"} is running`
203
+ : engineStarting
204
+ ? `${data.engine.app ?? "Docker"} is starting — this takes a moment`
205
+ : (data.reason ?? "Not running")}
206
+ </Text>
207
+ </View>
208
+
209
+ {data.engine.state === "stopped" ? (
210
+ <Pressable
211
+ accessibilityRole="button"
212
+ accessibilityLabel={`Start ${data.engine.app ?? "Docker"}`}
213
+ disabled={engine.isPending || engineStarting}
214
+ onPress={() => engine.mutate("start")}
215
+ style={({ pressed }) => [
216
+ styles.button,
217
+ { backgroundColor: theme.colors.accent, opacity: pressed || engineStarting ? 0.7 : 1 },
218
+ ]}
219
+ >
220
+ <Text style={styles.buttonText}>
221
+ {engineStarting ? "Starting…" : `Start ${data.engine.app ?? "Docker"}`}
222
+ </Text>
223
+ </Pressable>
224
+ ) : data.engine.state === "running" ? (
225
+ <Pressable
226
+ accessibilityRole="button"
227
+ accessibilityLabel="Quit the engine"
228
+ onPress={(event) =>
229
+ openMenu({
230
+ x: event.nativeEvent.pageX,
231
+ y: event.nativeEvent.pageY,
232
+ title: `Quit ${data.engine.app ?? "Docker"}? Every running container stops.`,
233
+ items: [
234
+ {
235
+ id: "quit",
236
+ label: "Confirm — quit",
237
+ tone: "danger",
238
+ onSelect: () => engine.mutate("quit"),
239
+ },
240
+ { id: "cancel", label: "Cancel", onSelect: () => undefined },
241
+ ],
242
+ })
243
+ }
244
+ style={({ pressed }) => [styles.ghostButton, { opacity: pressed ? 0.7 : 1 }]}
245
+ >
246
+ <Text style={styles.ghostButtonText}>Quit…</Text>
247
+ </Pressable>
248
+ ) : null}
249
+ </View>
250
+
251
+ {engine.data?.error ? <Text style={styles.danger}>{engine.data.error}</Text> : null}
252
+ {data.engine.state === "missing" ? (
253
+ <Text style={styles.muted}>
254
+ Install Docker Desktop, OrbStack or Rancher Desktop and this tab starts working.
255
+ </Text>
256
+ ) : null}
257
+ </Card>
258
+ ) : null}
259
+
260
+ {docker.error ? (
261
+ <Card chrome={chrome} title="Docker unavailable">
262
+ <Text style={styles.danger}>{(docker.error as Error).message}</Text>
263
+ </Card>
264
+ ) : null}
265
+
266
+ {viewLogs.data ? (
267
+ <LogView
268
+ chrome={chrome}
269
+ palette={palette}
270
+ name={viewLogs.data.name}
271
+ text={viewLogs.data.text}
272
+ error={viewLogs.data.error}
273
+ onDismiss={() => viewLogs.reset()}
274
+ />
275
+ ) : null}
276
+ {viewLogs.isPending ? <Text style={styles.muted}>Reading logs…</Text> : null}
277
+
278
+ {data?.available ? (
279
+ <View style={styles.band}>
280
+ <View style={{ flex: 1.3 }}>
281
+ <Card
282
+ chrome={chrome}
283
+ title="Containers"
284
+ subtitle={`${running.length} running of ${containers.length}`}
285
+ >
286
+ {!layout.compact ? (
287
+ <View style={[styles.processRow, { paddingVertical: 2 }]}>
288
+ <Text style={[styles.axis, { flex: 1 }]}>NAME</Text>
289
+ <Text style={[styles.axis, { width: 150 }]}>IMAGE</Text>
290
+ <Text style={[styles.axis, { width: 96 }]}>PORTS</Text>
291
+ <Text style={[styles.axis, { width: 120, textAlign: "right" }]}>STATUS</Text>
292
+ </View>
293
+ ) : null}
294
+
295
+ {containers.map((container) => (
296
+ <Pressable
297
+ key={container.id}
298
+ accessibilityRole="button"
299
+ accessibilityLabel={`${container.name}, ${container.image}, ${container.status}. Opens actions.`}
300
+ accessibilityState={{ selected: container.id === activeId }}
301
+ onPress={(event) => open(container, event.nativeEvent.pageX, event.nativeEvent.pageY)}
302
+ {...contextMenuTriggers((x, y) => open(container, x, y))}
303
+ style={({ pressed }) => [
304
+ styles.processRow,
305
+ {
306
+ backgroundColor: container.id === activeId ? palette.border : "transparent",
307
+ opacity: pressed ? 0.7 : 1,
308
+ },
309
+ ]}
310
+ >
311
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, flex: 1 }}>
312
+ <View
313
+ style={{
314
+ width: 8,
315
+ height: 8,
316
+ borderRadius: 4,
317
+ backgroundColor: container.running ? STATUS.good : palette.neutral,
318
+ }}
319
+ />
320
+ <Text style={styles.value} numberOfLines={1}>
321
+ {container.name}
322
+ </Text>
323
+ </View>
324
+ <Text style={[styles.muted, { width: 150 }]} numberOfLines={1}>
325
+ {container.image}
326
+ </Text>
327
+ <Text style={[styles.muted, { width: 96 }]} numberOfLines={1}>
328
+ {container.ports.length > 0
329
+ ? container.ports.map((port) => `:${port}`).join(" ")
330
+ : "—"}
331
+ </Text>
332
+ {/* The dot repeats the status word, never replaces it. */}
333
+ <Text style={[styles.muted, { width: 120, textAlign: "right" }]} numberOfLines={1}>
334
+ {container.status}
335
+ </Text>
336
+ </Pressable>
337
+ ))}
338
+
339
+ {containers.length === 0 ? (
340
+ <Text style={styles.muted}>No containers.</Text>
341
+ ) : (
342
+ <Text style={styles.axis}>right-click a container</Text>
343
+ )}
344
+
345
+ {act.data?.error ? <Text style={styles.danger}>{act.data.error}</Text> : null}
346
+ </Card>
347
+ </View>
348
+
349
+ <View style={{ flex: 1 }}>
350
+ <Card chrome={chrome} title="Disk usage">
351
+ {(data.usage ?? []).map((row) => (
352
+ <View key={row.label} style={{ gap: 2 }}>
353
+ <MetricRow
354
+ chrome={chrome}
355
+ label={`${row.label} (${row.active} of ${row.total} active)`}
356
+ value={formatBytes(row.sizeBytes)}
357
+ />
358
+ <Text style={styles.axis}>
359
+ {formatBytes(row.reclaimableBytes)} reclaimable
360
+ {row.safeToReclaim ? "" : " — but this is container data"}
361
+ </Text>
362
+ </View>
363
+ ))}
364
+
365
+ <View style={{ height: 4 }} />
366
+
367
+ <PruneButton
368
+ chrome={chrome}
369
+ label="Remove stopped containers…"
370
+ onPress={(x, y) => confirmPrune("containers", x, y)}
371
+ />
372
+ <PruneButton
373
+ chrome={chrome}
374
+ label="Remove dangling images…"
375
+ onPress={(x, y) => confirmPrune("images", x, y)}
376
+ />
377
+
378
+ {clean.isPending ? <Text style={styles.muted}>Pruning…</Text> : null}
379
+ {clean.data ? (
380
+ <Text style={clean.data.ok ? styles.value : styles.danger}>
381
+ {clean.data.ok
382
+ ? `Reclaimed ${formatBytes(clean.data.reclaimedBytes)}.`
383
+ : clean.data.error}
384
+ </Text>
385
+ ) : null}
386
+
387
+ {(data.notes ?? []).map((note) => (
388
+ <Text key={note} style={styles.muted}>
389
+ {note}
390
+ </Text>
391
+ ))}
392
+ </Card>
393
+
394
+ <View style={{ height: chrome.compact ? 10 : 14 }} />
395
+
396
+ <Card
397
+ chrome={chrome}
398
+ title="Images"
399
+ subtitle={`${data.images.length} · ${data.images.filter((image) => image.dangling).length} untagged`}
400
+ >
401
+ {data.images.slice(0, 12).map((image) => (
402
+ <Pressable
403
+ key={image.id}
404
+ accessibilityRole="button"
405
+ accessibilityLabel={`${image.reference}, ${formatBytes(image.sizeBytes)}. Opens actions.`}
406
+ onPress={(event) => openImageMenu(image, event.nativeEvent.pageX, event.nativeEvent.pageY)}
407
+ {...contextMenuTriggers((x, y) => openImageMenu(image, x, y))}
408
+ style={({ pressed }) => [styles.processRow, { opacity: pressed ? 0.7 : 1 }]}
409
+ >
410
+ <Text style={[styles.value, { flex: 1 }]} numberOfLines={1}>
411
+ {image.dangling ? "untagged" : image.reference}
412
+ </Text>
413
+ <Text style={[styles.muted, { width: 80, textAlign: "right" }]}>
414
+ {formatBytes(image.sizeBytes)}
415
+ </Text>
416
+ </Pressable>
417
+ ))}
418
+ {data.images.length > 12 ? (
419
+ <Text style={styles.axis}>{data.images.length - 12} more not shown</Text>
420
+ ) : null}
421
+ {imageAction.data?.error ? (
422
+ <Text style={styles.danger}>{imageAction.data.error}</Text>
423
+ ) : null}
424
+ </Card>
425
+ </View>
426
+ </View>
427
+ ) : null}
428
+ </ScrollView>
429
+ );
430
+ }
431
+
432
+ function PruneButton({
433
+ chrome,
434
+ label,
435
+ onPress,
436
+ }: {
437
+ chrome: Chrome;
438
+ label: string;
439
+ onPress: (x: number, y: number) => void;
440
+ }) {
441
+ const styles = useChromeStyles(chrome);
442
+ return (
443
+ <Pressable
444
+ accessibilityRole="button"
445
+ accessibilityLabel={label}
446
+ onPress={(event) => onPress(event.nativeEvent.pageX, event.nativeEvent.pageY)}
447
+ style={({ pressed }) => [styles.ghostButton, { opacity: pressed ? 0.7 : 1 }]}
448
+ >
449
+ <Text style={styles.ghostButtonText}>{label}</Text>
450
+ </Pressable>
451
+ );
452
+ }
453
+
454
+ /** Container logs, shown in the panel rather than sending the user to a terminal. */
455
+ function LogView({
456
+ chrome,
457
+ palette,
458
+ name,
459
+ text,
460
+ error,
461
+ onDismiss,
462
+ }: {
463
+ chrome: Chrome;
464
+ palette: VizPalette;
465
+ name: string;
466
+ text: string;
467
+ error: string | null;
468
+ onDismiss: () => void;
469
+ }) {
470
+ const styles = useChromeStyles(chrome);
471
+ return (
472
+ <Card chrome={chrome} title={`Logs · ${name}`}>
473
+ <View style={styles.row}>
474
+ <Text style={styles.axis}>last 200 lines</Text>
475
+ <Pressable accessibilityRole="button" accessibilityLabel="Dismiss logs" onPress={onDismiss}>
476
+ <Text style={styles.muted}>Dismiss</Text>
477
+ </Pressable>
478
+ </View>
479
+ {error ? <Text style={styles.danger}>{error}</Text> : null}
480
+ <View
481
+ style={{
482
+ borderRadius: 8,
483
+ borderWidth: StyleSheet.hairlineWidth,
484
+ borderColor: palette.border,
485
+ padding: 10,
486
+ }}
487
+ >
488
+ <Text style={[styles.muted, { fontFamily: "Menlo" }]} selectable>
489
+ {text.length > 0 ? text : "This container has produced no output."}
490
+ </Text>
491
+ </View>
492
+ </Card>
493
+ );
494
+ }
@@ -0,0 +1,181 @@
1
+ import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
2
+ import { Platform, Pressable, StyleSheet, Text, View, type GestureResponderEvent } from "react-native";
3
+ import { useChromeStyles, type Chrome } from "./ui";
4
+ import { paletteFor } from "../shared/viz";
5
+
6
+ /**
7
+ * A floating context menu, opened by right-click on desktop and by long-press on
8
+ * touch.
9
+ *
10
+ * React Native has no context-menu primitive and no portals here, so the menu is
11
+ * rendered by a provider that sits at the surface root, as a sibling *after* the
12
+ * scrolling content. Rendering it inside the list would clip it to the row and
13
+ * scroll it away with the content.
14
+ */
15
+
16
+ export interface ContextMenuItem {
17
+ id: string;
18
+ label: string;
19
+ tone?: "danger";
20
+ disabled?: boolean;
21
+ onSelect(): void;
22
+ }
23
+
24
+ export interface ContextMenuRequest {
25
+ /** Page coordinates of the click; the provider converts them to its own space. */
26
+ x: number;
27
+ y: number;
28
+ title?: string;
29
+ items: ContextMenuItem[];
30
+ }
31
+
32
+ const OpenMenu = createContext<(request: ContextMenuRequest) => void>(() => {});
33
+
34
+ export const useContextMenu = () => useContext(OpenMenu);
35
+
36
+ const MENU_WIDTH = 248;
37
+ const ITEM_HEIGHT = 36;
38
+ const EDGE_GAP = 8;
39
+
40
+ export function ContextMenuProvider({
41
+ chrome,
42
+ children,
43
+ }: {
44
+ chrome: Chrome;
45
+ children: React.ReactNode;
46
+ }) {
47
+ const styles = useChromeStyles(chrome);
48
+ const palette = paletteFor(chrome.theme.colors.surface0);
49
+ const [request, setRequest] = useState<ContextMenuRequest | null>(null);
50
+ const [size, setSize] = useState({ width: 0, height: 0 });
51
+ const origin = useRef({ x: 0, y: 0 });
52
+ const root = useRef<View>(null);
53
+
54
+ // Click coordinates are page-relative while the overlay is positioned inside
55
+ // this view, so the view's own page offset has to be subtracted.
56
+ const measure = useCallback(() => {
57
+ root.current?.measureInWindow?.((x, y, width, height) => {
58
+ origin.current = { x, y };
59
+ setSize({ width, height });
60
+ });
61
+ }, []);
62
+
63
+ const close = useCallback(() => setRequest(null), []);
64
+
65
+ const position = useMemo(() => {
66
+ if (!request) return { left: 0, top: 0 };
67
+ const height = (request.items.length + (request.title ? 1 : 0)) * ITEM_HEIGHT + 16;
68
+ const left = request.x - origin.current.x;
69
+ const top = request.y - origin.current.y;
70
+ return {
71
+ left: Math.max(EDGE_GAP, Math.min(left, Math.max(EDGE_GAP, size.width - MENU_WIDTH - EDGE_GAP))),
72
+ top: Math.max(EDGE_GAP, Math.min(top, Math.max(EDGE_GAP, size.height - height - EDGE_GAP))),
73
+ };
74
+ }, [request, size]);
75
+
76
+ return (
77
+ <OpenMenu.Provider value={setRequest}>
78
+ <View ref={root} style={{ flex: 1 }} onLayout={measure} collapsable={false}>
79
+ {children}
80
+
81
+ {request ? (
82
+ <View style={StyleSheet.absoluteFill}>
83
+ {/* Any click outside dismisses, which is what a context menu does. */}
84
+ <Pressable
85
+ accessibilityRole="button"
86
+ accessibilityLabel="Close menu"
87
+ onPress={close}
88
+ style={StyleSheet.absoluteFill}
89
+ />
90
+ <View
91
+ accessibilityRole="menu"
92
+ style={{
93
+ position: "absolute",
94
+ left: position.left,
95
+ top: position.top,
96
+ width: MENU_WIDTH,
97
+ borderRadius: 12,
98
+ borderWidth: StyleSheet.hairlineWidth,
99
+ borderColor: palette.borderStrong,
100
+ backgroundColor: chrome.theme.colors.surface0,
101
+ paddingVertical: 6,
102
+ // The menu floats over content, so it needs a shadow to read as
103
+ // a layer rather than as part of the list.
104
+ shadowColor: "#000",
105
+ shadowOpacity: 0.4,
106
+ shadowRadius: 24,
107
+ shadowOffset: { width: 0, height: 12 },
108
+ elevation: 12,
109
+ }}
110
+ >
111
+ {request.title ? (
112
+ <Text style={[styles.axis, { paddingHorizontal: 14, paddingVertical: 8 }]} numberOfLines={1}>
113
+ {request.title}
114
+ </Text>
115
+ ) : null}
116
+
117
+ {request.items.map((item) => (
118
+ <Pressable
119
+ key={item.id}
120
+ accessibilityRole="menuitem"
121
+ accessibilityLabel={item.label}
122
+ accessibilityState={{ disabled: item.disabled }}
123
+ disabled={item.disabled}
124
+ onPress={() => {
125
+ close();
126
+ item.onSelect();
127
+ }}
128
+ style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
129
+ height: ITEM_HEIGHT,
130
+ justifyContent: "center",
131
+ paddingHorizontal: 14,
132
+ marginHorizontal: 6,
133
+ borderRadius: 8,
134
+ opacity: item.disabled ? 0.4 : 1,
135
+ backgroundColor: pressed || hovered ? palette.border : "transparent",
136
+ })}
137
+ >
138
+ <Text
139
+ style={
140
+ item.tone === "danger"
141
+ ? { color: chrome.theme.colors.statusDanger, fontSize: chrome.compact ? 13 : 14 }
142
+ : styles.value
143
+ }
144
+ >
145
+ {item.label}
146
+ </Text>
147
+ </Pressable>
148
+ ))}
149
+ </View>
150
+ </View>
151
+ ) : null}
152
+ </View>
153
+ </OpenMenu.Provider>
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Props that make any Pressable open a context menu.
159
+ *
160
+ * `onContextMenu` is a DOM event that react-native-web forwards but React
161
+ * Native's types do not declare, so it is attached only on web and cast. Touch
162
+ * platforms get the long-press equivalent instead.
163
+ */
164
+ export function contextMenuTriggers(open: (x: number, y: number) => void) {
165
+ const fromTouch = (event: GestureResponderEvent) => {
166
+ const { pageX, pageY } = event.nativeEvent;
167
+ open(pageX, pageY);
168
+ };
169
+
170
+ const web =
171
+ Platform.OS === "web"
172
+ ? ({
173
+ onContextMenu: (event: { preventDefault?(): void; pageX?: number; pageY?: number }) => {
174
+ event.preventDefault?.();
175
+ open(event.pageX ?? 0, event.pageY ?? 0);
176
+ },
177
+ } as Record<string, unknown>)
178
+ : {};
179
+
180
+ return { onLongPress: fromTouch, ...web };
181
+ }