Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andlikaixuan c361835bfc test(app): cover overlapping floating panel host lifetimes
Exercise surviving host measurement through real portal components after the newest host unmounts.

Co-authored-by: likaixuan <likaixuan@mucang.cn>
2026-11-06 18:19:17 +00:00
likaixuan 61c9e803f1 fix(app): keep every floating panel host registered
Anchored panels resolve their portal host by name. Several screens can mount
a host under the same name, but the registry kept only the newest element:
when that element unmounted, its cleanup deleted the entry, and the surviving
host was no longer measurable. Every anchored panel then returned null with no
error and no log, until something remounted the screen or reloaded the window.

Track all hosts per name and measure the newest laid-out one, so unmounting a
host only removes that host. Measurement now skips hosts that report no size,
which covers retained copies that are still mounted but not laid out.
2026-11-06 18:18:14 +00:00
9 changed files with 182 additions and 87 deletions
@@ -45,11 +45,6 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -207,21 +202,6 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -258,23 +238,6 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
createFloatingPanelHosts,
type FloatingPanelHostRect,
type MeasurableFloatingPanelHost,
} from "./floating-panel-hosts";
function createHost(rect: FloatingPanelHostRect): MeasurableFloatingPanelHost {
return {
measureInWindow: (callback) => callback(rect.x, rect.y, rect.width, rect.height),
};
}
const WORKSPACE_RECT: FloatingPanelHostRect = { x: 10, y: 20, width: 800, height: 600 };
const RETAINED_VIEWPORT: FloatingPanelHostRect = { x: 0, y: 0, width: 1200, height: 900 };
describe("floating panel hosts", () => {
it("measures the newest host registered under the name", async () => {
const hosts = createFloatingPanelHosts();
hosts.register("workspace", createHost(WORKSPACE_RECT));
hosts.register("workspace", createHost(RETAINED_VIEWPORT));
expect(await hosts.measure("workspace")).toEqual(RETAINED_VIEWPORT);
});
it("keeps measuring a survivor after the newest host unregisters", async () => {
const hosts = createFloatingPanelHosts();
const unregisterSurvivor = hosts.register("workspace", createHost(WORKSPACE_RECT));
const unregisterNewest = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
unregisterNewest();
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
unregisterSurvivor();
});
it("returns null once every host under the name is gone", async () => {
const hosts = createFloatingPanelHosts();
const unregisterFirst = hosts.register("workspace", createHost(WORKSPACE_RECT));
const unregisterSecond = hosts.register("workspace", createHost(RETAINED_VIEWPORT));
unregisterFirst();
unregisterSecond();
expect(await hosts.measure("workspace")).toBeNull();
});
it("skips hosts that are not laid out", async () => {
const hosts = createFloatingPanelHosts();
hosts.register("workspace", createHost(WORKSPACE_RECT));
hosts.register("workspace", createHost({ x: 0, y: 0, width: 0, height: 0 }));
expect(await hosts.measure("workspace")).toEqual(WORKSPACE_RECT);
});
it("ignores a repeated unregister of the same host", async () => {
const hosts = createFloatingPanelHosts();
const unregister = hosts.register("workspace", createHost(WORKSPACE_RECT));
unregister();
unregister();
expect(await hosts.measure("workspace")).toBeNull();
});
});
@@ -0,0 +1,57 @@
export interface FloatingPanelHostRect {
x: number;
y: number;
width: number;
height: number;
}
export interface MeasurableFloatingPanelHost {
measureInWindow(callback: (x: number, y: number, width: number, height: number) => void): void;
}
export interface FloatingPanelHosts<T extends MeasurableFloatingPanelHost> {
register(name: string, host: T): () => void;
measure(name: string): Promise<FloatingPanelHostRect | null>;
}
function measureHost(host: MeasurableFloatingPanelHost): Promise<FloatingPanelHostRect> {
return new Promise((resolve) => {
host.measureInWindow((x, y, width, height) => resolve({ x, y, width, height }));
});
}
/**
* Hosts are addressed by name, and one name can have several live hosts: a workspace
* screen mounts its own host while retained copies of that screen stay mounted. Track
* every host so unmounting one leaves the others measurable.
*/
export function createFloatingPanelHosts<
T extends MeasurableFloatingPanelHost,
>(): FloatingPanelHosts<T> {
const hostsByName = new Map<string, T[]>();
function register(name: string, host: T): () => void {
const hosts = hostsByName.get(name) ?? [];
hostsByName.set(name, hosts);
hosts.push(host);
return () => {
const index = hosts.indexOf(host);
if (index === -1) return;
hosts.splice(index, 1);
if (hosts.length === 0 && hostsByName.get(name) === hosts) {
hostsByName.delete(name);
}
};
}
async function measure(name: string): Promise<FloatingPanelHostRect | null> {
const hosts = hostsByName.get(name);
if (!hosts || hosts.length === 0) return null;
const rects = await Promise.all(hosts.toReversed().map(measureHost));
return rects.find((rect) => rect.width > 0 && rect.height > 0) ?? rects[0];
}
return { register, measure };
}
@@ -0,0 +1,40 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, test } from "vitest";
import { Portal, PortalProvider } from "@gorhom/portal";
import { FloatingPanelPortalHost, measureFloatingPanelPortalHost } from "./floating-panel-portal";
describe("Floating panel host lifetime", () => {
test("removing the newest host preserves the surviving host measurement", async () => {
const container = document.createElement("div");
container.style.cssText = "position:relative;width:800px;height:600px";
document.body.appendChild(container);
const root = createRoot(container);
function Content({ overlap }: { overlap: boolean }) {
return (
<PortalProvider shouldAddRootHost={false}>
<FloatingPanelPortalHost name="workspace" />
{overlap && <FloatingPanelPortalHost name="workspace" />}
<Portal hostName="workspace" name="picker">
<div data-testid="picker">Commands</div>
</Portal>
</PortalProvider>
);
}
try {
await act(async () => root.render(<Content overlap={true} />));
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
width: 800,
height: 600,
});
await act(async () => root.render(<Content overlap={false} />));
expect(await measureFloatingPanelPortalHost("workspace")).toMatchObject({
width: 800,
height: 600,
});
} finally {
await act(async () => root.unmount());
container.remove();
}
});
});
@@ -8,32 +8,21 @@ import {
} from "react";
import { StyleSheet, View } from "react-native";
import { PortalHost } from "@gorhom/portal";
import {
createFloatingPanelHosts,
type FloatingPanelHostRect,
} from "@/components/ui/floating-panel-hosts";
export const DEFAULT_FLOATING_PANEL_PORTAL_HOST = "content-floating-panels";
const FloatingPanelPortalHostNameContext = createContext(DEFAULT_FLOATING_PANEL_PORTAL_HOST);
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
const portalHosts = createFloatingPanelHosts<View>();
const portalHostElements = new Map<string, View>();
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
export function measureFloatingPanelPortalHost(name: string): Promise<Rect | null> {
const element = portalHostElements.get(name);
if (!element) return Promise.resolve(null);
return measureElement(element);
export function measureFloatingPanelPortalHost(
name: string,
): Promise<FloatingPanelHostRect | null> {
return portalHosts.measure(name);
}
export function FloatingPanelPortalHostNameProvider({
@@ -64,12 +53,7 @@ export function FloatingPanelPortalHost({
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
portalHostElements.set(name, host);
return () => {
if (portalHostElements.get(name) === host) {
portalHostElements.delete(name);
}
};
return portalHosts.register(name, host);
}, [name]);
return (
@@ -74,8 +74,7 @@ describe("terminal file paste queue", () => {
firstUpload.resolve("/host/uploads/a.png");
await settle();
expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]);
expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' ");
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
});
@@ -86,7 +85,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("shot.png")), host);
await settle();
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']);
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
});
it("uploads under a file name that needs no shell quoting", async () => {
@@ -121,7 +120,7 @@ describe("terminal file paste queue", () => {
expect(events.failures).toEqual(["disk full"]);
expect(events.uploaded).toBe(1);
expect(host.pasted).toEqual(["'/host/uploads/b.png' "]);
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
});
it("reports unreadable and oversized files without uploading them", async () => {
@@ -167,7 +166,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("next.png")), host);
await settle();
expect(host.pasted).toEqual(["'/host/uploads/next.png' "]);
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
expect(events.uploading).toEqual([true, false, true, false]);
expect(queue.cancel()).toBe(false);
});
@@ -75,7 +75,7 @@ export class TerminalFilePasteQueue {
this.setUploading(true);
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
if (isCurrent()) {
target.paste(`${prepareTerminalPath(path)} `);
target.paste(prepareTerminalPath(path));
this.events.onUploaded();
}
} catch (error) {
@@ -48,8 +48,7 @@ afterEach(() => {
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
// Windows can briefly retain a lock on the copied executable after its probe exits.
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
rmSync(dir, { recursive: true, force: true });
}
});
@@ -1134,18 +1134,6 @@ describe("WorkspaceGitServiceImpl", () => {
});
test("repo-level fetch intervals are shared for workspaces in the same repo", async () => {
// Keep the second workspace's observation setup behind its initial snapshot.
const secondCwd = join(REPO_CWD, "packages", "server");
const runGitCommand = vi.fn(async (_args, options) => {
if (options.cwd === secondCwd) await delay(250);
return {
stdout: `${REPO_CWD}\n`,
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
};
});
const firstFetch = createDeferred<void>();
const runGitFetch = vi.fn(async () => {
await firstFetch.promise;
@@ -1167,7 +1155,6 @@ describe("WorkspaceGitServiceImpl", () => {
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
const service = createService({
runGitCommand,
getCheckoutSnapshotFacts,
getCheckoutRefDerivedState,
resolveAbsoluteGitDir,
@@ -1176,7 +1163,10 @@ describe("WorkspaceGitServiceImpl", () => {
});
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const second = service.registerWorkspace({ cwd: secondCwd }, vi.fn());
const second = service.registerWorkspace(
{ cwd: join(REPO_CWD, "packages", "server") },
vi.fn(),
);
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
expect(runGitFetch).toHaveBeenCalledTimes(1);
@@ -1187,8 +1177,6 @@ describe("WorkspaceGitServiceImpl", () => {
expect(hasOriginRemote).toHaveBeenCalledTimes(0);
expect(runGitFetch).toHaveBeenCalledTimes(1);
await waitForObservationToSettle(service, 2);
firstFetch.resolve();
await flushPromises();
await vi.advanceTimersByTimeAsync(1_000);