@stevecastaneda/paseo-progress 1.6.0 → 1.7.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/README.md +5 -0
- package/client/deliverables.tsx +1 -1
- package/client/dialog-stack.tsx +19 -7
- package/client/locales/en.ts +4 -0
- package/client/locales/zh-CN.ts +4 -0
- package/client/messages.ts +4 -0
- package/client/pill-popover.tsx +1 -1
- package/client/preview.tsx +170 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,6 +145,11 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
|
|
|
145
145
|
|
|
146
146
|
## Changelog
|
|
147
147
|
|
|
148
|
+
### 1.7.0
|
|
149
|
+
|
|
150
|
+
- [New] **See screenshots full screen.** In an image preview, press **Full screen** or click the image to fill the window with it. Esc takes you back to the preview, still open. The arrow keys still move between images. A long screenshot also gets **Fit to width**, which fills the width so you can scroll down it.
|
|
151
|
+
- [Fixed] **A preview that can't load says why right away.** A missing file, one too large to preview, or one outside the worktree showed a spinner for several seconds before the reason.
|
|
152
|
+
|
|
148
153
|
### 1.6.0
|
|
149
154
|
|
|
150
155
|
- [New] **Read the skill in Settings.** Progress settings has an Agent Skills section. **View skill** shows the instructions agents get, and **Install skill** appears there when the skill isn't installed.
|
package/client/deliverables.tsx
CHANGED
|
@@ -62,7 +62,7 @@ export function DeliverablesList({ colors, deliverables, now, workspaceId, works
|
|
|
62
62
|
))}
|
|
63
63
|
<ShowMoreRow colors={colors} total={deliverables.length} paged={paged} />
|
|
64
64
|
<StackedDialog colors={colors} now={now} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory, navigation }}
|
|
65
|
-
root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)) } : null} />
|
|
65
|
+
root={previewing ? { kind: "preview", attachment: previewing, group: galleryFiles(deliverables.map(deliverableAttachment)), fullScreen: false } : null} />
|
|
66
66
|
</View>
|
|
67
67
|
);
|
|
68
68
|
}
|
package/client/dialog-stack.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import type { Dashboard, Question } from "../shared/dashboard";
|
|
|
5
5
|
import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
|
|
6
6
|
import { NarrowProvider } from "./narrow";
|
|
7
7
|
import { BackButton, galleryFiles, PreviewContent } from "./preview";
|
|
8
|
+
import { previewKind } from "../shared/attachments";
|
|
8
9
|
import { QuestionView, useCopy } from "./questions";
|
|
9
10
|
import { TicketStoryView } from "./ticket-dialog";
|
|
10
11
|
import { ProgressLanguageProvider } from "./language";
|
|
@@ -12,11 +13,13 @@ import { ProgressLanguageProvider } from "./language";
|
|
|
12
13
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
13
14
|
type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navigation?: PluginWorkspacePanelProps["navigation"] };
|
|
14
15
|
|
|
15
|
-
// What the dialog is showing. A preview keeps the files beside it for Previous and Next
|
|
16
|
+
// What the dialog is showing. A preview keeps the files beside it for Previous and Next,
|
|
17
|
+
// and whether its image fills the window. `leftFullScreen` puts focus back on the
|
|
18
|
+
// Full screen button; it lives here because leaving can remount the preview.
|
|
16
19
|
export type Step =
|
|
17
20
|
| { kind: "ticket"; id: string }
|
|
18
21
|
| { kind: "question"; id: string }
|
|
19
|
-
| { kind: "preview"; attachment: Attachment; group: Attachment[] };
|
|
22
|
+
| { kind: "preview"; attachment: Attachment; group: Attachment[]; fullScreen: boolean; leftFullScreen?: boolean };
|
|
20
23
|
|
|
21
24
|
// Paseo gives each dialog its own backdrop, so opening a second one darkens
|
|
22
25
|
// the screen while the first fades out; steps swap content inside this one instead.
|
|
@@ -43,7 +46,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
|
|
|
43
46
|
const group = useRef<Attachment[]>([]);
|
|
44
47
|
const push = (step: Step) => setSteps((current) => [...current, step]);
|
|
45
48
|
const replaceTop = (step: Step) => setSteps((current) => [...current.slice(0, -1), step]);
|
|
46
|
-
const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current) }) });
|
|
49
|
+
const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current), fullScreen: false }) });
|
|
47
50
|
const openAttachment = (attachment: Attachment, from: Attachment[]) => {
|
|
48
51
|
group.current = from;
|
|
49
52
|
// A link opens elsewhere in Paseo; close first so it isn't left behind the dialog.
|
|
@@ -62,6 +65,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
|
|
|
62
65
|
const ticket = top?.kind === "ticket" ? dashboard?.tickets.find((candidate) => candidate.id === top.id) : undefined;
|
|
63
66
|
const question = top?.kind === "question" ? questions.find((candidate) => candidate.id === top.id) : undefined;
|
|
64
67
|
const preview = top?.kind === "preview" ? top : undefined;
|
|
68
|
+
const setFullScreen = (on: boolean) => preview && replaceTop({ ...preview, fullScreen: on, leftFullScreen: !on });
|
|
65
69
|
// The ticket or question it opened on went away (answered elsewhere, or a new run): close rather than show a blank dialog.
|
|
66
70
|
const rootMissing = root !== null && (root.kind === "ticket" ? !dashboard?.tickets.some((candidate) => candidate.id === root.id)
|
|
67
71
|
: root.kind === "question" ? !questions.some((candidate) => candidate.id === root.id) : false);
|
|
@@ -74,9 +78,16 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
|
|
|
74
78
|
|
|
75
79
|
return (
|
|
76
80
|
<Modal title={title} icon={<Icon name={icon} size={16} color={colors.foregroundMuted} />}
|
|
77
|
-
open={Boolean(root)} onOpenChange={(next) => {
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
open={Boolean(root)} onOpenChange={(next) => {
|
|
82
|
+
if (next) return;
|
|
83
|
+
// Esc leaves full screen first; the dialog stays open under it.
|
|
84
|
+
if (preview?.fullScreen) setFullScreen(false);
|
|
85
|
+
else onClose();
|
|
86
|
+
}}>
|
|
87
|
+
{/* One Modal.Content for every step: replacing it would re-present the sheet on phones.
|
|
88
|
+
Full screen turns its scrolling off. The image's fixed position reaches the window only
|
|
89
|
+
when no ancestor has a transform, and a scroll view has one. */}
|
|
90
|
+
<Modal.Content scrollable={!(preview && (previewLong || preview.fullScreen))}>
|
|
80
91
|
<ProgressLanguageProvider>
|
|
81
92
|
{/* A dialog has room for clock times, however narrow the panel that opened it. */}
|
|
82
93
|
<NarrowProvider value={false}>
|
|
@@ -84,7 +95,8 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
|
|
|
84
95
|
<PreviewContent colors={colors} attachment={preview.attachment} workspaceId={context.workspaceId} workspaceDirectory={context.workspaceDirectory}
|
|
85
96
|
onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }}
|
|
86
97
|
onCopyPath={() => void opener.copyPath(preview.attachment)} onBack={back}
|
|
87
|
-
gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment }) }}
|
|
98
|
+
gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment, fullScreen: preview.fullScreen && previewKind(attachment.path ?? "") === "image", leftFullScreen: false }) }}
|
|
99
|
+
onLong={setPreviewLong} fullScreen={preview.fullScreen} leftFullScreen={preview.leftFullScreen === true} onFullScreen={setFullScreen}
|
|
88
100
|
onOpenEntry={openAttachment} />
|
|
89
101
|
) : (
|
|
90
102
|
<>
|
package/client/locales/en.ts
CHANGED
|
@@ -96,6 +96,10 @@ export const messages: ProgressMessages = {
|
|
|
96
96
|
back: "Back",
|
|
97
97
|
previousFile: "Previous file",
|
|
98
98
|
nextFile: "Next file",
|
|
99
|
+
fullScreen: "Full screen",
|
|
100
|
+
exitFullScreen: "Exit full screen",
|
|
101
|
+
fitToWidth: "Fit to width",
|
|
102
|
+
fitToScreen: "Fit to screen",
|
|
99
103
|
videoChanged: "the video changed while it was loading. Try again.",
|
|
100
104
|
localHistory: "Run history stays on this computer.",
|
|
101
105
|
historyStopped: "Runs will stay on this computer. Files already saved stay in the repo.",
|
package/client/locales/zh-CN.ts
CHANGED
|
@@ -96,6 +96,10 @@ export const messages: ProgressMessages = {
|
|
|
96
96
|
back: "返回",
|
|
97
97
|
previousFile: "上一个文件",
|
|
98
98
|
nextFile: "下一个文件",
|
|
99
|
+
fullScreen: "全屏",
|
|
100
|
+
exitFullScreen: "退出全屏",
|
|
101
|
+
fitToWidth: "适应宽度",
|
|
102
|
+
fitToScreen: "适应屏幕",
|
|
99
103
|
videoChanged: "视频在加载过程中发生了变化。请重试。",
|
|
100
104
|
localHistory: "运行记录保存在这台电脑上。",
|
|
101
105
|
historyStopped: "运行记录将保存在这台电脑上。已保存的文件会保留在仓库中。",
|
package/client/messages.ts
CHANGED
|
@@ -78,6 +78,10 @@ export interface ProgressMessages {
|
|
|
78
78
|
back: string;
|
|
79
79
|
previousFile: string;
|
|
80
80
|
nextFile: string;
|
|
81
|
+
fullScreen: string;
|
|
82
|
+
exitFullScreen: string;
|
|
83
|
+
fitToWidth: string;
|
|
84
|
+
fitToScreen: string;
|
|
81
85
|
videoChanged: string;
|
|
82
86
|
localHistory: string;
|
|
83
87
|
historyStopped: string;
|
package/client/pill-popover.tsx
CHANGED
|
@@ -59,7 +59,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
59
59
|
</Text>
|
|
60
60
|
)}
|
|
61
61
|
<StackedDialog colors={colors} now={Date.now()} onClose={() => setPreviewing(null)} context={{ workspaceId, workspaceDirectory: root }}
|
|
62
|
-
root={previewing ? { kind: "preview", ...previewing } : null} />
|
|
62
|
+
root={previewing ? { kind: "preview", ...previewing, fullScreen: false } : null} />
|
|
63
63
|
<View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
64
64
|
{/* A full 44pt touch target on phones; desktop keeps the compact button. */}
|
|
65
65
|
<PressScale accessibilityRole="button" onPress={() => { close(); openPanel(); }}
|
package/client/preview.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import React, { useEffect, useRef } from "react";
|
|
5
|
-
import { ActivityIndicator, Image, Platform, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
|
|
4
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { ActivityIndicator, Image, Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
|
|
6
6
|
import { ATTACHMENT_ROW, type Attachment, AttachmentList, isPreviewable } from "./attachments";
|
|
7
7
|
import { errorText } from "./errors";
|
|
8
8
|
import { folderAttachments, isFolder, previewKind } from "../shared/attachments";
|
|
@@ -39,7 +39,12 @@ type PreviewProps = {
|
|
|
39
39
|
|
|
40
40
|
// `onLong` says when the file is too tall for the dialog, so the host turns off
|
|
41
41
|
// its own scrolling and the file scrolls with the actions pinned below.
|
|
42
|
-
export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong }: PreviewProps & {
|
|
42
|
+
export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong, fullScreen, leftFullScreen, onFullScreen }: PreviewProps & {
|
|
43
|
+
onLong(long: boolean): void;
|
|
44
|
+
fullScreen: boolean;
|
|
45
|
+
leftFullScreen: boolean;
|
|
46
|
+
onFullScreen(on: boolean): void;
|
|
47
|
+
}) {
|
|
43
48
|
const { messages: m } = useProgressLanguage();
|
|
44
49
|
const fetchPreview = useRpc(previewDeliverable);
|
|
45
50
|
const readPiece = useRpc(readDeliverablePiece);
|
|
@@ -60,7 +65,7 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
60
65
|
// and so on, so a tall image made the dialog jump between two sizes until closed.
|
|
61
66
|
queryFn: async () => {
|
|
62
67
|
const data = await fetchPreview({ workspaceId, workspaceDirectory, ref: target.ref });
|
|
63
|
-
if (data.kind === "image") return { ...data,
|
|
68
|
+
if (data.kind === "image") return { ...data, ...await imageSize(data.dataUri) };
|
|
64
69
|
if (data.kind !== "video") return data;
|
|
65
70
|
queryClient.setQueryData(loadingKey(target), 0);
|
|
66
71
|
const video = await loadVideo({
|
|
@@ -76,6 +81,9 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
76
81
|
// Images arrive as data URIs up to ~13 MB and videos run to 200 MB; let them
|
|
77
82
|
// go soon after the dialog moves on.
|
|
78
83
|
gcTime: 30_000,
|
|
84
|
+
// A file that's missing, too large or outside the worktree fails the same way
|
|
85
|
+
// every time, so the reason shows at once instead of after seconds of retries.
|
|
86
|
+
retry: false,
|
|
79
87
|
});
|
|
80
88
|
// Stepping to another file keeps this one on screen (dimmed) until the next loads,
|
|
81
89
|
// so the dialog doesn't collapse to a spinner and back.
|
|
@@ -102,15 +110,39 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
102
110
|
useArrowKeys(canStep ? step : null);
|
|
103
111
|
// Next has focus when the preview opens, so Enter or Space moves on too.
|
|
104
112
|
const nextButton = useRef<View>(null);
|
|
113
|
+
const fullScreenButton = useRef<View>(null);
|
|
114
|
+
// Leaving full screen puts focus back on the Full screen button without the ring
|
|
115
|
+
// Esc would draw, which read as if the arrows now acted on it. Set during render so
|
|
116
|
+
// the ring never paints, and from `leftFullScreen` because leaving can remount this.
|
|
117
|
+
const [quiet, setQuiet] = useState(leftFullScreen && !fullScreen);
|
|
118
|
+
const [wasFullScreen, setWasFullScreen] = useState(fullScreen);
|
|
119
|
+
if (wasFullScreen !== fullScreen) {
|
|
120
|
+
setWasFullScreen(fullScreen);
|
|
121
|
+
setQuiet(!fullScreen && leftFullScreen);
|
|
122
|
+
}
|
|
105
123
|
useEffect(() => {
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
if (fullScreen) return;
|
|
125
|
+
const restore = leftFullScreen && fullScreenButton.current !== null;
|
|
126
|
+
if (!restore) setQuiet(false);
|
|
127
|
+
focus(restore ? fullScreenButton : nextButton);
|
|
128
|
+
}, [fullScreen]);
|
|
108
129
|
// Screenshots and recordings vary from phone-tall to full-width; size to the real
|
|
109
130
|
// file. While stepping, the dimmed one on screen keeps its shape until the next arrives.
|
|
110
131
|
const aspectRatio = preview.data && "ratio" in preview.data && preview.data.ratio ? preview.data.ratio : 16 / 10;
|
|
111
132
|
// A video fits the dialog whole, so the controls along its bottom stay in view
|
|
112
133
|
// without scrolling. A tall image scrolls instead, to keep it readable.
|
|
113
|
-
const { height: windowHeight } = useWindowDimensions();
|
|
134
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
135
|
+
// Phones and narrow windows get Paseo's bottom sheet, whose transforms would
|
|
136
|
+
// keep the full-screen layer inside it.
|
|
137
|
+
const fullScreenFits = Platform.OS === "web" && windowWidth >= COMPACT_BELOW;
|
|
138
|
+
// By the file, not the data: while stepping, the data is still the last file's.
|
|
139
|
+
const canFullScreen = fullScreenFits && previewKind(attachment.path ?? "") === "image" && preview.data?.kind === "image";
|
|
140
|
+
// While stepping, the last image fills the layer until the next arrives. A file
|
|
141
|
+
// that fails to load has no data at all, so full screen ends with it.
|
|
142
|
+
const showsLayer = fullScreenFits && preview.data?.kind === "image";
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (fullScreen && !preview.isPending && !showsLayer) onFullScreen(false);
|
|
145
|
+
}, [fullScreen, preview.isPending, showsLayer]);
|
|
114
146
|
const room = roomForContent(windowHeight);
|
|
115
147
|
const maxMediaHeight = preview.data?.kind === "video" ? Math.min(640, Math.max(200, room)) : 640;
|
|
116
148
|
const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
|
|
@@ -152,7 +184,11 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
152
184
|
dimmed={stepping} onOpen={onOpenEntry} />
|
|
153
185
|
) : null}
|
|
154
186
|
{preview.data?.kind === "image" ? (
|
|
155
|
-
|
|
187
|
+
// The Full screen button is the keyboard's way in, so the image isn't a second tab stop.
|
|
188
|
+
<Pressable disabled={!canFullScreen} focusable={false} onPress={() => onFullScreen(true)}
|
|
189
|
+
style={{ width: "100%", cursor: canFullScreen ? "zoom-in" as unknown as ViewStyle["cursor"] : undefined }}>
|
|
190
|
+
<Image accessibilityLabel={attachment.title} source={{ uri: preview.data.dataUri }} resizeMode="contain" style={mediaStyle} />
|
|
191
|
+
</Pressable>
|
|
156
192
|
) : null}
|
|
157
193
|
{preview.data?.kind === "video" ? <VideoPlayer video={preview.data.video} label={attachment.title} style={mediaStyle} /> : null}
|
|
158
194
|
</>
|
|
@@ -182,28 +218,136 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
|
|
|
182
218
|
{onBack ? <BackButton colors={colors} onPress={onBack} /> : null}
|
|
183
219
|
</View>
|
|
184
220
|
{canStep ? <Stepper colors={colors} index={index} count={items.length} onStep={step} nextRef={nextButton} /> : null}
|
|
185
|
-
<View style={{ flex: 1, alignItems: "flex-end" }}>
|
|
221
|
+
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 }}>
|
|
222
|
+
{canFullScreen ? (
|
|
223
|
+
// An inline outline beats Paseo's :focus-visible ring.
|
|
224
|
+
<DialogButton ref={fullScreenButton} colors={colors} icon="Maximize2" accessibilityLabel={m.fullScreen} onPress={() => onFullScreen(true)}
|
|
225
|
+
outerStyle={quiet ? { outlineWidth: 0 } : undefined} onBlur={() => setQuiet(false)} />
|
|
226
|
+
) : null}
|
|
186
227
|
{canOpen ? <DialogButton colors={colors} icon="ExternalLink" label={m.open} accessibilityLabel={m.openDefault} onPress={onOpenOnHost} /> : null}
|
|
187
228
|
</View>
|
|
188
229
|
</View>
|
|
189
230
|
);
|
|
190
|
-
|
|
231
|
+
const layerShown = fullScreen && showsLayer;
|
|
232
|
+
// An SVG stays sharp at any size, so only a bitmap stops at its own pixels. An
|
|
233
|
+
// icon's SVG can say it's 24px wide.
|
|
234
|
+
const fullScreenLayer = layerShown && preview.data?.kind === "image" ? (
|
|
235
|
+
<FullScreenImage title={attachment.title} uri={preview.data.dataUri} ratio={aspectRatio} dimmed={stepping} onExit={() => onFullScreen(false)}
|
|
236
|
+
naturalWidth={preview.data.dataUri.startsWith("data:image/svg+xml") ? null : preview.data.width} />
|
|
237
|
+
) : null;
|
|
238
|
+
// Paseo's focus trap counts the controls under the layer, so Shift+Tab would
|
|
239
|
+
// reach a hidden Open. Inert takes them out until full screen ends.
|
|
240
|
+
const body = useRef<View>(null);
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (!layerShown) return;
|
|
243
|
+
const element = body.current as unknown as { setAttribute?(name: string, value: string): void; removeAttribute?(name: string): void } | null;
|
|
244
|
+
element?.setAttribute?.("inert", "");
|
|
245
|
+
return () => element?.removeAttribute?.("inert");
|
|
246
|
+
}, [layerShown]);
|
|
247
|
+
return (
|
|
191
248
|
<>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
249
|
+
{long ? (
|
|
250
|
+
<View ref={body} style={{ flex: 1 }}>
|
|
251
|
+
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ gap: 12 }}>{content}</ScrollView>
|
|
252
|
+
<View style={{ marginTop: 8 }}>{path}</View>
|
|
253
|
+
{actions}
|
|
254
|
+
</View>
|
|
255
|
+
) : (
|
|
256
|
+
<View ref={body} style={{ gap: 20 }}>
|
|
257
|
+
<View style={{ gap: 8 }}>
|
|
258
|
+
<View style={{ gap: 12 }}>{content}</View>
|
|
259
|
+
{path}
|
|
260
|
+
</View>
|
|
261
|
+
{actions}
|
|
262
|
+
</View>
|
|
263
|
+
)}
|
|
264
|
+
{fullScreenLayer}
|
|
195
265
|
</>
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const FULL_SCREEN_MARGIN = 24;
|
|
270
|
+
const FULL_SCREEN_BAR = 52;
|
|
271
|
+
|
|
272
|
+
// The image over the whole window, never past its own pixels. It fits whole, or a
|
|
273
|
+
// long one can fill the width and scroll.
|
|
274
|
+
function FullScreenImage({ title, uri, naturalWidth, ratio, dimmed, onExit }: { title: string; uri: string; naturalWidth: number | null; ratio: number; dimmed: boolean; onExit(): void }) {
|
|
275
|
+
const { messages: m } = useProgressLanguage();
|
|
276
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
277
|
+
const closeButton = useRef<View>(null);
|
|
278
|
+
const fitButton = useRef<View>(null);
|
|
279
|
+
const scrollArea = useRef<React.ComponentRef<typeof ScrollView>>(null);
|
|
280
|
+
useEffect(() => focus(closeButton), []);
|
|
281
|
+
const [fit, setFit] = useState<"screen" | "width">("screen");
|
|
282
|
+
const room = windowHeight - 2 * FULL_SCREEN_MARGIN - FULL_SCREEN_BAR;
|
|
283
|
+
const widthFit = Math.max(0, Math.min(naturalWidth ?? Infinity, windowWidth - 2 * FULL_SCREEN_MARGIN));
|
|
284
|
+
const screenFit = Math.min(widthFit, room * ratio);
|
|
285
|
+
// Only an image that fills the width at least half again as large as it fits
|
|
286
|
+
// whole gets the choice. An ordinary window screenshot gains a little and reads
|
|
287
|
+
// fine whole; a phone screenshot or a long page doesn't.
|
|
288
|
+
const long = widthFit >= screenFit * 1.5;
|
|
289
|
+
const scrolls = long && fit === "width";
|
|
290
|
+
const toggle = () => setFit(scrolls ? "screen" : "width");
|
|
291
|
+
// Fit to width hands the keys to the scroll area, so the arrows, Page Up and
|
|
292
|
+
// Down, Home, End and Space scroll it. When the area goes, focus goes back to the bar.
|
|
293
|
+
const scrolled = useRef(false);
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (scrolls) focus(scrollArea);
|
|
296
|
+
else if (scrolled.current) focus(long ? fitButton : closeButton);
|
|
297
|
+
scrolled.current = scrolls;
|
|
298
|
+
}, [scrolls, uri]);
|
|
299
|
+
const light = "rgba(255, 255, 255, 0.85)";
|
|
300
|
+
const barButton = ({ pressed, hovered }: { pressed: boolean; hovered: boolean }) => ({
|
|
301
|
+
height: 36, alignItems: "center", justifyContent: "center", borderRadius: 6,
|
|
302
|
+
backgroundColor: pressed ? "rgba(255, 255, 255, 0.16)" : hovered ? "rgba(255, 255, 255, 0.1)" : "transparent",
|
|
303
|
+
}) as const;
|
|
304
|
+
// A press on the image stops there, so only the black around it exits.
|
|
305
|
+
// The fit button is the keyboard's way to the toggle, so the image isn't a tab stop.
|
|
306
|
+
const area = (
|
|
307
|
+
<Pressable accessible={false} focusable={false} onPress={onExit}
|
|
308
|
+
style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", padding: FULL_SCREEN_MARGIN }}>
|
|
309
|
+
<Pressable accessible={false} focusable={false} onPress={long ? toggle : () => {}}
|
|
310
|
+
style={{ cursor: (long ? (scrolls ? "zoom-out" : "zoom-in") : "default") as unknown as ViewStyle["cursor"] }}>
|
|
311
|
+
<Image accessibilityLabel={title} source={{ uri }} resizeMode="contain"
|
|
312
|
+
style={{ width: scrolls ? widthFit : screenFit, aspectRatio: ratio, borderRadius: 6, borderWidth: 1, borderColor: "rgba(255, 255, 255, 0.1)", opacity: dimmed ? 0.6 : 1 }} />
|
|
313
|
+
</Pressable>
|
|
314
|
+
</Pressable>
|
|
315
|
+
);
|
|
316
|
+
return (
|
|
317
|
+
<View style={{ position: "fixed" as unknown as ViewStyle["position"], top: 0, right: 0, bottom: 0, left: 0, zIndex: 1000, backgroundColor: "black" }}>
|
|
318
|
+
<View style={{ height: FULL_SCREEN_BAR, flexDirection: "row", alignItems: "center", gap: 12, paddingStart: FULL_SCREEN_MARGIN, paddingEnd: 12 }}>
|
|
319
|
+
<Text numberOfLines={1} style={{ flex: 1, minWidth: 0, color: light, fontSize: 13, lineHeight: 18 }}>{title}</Text>
|
|
320
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
321
|
+
{long ? (
|
|
322
|
+
<PressScale ref={fitButton} accessibilityRole="button" onPress={toggle}
|
|
323
|
+
style={(state) => ({ ...barButton(state), flexDirection: "row", gap: 6, paddingHorizontal: 10 })}>
|
|
324
|
+
<Icon name={scrolls ? "Shrink" : "MoveHorizontal"} size={16} color={light} />
|
|
325
|
+
<Text style={{ color: light, fontSize: 13, lineHeight: 18 }}>{scrolls ? m.fitToScreen : m.fitToWidth}</Text>
|
|
326
|
+
</PressScale>
|
|
327
|
+
) : null}
|
|
328
|
+
<PressScale ref={closeButton} accessibilityRole="button" accessibilityLabel={m.exitFullScreen} onPress={onExit}
|
|
329
|
+
style={(state) => ({ ...barButton(state), width: 36 })}>
|
|
330
|
+
<Icon name="X" size={18} color={light} />
|
|
331
|
+
</PressScale>
|
|
332
|
+
</View>
|
|
201
333
|
</View>
|
|
202
|
-
{
|
|
334
|
+
{/* Keyed so the next image starts at its top. The fit button stays the visible
|
|
335
|
+
keyboard control, so the scroll area draws no focus ring. */}
|
|
336
|
+
{scrolls ? (
|
|
337
|
+
<ScrollView key={uri} ref={scrollArea} focusable style={{ flex: 1, outlineWidth: 0 }} contentContainerStyle={{ flexGrow: 1 }}>{area}</ScrollView>
|
|
338
|
+
) : area}
|
|
203
339
|
</View>
|
|
204
340
|
);
|
|
205
341
|
}
|
|
206
342
|
|
|
343
|
+
// Only views on web have focus(); elsewhere this does nothing.
|
|
344
|
+
function focus(ref: React.RefObject<unknown>) {
|
|
345
|
+
(ref.current as { focus?(): void } | null)?.focus?.();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Paseo draws dialogs as bottom sheets below this window width.
|
|
349
|
+
const COMPACT_BELOW = 720;
|
|
350
|
+
|
|
207
351
|
// Width left for content: Paseo's 520px dialog less its 24px padding each side.
|
|
208
352
|
const CONTENT_WIDTH = 472;
|
|
209
353
|
// Characters of 12px monospace per line of the text box (10px padding each side).
|
|
@@ -232,11 +376,12 @@ function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { co
|
|
|
232
376
|
);
|
|
233
377
|
}
|
|
234
378
|
|
|
235
|
-
// An image's width over its height, or
|
|
236
|
-
// because onLoad doesn't report a size for data URIs on every platform.
|
|
237
|
-
function
|
|
379
|
+
// An image's pixel width and its width over its height, or nulls if they can't be read.
|
|
380
|
+
// Read from the data because onLoad doesn't report a size for data URIs on every platform.
|
|
381
|
+
function imageSize(uri: string): Promise<{ width: number | null; ratio: number | null }> {
|
|
238
382
|
return new Promise((resolve) => {
|
|
239
|
-
Image.getSize(uri, (width, height) => resolve(width && height ? width / height : null
|
|
383
|
+
Image.getSize(uri, (width, height) => resolve(width && height ? { width, ratio: width / height } : { width: null, ratio: null }),
|
|
384
|
+
() => resolve({ width: null, ratio: null }));
|
|
240
385
|
});
|
|
241
386
|
}
|
|
242
387
|
|
|
@@ -248,9 +393,9 @@ function roomForContent(windowHeight: number): number {
|
|
|
248
393
|
}
|
|
249
394
|
|
|
250
395
|
// A dialog footer button: 13px label and 14px icon, 30px tall. Icon-only is a 30px square.
|
|
251
|
-
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> }) {
|
|
396
|
+
export function DialogButton({ colors, icon, label, accessibilityLabel, onPress, onBlur, outerStyle, ref }: { colors: Colors; icon: string; label?: string; accessibilityLabel?: string; onPress(): void; onBlur?(): void; outerStyle?: ViewStyle; ref?: React.Ref<View> }) {
|
|
252
397
|
return (
|
|
253
|
-
<PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} outerStyle={outerStyle}
|
|
398
|
+
<PressScale ref={ref} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} onPress={onPress} onBlur={onBlur} outerStyle={outerStyle}
|
|
254
399
|
style={({ pressed }) => ({
|
|
255
400
|
flexDirection: "row", alignItems: "center", gap: 6,
|
|
256
401
|
paddingVertical: label ? 6 : 8, paddingLeft: label ? 8 : 8, paddingRight: label ? 10 : 8,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|