Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 62ccd336d4 test(app): assert displayed pixels after image replacement 2026-10-12 21:29:27 +02:00
paseo-bot[bot] a7d665515f test(app): reproduce stale embedded Markdown images 2026-10-12 21:26:02 +02:00
paseo-bot[bot]andOuyang Xingyuan f518e18a72 fix(app): show local images in Markdown file previews
Carry forward document-relative image loading and preserve the host home
directory when resolving images beside home-relative Markdown files.
Decode file URIs once, and require decoded images in the file-pane regression.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
2026-10-12 21:03:21 +02:00
paseo-bot[bot] 301d8618cd Merge commit 'refs/pull/4897/head' of https://github.com/getpaseo/paseo into bot/diagnose-4896-c1b9fd38 2026-10-12 20:51:25 +02:00
paseo-bot[bot]andmistop 9551b2cc11 Open loaded Linux desktop windows when hidden rendering stalls (#6726)
* fix(desktop): show the window when ready-to-show never fires on Linux

Reveal hidden Linux windows that do not receive an initial compositor frame.

* fix(desktop): open loaded Linux windows without waiting for a frame

A hidden Linux window can finish loading without painting, so waiting only for ready-to-show leaves the application invisible. Reveal the loaded document and retain the frame-ready path on other platforms.

Co-authored-by: mistop <jhlim@mistop.org>

---------

Co-authored-by: mistop <jhlim@mistop.org>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 18:05:04 +02:00
Elowen 6f62125ee0 fix: return dot-prefixed paths in workspace suggestions (#6720)
* fix(paths): allow double-dot names in contained paths

* test(paths): cover dot-prefixed workspace suggestions
2026-10-12 17:14:54 +02:00
Ouyang Xingyuan 716248b70b test(app): cover local relative images in markdown preview e2e 2026-10-09 11:54:03 +08:00
Ouyang Xingyuan e92dde7033 fix(app): render local relative images in markdown file preview
File preview dropped relative images because it only accepted http/data URIs and never resolved paths against the opened markdown file.
2026-10-09 11:54:03 +08:00
16 changed files with 867 additions and 11 deletions
+16
View File
@@ -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", "", "![sibling](./sibling.png)", "", "![hero](../data/cache/hero.png)", ""].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![hero](./hero.png)\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",
},
}));
+28 -1
View File
@@ -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 });
}
+2 -1
View File
@@ -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": "*",
+3 -2
View File
@@ -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" },
+29
View File
@@ -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(
+5 -4
View File
@@ -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;
}