mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c361835bfc | ||
|
|
61c9e803f1 |
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user