@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.
@@ -1,41 +1,18 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, Modal, ScrollView } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
- import React, { useEffect, useRef, useState } from "react";
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, attachmentIcon, isPreviewable } from "./attachments";
7
- import { previewDeliverable } from "../shared/rpc";
8
- import { PressScale, useLastPresent } from "./motion";
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
- // Keeps only what the preview can show, for a Gallery's items.
47
- export function previewable(group: Attachment[]): Attachment[] {
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, backLabel, onBack, gallery, onLong }: PreviewProps & { onLong(long: boolean): void }) {
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
- queryFn: () => fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref }),
77
- staleTime: 30_000,
78
- // Images arrive as data URIs up to ~13 MB; let them go soon after the dialog moves on.
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
- // Measure the image from its data rather than waiting on onLoad, which doesn't
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
- const queryClient = useQueryClient();
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
- void queryClient.prefetchQuery(previewQuery(items[(index + step + items.length) % items.length]));
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
- // Screenshots vary from phone-tall to full-width; size to the real image once it loads.
109
- // Kept per file, so stepping to the next image doesn't borrow this one's shape.
110
- const [measured, setMeasured] = useState<{ ref: string; ratio: number } | null>(null);
111
- const aspectRatio = measured && (measured.ref === attachment.ref || stepping) ? measured.ratio : 16 / 10;
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 { height: windowHeight } = useWindowDimensions();
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 ? <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel="Loading preview" style={{ padding: 24 }} /> : null}
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.message}
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
- <Text selectable numberOfLines={1} ellipsizeMode="middle" style={{ marginTop: -4, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
147
- {attachment.path}
148
- </Text>
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} label={backLabel ?? "Back"} onPress={onBack} /> : null}
169
+ {onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
156
170
  </View>
157
- {gallery && gallery.items.length > 1 ? <Stepper colors={colors} gallery={gallery} current={attachment} /> : null}
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 === "image") return Math.min(CONTENT_WIDTH / aspectRatio, 640);
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, label, onPress }: { colors: Colors; label: string; onPress(): void }) {
213
- return <DialogButton colors={colors} icon="ChevronLeft" label={label} onPress={onPress} outerStyle={{ alignSelf: "flex-start" }} />;
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, wrapping at the ends, with "2 of 3" between.
217
- function Stepper({ colors, gallery, current }: { colors: Colors; gallery: Gallery; current: Attachment }) {
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={() => go(-1)} />
224
- <Text style={{ minWidth: 44, textAlign: "center", color: colors.foregroundMuted, fontSize: 13, lineHeight: 18, fontVariant: ["tabular-nums"] }}>{index + 1} of {items.length}</Text>
225
- <DialogButton colors={colors} icon="ChevronRight" accessibilityLabel="Next file" onPress={() => go(1)} />
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
+ };
@@ -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
+ }
@@ -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.message}</Text> : null}
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 />
@@ -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.0.0",
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
  }