@stevecastaneda/paseo-progress 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +1 -1
- package/README.md +18 -4
- package/client/attachments.tsx +7 -8
- package/client/deliverables.tsx +5 -6
- package/client/dialog-stack.tsx +24 -29
- package/client/errors.ts +6 -0
- package/client/history.tsx +3 -2
- package/client/launcher.tsx +3 -2
- package/client/motion.tsx +3 -9
- package/client/panel.tsx +2 -1
- package/client/pill-popover.tsx +7 -6
- package/client/preview.tsx +164 -96
- package/client/video.tsx +90 -0
- package/client/write-up.tsx +2 -1
- package/dist/server/cli.js +1 -1
- package/index.server.ts +4 -2
- package/package.json +1 -1
- package/server/cli.ts +1 -1
- package/server/open.ts +89 -18
- package/shared/attachments.ts +44 -5
- package/shared/rpc.ts +26 -1
- package/skills/paseo-progress/SKILL.md +2 -2
package/client/preview.tsx
CHANGED
|
@@ -1,41 +1,18 @@
|
|
|
1
1
|
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
-
import { Icon,
|
|
2
|
+
import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import React, { useEffect, useRef
|
|
4
|
+
import React, { useEffect, useRef } from "react";
|
|
5
5
|
import { ActivityIndicator, Image, Platform, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
|
|
6
|
-
import { type Attachment,
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { ATTACHMENT_ROW, type Attachment, AttachmentList, isPreviewable } from "./attachments";
|
|
7
|
+
import { errorText } from "./errors";
|
|
8
|
+
import { folderAttachments, isFolder, previewKind } from "../shared/attachments";
|
|
9
|
+
import { canOpenOnHost, previewDeliverable, readDeliverablePiece } from "../shared/rpc";
|
|
10
|
+
import { PressScale } from "./motion";
|
|
9
11
|
import { imageOutline, raised } from "./surfaces";
|
|
12
|
+
import { loadVideo, VideoPlayer } from "./video";
|
|
10
13
|
|
|
11
14
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
12
15
|
|
|
13
|
-
// An image or text attachment shown in a dialog, with a way out to the host's own app.
|
|
14
|
-
// `onBack` returns to where the preview was opened from, e.g. a question's dialog.
|
|
15
|
-
export function PreviewDialog({ colors, attachment, workspaceId, workspaceDirectory, onClose, onOpenOnHost, backLabel, onBack, gallery }: {
|
|
16
|
-
colors: Colors;
|
|
17
|
-
attachment: Attachment | null;
|
|
18
|
-
workspaceId: string;
|
|
19
|
-
workspaceDirectory: string;
|
|
20
|
-
onClose(): void;
|
|
21
|
-
onOpenOnHost(attachment: Attachment): void;
|
|
22
|
-
backLabel?: string;
|
|
23
|
-
onBack?(): void;
|
|
24
|
-
gallery?: Gallery;
|
|
25
|
-
}) {
|
|
26
|
-
const open = Boolean(attachment);
|
|
27
|
-
attachment = useLastPresent(attachment);
|
|
28
|
-
return (
|
|
29
|
-
<Modal title={attachment?.title ?? "Preview"} icon={<Icon name={attachment ? attachmentIcon(attachment) : "FileText"} size={16} color={colors.foregroundMuted} />}
|
|
30
|
-
open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
|
|
31
|
-
{attachment ? (
|
|
32
|
-
<PreviewBody colors={colors} attachment={attachment} workspaceId={workspaceId} workspaceDirectory={workspaceDirectory}
|
|
33
|
-
onOpenOnHost={() => { onClose(); onOpenOnHost(attachment); }} backLabel={backLabel} onBack={onBack} gallery={gallery} />
|
|
34
|
-
) : <Modal.Content>{null}</Modal.Content>}
|
|
35
|
-
</Modal>
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
16
|
// The other previewable files beside this one (a question's images, a ticket's
|
|
40
17
|
// screenshots), for Previous and Next. Only shown when there's more than one.
|
|
41
18
|
export interface Gallery {
|
|
@@ -43,9 +20,8 @@ export interface Gallery {
|
|
|
43
20
|
onSelect(attachment: Attachment): void;
|
|
44
21
|
}
|
|
45
22
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return group.filter(isPreviewable);
|
|
23
|
+
export function galleryFiles(group: Attachment[]): Attachment[] {
|
|
24
|
+
return group.filter((attachment) => isPreviewable(attachment) && !isFolder(attachment));
|
|
49
25
|
}
|
|
50
26
|
|
|
51
27
|
type PreviewProps = {
|
|
@@ -54,48 +30,58 @@ type PreviewProps = {
|
|
|
54
30
|
workspaceId: string;
|
|
55
31
|
workspaceDirectory: string;
|
|
56
32
|
onOpenOnHost(): void;
|
|
57
|
-
backLabel?: string;
|
|
58
33
|
onBack?(): void;
|
|
59
34
|
gallery?: Gallery;
|
|
35
|
+
onOpenEntry(attachment: Attachment, group: Attachment[]): void;
|
|
60
36
|
};
|
|
61
37
|
|
|
62
|
-
// The preview dialog's body in its own Modal.Content.
|
|
63
|
-
export function PreviewBody(props: PreviewProps) {
|
|
64
|
-
const [long, setLong] = useState(false);
|
|
65
|
-
return <Modal.Content scrollable={!long}><PreviewContent {...props} onLong={setLong} /></Modal.Content>;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// The preview without its Modal.Content, for a dialog that already has one (see
|
|
69
|
-
// StackedDialog): swapping Modal.Content itself would re-present the sheet on phones.
|
|
70
38
|
// `onLong` says when the file is too tall for the dialog, so the host turns off
|
|
71
39
|
// its own scrolling and the file scrolls with the actions pinned below.
|
|
72
|
-
export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost,
|
|
40
|
+
export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onBack, gallery, onOpenEntry, onLong }: PreviewProps & { onLong(long: boolean): void }) {
|
|
73
41
|
const fetchPreview = useRpc(previewDeliverable);
|
|
42
|
+
const readPiece = useRpc(readDeliverablePiece);
|
|
43
|
+
const checkHost = useRpc(canOpenOnHost);
|
|
44
|
+
const queryClient = useQueryClient();
|
|
45
|
+
const canOpen = useQuery({
|
|
46
|
+
queryKey: ["progress", "can-open", workspaceId],
|
|
47
|
+
queryFn: () => checkHost({ workspaceId, workspaceDirectory }),
|
|
48
|
+
staleTime: Infinity,
|
|
49
|
+
}).data?.canOpen === true;
|
|
50
|
+
// How much of a video has arrived, for the line under the spinner.
|
|
51
|
+
const loadingKey = (target: Attachment) => ["progress", "preview-loading", workspaceId, target.ref, target.path];
|
|
74
52
|
const previewQuery = (target: Attachment) => ({
|
|
75
53
|
queryKey: ["progress", "preview", workspaceId, target.ref, target.path],
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
54
|
+
// An image or video's shape is measured here and cached with it, not held in
|
|
55
|
+
// this component: Paseo remounts it when `onLong` flips the host's scrolling,
|
|
56
|
+
// and a remount that forgot the shape fell back to 16:10, flipped `long` back,
|
|
57
|
+
// and so on, so a tall image made the dialog jump between two sizes until closed.
|
|
58
|
+
queryFn: async () => {
|
|
59
|
+
const data = await fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref });
|
|
60
|
+
if (data.kind === "image") return { ...data, ratio: await imageRatio(data.dataUri) };
|
|
61
|
+
if (data.kind !== "video") return data;
|
|
62
|
+
queryClient.setQueryData(loadingKey(target), 0);
|
|
63
|
+
const video = await loadVideo({
|
|
64
|
+
bytes: data.bytes,
|
|
65
|
+
mimeType: data.mimeType,
|
|
66
|
+
readPiece: (offset) => readPiece({ workspaceId, workspaceDirectory, ref: target.ref, offset }),
|
|
67
|
+
onProgress: (loaded) => queryClient.setQueryData(loadingKey(target), loaded / data.bytes),
|
|
68
|
+
});
|
|
69
|
+
return { ...data, video, ratio: video.ratio };
|
|
70
|
+
},
|
|
71
|
+
// A video isn't refetched while it's open: that would load it again and stop it playing.
|
|
72
|
+
staleTime: previewKind(target.path ?? "") === "video" ? Infinity : 30_000,
|
|
73
|
+
// Images arrive as data URIs up to ~13 MB and videos run to 200 MB; let them
|
|
74
|
+
// go soon after the dialog moves on.
|
|
79
75
|
gcTime: 30_000,
|
|
80
76
|
});
|
|
81
77
|
// Stepping to another file keeps this one on screen (dimmed) until the next loads,
|
|
82
78
|
// so the dialog doesn't collapse to a spinner and back.
|
|
83
79
|
const preview = useQuery({ ...previewQuery(attachment), placeholderData: (previous) => previous });
|
|
84
80
|
const stepping = preview.isPlaceholderData;
|
|
85
|
-
|
|
86
|
-
// report a size for data URIs on every platform; without it the box stays 16:10.
|
|
87
|
-
const imageUri = !stepping && preview.data?.kind === "image" ? preview.data.dataUri : null;
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
if (!imageUri) return;
|
|
90
|
-
let current = true;
|
|
91
|
-
Image.getSize(imageUri, (width, height) => {
|
|
92
|
-
if (current && width && height) setMeasured({ ref: attachment.ref, ratio: width / height });
|
|
93
|
-
}, () => {});
|
|
94
|
-
return () => { current = false; };
|
|
95
|
-
}, [imageUri, attachment.ref]);
|
|
81
|
+
const loading = useQuery({ queryKey: loadingKey(attachment), queryFn: () => 0, enabled: false }).data;
|
|
96
82
|
// Load the next file in the direction of travel ahead of time, so stepping is
|
|
97
|
-
// instant without holding both neighbors in memory.
|
|
98
|
-
|
|
83
|
+
// instant without holding both neighbors in memory. Not a video: it can be
|
|
84
|
+
// 200 MB, so it loads only when opened.
|
|
99
85
|
const items = gallery?.items ?? [];
|
|
100
86
|
const index = items.findIndex((item) => item.ref === attachment.ref);
|
|
101
87
|
const lastIndex = useRef(index);
|
|
@@ -103,23 +89,47 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
103
89
|
if (index < 0 || items.length < 2) return;
|
|
104
90
|
const step = (lastIndex.current - index + items.length) % items.length === 1 ? -1 : 1;
|
|
105
91
|
lastIndex.current = index;
|
|
106
|
-
|
|
92
|
+
const next = items[(index + step + items.length) % items.length];
|
|
93
|
+
if (previewKind(next.path ?? "") !== "video") void queryClient.prefetchQuery(previewQuery(next));
|
|
107
94
|
}, [attachment.ref, items.length]);
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
95
|
+
// Previous and Next, wrapping at the ends. Left and right arrows do the same.
|
|
96
|
+
const step = (offset: number) => gallery?.onSelect(items[(index + offset + items.length) % items.length]);
|
|
97
|
+
const inGallery = index >= 0;
|
|
98
|
+
const canStep = inGallery && items.length > 1;
|
|
99
|
+
useArrowKeys(canStep ? step : null);
|
|
100
|
+
// Next has focus when the preview opens, so Enter or Space moves on too.
|
|
101
|
+
const nextButton = useRef<View>(null);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
(nextButton.current as { focus?(): void } | null)?.focus?.();
|
|
104
|
+
}, []);
|
|
105
|
+
// Screenshots and recordings vary from phone-tall to full-width; size to the real
|
|
106
|
+
// file. While stepping, the dimmed one on screen keeps its shape until the next arrives.
|
|
107
|
+
const aspectRatio = preview.data && "ratio" in preview.data && preview.data.ratio ? preview.data.ratio : 16 / 10;
|
|
108
|
+
// A video fits the dialog whole, so the controls along its bottom stay in view
|
|
109
|
+
// without scrolling. A tall image scrolls instead, to keep it readable.
|
|
110
|
+
const { height: windowHeight } = useWindowDimensions();
|
|
111
|
+
const room = roomForContent(windowHeight);
|
|
112
|
+
const maxMediaHeight = preview.data?.kind === "video" ? Math.min(640, Math.max(200, room)) : 640;
|
|
113
|
+
const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
|
|
112
114
|
// A long file gets its own scroll area so the actions below it stay in view.
|
|
113
115
|
// Short ones keep the dialog sized to them.
|
|
114
|
-
const
|
|
115
|
-
const long = estimatedHeight(preview.data, aspectRatio) > roomForContent(windowHeight);
|
|
116
|
+
const long = estimatedHeight(preview.data, aspectRatio, maxMediaHeight) > room;
|
|
116
117
|
useEffect(() => onLong(long), [long]);
|
|
118
|
+
// How far a video has loaded: under the spinner, or in place of the path while
|
|
119
|
+
// the last file stays on screen, so nothing moves.
|
|
120
|
+
const videoLoading = preview.isFetching && loading !== undefined && loading < 1 ? `Loading video, ${Math.floor(loading * 100)}%` : null;
|
|
121
|
+
const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
|
|
117
122
|
const content = (
|
|
118
123
|
<>
|
|
119
|
-
{preview.isPending ?
|
|
124
|
+
{preview.isPending ? (
|
|
125
|
+
<View style={{ alignItems: "center", gap: 8, padding: 24 }}>
|
|
126
|
+
<ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading preview" />
|
|
127
|
+
{videoLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text> : null}
|
|
128
|
+
</View>
|
|
129
|
+
) : null}
|
|
120
130
|
{preview.error ? (
|
|
121
131
|
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
|
|
122
|
-
Could not preview it: {preview.error
|
|
132
|
+
Could not preview it: {errorText(preview.error, "something went wrong")}
|
|
123
133
|
</Text>
|
|
124
134
|
) : null}
|
|
125
135
|
{preview.data?.kind === "text" ? (
|
|
@@ -129,34 +139,38 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
129
139
|
</Text>
|
|
130
140
|
{preview.data.truncated ? (
|
|
131
141
|
<Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop: 8 }}>
|
|
132
|
-
Showing the first 256 KB. Press Open to see the rest in its default app.
|
|
142
|
+
Showing the first 256 KB.{canOpen ? " Press Open to see the rest in its default app." : ""}
|
|
133
143
|
</Text>
|
|
134
144
|
) : null}
|
|
135
145
|
</View>
|
|
136
146
|
) : null}
|
|
147
|
+
{preview.data?.kind === "folder" ? (
|
|
148
|
+
<FolderList colors={colors} folder={attachment} entries={folderAttachments(attachment, preview.data.entries)} truncated={preview.data.truncated}
|
|
149
|
+
dimmed={stepping} onOpen={onOpenEntry} />
|
|
150
|
+
) : null}
|
|
137
151
|
{preview.data?.kind === "image" ? (
|
|
138
|
-
<Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain"
|
|
139
|
-
onLoad={(event) => {
|
|
140
|
-
const { width, height } = event.nativeEvent.source ?? {};
|
|
141
|
-
if (width && height && !stepping) setMeasured({ ref: attachment.ref, ratio: width / height });
|
|
142
|
-
}}
|
|
143
|
-
style={{ width: "100%", aspectRatio, maxHeight: 640, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 }} />
|
|
152
|
+
<Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
|
|
144
153
|
) : null}
|
|
154
|
+
{preview.data?.kind === "video" ? <VideoPlayer video={preview.data.video} label={attachment.title} style={mediaStyle} /> : null}
|
|
145
155
|
{/* The path as a caption under the file, where it has the full width. */}
|
|
146
|
-
|
|
147
|
-
{
|
|
148
|
-
|
|
156
|
+
{!preview.isPending && videoLoading ? (
|
|
157
|
+
<Text style={[caption, { marginTop: -4, fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text>
|
|
158
|
+
) : (
|
|
159
|
+
<Text selectable numberOfLines={1} ellipsizeMode="middle" style={[caption, { marginTop: -4 }]}>
|
|
160
|
+
{attachment.path}
|
|
161
|
+
</Text>
|
|
162
|
+
)}
|
|
149
163
|
</>
|
|
150
164
|
);
|
|
151
165
|
// Back on the left, Previous and Next in the middle, Open on the right.
|
|
152
166
|
const actions = (
|
|
153
167
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
154
168
|
<View style={{ flex: 1, alignItems: "flex-start" }}>
|
|
155
|
-
{onBack ? <BackButton colors={colors}
|
|
169
|
+
{onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
|
|
156
170
|
</View>
|
|
157
|
-
{
|
|
171
|
+
{canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
|
|
158
172
|
<View style={{ flex: 1, alignItems: "flex-end" }}>
|
|
159
|
-
<DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} />
|
|
173
|
+
{canOpen ? <DialogButton colors={colors} icon="ExternalLink" label="Open" accessibilityLabel="Open in its default app" onPress={onOpenOnHost} /> : null}
|
|
160
174
|
</View>
|
|
161
175
|
</View>
|
|
162
176
|
);
|
|
@@ -178,14 +192,36 @@ const CONTENT_WIDTH = 472;
|
|
|
178
192
|
// Characters of 12px monospace per line of the text box (10px padding each side).
|
|
179
193
|
const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
|
|
180
194
|
|
|
195
|
+
const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
|
|
196
|
+
|
|
181
197
|
// Roughly how tall the preview draws, including wrapped lines.
|
|
182
|
-
function estimatedHeight(data: { kind: "image" } | { kind: "text"; text: string } | undefined, aspectRatio: number): number {
|
|
198
|
+
function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
|
|
183
199
|
if (!data) return 0;
|
|
184
|
-
if (data.kind === "
|
|
200
|
+
if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
|
|
201
|
+
if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
|
|
185
202
|
const lines = data.text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / CHARS_PER_LINE)), 0);
|
|
186
203
|
return lines * 18 + 20;
|
|
187
204
|
}
|
|
188
205
|
|
|
206
|
+
function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { colors: Colors; folder: Attachment; entries: Attachment[]; truncated: boolean; dimmed: boolean; onOpen(attachment: Attachment, group: Attachment[]): void }) {
|
|
207
|
+
const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
|
|
208
|
+
if (!entries.length) return <Text style={caption}>Nothing in {folder.title} yet.</Text>;
|
|
209
|
+
return (
|
|
210
|
+
<View style={{ gap: 8, opacity: dimmed ? 0.6 : 1 }}>
|
|
211
|
+
<AttachmentList colors={colors} attachments={entries} onOpen={onOpen} />
|
|
212
|
+
{truncated ? <Text style={caption}>Showing the first {entries.length}.</Text> : null}
|
|
213
|
+
</View>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// An image's width over its height, or null if it can't be read. Read from the data
|
|
218
|
+
// because onLoad doesn't report a size for data URIs on every platform.
|
|
219
|
+
function imageRatio(uri: string): Promise<number | null> {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
Image.getSize(uri, (width, height) => resolve(width && height ? width / height : null), () => resolve(null));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
189
225
|
// Height the dialog has for the preview before it must scroll: Paseo caps the card
|
|
190
226
|
// at 85% of the window (inside a 24px margin), less its header and padding, the
|
|
191
227
|
// path caption (17px after an 8px gap) and the footer (30px after a 20px gap).
|
|
@@ -194,9 +230,9 @@ function roomForContent(windowHeight: number): number {
|
|
|
194
230
|
}
|
|
195
231
|
|
|
196
232
|
// A dialog footer button: 13px label and 14px icon, 30px tall. Icon-only is a 30px square.
|
|
197
|
-
export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, outerStyle }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; outerStyle?: ViewStyle }) {
|
|
233
|
+
export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, outerStyle, ref }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; outerStyle?: ViewStyle; ref?: React.Ref<View> }) {
|
|
198
234
|
return (
|
|
199
|
-
<PressScale accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
|
|
235
|
+
<PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
|
|
200
236
|
style={({ pressed }) => ({
|
|
201
237
|
flexDirection: "row", alignItems: "center", gap: 6,
|
|
202
238
|
paddingVertical: label ? 6 : 8, paddingLeft: label ? 8 : 8, paddingRight: label ? 10 : 8,
|
|
@@ -209,20 +245,52 @@ export function DialogButton({ colors, icon, label, accessibilityLabel, onPress,
|
|
|
209
245
|
}
|
|
210
246
|
|
|
211
247
|
// Returns to the dialog this one was opened from. For surface1 dialogs.
|
|
212
|
-
export function BackButton({ colors,
|
|
213
|
-
return <DialogButton colors={colors} icon="ChevronLeft" label=
|
|
248
|
+
export function BackButton({ colors, onPress }: { colors: Colors; onPress(): void }) {
|
|
249
|
+
return <DialogButton colors={colors} icon="ChevronLeft" label="Back" onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
|
|
214
250
|
}
|
|
215
251
|
|
|
216
|
-
// Previous and next through a gallery,
|
|
217
|
-
function Stepper({ colors,
|
|
218
|
-
const { items } = gallery;
|
|
219
|
-
const index = Math.max(0, items.findIndex((item) => item.ref === current.ref));
|
|
220
|
-
const go = (step: number) => gallery.onSelect(items[(index + step + items.length) % items.length]);
|
|
252
|
+
// Previous and next through a gallery, with "2 of 3" between.
|
|
253
|
+
function Stepper({ colors, index, count, onStep, nextRef }: { colors: Colors; index: number; count: number; onStep(offset: number): void; nextRef: React.Ref<View> }) {
|
|
221
254
|
return (
|
|
222
255
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
223
|
-
<DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel="Previous file" onPress={() =>
|
|
224
|
-
<Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{index + 1} of {
|
|
225
|
-
<DialogButton colors={colors} icon="ChevronRight" accessibilityLabel="Next file" onPress={() =>
|
|
256
|
+
<DialogButton colors={colors} icon="ChevronLeft" accessibilityLabel="Previous file" onPress={() => onStep(-1)} />
|
|
257
|
+
<Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{index + 1} of {count}</Text>
|
|
258
|
+
<DialogButton ref={nextRef} colors={colors} icon="ChevronRight" accessibilityLabel="Next file" onPress={() => onStep(1)} />
|
|
226
259
|
</View>
|
|
227
260
|
);
|
|
228
261
|
}
|
|
262
|
+
|
|
263
|
+
// Calls `onStep` with -1 or 1 for the left and right arrow keys, in the desktop
|
|
264
|
+
// app. Not while typing, nor on a focused video, whose controls seek with them.
|
|
265
|
+
function useArrowKeys(onStep: ((offset: number) => void) | null) {
|
|
266
|
+
const latest = useRef(onStep);
|
|
267
|
+
latest.current = onStep;
|
|
268
|
+
const listening = Platform.OS === "web" && onStep !== null;
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (!listening) return;
|
|
271
|
+
const window = globalThis as unknown as {
|
|
272
|
+
addEventListener(type: "keydown", listener: (event: KeyEvent) => void): void;
|
|
273
|
+
removeEventListener(type: "keydown", listener: (event: KeyEvent) => void): void;
|
|
274
|
+
};
|
|
275
|
+
const onKeyDown = (event: KeyEvent) => {
|
|
276
|
+
const offset = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : 0;
|
|
277
|
+
if (!offset || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
278
|
+
if (/^(VIDEO|INPUT|TEXTAREA|SELECT)$/.test(event.target?.tagName ?? "") || event.target?.isContentEditable) return;
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
latest.current?.(offset);
|
|
281
|
+
};
|
|
282
|
+
window.addEventListener("keydown", onKeyDown);
|
|
283
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
284
|
+
}, [listening]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
type KeyEvent = {
|
|
288
|
+
key: string;
|
|
289
|
+
altKey: boolean;
|
|
290
|
+
ctrlKey: boolean;
|
|
291
|
+
metaKey: boolean;
|
|
292
|
+
shiftKey: boolean;
|
|
293
|
+
defaultPrevented: boolean;
|
|
294
|
+
target: { tagName?: string; isContentEditable?: boolean } | null;
|
|
295
|
+
preventDefault(): void;
|
|
296
|
+
};
|
package/client/video.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { View, type ViewStyle } from "react-native";
|
|
3
|
+
|
|
4
|
+
// Videos play only in Paseo's desktop app (see canPreview), which is a browser
|
|
5
|
+
// underneath. The plugin compiles without the DOM's types, so these are just the
|
|
6
|
+
// browser APIs used here.
|
|
7
|
+
interface VideoElement {
|
|
8
|
+
preload: string;
|
|
9
|
+
src: string;
|
|
10
|
+
videoWidth: number;
|
|
11
|
+
videoHeight: number;
|
|
12
|
+
onloadedmetadata: (() => void) | null;
|
|
13
|
+
onerror: (() => void) | null;
|
|
14
|
+
}
|
|
15
|
+
const browser = globalThis as unknown as {
|
|
16
|
+
atob(data: string): string;
|
|
17
|
+
Blob: new (parts: Uint8Array[], options: { type: string }) => VideoBlob;
|
|
18
|
+
URL: { createObjectURL(blob: VideoBlob): string; revokeObjectURL(url: string): void };
|
|
19
|
+
document: { createElement(tag: "video"): VideoElement };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// A whole video held in the app, ready to play.
|
|
23
|
+
export type VideoBlob = { readonly size: number };
|
|
24
|
+
|
|
25
|
+
export interface LoadedVideo {
|
|
26
|
+
blob: VideoBlob;
|
|
27
|
+
// Width over height, or null when the app can't read it (the box stays 16:10).
|
|
28
|
+
ratio: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fetches a video piece by piece into one blob, then reads its shape.
|
|
32
|
+
export async function loadVideo({ bytes, mimeType, readPiece, onProgress }: {
|
|
33
|
+
bytes: number;
|
|
34
|
+
mimeType: string;
|
|
35
|
+
readPiece(offset: number): Promise<{ data: string; bytes: number }>;
|
|
36
|
+
onProgress(loaded: number): void;
|
|
37
|
+
}): Promise<LoadedVideo> {
|
|
38
|
+
const pieces: Uint8Array[] = [];
|
|
39
|
+
let loaded = 0;
|
|
40
|
+
while (loaded < bytes) {
|
|
41
|
+
const piece = await readPiece(loaded);
|
|
42
|
+
const decoded = decode(piece.data);
|
|
43
|
+
if (piece.bytes !== bytes || !decoded.length) throw new Error("the video changed while it was loading. Try again.");
|
|
44
|
+
pieces.push(decoded);
|
|
45
|
+
loaded += decoded.length;
|
|
46
|
+
onProgress(loaded);
|
|
47
|
+
}
|
|
48
|
+
const blob = new browser.Blob(pieces, { type: mimeType });
|
|
49
|
+
return { blob, ratio: await videoRatio(blob) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decode(data: string): Uint8Array {
|
|
53
|
+
const binary = browser.atob(data);
|
|
54
|
+
const bytes = new Uint8Array(binary.length);
|
|
55
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
56
|
+
return bytes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Reads the size from the video's header without playing it. Gives up after a
|
|
60
|
+
// few seconds, so a video the app can't decode still shows its player and error.
|
|
61
|
+
function videoRatio(blob: VideoBlob): Promise<number | null> {
|
|
62
|
+
const url = browser.URL.createObjectURL(blob);
|
|
63
|
+
const video = browser.document.createElement("video");
|
|
64
|
+
return new Promise<number | null>((resolve) => {
|
|
65
|
+
setTimeout(() => resolve(null), 5_000);
|
|
66
|
+
video.onloadedmetadata = () => resolve(video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : null);
|
|
67
|
+
video.onerror = () => resolve(null);
|
|
68
|
+
video.preload = "metadata";
|
|
69
|
+
video.src = url;
|
|
70
|
+
}).finally(() => {
|
|
71
|
+
video.onloadedmetadata = video.onerror = null;
|
|
72
|
+
video.src = "";
|
|
73
|
+
browser.URL.revokeObjectURL(url);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The browser's own player, with its controls, in a box styled like an image's.
|
|
78
|
+
export function VideoPlayer({ video, label, style }: { video: LoadedVideo; label: string; style: ViewStyle }) {
|
|
79
|
+
const [url, setUrl] = useState<string | null>(null);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const created = browser.URL.createObjectURL(video.blob);
|
|
82
|
+
setUrl(created);
|
|
83
|
+
return () => browser.URL.revokeObjectURL(created);
|
|
84
|
+
}, [video.blob]);
|
|
85
|
+
return (
|
|
86
|
+
<View style={[style, { overflow: "hidden" }]}>
|
|
87
|
+
{url ? <video src={url} controls playsInline aria-label={label} style={{ width: "100%", height: "100%", display: "block", objectFit: "contain" }} /> : null}
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
package/client/write-up.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { previewDeliverable } from "../shared/rpc";
|
|
|
8
8
|
import { type Block, parseWriteUp, type Span, withoutTitle, writeUpExcerpt } from "../shared/write-up";
|
|
9
9
|
import { type Attachment, attachmentIcon } from "./attachments";
|
|
10
10
|
import { PressableRow } from "./row";
|
|
11
|
+
import { errorText } from "./errors";
|
|
11
12
|
|
|
12
13
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
13
14
|
const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
|
|
@@ -37,7 +38,7 @@ export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpe
|
|
|
37
38
|
return (
|
|
38
39
|
<View style={{ gap: 12, padding: 14, borderRadius: 8, backgroundColor: colors.surface0 }}>
|
|
39
40
|
{shown.length ? <View style={{ gap: 8 }}>{shown.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View> : null}
|
|
40
|
-
{preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Couldn't read it here: {preview.error
|
|
41
|
+
{preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Couldn't read it here: {errorText(preview.error, "something went wrong")}</Text> : null}
|
|
41
42
|
{/* The file on the left, opening it whole; expanding in place on the right. */}
|
|
42
43
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
43
44
|
<TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={`Open ${source.title}`} onPress={() => onOpen(source, [source])} grow />
|
package/dist/server/cli.js
CHANGED
|
@@ -20928,7 +20928,7 @@ var commands = {
|
|
|
20928
20928
|
function rootRelative(target, cwd, root) {
|
|
20929
20929
|
const absolute = resolve3(cwd, target);
|
|
20930
20930
|
const inside = relative(root, absolute);
|
|
20931
|
-
const suffix = /[\/]$/.test(target) ? "/" : "";
|
|
20931
|
+
const suffix = /[\/]$/.test(target) || statSync(absolute, { throwIfNoEntry: false })?.isDirectory() ? "/" : "";
|
|
20932
20932
|
if (!inside) return "./";
|
|
20933
20933
|
return outside(inside) ? absolute + suffix : inside + suffix;
|
|
20934
20934
|
}
|
package/index.server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
2
|
import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPanelOpened, handleSetHistoryFolder } from "./server/dashboard";
|
|
3
3
|
import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
|
|
4
|
-
import { handleOpenDeliverable, handlePreviewDeliverable } from "./server/open";
|
|
4
|
+
import { handleCanOpenOnHost, handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
|
|
5
5
|
import { handleInstallSkill, handleSkillStatus } from "./server/skill";
|
|
6
|
-
import { getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, setHistoryFolder } from "./shared/rpc";
|
|
6
|
+
import { canOpenOnHost, getAttention, getDashboard, getLauncherStatus, getSkillStatus, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
7
7
|
|
|
8
8
|
export default function contribute(server: PluginServerContext) {
|
|
9
9
|
server.handle(getDashboard, handleGetDashboard);
|
|
@@ -12,7 +12,9 @@ export default function contribute(server: PluginServerContext) {
|
|
|
12
12
|
server.handle(getLauncherStatus, handleLauncherStatus);
|
|
13
13
|
server.handle(installLauncher, handleInstallLauncher);
|
|
14
14
|
server.handle(openDeliverable, handleOpenDeliverable);
|
|
15
|
+
server.handle(canOpenOnHost, handleCanOpenOnHost);
|
|
15
16
|
server.handle(previewDeliverable, handlePreviewDeliverable);
|
|
17
|
+
server.handle(readDeliverablePiece, handleReadDeliverablePiece);
|
|
16
18
|
server.handle(getSkillStatus, handleSkillStatus);
|
|
17
19
|
server.handle(installSkill, handleInstallSkill);
|
|
18
20
|
server.handle(listFolders, handleListFolders);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steve Castaneda",
|
package/server/cli.ts
CHANGED
|
@@ -447,7 +447,7 @@ const commands: Record<string, Command> = {
|
|
|
447
447
|
function rootRelative(target: string, cwd: string, root: string): string {
|
|
448
448
|
const absolute = resolve(cwd, target);
|
|
449
449
|
const inside = relative(root, absolute);
|
|
450
|
-
const suffix = /[\/]$/.test(target) ? "/" : "";
|
|
450
|
+
const suffix = /[\/]$/.test(target) || statSync(absolute, { throwIfNoEntry: false })?.isDirectory() ? "/" : "";
|
|
451
451
|
if (!inside) return "./";
|
|
452
452
|
return outside(inside) ? absolute + suffix : inside + suffix;
|
|
453
453
|
}
|