mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62ccd336d4 | ||
|
|
a7d665515f | ||
|
|
f518e18a72 | ||
|
|
301d8618cd | ||
|
|
9551b2cc11 | ||
|
|
6f62125ee0 | ||
|
|
716248b70b | ||
|
|
e92dde7033 |
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
||||
guards already prevent throttling from causing a false stall.
|
||||
|
||||
### Desktop Linux hidden-window fallback
|
||||
|
||||
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
|
||||
forever without a single compositor frame: the renderer loads and runs, but
|
||||
`ready-to-show` never fires, so the window is never shown and the app looks like
|
||||
it did not start while every process is alive. `showWindowWhenReady`
|
||||
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
|
||||
the document finishes loading if the event has not arrived. The fallback is
|
||||
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
|
||||
|
||||
Run the startup regression on Linux with an isolated display:
|
||||
|
||||
```bash
|
||||
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
|
||||
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[] {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
@@ -684,6 +693,35 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
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 ({
|
||||
page,
|
||||
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 type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ASTNode, RenderRules } from "react-native-markdown-display";
|
||||
import { Text, View } from "react-native";
|
||||
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 { 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 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 (
|
||||
<View style={styles.outerGutter}>
|
||||
@@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) {
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<MarkdownRenderer text={document.body} />
|
||||
<MarkdownRenderer text={document.body} rules={rules} />
|
||||
</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;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
@@ -135,6 +138,9 @@ function FilePreviewBody({
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
@@ -182,7 +188,13 @@ function FilePreviewBody({
|
||||
style={styles.previewContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<FileMarkdownPreview source={preview.content ?? ""} />
|
||||
<FileMarkdownPreview
|
||||
source={preview.content ?? ""}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -297,6 +309,7 @@ export function FilePane({
|
||||
return (
|
||||
<FilePanePresentation
|
||||
serverId={serverId}
|
||||
workspaceRoot={normalizedWorkspaceRoot}
|
||||
client={client}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
@@ -338,6 +351,7 @@ function isEditableTextFile(input: {
|
||||
|
||||
function FilePanePresentation({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
@@ -359,6 +373,7 @@ function FilePanePresentation({
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
@@ -393,6 +408,8 @@ function FilePanePresentation({
|
||||
return (
|
||||
<EditableFilePane
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
@@ -449,12 +466,17 @@ function FilePanePresentation({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
cwd,
|
||||
path,
|
||||
@@ -470,6 +492,8 @@ function EditableFilePane({
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
path: string;
|
||||
@@ -622,6 +646,9 @@ function EditableFilePane({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={null}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { _electron as electron, expect } from "playwright/test";
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-"));
|
||||
const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url));
|
||||
|
||||
async function launchWindowWithoutHiddenFrames() {
|
||||
const main = path.join(root, "main.cjs");
|
||||
await writeFile(
|
||||
main,
|
||||
`const { app, BrowserWindow } = require("electron");
|
||||
const { showWindowWhenReady } = require(${JSON.stringify(modulePath)});
|
||||
app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))});
|
||||
app.whenReady().then(async () => {
|
||||
const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false });
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
await win.loadURL("data:text/html,<h1>Desktop ready</h1>");
|
||||
global.visibleAfterLoad = win.isVisible();
|
||||
});`,
|
||||
);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"),
|
||||
);
|
||||
const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : [];
|
||||
return electron.launch({
|
||||
args: ["--no-sandbox", ...displayArgs, main],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectLoadedDesktopIsShown(desktop) {
|
||||
await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true);
|
||||
const page = await desktop.firstWindow();
|
||||
await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible();
|
||||
}
|
||||
|
||||
let desktop;
|
||||
try {
|
||||
desktop = await launchWindowWithoutHiddenFrames();
|
||||
await expectLoadedDesktopIsShown(desktop);
|
||||
console.log("Loaded desktop is visible without hidden compositor frames");
|
||||
} finally {
|
||||
await desktop?.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -25,7 +25,8 @@
|
||||
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
|
||||
"test": "install-electron && vitest run --exclude \"e2e/**\"",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
|
||||
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs",
|
||||
"test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
buildStandardContextMenuItems,
|
||||
} from "./window/window-manager.js";
|
||||
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||
import { showWindowWhenReady } from "./window/show-when-ready.js";
|
||||
import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import {
|
||||
@@ -773,8 +774,8 @@ async function createWindow(
|
||||
registerBrowserWebviewNavigationGuards(contents);
|
||||
});
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
showWindowWhenReady(mainWindow, {
|
||||
showOnLoad: process.platform === "linux",
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { showWindowWhenReady } from "./show-when-ready.js";
|
||||
|
||||
class Window extends EventEmitter {
|
||||
webContents = new EventEmitter();
|
||||
shows = 0;
|
||||
destroyed = false;
|
||||
show() {
|
||||
this.shows += 1;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
close() {
|
||||
this.destroyed = true;
|
||||
this.emit("closed");
|
||||
}
|
||||
}
|
||||
|
||||
describe("showWindowWhenReady", () => {
|
||||
it("shows a loaded Linux window even when no initial frame was painted", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
expect(win.shows).toBe(0);
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(1);
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("shows on the first frame without showing again after load", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
win.emit("ready-to-show");
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves waiting for the initial frame when show on load is disabled", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: false });
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(0);
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a closed window closed when readiness arrives later", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
win.close();
|
||||
win.webContents.emit("did-finish-load");
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
interface ReadyToShowWindow {
|
||||
once(event: "ready-to-show", listener: () => void): unknown;
|
||||
webContents: {
|
||||
once(event: "did-finish-load", listener: () => void): unknown;
|
||||
};
|
||||
show(): void;
|
||||
isDestroyed(): boolean;
|
||||
}
|
||||
|
||||
export function showWindowWhenReady(
|
||||
win: ReadyToShowWindow,
|
||||
options: { showOnLoad: boolean },
|
||||
): void {
|
||||
let shown = false;
|
||||
const show = () => {
|
||||
if (shown || win.isDestroyed()) return;
|
||||
shown = true;
|
||||
win.show();
|
||||
};
|
||||
|
||||
win.once("ready-to-show", show);
|
||||
// On Linux a hidden window can wait for compositor frames until it is shown.
|
||||
// Loading the document is sufficient to reveal it without depending on a frame.
|
||||
if (options.showOnLoad) win.webContents.once("did-finish-load", show);
|
||||
}
|
||||
@@ -23,6 +23,89 @@ afterEach(async () => {
|
||||
}, 60000);
|
||||
|
||||
describe("file explorer", () => {
|
||||
test.each(["..", "..."])(
|
||||
"workspace suggestions resolve exact paths starting with %s",
|
||||
async (prefix) => {
|
||||
const cwd = tmpCwd();
|
||||
const directoryName = `${prefix}docs`;
|
||||
const nestedPath = `${directoryName}/notes.md`;
|
||||
const fileName = `${prefix}notes.md`;
|
||||
try {
|
||||
mkdirSync(path.join(cwd, directoryName));
|
||||
writeFileSync(path.join(cwd, nestedPath), "nested payload\n");
|
||||
writeFileSync(path.join(cwd, fileName), "direct payload\n");
|
||||
const file = await ctx.client.readFile(cwd, nestedPath);
|
||||
expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n");
|
||||
|
||||
const nestedEntry = { path: nestedPath, kind: "file" };
|
||||
const entries = [
|
||||
nestedEntry,
|
||||
{ path: directoryName, kind: "directory" },
|
||||
{ path: fileName, kind: "file" },
|
||||
];
|
||||
const cases = entries.flatMap((entry) => [
|
||||
{ query: `./${entry.path}`, entry },
|
||||
{ query: path.resolve(cwd, entry.path), entry },
|
||||
]);
|
||||
cases.unshift({ query: nestedPath, entry: nestedEntry });
|
||||
for (const { query, entry } of cases) {
|
||||
const response = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query,
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.entries).toEqual([entry]);
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("workspace suggestions exclude exact parent and sibling paths", async () => {
|
||||
const directory = tmpCwd();
|
||||
const cwd = path.join(directory, "workspace");
|
||||
const sibling = path.join(directory, "workspace-other");
|
||||
try {
|
||||
mkdirSync(cwd);
|
||||
mkdirSync(sibling);
|
||||
writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n");
|
||||
writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n");
|
||||
for (const query of [
|
||||
"./..",
|
||||
"../workspace-other/outside.txt",
|
||||
path.join(sibling, "outside.txt"),
|
||||
]) {
|
||||
const response = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query,
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.entries).toEqual([]);
|
||||
}
|
||||
const control = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "./visible.txt",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(control.error).toBeNull();
|
||||
expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
|
||||
{ description: "an ordinary name", fileName: "notes.txt" },
|
||||
|
||||
@@ -42,6 +42,35 @@ describe("path equivalence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ root: "/repo", candidate: "/repo/..notes.txt", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true },
|
||||
{ root: "/repo", candidate: "/repo", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false },
|
||||
{ root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false },
|
||||
{ root: "/repo", candidate: "/repo-other/notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false },
|
||||
{
|
||||
root: String.raw`\\server\share\repo`,
|
||||
candidate: String.raw`\\server\share\repo\..drafts\notes.md`,
|
||||
inside: true,
|
||||
},
|
||||
{
|
||||
root: String.raw`\\?\C:\Repo`,
|
||||
candidate: "C:\\Repo\\..notes.txt",
|
||||
inside: true,
|
||||
},
|
||||
])("checks named path containment for $candidate", ({ root, candidate, inside }) => {
|
||||
expect(isPathInsideRoot(root, candidate)).toBe(inside);
|
||||
});
|
||||
|
||||
test("preserves the casing of Windows relative suffixes", () => {
|
||||
expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD");
|
||||
expect(
|
||||
|
||||
@@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu
|
||||
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
||||
const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
||||
|
||||
if (
|
||||
comparableRelative !== "" &&
|
||||
(comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative))
|
||||
) {
|
||||
const isOutsideRoot =
|
||||
comparableRelative === ".." ||
|
||||
comparableRelative.startsWith(`..${platformPath.sep}`) ||
|
||||
platformPath.isAbsolute(comparableRelative);
|
||||
if (isOutsideRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user