@stevecastaneda/paseo-progress 1.7.0 → 1.9.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 +11 -1
- package/client/attachments.tsx +10 -2
- package/client/dialog-stack.tsx +4 -2
- package/client/locales/en.ts +3 -0
- package/client/locales/zh-CN.ts +3 -0
- package/client/messages.ts +3 -0
- package/client/page-opener.ts +9 -0
- package/client/page-screen.tsx +97 -0
- package/client/pill-popover.tsx +127 -36
- package/client/preview.tsx +1 -1
- package/client/surfaces.ts +5 -2
- 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
|
@@ -20,7 +20,7 @@ The dashboard shows:
|
|
|
20
20
|
|
|
21
21
|
<img src="images/5-settings.png" alt="Progress settings in Paseo: Questions set to Work around, Language set to System, and the Agent Skills section with View skill" width="600">
|
|
22
22
|
|
|
23
|
-
The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new once, including any versions you skipped. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with
|
|
23
|
+
The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new once, including any versions you skipped. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with an open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. With more than one question, the arrows beside **Question 1 of 3** step through them. After you copy an answer, the popover opens on the next question. **Open Progress** at the bottom opens the panel in Explorer.
|
|
24
24
|
|
|
25
25
|
<img src="images/4-pill.png" alt="The pill above the message box, showing two questions waiting" width="600">
|
|
26
26
|
|
|
@@ -145,6 +145,16 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
|
|
|
145
145
|
|
|
146
146
|
## Changelog
|
|
147
147
|
|
|
148
|
+
### 1.9.0
|
|
149
|
+
|
|
150
|
+
- [Improved] **The pill's popover shows one question at a time.** You used to scroll past every open question to reach the one you wanted. Now **Question 2 of 3** sits at the top with back and next arrows. Copy an answer and the popover opens on the next question the next time you press the pill.
|
|
151
|
+
- [Improved] **A long question shows there's more below.** When a question runs past the bottom of the popover, its last lines fade out until you scroll to the end. Before, it stopped at the edge and looked finished.
|
|
152
|
+
|
|
153
|
+
### 1.8.0
|
|
154
|
+
|
|
155
|
+
- [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.
|
|
156
|
+
- [Improved] **What's new banner.** The headline gets the banner's full width, with **See what's new** under it.
|
|
157
|
+
|
|
148
158
|
### 1.7.0
|
|
149
159
|
|
|
150
160
|
- [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.
|
package/client/locales/en.ts
CHANGED
|
@@ -67,6 +67,9 @@ export const messages: ProgressMessages = {
|
|
|
67
67
|
openProgress: "Open Progress",
|
|
68
68
|
needsYou: "Progress needs you",
|
|
69
69
|
nothingNeedsYou: "Nothing needs you right now.",
|
|
70
|
+
questionPosition: (total: number) => ["Question ", ` of ${total}`],
|
|
71
|
+
previousQuestion: "Previous question",
|
|
72
|
+
nextQuestion: "Next question",
|
|
70
73
|
overdueAside: " (no update past its estimate)",
|
|
71
74
|
writeUp: "Write-up",
|
|
72
75
|
timeline: "Timeline",
|
package/client/locales/zh-CN.ts
CHANGED
|
@@ -67,6 +67,9 @@ export const messages: ProgressMessages = {
|
|
|
67
67
|
openProgress: "打开 Progress",
|
|
68
68
|
needsYou: "Progress 需要你处理",
|
|
69
69
|
nothingNeedsYou: "目前没有需要你处理的事项。",
|
|
70
|
+
questionPosition: (total: number) => ["第 ", ` 个问题,共 ${total} 个`],
|
|
71
|
+
previousQuestion: "上一个问题",
|
|
72
|
+
nextQuestion: "下一个问题",
|
|
70
73
|
overdueAside: "(超过预计时长未更新)",
|
|
71
74
|
writeUp: "总结",
|
|
72
75
|
timeline: "时间线",
|
package/client/messages.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface ProgressMessages {
|
|
|
49
49
|
openProgress: string;
|
|
50
50
|
needsYou: string;
|
|
51
51
|
nothingNeedsYou: string;
|
|
52
|
+
questionPosition: (total: number) => Around;
|
|
53
|
+
previousQuestion: string;
|
|
54
|
+
nextQuestion: string;
|
|
52
55
|
overdueAside: string;
|
|
53
56
|
writeUp: string;
|
|
54
57
|
timeline: string;
|
|
@@ -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/pill-popover.tsx
CHANGED
|
@@ -11,14 +11,20 @@ import { StackedDialog } from "./dialog-stack";
|
|
|
11
11
|
import { galleryFiles } from "./preview";
|
|
12
12
|
import { QuestionDetail, QuestionState, useCopy } from "./questions";
|
|
13
13
|
import { errorText } from "./errors";
|
|
14
|
-
import { raised } from "./surfaces";
|
|
14
|
+
import { raised, withAlpha } from "./surfaces";
|
|
15
15
|
import { useProgressLanguage } from "./language";
|
|
16
16
|
|
|
17
17
|
type Colors = PluginButtonContentProps["theme"]["colors"];
|
|
18
|
+
type Question = Dashboard["questions"]["open"][number];
|
|
18
19
|
|
|
19
20
|
// On desktop, Paseo caps the popover at 440px and scrolls all of it, padding included. The
|
|
20
21
|
// list scrolls on its own below that cap so Open Progress stays in view.
|
|
21
22
|
const LIST_MAX_HEIGHT = 340;
|
|
23
|
+
const HEADER_GAP = 12;
|
|
24
|
+
const TOUCH_TARGET = 44;
|
|
25
|
+
|
|
26
|
+
// Paseo unmounts the popover when it closes, so reopening comes back to where the user was.
|
|
27
|
+
const shownQuestions = new Map<string, string>();
|
|
22
28
|
|
|
23
29
|
// What the pill opens: the open questions with their Copy buttons, and anything
|
|
24
30
|
// stuck, so the user can answer without leaving the chat.
|
|
@@ -43,8 +49,8 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
43
49
|
return (
|
|
44
50
|
<View style={{ width: 360, maxWidth: "100%", gap: 8 }}>
|
|
45
51
|
{dashboard ? (
|
|
46
|
-
<
|
|
47
|
-
|
|
52
|
+
<AttentionList colors={colors} dashboard={dashboard} workspaceId={workspaceId} compact={layout.compact} touch={touch} onSurface1={onSurface1}
|
|
53
|
+
onCopy={async (question, letter) => {
|
|
48
54
|
await copy(question, letter);
|
|
49
55
|
close();
|
|
50
56
|
}} onOpenAttachment={(attachment, from) => {
|
|
@@ -52,7 +58,6 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
52
58
|
if (!isPreviewable(attachment)) close();
|
|
53
59
|
void opener.open(attachment);
|
|
54
60
|
}} />
|
|
55
|
-
</List>
|
|
56
61
|
) : (
|
|
57
62
|
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>
|
|
58
63
|
{query.error ? m.readProgressError(errorText(query.error, m.unknownError)) : m.loading}
|
|
@@ -63,7 +68,7 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
63
68
|
<View style={{ paddingTop: touch ? 12 : 8, borderTopWidth: 1, borderTopColor: colors.border }}>
|
|
64
69
|
{/* A full 44pt touch target on phones; desktop keeps the compact button. */}
|
|
65
70
|
<PressScale accessibilityRole="button" onPress={() => { close(); openPanel(); }}
|
|
66
|
-
style={({ pressed }) => ({ flexDirection: "row", justifyContent: "center", alignItems: "center", gap: touch ? 8 : 6, paddingVertical: 6, minHeight: touch ?
|
|
71
|
+
style={({ pressed }) => ({ flexDirection: "row", justifyContent: "center", alignItems: "center", gap: touch ? 8 : 6, paddingVertical: 6, minHeight: touch ? TOUCH_TARGET : undefined, borderRadius: touch ? 8 : 4, ...raised(colors),
|
|
67
72
|
backgroundColor: onSurface1 ? (pressed ? colors.surface1 : colors.surface2) : pressed ? colors.surface2 : colors.surface1 })}>
|
|
68
73
|
{/* Beside the chat on desktop; on a phone it opens as its own tab, shown with the tab's icon. */}
|
|
69
74
|
<Icon name={touch ? "ListChecks" : "PanelRight"} size={touch ? 16 : 12} color={colors.foreground} />
|
|
@@ -77,54 +82,140 @@ export function AttentionPopover({ theme, host, layout, workspaceId, close, open
|
|
|
77
82
|
// On a phone the popover is a sheet that already scrolls, and sizes itself to
|
|
78
83
|
// whichever scroll view reports last. Paseo 0.10.1 turns a ScrollView inside it
|
|
79
84
|
// into one of those, so a short list would shrink the sheet to its title.
|
|
80
|
-
function List({ compact, children }: { compact: boolean; children: React.ReactNode }) {
|
|
85
|
+
function List({ compact, maxHeight, surface, children }: { compact: boolean; maxHeight: number; surface: string; children: React.ReactNode }) {
|
|
86
|
+
const [moreBelow, setMoreBelow] = useState(false);
|
|
87
|
+
const size = useRef({ viewport: 0, content: 0, offset: 0 });
|
|
81
88
|
if (compact) return <View style={{ gap: 12 }}>{children}</View>;
|
|
82
|
-
|
|
89
|
+
const measure = (next: Partial<typeof size.current>) => {
|
|
90
|
+
const s = Object.assign(size.current, next);
|
|
91
|
+
const below = s.offset + s.viewport < s.content - 1;
|
|
92
|
+
if (below !== moreBelow) setMoreBelow(below);
|
|
93
|
+
};
|
|
94
|
+
return (
|
|
95
|
+
<View style={{ position: "relative" }}>
|
|
96
|
+
<ScrollView style={{ maxHeight }} contentContainerStyle={{ gap: 12 }} scrollEventThrottle={16}
|
|
97
|
+
onLayout={(event) => measure({ viewport: event.nativeEvent.layout.height })}
|
|
98
|
+
onContentSizeChange={(_, height) => measure({ content: height })}
|
|
99
|
+
onScroll={(event) => measure({ offset: event.nativeEvent.contentOffset.y })}>
|
|
100
|
+
{children}
|
|
101
|
+
</ScrollView>
|
|
102
|
+
{moreBelow ? <BottomFade color={surface} /> : null}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const FADE_SLICES = 8;
|
|
108
|
+
|
|
109
|
+
// React Native Web 0.21 has no experimental_backgroundImage, so no single gradient style draws on both desktop and iPad.
|
|
110
|
+
function BottomFade({ color }: { color: string }) {
|
|
111
|
+
return (
|
|
112
|
+
<View pointerEvents="none" style={{ position: "absolute", left: 0, right: 0, bottom: 0 }}>
|
|
113
|
+
{Array.from({ length: FADE_SLICES }, (_, i) => (
|
|
114
|
+
<View key={i} style={{ height: 3, backgroundColor: withAlpha(color, (0.9 * i) / (FADE_SLICES - 1)) ?? undefined }} />
|
|
115
|
+
))}
|
|
116
|
+
</View>
|
|
117
|
+
);
|
|
83
118
|
}
|
|
84
119
|
|
|
85
|
-
export function AttentionList({ colors, dashboard, onSurface1, onCopy, onOpenAttachment }: {
|
|
120
|
+
export function AttentionList({ colors, dashboard, workspaceId, compact, touch, onSurface1, onCopy, onOpenAttachment }: {
|
|
86
121
|
colors: Colors;
|
|
87
122
|
dashboard: Dashboard;
|
|
123
|
+
workspaceId: string;
|
|
124
|
+
compact: boolean;
|
|
125
|
+
touch: boolean;
|
|
88
126
|
onSurface1: boolean;
|
|
89
|
-
onCopy(question:
|
|
127
|
+
onCopy(question: Question, letter: string): void;
|
|
90
128
|
onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
|
|
91
129
|
}) {
|
|
92
130
|
const { messages: m } = useProgressLanguage();
|
|
131
|
+
const [shownId, setShownId] = useState(() => shownQuestions.get(workspaceId));
|
|
93
132
|
const now = Date.now();
|
|
133
|
+
const open = dashboard.questions.open;
|
|
134
|
+
const found = open.findIndex((question) => question.id === shownId);
|
|
135
|
+
const index = found === -1 ? 0 : found;
|
|
136
|
+
const question: Question | undefined = open[index];
|
|
94
137
|
// The last question can be answered while the popover is open.
|
|
95
|
-
if (!
|
|
138
|
+
if (!question && !dashboard.stuck.length) {
|
|
96
139
|
return <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{m.nothingNeedsYou}</Text>;
|
|
97
140
|
}
|
|
141
|
+
const show = (target: Question) => {
|
|
142
|
+
shownQuestions.set(workspaceId, target.id);
|
|
143
|
+
setShownId(target.id);
|
|
144
|
+
};
|
|
145
|
+
const advancePast = (copied: Question) => shownQuestions.set(workspaceId, (open[index + 1] ?? copied).id);
|
|
146
|
+
const step = { colors, touch, onSurface1, onPress: show };
|
|
147
|
+
const stepping = open.length > 1;
|
|
148
|
+
const position = m.questionPosition(open.length);
|
|
98
149
|
return (
|
|
99
|
-
|
|
100
|
-
{
|
|
101
|
-
<View
|
|
102
|
-
<
|
|
103
|
-
<Text style={{ color: colors.foreground,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
150
|
+
<View style={{ gap: HEADER_GAP }}>
|
|
151
|
+
{stepping ? (
|
|
152
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
153
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontVariant: ["tabular-nums"] }}>
|
|
154
|
+
{position[0]}<Text style={{ color: colors.foreground, fontWeight: "600" }}>{index + 1}</Text>{position[1]}
|
|
155
|
+
</Text>
|
|
156
|
+
<View style={{ flexDirection: "row", gap: touch ? stepSlop * 2 : 4 }}>
|
|
157
|
+
<StepButton {...step} icon="ChevronLeft" label={m.previousQuestion} target={open[index - 1]} />
|
|
158
|
+
<StepButton {...step} icon="ChevronRight" label={m.nextQuestion} target={open[index + 1]} />
|
|
108
159
|
</View>
|
|
109
|
-
<QuestionDetail colors={colors} question={question} now={now} onSurface1={onSurface1} onCopy={(letter) => onCopy(question, letter)} onOpenAttachment={onOpenAttachment} />
|
|
110
160
|
</View>
|
|
111
|
-
)
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
<View style={{
|
|
115
|
-
<
|
|
116
|
-
|
|
161
|
+
) : null}
|
|
162
|
+
<List key={question?.id} compact={compact} surface={onSurface1 ? colors.surface1 : colors.surface0} maxHeight={stepping ? LIST_MAX_HEIGHT - stepSize(touch) - HEADER_GAP : LIST_MAX_HEIGHT}>
|
|
163
|
+
{question ? (
|
|
164
|
+
<View style={{ gap: 6 }}>
|
|
165
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 8 }}>
|
|
166
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19, fontWeight: "600" }}>{question.id}</Text>
|
|
167
|
+
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
168
|
+
<Text style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{question.question}</Text>
|
|
169
|
+
<QuestionState colors={colors} question={question} />
|
|
170
|
+
</View>
|
|
171
|
+
</View>
|
|
172
|
+
<QuestionDetail colors={colors} question={question} now={now} onSurface1={onSurface1} onOpenAttachment={onOpenAttachment} onCopy={(letter) => {
|
|
173
|
+
advancePast(question);
|
|
174
|
+
onCopy(question, letter);
|
|
175
|
+
}} />
|
|
117
176
|
</View>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
177
|
+
) : null}
|
|
178
|
+
{dashboard.stuck.length ? (
|
|
179
|
+
<View style={{ gap: 4 }}>
|
|
180
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
181
|
+
<Icon name="OctagonAlert" size={12} color={colors.statusDanger} />
|
|
182
|
+
<Text style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 17, fontWeight: "600" }}>{m.stuck}</Text>
|
|
183
|
+
</View>
|
|
184
|
+
{dashboard.stuck.map((item) => (
|
|
185
|
+
<Text key={item.key} style={{ color: colors.foreground, fontSize: 12, lineHeight: 17 }}>
|
|
186
|
+
{item.title}
|
|
187
|
+
<Text style={{ color: colors.foregroundMuted }}>
|
|
188
|
+
{item.kind === "overdue" ? m.overdueAside : item.kind === "blocked" ? m.blockedAside(item.note) : ""}
|
|
189
|
+
</Text>
|
|
123
190
|
</Text>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
191
|
+
))}
|
|
192
|
+
</View>
|
|
193
|
+
) : null}
|
|
194
|
+
</List>
|
|
195
|
+
</View>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const stepSize = (touch: boolean) => (touch ? 36 : 24);
|
|
200
|
+
const stepSlop = (TOUCH_TARGET - stepSize(true)) / 2;
|
|
201
|
+
|
|
202
|
+
function StepButton({ colors, touch, onSurface1, icon, label, target, onPress }: {
|
|
203
|
+
colors: Colors;
|
|
204
|
+
touch: boolean;
|
|
205
|
+
onSurface1: boolean;
|
|
206
|
+
icon: "ChevronLeft" | "ChevronRight";
|
|
207
|
+
label: string;
|
|
208
|
+
target: Question | undefined;
|
|
209
|
+
onPress(target: Question): void;
|
|
210
|
+
}) {
|
|
211
|
+
const button = onSurface1 ? { rest: colors.surface2, pressed: colors.surface1 } : { rest: colors.surface1, pressed: colors.surface2 };
|
|
212
|
+
const size = stepSize(touch);
|
|
213
|
+
return (
|
|
214
|
+
<PressScale accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled: !target }} disabled={!target}
|
|
215
|
+
onPress={target ? () => onPress(target) : undefined} hitSlop={touch ? stepSlop : undefined}
|
|
216
|
+
style={({ pressed }) => ({ width: size, height: size, alignItems: "center", justifyContent: "center", borderRadius: touch ? 8 : 4, opacity: target ? 1 : 0.35, ...raised(colors),
|
|
217
|
+
backgroundColor: pressed ? button.pressed : button.rest })}>
|
|
218
|
+
<Icon name={icon} size={touch ? 16 : 14} color={colors.foreground} />
|
|
219
|
+
</PressScale>
|
|
129
220
|
);
|
|
130
221
|
}
|
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/surfaces.ts
CHANGED
|
@@ -51,7 +51,10 @@ export function raised(colors: Colors): ViewStyle {
|
|
|
51
51
|
// drifting second color. Paseo's own status badges use 12% in light themes and
|
|
52
52
|
// 16% in dark ones, where the surface swallows more of it.
|
|
53
53
|
export function tint(color: string, colors: Colors): string {
|
|
54
|
+
return withAlpha(color, isLight(colors) ? 0.12 : 0.16) ?? colors.surface2;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function withAlpha(color: string, alpha: number): string | null {
|
|
54
58
|
const rgb = channels(color);
|
|
55
|
-
|
|
56
|
-
return `rgba(${rgb.join(", ")}, ${isLight(colors) ? 0.12 : 0.16})`;
|
|
59
|
+
return rgb ? `rgba(${rgb.join(", ")}, ${alpha})` : null;
|
|
57
60
|
}
|
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.9.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
|
});
|