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.
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/client/charts.tsx +335 -0
- package/client/cleanup.tsx +169 -0
- package/client/docker.tsx +494 -0
- package/client/menu.tsx +181 -0
- package/client/pill-card.tsx +195 -0
- package/client/pill.tsx +155 -0
- package/client/processes.tsx +321 -0
- package/client/services.tsx +251 -0
- package/client/status.tsx +514 -0
- package/client/ui.tsx +263 -0
- package/index.client.tsx +31 -0
- package/index.server.ts +41 -0
- package/package.json +45 -0
- package/paseo-plugin.json +4 -0
- package/server/cleanup.ts +222 -0
- package/server/docker.ts +260 -0
- package/server/exec.ts +62 -0
- package/server/explain-rules.ts +188 -0
- package/server/explain.ts +95 -0
- package/server/metrics.ts +312 -0
- package/server/processes.ts +165 -0
- package/server/services.ts +428 -0
- package/shared/contracts.ts +170 -0
- package/shared/docker.ts +95 -0
- package/shared/explain.ts +24 -0
- package/shared/format.ts +39 -0
- package/shared/pill.ts +63 -0
- package/shared/services-classify.ts +190 -0
- package/shared/services.ts +52 -0
- package/shared/viz.ts +159 -0
|
@@ -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
|
+
}
|
package/client/menu.tsx
ADDED
|
@@ -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
|
+
}
|