mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ccd336d4 | ||
|
|
a7d665515f | ||
|
|
f518e18a72 | ||
|
|
301d8618cd | ||
|
|
716248b70b | ||
|
|
e92dde7033 |
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import {
|
import {
|
||||||
|
expandFolder,
|
||||||
openFileExplorer,
|
openFileExplorer,
|
||||||
openFileFromExplorer,
|
openFileFromExplorer,
|
||||||
expectFileTabOpen,
|
expectFileTabOpen,
|
||||||
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
|
|||||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
||||||
|
const image = page.getByRole("img", { name, exact: true });
|
||||||
|
await expect(image).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
function watchRequestsTo(page: Page, origin: string): string[] {
|
function watchRequestsTo(page: Page, origin: string): string[] {
|
||||||
const requests: string[] = [];
|
const requests: string[] = [];
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
@@ -684,6 +693,35 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||||
|
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||||
|
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||||
|
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||||
|
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||||
|
["# Guide", "", "", "", "", ""].join(
|
||||||
|
"\n",
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await openFileFromExplorer(page, "guide.md");
|
||||||
|
await expectFileTabOpen(page, "docs/guide.md");
|
||||||
|
|
||||||
|
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||||
|
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||||
|
await expectMarkdownImageLoaded(page, "sibling");
|
||||||
|
await expectMarkdownImageLoaded(page, "hero");
|
||||||
|
await visibleFilePane.screenshot({
|
||||||
|
path: test.info().outputPath("markdown-relative-images.png"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||||
page,
|
page,
|
||||||
withWorkspace,
|
withWorkspace,
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Locator } from "@playwright/test";
|
||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||||
|
|
||||||
|
const FIRST = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const SECOND = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
function displayedPixel(image: Locator): Promise<number[]> {
|
||||||
|
return image.evaluate((element: HTMLImageElement) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = canvas.height = 1;
|
||||||
|
const context = canvas.getContext("2d")!;
|
||||||
|
context.drawImage(element, 0, 0, 1, 1);
|
||||||
|
return Array.from(context.getImageData(0, 0, 1, 1).data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("embedded Markdown image reflects a file replacement while Preview stays open", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "markdown-image-refresh-" });
|
||||||
|
const imagePath = path.join(workspace.repoPath, "hero.png");
|
||||||
|
await writeFile(imagePath, FIRST);
|
||||||
|
await writeFile(path.join(workspace.repoPath, "guide.md"), "# Guide\n\n\n");
|
||||||
|
await workspace.navigateTo();
|
||||||
|
await openFileExplorer(page);
|
||||||
|
await openFileFromExplorer(page, "guide.md");
|
||||||
|
const image = page.getByRole("img", { name: "hero", exact: true }).locator("img");
|
||||||
|
await expect
|
||||||
|
.poll(() => image.evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||||
|
.toBeGreaterThan(0);
|
||||||
|
const initialPixel = await displayedPixel(image);
|
||||||
|
await writeFile(imagePath, SECOND);
|
||||||
|
await expect.poll(() => displayedPixel(image), { timeout: 5000 }).not.toEqual(initialPixel);
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source";
|
||||||
|
|
||||||
|
describe("resolveMarkdownFileDirectory", () => {
|
||||||
|
it("uses the parent of an absolute markdown path", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileDirectory({
|
||||||
|
markdownPath: "/Users/test/project/docs/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("/Users/test/project/docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins a workspace-relative markdown path to the workspace root", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileDirectory({
|
||||||
|
markdownPath: "docs/星谷云/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("/Users/test/project/docs/星谷云");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the host home directory for home-relative markdown paths", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownFileDirectory({
|
||||||
|
markdownPath: "~/.paseo/plans/note.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("~/.paseo/plans");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveMarkdownPreviewImageSource", () => {
|
||||||
|
it("passes through remote and data URIs", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "https://example.com/image.png",
|
||||||
|
markdownPath: "docs/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("https://example.com/image.png");
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "data:image/png;base64,abc",
|
||||||
|
markdownPath: "docs/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("data:image/png;base64,abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a sibling image against the markdown file", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "./images/hero.png",
|
||||||
|
markdownPath: "docs/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("/Users/test/project/docs/images/hero.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves parent-relative images that stay inside the workspace", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "../../data/cache/xinggu_docs/12-linkedin/token.png",
|
||||||
|
markdownPath: "docs/星谷云/12-LinkedIn.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps absolute and home-relative image paths", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "/tmp/screenshot.png",
|
||||||
|
markdownPath: "docs/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("/tmp/screenshot.png");
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "~/.paseo/screenshots/output.png",
|
||||||
|
markdownPath: "docs/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("~/.paseo/screenshots/output.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file URIs into host paths", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "file:///tmp/paseo-preview.png",
|
||||||
|
markdownPath: "docs/guide.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("/tmp/paseo-preview.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves images beside a home-relative document without using the workspace root", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "screenshots/output.png",
|
||||||
|
markdownPath: "~/.paseo/plans/note.md",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toBe("~/.paseo/plans/screenshots/output.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a file URI exactly once", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "file:///tmp/hero%2520image.png",
|
||||||
|
markdownPath: "docs/guide.md",
|
||||||
|
workspaceRoot: "/workspace",
|
||||||
|
}),
|
||||||
|
).toBe("/tmp/hero%20image.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves parent segments from a home-relative document", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "../images/hero.png",
|
||||||
|
markdownPath: "~/docs/guide.md",
|
||||||
|
workspaceRoot: "/workspace",
|
||||||
|
}),
|
||||||
|
).toBe("~/images/hero.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves Windows relative images against a drive-rooted markdown file", () => {
|
||||||
|
expect(
|
||||||
|
resolveMarkdownPreviewImageSource({
|
||||||
|
source: "..\\images\\hero.png",
|
||||||
|
markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md",
|
||||||
|
workspaceRoot: "C:\\Users\\test\\repo",
|
||||||
|
}),
|
||||||
|
).toBe("C:/Users/test/repo/images/hero.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { localFileSourceToPath } from "@/attachments/utils";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
|
function trimTrailingSeparators(value: string): string {
|
||||||
|
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
|
||||||
|
return value.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
return value.replace(/[\\/]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathSegments(value: string): string {
|
||||||
|
const segments: string[] = [];
|
||||||
|
for (const segment of value.split("/")) {
|
||||||
|
if (!segment || segment === ".") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segment === "..") {
|
||||||
|
if (segments.length > 0) {
|
||||||
|
segments.pop();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
segments.push(segment);
|
||||||
|
}
|
||||||
|
return segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAbsolutePath(value: string): string | null {
|
||||||
|
const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/"));
|
||||||
|
if (isHomeRelativePath(normalizedInput)) {
|
||||||
|
return `~/${normalizePathSegments(normalizedInput.slice(2))}`;
|
||||||
|
}
|
||||||
|
if (!isAbsolutePath(normalizedInput)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
|
||||||
|
if (drivePath) {
|
||||||
|
return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
|
||||||
|
return trimTrailingSeparators(
|
||||||
|
`${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentDirectory(path: string): string | null {
|
||||||
|
const normalized = trimTrailingSeparators(path.replace(/\\/g, "/"));
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) {
|
||||||
|
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = normalized.lastIndexOf("/");
|
||||||
|
if (index < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (index === 0) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
const parent = normalized.slice(0, index);
|
||||||
|
if (/^[A-Za-z]:$/.test(parent)) {
|
||||||
|
return `${parent}/`;
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinAbsolute(baseDirectory: string, relativePath: string): string | null {
|
||||||
|
const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/"));
|
||||||
|
const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
|
if (!directory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeAbsolutePath(`${directory}/${relative}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHomeRelativePath(value: string): boolean {
|
||||||
|
return value === "~" || value.startsWith("~/") || value.startsWith("~\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRelativeSource(source: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(source);
|
||||||
|
} catch {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMarkdownFileDirectory(input: {
|
||||||
|
markdownPath: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}): string | null {
|
||||||
|
const markdownPath = input.markdownPath.trim().replace(/\\/g, "/");
|
||||||
|
if (!markdownPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) {
|
||||||
|
const absolute = normalizeAbsolutePath(markdownPath);
|
||||||
|
return absolute ? parentDirectory(absolute) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = input.workspaceRoot?.trim();
|
||||||
|
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath);
|
||||||
|
return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMarkdownPreviewImageSource(input: {
|
||||||
|
source: string;
|
||||||
|
markdownPath: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}): string | null {
|
||||||
|
const source = input.source.trim();
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(source)) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = localFileSourceToPath(
|
||||||
|
source.startsWith("file://") ? source : decodeRelativeSource(source),
|
||||||
|
);
|
||||||
|
if (!path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHomeRelativePath(path) || isAbsolutePath(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = resolveMarkdownFileDirectory({
|
||||||
|
markdownPath: input.markdownPath,
|
||||||
|
workspaceRoot: input.workspaceRoot,
|
||||||
|
});
|
||||||
|
if (!directory) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinAbsolute(directory, path);
|
||||||
|
}
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import type { ASTNode, RenderRules } from "react-native-markdown-display";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
import {
|
||||||
|
createSharedMarkdownRules,
|
||||||
|
MarkdownRenderer,
|
||||||
|
type MarkdownStyles,
|
||||||
|
} from "@/components/markdown/renderer";
|
||||||
import { parseMarkdownPreviewDocument } from "./document";
|
import { parseMarkdownPreviewDocument } from "./document";
|
||||||
|
import { resolveMarkdownPreviewImageSource } from "./image-source";
|
||||||
|
import { FileMarkdownPreviewImage } from "./preview-image";
|
||||||
|
|
||||||
export function FileMarkdownPreview({ source }: { source: string }) {
|
export function FileMarkdownPreview({
|
||||||
|
source,
|
||||||
|
filePath,
|
||||||
|
workspaceRoot,
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
filePath: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
client?: DaemonClient | null;
|
||||||
|
serverId?: string;
|
||||||
|
}) {
|
||||||
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
|
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
|
||||||
|
const rules = useMemo<RenderRules>(
|
||||||
|
() => ({
|
||||||
|
...createSharedMarkdownRules(),
|
||||||
|
image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => {
|
||||||
|
const resolved = resolveMarkdownPreviewImageSource({
|
||||||
|
source: String(node.attributes?.src ?? ""),
|
||||||
|
markdownPath: filePath,
|
||||||
|
workspaceRoot,
|
||||||
|
});
|
||||||
|
if (!resolved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileMarkdownPreviewImage
|
||||||
|
key={node.key}
|
||||||
|
source={resolved}
|
||||||
|
occurrenceKey={`${filePath}:${node.key}`}
|
||||||
|
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||||
|
client={client}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[client, filePath, serverId, workspaceRoot],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.outerGutter}>
|
<View style={styles.outerGutter}>
|
||||||
@@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<MarkdownRenderer text={document.body} />
|
<MarkdownRenderer text={document.body} rules={rules} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||||||
|
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||||
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const PREVIEW_IMAGE_MIN_HEIGHT = 160;
|
||||||
|
|
||||||
|
export function FileMarkdownPreviewImage({
|
||||||
|
source,
|
||||||
|
occurrenceKey,
|
||||||
|
alt,
|
||||||
|
client,
|
||||||
|
workspaceRoot,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
occurrenceKey: string;
|
||||||
|
alt?: string;
|
||||||
|
client?: DaemonClient | null;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [viewerOpen, setViewerOpen] = useState(false);
|
||||||
|
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||||
|
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||||
|
const image = useAssistantImage({
|
||||||
|
source,
|
||||||
|
occurrenceKey,
|
||||||
|
client,
|
||||||
|
workspaceRoot,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
const binding = image.status === "failed" ? null : image.binding;
|
||||||
|
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
|
||||||
|
const imageUri = binding?.uri ?? "";
|
||||||
|
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
|
||||||
|
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||||||
|
if (aspectRatio) {
|
||||||
|
return { aspectRatio };
|
||||||
|
}
|
||||||
|
return { height: PREVIEW_IMAGE_MIN_HEIGHT };
|
||||||
|
}, [aspectRatio]);
|
||||||
|
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
|
() => [styles.surface, imageSizeStyle],
|
||||||
|
[imageSizeStyle],
|
||||||
|
);
|
||||||
|
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
|
||||||
|
if (!viewerOpen || !imageUri) return null;
|
||||||
|
return {
|
||||||
|
type: "uri",
|
||||||
|
uri: imageUri,
|
||||||
|
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
|
||||||
|
};
|
||||||
|
}, [aspectRatio, imageUri, viewerOpen]);
|
||||||
|
|
||||||
|
if (image.status === "failed") {
|
||||||
|
return (
|
||||||
|
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-failed">
|
||||||
|
<Text style={styles.errorText}>{image.message}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!binding) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-loading">
|
||||||
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.frame} testID="markdown-preview-image">
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel={t("composer.attachments.openImage")}
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={image.status !== "loaded"}
|
||||||
|
onPress={openViewer}
|
||||||
|
style={surfaceStyle}
|
||||||
|
>
|
||||||
|
<View style={styles.image} accessibilityRole="image" accessibilityLabel={alt}>
|
||||||
|
<Image
|
||||||
|
ref={binding.onRef}
|
||||||
|
source={imageSource}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="contain"
|
||||||
|
onLoad={binding.onLoad}
|
||||||
|
onError={binding.onError}
|
||||||
|
/>
|
||||||
|
{image.status === "loading" ? (
|
||||||
|
<View pointerEvents="none" style={styles.loadingOverlay}>
|
||||||
|
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
frame: {
|
||||||
|
width: "100%",
|
||||||
|
marginBottom: theme.spacing[4],
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
width: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
minHeight: PREVIEW_IMAGE_MIN_HEIGHT,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[6],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -54,6 +54,9 @@ interface FilePreviewBodyProps {
|
|||||||
location: WorkspaceFileLocation;
|
location: WorkspaceFileLocation;
|
||||||
navigationRevision: number;
|
navigationRevision: number;
|
||||||
imagePreviewUri: string | null;
|
imagePreviewUri: string | null;
|
||||||
|
workspaceRoot: string;
|
||||||
|
client: DaemonClient | null;
|
||||||
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||||
@@ -135,6 +138,9 @@ function FilePreviewBody({
|
|||||||
location,
|
location,
|
||||||
navigationRevision,
|
navigationRevision,
|
||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
|
workspaceRoot,
|
||||||
|
client,
|
||||||
|
serverId,
|
||||||
}: FilePreviewBodyProps) {
|
}: FilePreviewBodyProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const filePath = location.path;
|
const filePath = location.path;
|
||||||
@@ -182,7 +188,13 @@ function FilePreviewBody({
|
|||||||
style={styles.previewContent}
|
style={styles.previewContent}
|
||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
>
|
>
|
||||||
<FileMarkdownPreview source={preview.content ?? ""} />
|
<FileMarkdownPreview
|
||||||
|
source={preview.content ?? ""}
|
||||||
|
filePath={filePath}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
client={client}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
</RNScrollView>
|
</RNScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -297,6 +309,7 @@ export function FilePane({
|
|||||||
return (
|
return (
|
||||||
<FilePanePresentation
|
<FilePanePresentation
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
workspaceRoot={normalizedWorkspaceRoot}
|
||||||
client={client}
|
client={client}
|
||||||
readTarget={readTarget}
|
readTarget={readTarget}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
@@ -338,6 +351,7 @@ function isEditableTextFile(input: {
|
|||||||
|
|
||||||
function FilePanePresentation({
|
function FilePanePresentation({
|
||||||
serverId,
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
client,
|
client,
|
||||||
readTarget,
|
readTarget,
|
||||||
preview,
|
preview,
|
||||||
@@ -359,6 +373,7 @@ function FilePanePresentation({
|
|||||||
imagePreviewUri,
|
imagePreviewUri,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceRoot: string;
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
readTarget: { cwd: string; path: string } | null;
|
readTarget: { cwd: string; path: string } | null;
|
||||||
preview: ExplorerFile | null;
|
preview: ExplorerFile | null;
|
||||||
@@ -393,6 +408,8 @@ function FilePanePresentation({
|
|||||||
return (
|
return (
|
||||||
<EditableFilePane
|
<EditableFilePane
|
||||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
client={client}
|
client={client}
|
||||||
cwd={readTarget.cwd}
|
cwd={readTarget.cwd}
|
||||||
path={readTarget.path}
|
path={readTarget.path}
|
||||||
@@ -449,12 +466,17 @@ function FilePanePresentation({
|
|||||||
location={location}
|
location={location}
|
||||||
navigationRevision={navigationRevision}
|
navigationRevision={navigationRevision}
|
||||||
imagePreviewUri={imagePreviewUri}
|
imagePreviewUri={imagePreviewUri}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
client={client}
|
||||||
|
serverId={serverId}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditableFilePane({
|
function EditableFilePane({
|
||||||
|
serverId,
|
||||||
|
workspaceRoot,
|
||||||
client,
|
client,
|
||||||
cwd,
|
cwd,
|
||||||
path,
|
path,
|
||||||
@@ -470,6 +492,8 @@ function EditableFilePane({
|
|||||||
location,
|
location,
|
||||||
navigationRevision,
|
navigationRevision,
|
||||||
}: {
|
}: {
|
||||||
|
serverId: string;
|
||||||
|
workspaceRoot: string;
|
||||||
client: DaemonClient;
|
client: DaemonClient;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -622,6 +646,9 @@ function EditableFilePane({
|
|||||||
location={location}
|
location={location}
|
||||||
navigationRevision={navigationRevision}
|
navigationRevision={navigationRevision}
|
||||||
imagePreviewUri={null}
|
imagePreviewUri={null}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
client={client}
|
||||||
|
serverId={serverId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Reference in New Issue
Block a user