@stevecastaneda/paseo-progress 1.7.0 → 1.8.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/attachments.tsx +10 -2
- package/client/dialog-stack.tsx +4 -2
- package/client/page-opener.ts +9 -0
- package/client/page-screen.tsx +97 -0
- package/client/preview.tsx +1 -1
- package/client/whats-new.tsx +13 -12
- package/index.client.tsx +3 -0
- package/package.json +1 -1
- package/server/open.ts +58 -5
- package/shared/attachments.ts +13 -4
- package/shared/rpc.ts +3 -2
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.8.0
|
|
149
|
+
|
|
150
|
+
- [New] **HTML reports open on a server with no screen.** An HTML report on a computer that can't open it, like a Linux server, used to fail and copy the file's path. In the desktop app it now opens across Paseo's main area, with its pictures, styles and scripts. Esc or Close takes you back. Where the computer can open it, it still opens in your browser.
|
|
151
|
+
- [Improved] **What's new banner.** The headline gets the banner's full width, with **See what's new** under it.
|
|
152
|
+
|
|
148
153
|
### 1.7.0
|
|
149
154
|
|
|
150
155
|
- [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.
|
package/client/attachments.tsx
CHANGED
|
@@ -2,10 +2,11 @@ import { openExternalUrl, type PluginWorkspacePanelProps, useRpc } from "@getpas
|
|
|
2
2
|
import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { Platform, Text, View } from "react-native";
|
|
5
|
-
import { type Attachment, attachmentIcon, canPreview, shortName } from "../shared/attachments";
|
|
5
|
+
import { type Attachment, attachmentIcon, canPreview, opensAsPage, shortName } from "../shared/attachments";
|
|
6
|
+
import { openPage } from "./page-opener";
|
|
6
7
|
import { errorText } from "./errors";
|
|
7
8
|
import { PressableRow } from "./row";
|
|
8
|
-
import { openDeliverable } from "../shared/rpc";
|
|
9
|
+
import { canOpenOnHost, openDeliverable } from "../shared/rpc";
|
|
9
10
|
import { useProgressLanguage } from "./language";
|
|
10
11
|
|
|
11
12
|
export { type Attachment, attachmentIcon, deliverableAttachment, questionAttachments, shortName, ticketSourceAttachment } from "../shared/attachments";
|
|
@@ -35,6 +36,7 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
|
|
|
35
36
|
const { messages: m } = useProgressLanguage();
|
|
36
37
|
const toast = useToast();
|
|
37
38
|
const openOnHostRpc = useRpc(openDeliverable);
|
|
39
|
+
const checkHost = useRpc(canOpenOnHost);
|
|
38
40
|
async function copyPath(attachment: Attachment, prefix = "") {
|
|
39
41
|
try {
|
|
40
42
|
await copyText(absolutePath(attachment.path!, workspaceDirectory));
|
|
@@ -61,6 +63,12 @@ export function useAttachmentOpener({ workspaceId, workspaceDirectory, navigatio
|
|
|
61
63
|
}
|
|
62
64
|
return;
|
|
63
65
|
}
|
|
66
|
+
// A page opens on the computer holding it, like a double-click, and in
|
|
67
|
+
// Paseo only where that computer has no screen to open it on.
|
|
68
|
+
if (openPage && opensAsPage(attachment, { browser: Platform.OS === "web" }) && !(await checkHost({ workspaceId, workspaceDirectory }).then((result) => result.canOpen, () => false))) {
|
|
69
|
+
openPage({ workspaceId, workspaceDirectory, ref: attachment.ref, title: attachment.title });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
64
72
|
if (onPreview && isPreviewable(attachment)) {
|
|
65
73
|
onPreview(attachment);
|
|
66
74
|
return;
|
package/client/dialog-stack.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
|
|
3
3
|
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Platform } from "react-native";
|
|
5
|
+
import { opensAsPage } from "../shared/attachments";
|
|
4
6
|
import type { Dashboard, Question } from "../shared/dashboard";
|
|
5
7
|
import { type Attachment, attachmentIcon, useAttachmentOpener } from "./attachments";
|
|
6
8
|
import { NarrowProvider } from "./narrow";
|
|
@@ -49,8 +51,8 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
|
|
|
49
51
|
const opener = useAttachmentOpener({ ...context, onPreview: (attachment) => push({ kind: "preview", attachment, group: galleryFiles(group.current), fullScreen: false }) });
|
|
50
52
|
const openAttachment = (attachment: Attachment, from: Attachment[]) => {
|
|
51
53
|
group.current = from;
|
|
52
|
-
// A link opens
|
|
53
|
-
if (attachment.url) onClose();
|
|
54
|
+
// A link or a page opens outside the dialog; close first so it isn't left behind it.
|
|
55
|
+
if (attachment.url || opensAsPage(attachment, { browser: Platform.OS === "web" })) onClose();
|
|
54
56
|
void opener.open(attachment);
|
|
55
57
|
};
|
|
56
58
|
// Opens the agent's chat in its workspace, with the dialog out of the way.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type PageParams = { workspaceId: string; workspaceDirectory: string; ref: string; title: string };
|
|
2
|
+
|
|
3
|
+
// Panels can't open a screen themselves, only the plugin's client can, so it
|
|
4
|
+
// sets this once it has added the page screen.
|
|
5
|
+
export let openPage: ((params: PageParams) => void) | null = null;
|
|
6
|
+
|
|
7
|
+
export function setPageOpener(opener: typeof openPage) {
|
|
8
|
+
openPage = opener;
|
|
9
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type PluginClientContext, type PluginScreenProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { ActivityIndicator, Text, View } from "react-native";
|
|
5
|
+
import { previewDeliverable } from "../shared/rpc";
|
|
6
|
+
import { errorText } from "./errors";
|
|
7
|
+
import { ProgressLanguageProvider, useProgressLanguage } from "./language";
|
|
8
|
+
import { type PageParams, setPageOpener } from "./page-opener";
|
|
9
|
+
|
|
10
|
+
const PAGE_SCREEN_ID = "page";
|
|
11
|
+
|
|
12
|
+
export function addPageScreen(client: PluginClientContext) {
|
|
13
|
+
setPageOpener((params) => client.openScreen({ screenId: PAGE_SCREEN_ID, params }));
|
|
14
|
+
const stop = client.addScreen({
|
|
15
|
+
id: PAGE_SCREEN_ID,
|
|
16
|
+
title: (params) => params.title ?? "",
|
|
17
|
+
Component: (props) => <ProgressLanguageProvider><PageScreen {...props} /></ProgressLanguageProvider>,
|
|
18
|
+
});
|
|
19
|
+
return () => {
|
|
20
|
+
setPageOpener(null);
|
|
21
|
+
void stop();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Keys pressed inside the page go to the page, so it tells Progress about
|
|
26
|
+
// Escape itself, unless one of its own handlers has used the key.
|
|
27
|
+
const ESCAPE = "progress:page-escape";
|
|
28
|
+
const ESCAPE_SCRIPT = `<script>addEventListener("keydown",function(e){if(e.key==="Escape"&&!e.defaultPrevented)parent.postMessage("${ESCAPE}","*")})</script>`;
|
|
29
|
+
|
|
30
|
+
// The plugin compiles without the DOM's types; these are the browser APIs used here.
|
|
31
|
+
type KeyEvent = { key: string; defaultPrevented: boolean };
|
|
32
|
+
type FrameMessage = { data: unknown; source: unknown };
|
|
33
|
+
type Frame = { contentWindow: unknown; getClientRects(): { length: number } };
|
|
34
|
+
const browser = globalThis as unknown as {
|
|
35
|
+
addEventListener(type: "keydown", listener: (event: KeyEvent) => void): void;
|
|
36
|
+
addEventListener(type: "message", listener: (event: FrameMessage) => void): void;
|
|
37
|
+
removeEventListener(type: "keydown" | "message", listener: (event: never) => void): void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Escape leaves the page, like the Close button in Paseo's header.
|
|
41
|
+
// Paseo can keep a screen mounted, hidden, after leaving it, so Escape only
|
|
42
|
+
// counts while the page is on screen.
|
|
43
|
+
function useEscapeToClose(frame: React.RefObject<Frame | null>, close: (() => void) | undefined) {
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!close || typeof browser.addEventListener !== "function") return;
|
|
46
|
+
const onKey = (event: KeyEvent) => {
|
|
47
|
+
if (event.key === "Escape" && !event.defaultPrevented && frame.current?.getClientRects().length) close();
|
|
48
|
+
};
|
|
49
|
+
const onMessage = (event: FrameMessage) => {
|
|
50
|
+
if (event.data === ESCAPE && event.source === frame.current?.contentWindow) close();
|
|
51
|
+
};
|
|
52
|
+
browser.addEventListener("keydown", onKey);
|
|
53
|
+
browser.addEventListener("message", onMessage);
|
|
54
|
+
return () => {
|
|
55
|
+
browser.removeEventListener("keydown", onKey);
|
|
56
|
+
browser.removeEventListener("message", onMessage);
|
|
57
|
+
};
|
|
58
|
+
}, [close]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// An HTML deliverable across the whole screen, the width it was written for.
|
|
62
|
+
function PageScreen({ params, theme: { colors }, navigation }: PluginScreenProps) {
|
|
63
|
+
const { messages: m } = useProgressLanguage();
|
|
64
|
+
const fetchPreview = useRpc(previewDeliverable);
|
|
65
|
+
const { workspaceId, workspaceDirectory, ref } = params as PageParams;
|
|
66
|
+
const page = useQuery({
|
|
67
|
+
queryKey: ["progress", "page", workspaceId, ref],
|
|
68
|
+
queryFn: () => fetchPreview({ workspaceId, workspaceDirectory, ref }),
|
|
69
|
+
});
|
|
70
|
+
// The page stays hidden on Paseo's background until it has drawn, so opening it
|
|
71
|
+
// doesn't flash an empty white frame first.
|
|
72
|
+
const [shownHtml, setShownHtml] = useState<string | null>(null);
|
|
73
|
+
const frame = useRef<Frame | null>(null);
|
|
74
|
+
useEscapeToClose(frame, navigation ? () => navigation.openWorkspace({ workspaceId }) : undefined);
|
|
75
|
+
if (page.data?.kind === "html") {
|
|
76
|
+
const html = page.data.html + ESCAPE_SCRIPT;
|
|
77
|
+
return (
|
|
78
|
+
<View style={{ flex: 1, backgroundColor: colors.surface0 }}>
|
|
79
|
+
{/* `allow-scripts` alone, as in Paseo's own HTML preview: the page runs its scripts
|
|
80
|
+
(and loads them from the web, so no CSP) in an opaque origin that can't reach
|
|
81
|
+
Paseo's DOM or storage, navigate the app, open windows or submit forms. */}
|
|
82
|
+
<iframe ref={frame as never} srcDoc={html} sandbox="allow-scripts" referrerPolicy="no-referrer" title={params.title} onLoad={() => setShownHtml(html)}
|
|
83
|
+
style={{ flex: 1, width: "100%", height: "100%", display: "block", border: "none", backgroundColor: "white",
|
|
84
|
+
opacity: shownHtml === html ? 1 : 0, transition: "opacity 150ms ease-out" }} />
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return (
|
|
89
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }}>
|
|
90
|
+
{page.error ? (
|
|
91
|
+
<Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
|
|
92
|
+
{m.previewError(errorText(page.error, m.unknownError))}
|
|
93
|
+
</Text>
|
|
94
|
+
) : <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingPreview} />}
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
package/client/preview.tsx
CHANGED
|
@@ -356,7 +356,7 @@ const CHARS_PER_LINE = Math.floor((CONTENT_WIDTH - 20) / 7.2);
|
|
|
356
356
|
const FOLDER_ROW_HEIGHT = ATTACHMENT_ROW.lineHeight + ATTACHMENT_ROW.paddingVertical * 2 + ATTACHMENT_ROW.gap;
|
|
357
357
|
|
|
358
358
|
// Roughly how tall the preview draws, including wrapped lines.
|
|
359
|
-
function estimatedHeight(data: { kind: "image" | "video" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
|
|
359
|
+
function estimatedHeight(data: { kind: "image" | "video" | "html" } | { kind: "text"; text: string } | { kind: "folder"; entries: unknown[]; truncated: boolean } | undefined, aspectRatio: number, maxMediaHeight: number): number {
|
|
360
360
|
if (!data) return 0;
|
|
361
361
|
if (data.kind === "folder") return Math.max(1, data.entries.length + (data.truncated ? 1 : 0)) * FOLDER_ROW_HEIGHT;
|
|
362
362
|
if (data.kind !== "text") return Math.min(CONTENT_WIDTH / aspectRatio, maxMediaHeight);
|
package/client/whats-new.tsx
CHANGED
|
@@ -39,22 +39,23 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
|
|
|
39
39
|
return (
|
|
40
40
|
<>
|
|
41
41
|
<Presence show={Boolean(whatsNew)}>{whatsNew ? (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
style={({ pressed, hovered }) => ({ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
51
|
-
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.seeWhatsNew}</Text>
|
|
52
|
-
</PressScale>
|
|
42
|
+
// The headline gets the full width, with the button under it on the text's edge.
|
|
43
|
+
<View style={{ gap: 8, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
|
|
44
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
|
|
45
|
+
<View style={{ height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
|
|
46
|
+
<Text selectable style={{ flex: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
47
|
+
<Text style={{ color: colors.foreground, fontWeight: "500" }}>{whatsNew.releases.length > 1 ? m.updatedTo(whatsNew.version) : m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
|
|
48
|
+
</Text>
|
|
49
|
+
{/* Centered on the first line without making it taller. */}
|
|
53
50
|
<PressScale accessibilityRole="button" accessibilityLabel={m.dismissWhatsNew} hitSlop={6} onPress={() => close(whatsNew.version)}
|
|
54
|
-
style={({ pressed, hovered }) => ({ padding: 5, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
51
|
+
style={({ pressed, hovered }) => ({ marginVertical: -4.5, padding: 5, borderRadius: 4, backgroundColor: pressed ? colors.border : hovered ? colors.surface2 : "transparent" })}>
|
|
55
52
|
<Icon name="X" size={16} color={colors.foregroundMuted} />
|
|
56
53
|
</PressScale>
|
|
57
54
|
</View>
|
|
55
|
+
<PressScale accessibilityRole="button" onPress={() => { setShown(whatsNew); setOpen(true); }}
|
|
56
|
+
style={({ pressed, hovered }) => ({ alignSelf: "flex-start", marginStart: 24, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 4, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
57
|
+
<Text style={{ color: colors.accentForeground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{m.seeWhatsNew}</Text>
|
|
58
|
+
</PressScale>
|
|
58
59
|
</View>
|
|
59
60
|
) : null}</Presence>
|
|
60
61
|
<Modal title={!shown ? "" : shown.releases.length > 1 ? m.whatsNewAll : m.whatsNewTitle(shown.version)} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
|
package/index.client.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { ProgressPanel } from "./client/panel";
|
|
|
3
3
|
import { contributePills, progressPanel } from "./client/pills";
|
|
4
4
|
import { QuestionSettings } from "./client/settings";
|
|
5
5
|
import { ProgressLanguageProvider } from "./client/language";
|
|
6
|
+
import { addPageScreen } from "./client/page-screen";
|
|
6
7
|
|
|
7
8
|
const SETTINGS_ID = "settings";
|
|
8
9
|
|
|
@@ -29,6 +30,7 @@ export default function contribute(client: PluginClientContext) {
|
|
|
29
30
|
},
|
|
30
31
|
});
|
|
31
32
|
|
|
33
|
+
const stopPage = addPageScreen(client);
|
|
32
34
|
const stopSettings = client.addSettingsScreen({
|
|
33
35
|
id: SETTINGS_ID,
|
|
34
36
|
title: "Settings",
|
|
@@ -37,6 +39,7 @@ export default function contribute(client: PluginClientContext) {
|
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
return () => {
|
|
42
|
+
stopPage();
|
|
40
43
|
void stopSettings();
|
|
41
44
|
void stopPanel();
|
|
42
45
|
void stopCommand();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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/open.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
5
5
|
import { homedir, hostname } from "node:os";
|
|
6
|
-
import { delimiter, join, relative, resolve } from "node:path";
|
|
6
|
+
import { delimiter, dirname, join, relative, resolve } from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { findAttachment, type FolderEntry, imageMimeType, previewKind, splitRef, videoMimeType } from "../shared/attachments.ts";
|
|
9
9
|
import { readProgress } from "./progress-file.ts";
|
|
@@ -53,7 +53,7 @@ export function hostOpener(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): O
|
|
|
53
53
|
// A deliverable or question attachment the agent recorded, found by reference,
|
|
54
54
|
// so the panel can never reach an arbitrary path. Only files inside the
|
|
55
55
|
// worktree resolve.
|
|
56
|
-
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string }> {
|
|
56
|
+
async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string; root: string }> {
|
|
57
57
|
const { base, inner } = splitRef(ref);
|
|
58
58
|
const worktree = findRoot(directory);
|
|
59
59
|
const { dashboard } = await readProgress((await findStore(worktree)).directory);
|
|
@@ -66,7 +66,7 @@ async function resolveAttachment(directory: string, ref: string, verb: string):
|
|
|
66
66
|
if (!target) throw new Error(`${path} no longer exists.`);
|
|
67
67
|
const inside = relative(root, target);
|
|
68
68
|
if (outside(inside)) throw new Error(`${path} is outside this worktree, so it was not ${verb}.`);
|
|
69
|
-
return { path, target };
|
|
69
|
+
return { path, target, root };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export async function openDeliverable(directory: string, id: string, opener: Opener): Promise<{ opened: string }> {
|
|
@@ -93,14 +93,15 @@ export type Preview =
|
|
|
93
93
|
| { kind: "image"; dataUri: string; bytes: number }
|
|
94
94
|
| { kind: "video"; mimeType: string; bytes: number }
|
|
95
95
|
| { kind: "text"; text: string; bytes: number; truncated: boolean }
|
|
96
|
+
| { kind: "html"; html: string; bytes: number }
|
|
96
97
|
| { kind: "folder"; entries: FolderEntry[]; truncated: boolean };
|
|
97
98
|
|
|
98
99
|
export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
|
|
99
|
-
const { path, target } = await resolveAttachment(directory, id, "previewed");
|
|
100
|
+
const { path, target, root } = await resolveAttachment(directory, id, "previewed");
|
|
100
101
|
const stats = await stat(target);
|
|
101
102
|
if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(target)) };
|
|
102
103
|
const kind = previewKind(target);
|
|
103
|
-
if (!kind) throw new Error(`${path} isn't an image, video or
|
|
104
|
+
if (!kind) throw new Error(`${path} isn't an image, video, text or HTML file, so it can't be previewed.`);
|
|
104
105
|
const { size } = stats;
|
|
105
106
|
if (kind === "video") {
|
|
106
107
|
if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
|
|
@@ -117,6 +118,7 @@ export async function previewDeliverable(directory: string, id: string): Promise
|
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
|
|
121
|
+
if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(target, "utf8"), dirname(target), root), bytes: size };
|
|
120
122
|
const data = await readFile(target);
|
|
121
123
|
return { kind, dataUri: `data:${imageMimeType(target)};base64,${data.toString("base64")}`, bytes: size };
|
|
122
124
|
}
|
|
@@ -132,6 +134,57 @@ async function listFolder(target: string): Promise<{ entries: FolderEntry[]; tru
|
|
|
132
134
|
return { entries: entries.slice(0, MAX_FOLDER_ENTRIES), truncated: entries.length > MAX_FOLDER_ENTRIES };
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
// The preview gets only the page, so the files beside it that it loads (its
|
|
138
|
+
// screenshots, a script, a stylesheet) are written into it. Only files inside the
|
|
139
|
+
// worktree, up to MAX_PREVIEW_BYTES in all; anything else is left as written and
|
|
140
|
+
// fails to load. Web addresses are left alone.
|
|
141
|
+
async function inlineLocalFiles(html: string, directory: string, root: string): Promise<string> {
|
|
142
|
+
let room = MAX_PREVIEW_BYTES - Buffer.byteLength(html);
|
|
143
|
+
const inline = async (url: string, encode: (data: Buffer, file: string) => string): Promise<string | null> => {
|
|
144
|
+
if (!url || /^([a-z][a-z0-9+.-]*:|[/\\#?])/i.test(url)) return null;
|
|
145
|
+
try {
|
|
146
|
+
const file = await realpath(resolve(directory, decodeURI(url.replace(/[?#].*$/, ""))));
|
|
147
|
+
const stats = await stat(file);
|
|
148
|
+
if (outside(relative(root, file)) || !stats.isFile() || stats.size > room) return null;
|
|
149
|
+
const encoded = encode(await readFile(file), file);
|
|
150
|
+
if (encoded.length > room) return null;
|
|
151
|
+
room -= encoded.length;
|
|
152
|
+
return encoded;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const dataUri = (data: Buffer, file: string) => `data:${imageMimeType(file) ?? videoMimeType(file) ?? "application/octet-stream"};base64,${data.toString("base64")}`;
|
|
158
|
+
// Its own end tag would close the element early.
|
|
159
|
+
const body = (tag: string) => (data: Buffer) => data.toString("utf8").replace(new RegExp(`</${tag}`, "gi"), `<\\/${tag}`);
|
|
160
|
+
html = await replaceEach(html, /<(?:img|source|video)\b[^>]*>/gi, (tag) =>
|
|
161
|
+
replaceEach(tag[0], /(?<=\s)(src|poster)(\s*=\s*)(["'])([^"']*)\3/gi, async ([, name, equals, quote, url]) => {
|
|
162
|
+
const uri = await inline(url, dataUri);
|
|
163
|
+
return uri && `${name}${equals}${quote}${uri}${quote}`;
|
|
164
|
+
}));
|
|
165
|
+
html = await replaceEach(html, /<link\b[^>]*>/gi, async ([tag]) => {
|
|
166
|
+
const href = tag.match(/\bhref\s*=\s*(["'])([^"']*)\1/i)?.[2];
|
|
167
|
+
if (!href || !/\brel\s*=\s*(["'])\s*stylesheet\s*\1/i.test(tag)) return null;
|
|
168
|
+
const css = await inline(href, body("style"));
|
|
169
|
+
return css === null ? null : `<style>${css}</style>`;
|
|
170
|
+
});
|
|
171
|
+
return replaceEach(html, /<script\b([^>]*?)\s+src\s*=\s*(["'])([^"']*)\2([^>]*)>\s*<\/script\s*>/gi, async ([, before, , src, after]) => {
|
|
172
|
+
const script = await inline(src, body("script"));
|
|
173
|
+
return script === null ? null : `<script${before}${after}>${script}</script>`;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// `replace` on each match in turn; a null keeps the match as it was.
|
|
178
|
+
async function replaceEach(text: string, pattern: RegExp, replace: (match: RegExpMatchArray) => Promise<string | null>): Promise<string> {
|
|
179
|
+
let result = "";
|
|
180
|
+
let last = 0;
|
|
181
|
+
for (const match of text.matchAll(pattern)) {
|
|
182
|
+
result += text.slice(last, match.index) + ((await replace(match)) ?? match[0]);
|
|
183
|
+
last = match.index! + match[0].length;
|
|
184
|
+
}
|
|
185
|
+
return result + text.slice(last);
|
|
186
|
+
}
|
|
187
|
+
|
|
135
188
|
function tooLarge(path: string, size: number): string {
|
|
136
189
|
return `${path} is ${Math.round(size / 1024 / 1024)} MB, too large to preview.`;
|
|
137
190
|
}
|
package/shared/attachments.ts
CHANGED
|
@@ -65,7 +65,7 @@ const VIDEO_TYPES: Record<string, string> = {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
// Plain text shown as-is in the preview dialog, and opened in the browser on
|
|
68
|
-
// the host.
|
|
68
|
+
// the host.
|
|
69
69
|
const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
|
|
70
70
|
|
|
71
71
|
function extension(path: string): string | null {
|
|
@@ -82,10 +82,12 @@ export function videoMimeType(path: string): string | null {
|
|
|
82
82
|
return VIDEO_TYPES[extension(path) ?? ""] ?? null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
export function previewKind(path: string): "image" | "video" | "text" | null {
|
|
85
|
+
export function previewKind(path: string): "image" | "video" | "text" | "html" | null {
|
|
86
86
|
if (imageMimeType(path)) return "image";
|
|
87
87
|
if (videoMimeType(path)) return "video";
|
|
88
|
-
|
|
88
|
+
const ext = extension(path) ?? "";
|
|
89
|
+
if (ext === ".html" || ext === ".htm") return "html";
|
|
90
|
+
return TEXT_EXTENSIONS.has(ext) ? "text" : null;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export function isFolder(attachment: Attachment): boolean {
|
|
@@ -118,6 +120,7 @@ export function canPreview(attachment: Attachment, where: { browser: boolean }):
|
|
|
118
120
|
if (!attachment.path) return false;
|
|
119
121
|
if (isFolder(attachment)) return true;
|
|
120
122
|
const kind = previewKind(attachment.path);
|
|
123
|
+
if (kind === "html") return false;
|
|
121
124
|
if (kind === "video" || (kind === "image" && extension(attachment.path) === ".svg")) return where.browser;
|
|
122
125
|
return kind !== null;
|
|
123
126
|
}
|
|
@@ -137,7 +140,13 @@ export function attachmentIcon(attachment: Attachment): string {
|
|
|
137
140
|
if (attachment.url) return "Globe";
|
|
138
141
|
if (!attachment.path) return "File";
|
|
139
142
|
const kind = previewKind(attachment.path);
|
|
140
|
-
return kind === "image" ? "Image" : kind === "video" ? "Video" : kind === "text" ? "FileText" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
143
|
+
return kind === "image" ? "Image" : kind === "video" ? "Video" : kind === "text" ? "FileText" : kind === "html" ? "FileCode" : /[\/]$/.test(attachment.path) ? "Folder" : "File";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An HTML page is too wide for the dialog, so where the host can't open it the
|
|
147
|
+
// desktop app gives it a screen of its own. The phone apps can't draw one.
|
|
148
|
+
export function opensAsPage(attachment: Attachment, where: { browser: boolean }): boolean {
|
|
149
|
+
return where.browser && Boolean(attachment.path) && previewKind(attachment.path!) === "html";
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
// The short name people recognize: a file or folder's own name, or a link's site.
|
package/shared/rpc.ts
CHANGED
|
@@ -142,8 +142,8 @@ export const canOpenOnHost = defineRpc({
|
|
|
142
142
|
output: z.object({ canOpen: z.boolean() }),
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
// An image or
|
|
146
|
-
// as its type and size only; its bytes follow in pieces (readDeliverablePiece).
|
|
145
|
+
// An image, text or HTML file's contents, for the preview dialog. A video comes
|
|
146
|
+
// back as its type and size only; its bytes follow in pieces (readDeliverablePiece).
|
|
147
147
|
export const previewDeliverable = defineRpc({
|
|
148
148
|
name: "progress.deliverable.preview",
|
|
149
149
|
input: z.object({
|
|
@@ -156,6 +156,7 @@ export const previewDeliverable = defineRpc({
|
|
|
156
156
|
z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
|
|
157
157
|
z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
|
|
158
158
|
z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
|
|
159
|
+
z.object({ kind: z.literal("html"), html: z.string(), bytes: z.number() }),
|
|
159
160
|
z.object({ kind: z.literal("folder"), entries: z.array(z.object({ name: z.string(), folder: z.boolean() })), truncated: z.boolean() }),
|
|
160
161
|
]),
|
|
161
162
|
});
|