@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 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.
@@ -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;
@@ -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 elsewhere in Paseo; close first so it isn't left behind the dialog.
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
+ }
@@ -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);
@@ -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
- <View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
43
- <View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
44
- <Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
45
- <Text style={{ color: colors.foreground, fontWeight: "500" }}>{whatsNew.releases.length > 1 ? m.updatedTo(whatsNew.version) : m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
46
- </Text>
47
- {/* 8px keeps the close button's 6px hit slop off the button beside it. */}
48
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
49
- <PressScale accessibilityRole="button" onPress={() => { setShown(whatsNew); setOpen(true); }}
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.7.0",
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 text file, so it can't be previewed.`);
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
  }
@@ -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. HTML is left to the browser either way.
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
- return TEXT_EXTENSIONS.has(extension(path) ?? "") ? "text" : null;
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 text file's contents, for the preview dialog. A video comes back
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
  });