mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -0,0 +1,920 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import type { Host } from "../../../src/frontend/types";
|
||||
|
||||
const mobileApi = vi.hoisted(() => ({
|
||||
isMobile: false as boolean | undefined,
|
||||
}));
|
||||
const slots = vi.hoisted(() => ({
|
||||
toolbar: [] as Array<{ actionId: string; titleKey: string }>,
|
||||
invoked: [] as unknown[][],
|
||||
}));
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
"terminalToolbar.detachTmux": "Detach tmux",
|
||||
"terminalToolbar.detachTmuxDescription": "Detach tmux session",
|
||||
"terminalToolbar.image": "Image actions (images only)",
|
||||
"terminalToolbar.upload": "Upload image",
|
||||
"terminalToolbar.uploadImage": "Upload an image to the host",
|
||||
"terminalToolbar.uploadingImage": "Uploading image…",
|
||||
"terminalToolbar.paste": "Paste image",
|
||||
"terminalToolbar.pasteImage": "Paste image from clipboard (images only)",
|
||||
"terminalToolbar.showActions": "Show toolbar",
|
||||
"terminalToolbar.showToolbar": "Expand toolbar",
|
||||
"terminalToolbar.hideToolbar": "Hide toolbar",
|
||||
"terminalToolbar.layout": "Toolbar display mode",
|
||||
"terminalToolbar.layoutIcon": "Icon only",
|
||||
"terminalToolbar.layoutLabeled": "Icons and labels",
|
||||
"terminalToolbar.layoutExpanded": "Expanded with metrics",
|
||||
"terminalToolbar.moveToolbar": "Move toolbar",
|
||||
"terminalToolbar.moveToolbarHint": "Drag to reposition the toolbar",
|
||||
"terminalToolbar.imageActionFailed": "Image action failed",
|
||||
"terminalToolbar.retry": "Retry",
|
||||
"terminalToolbar.dismissError": "Dismiss image error",
|
||||
"ai.assistant": "AI Assistant",
|
||||
};
|
||||
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => labels[key] ?? key,
|
||||
language: "en",
|
||||
}),
|
||||
useSlotContributions: (slotId: string) =>
|
||||
slotId === "terminal.toolbar" ? slots.toolbar : [],
|
||||
}));
|
||||
|
||||
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
useIsMobile: () => mobileApi.isMobile,
|
||||
// The real slot reads the action registry; the toolbar only decides where
|
||||
// it goes and what it is handed.
|
||||
ActionSlot: ({
|
||||
context,
|
||||
enabled,
|
||||
}: {
|
||||
context: () => unknown[];
|
||||
enabled?: boolean;
|
||||
}) =>
|
||||
enabled === false
|
||||
? null
|
||||
: slots.toolbar.map((contribution) => (
|
||||
<button
|
||||
key={contribution.actionId}
|
||||
type="button"
|
||||
onClick={() => slots.invoked.push(context())}
|
||||
>
|
||||
{labels[contribution.titleKey] ?? contribution.titleKey}
|
||||
</button>
|
||||
)),
|
||||
ComponentSlot: () => null,
|
||||
}));
|
||||
|
||||
import { TerminalToolbar } from "../../../src/frontend/terminal/TerminalToolbar";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
getResponsiveToolbarDensity,
|
||||
} from "../../../src/frontend/terminal/toolbar-geometry";
|
||||
|
||||
const host = { id: 7, connectionType: "ssh" } as unknown as Host;
|
||||
|
||||
function renderToolbar(
|
||||
overrides: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
|
||||
) {
|
||||
const props: React.ComponentProps<typeof TerminalToolbar> = {
|
||||
host,
|
||||
isConnected: true,
|
||||
isTmuxAttached: true,
|
||||
onTmuxDetach: vi.fn(),
|
||||
isImageUploading: false,
|
||||
onUploadImage: vi.fn(),
|
||||
onPasteImage: vi.fn(),
|
||||
isFocused: true,
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<TerminalToolbar {...props} />), props };
|
||||
}
|
||||
|
||||
// jsdom lacks the pointer APIs Radix Select needs to open its popup.
|
||||
beforeEach(() => {
|
||||
Element.prototype.hasPointerCapture ??= () => false;
|
||||
Element.prototype.setPointerCapture ??= () => {};
|
||||
Element.prototype.releasePointerCapture ??= () => {};
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
vi.clearAllMocks();
|
||||
mobileApi.isMobile = false;
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
});
|
||||
localStorage.clear();
|
||||
slots.toolbar = [];
|
||||
slots.invoked = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("responsive toolbar density", () => {
|
||||
it("enters below 110 percent and restores at 115 percent", () => {
|
||||
expect(getResponsiveToolbarDensity("labeled", "labeled", 1099, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("labeled", "labeled", 1100, 1000)).toBe(
|
||||
"labeled",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("labeled", "icon", 1149, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("labeled", "icon", 1150, 1000)).toBe(
|
||||
"labeled",
|
||||
);
|
||||
});
|
||||
|
||||
it("never falls back when Icon is explicitly selected", () => {
|
||||
expect(getResponsiveToolbarDensity("icon", "icon", 10, 1000)).toBe("icon");
|
||||
expect(getResponsiveToolbarDensity("icon", "icon", 2000, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the prior effective state while measurement is unavailable", () => {
|
||||
expect(getResponsiveToolbarDensity("expanded", "expanded", 0, 1000)).toBe(
|
||||
"expanded",
|
||||
);
|
||||
expect(getResponsiveToolbarDensity("expanded", "icon", 0, 1000)).toBe(
|
||||
"icon",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalToolbar Phase 1", () => {
|
||||
it("clamps vertical movement and preserves over-tall recovery", () => {
|
||||
const hostRect = {
|
||||
left: 0,
|
||||
right: 400,
|
||||
top: 0,
|
||||
bottom: 200,
|
||||
width: 400,
|
||||
height: 200,
|
||||
};
|
||||
const toolbarRect = {
|
||||
left: 100,
|
||||
right: 300,
|
||||
top: 140,
|
||||
bottom: 190,
|
||||
width: 200,
|
||||
height: 50,
|
||||
};
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: -999 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}).y,
|
||||
).toBe(-132);
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: 999 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}).y,
|
||||
).toBe(2);
|
||||
expect(
|
||||
clampToolbarPosition(
|
||||
{ x: 0, y: -999 },
|
||||
{ ...toolbarRect, top: 150, bottom: 350, height: 200 },
|
||||
hostRect,
|
||||
{ x: 0, y: 0 },
|
||||
).y,
|
||||
).toBe(-298);
|
||||
});
|
||||
|
||||
it("keeps horizontal outputs unchanged during simultaneous movement", () => {
|
||||
const hostRect = {
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: 50,
|
||||
bottom: 350,
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
const toolbarRect = {
|
||||
left: 250,
|
||||
right: 450,
|
||||
top: 280,
|
||||
bottom: 330,
|
||||
width: 200,
|
||||
height: 50,
|
||||
};
|
||||
expect(
|
||||
clampToolbarPosition({ x: -999, y: -100 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({ x: -142, y: -100 });
|
||||
expect(
|
||||
clampToolbarPosition({ x: 999, y: 12 }, toolbarRect, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({ x: 42, y: 12 });
|
||||
});
|
||||
|
||||
it("fills the full terminal surface while remaining bottom-centered and pointer transparent", () => {
|
||||
const { container } = renderToolbar();
|
||||
const toolbarHost = container.querySelector("[data-terminal-toolbar-host]");
|
||||
const toolbar = container.querySelector("[data-terminal-toolbar-wide]");
|
||||
expect(toolbarHost).toHaveClass(
|
||||
"absolute",
|
||||
"inset-0",
|
||||
"flex",
|
||||
"items-end",
|
||||
"justify-center",
|
||||
"pointer-events-none",
|
||||
"pb-2",
|
||||
);
|
||||
expect(toolbarHost).not.toHaveClass("inset-x-2", "bottom-2");
|
||||
expect(toolbar).toHaveClass("pointer-events-auto");
|
||||
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
|
||||
});
|
||||
|
||||
it("clamps every edge and preserves recovery for an over-wide toolbar", () => {
|
||||
const hostRect = {
|
||||
left: 100,
|
||||
right: 500,
|
||||
top: 50,
|
||||
bottom: 350,
|
||||
width: 400,
|
||||
height: 300,
|
||||
};
|
||||
const toolbar = {
|
||||
left: 250,
|
||||
right: 450,
|
||||
top: 280,
|
||||
bottom: 330,
|
||||
width: 200,
|
||||
height: 50,
|
||||
};
|
||||
expect(
|
||||
clampToolbarPosition({ x: -500, y: 0 }, toolbar, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
x: -142,
|
||||
y: 0,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition({ x: 500, y: 0 }, toolbar, hostRect, { x: 0, y: 0 }),
|
||||
).toEqual({
|
||||
x: 42,
|
||||
y: 0,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: -500 }, toolbar, hostRect, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
x: 0,
|
||||
y: -222,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition({ x: 0, y: 500 }, toolbar, hostRect, { x: 0, y: 0 }),
|
||||
).toEqual({
|
||||
x: 0,
|
||||
y: 12,
|
||||
});
|
||||
expect(
|
||||
clampToolbarPosition(
|
||||
{ x: -999, y: 0 },
|
||||
{ ...toolbar, left: -100, right: 600, width: 700 },
|
||||
hostRect,
|
||||
{ x: 0, y: 0 },
|
||||
).x,
|
||||
).toBe(-448);
|
||||
});
|
||||
|
||||
it("clamps an off-screen restored position to actual host bounds", async () => {
|
||||
localStorage.setItem(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
JSON.stringify({ x: 9000, y: -9000 }),
|
||||
);
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
const x = Number(match?.[1] ?? 0);
|
||||
const y = Number(match?.[2] ?? 0);
|
||||
return DOMRect.fromRect({
|
||||
x: 250 + x,
|
||||
y: 250 + y,
|
||||
width: 250,
|
||||
height: 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
const { container } = renderToolbar();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(container.querySelector("[data-terminal-toolbar-wide]") as HTMLElement)
|
||||
.style.transform,
|
||||
).toBe("translate(0px, 0px)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the PC toolbar and Image group with adjacent actions", () => {
|
||||
const { container } = renderToolbar();
|
||||
expect(container.querySelector("[data-terminal-toolbar-host]")).toHaveClass(
|
||||
"@container",
|
||||
);
|
||||
expect(container.querySelector("[data-terminal-toolbar-wide]")).toHaveClass(
|
||||
"pointer-events-auto",
|
||||
);
|
||||
expect(
|
||||
container.querySelector("[data-terminal-toolbar-narrow]"),
|
||||
).toBeNull();
|
||||
|
||||
const imageGroup = screen.getAllByRole("group", {
|
||||
name: "Image actions (images only)",
|
||||
})[0];
|
||||
expect(imageGroup.querySelectorAll("button, label")).toHaveLength(2);
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0],
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("uses explicit labels, conditional tmux, and compact interactive targets", () => {
|
||||
const { rerender, props } = renderToolbar();
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
|
||||
).toHaveTextContent("Detach tmux");
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
|
||||
).toHaveClass("min-h-8", "min-w-8");
|
||||
rerender(<TerminalToolbar {...props} isTmuxAttached={false} />);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Detach tmux session" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes density as a select and applies the chosen mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderToolbar();
|
||||
const trigger = screen.getByRole("combobox", {
|
||||
name: "Toolbar display mode",
|
||||
});
|
||||
expect(trigger).toHaveTextContent("");
|
||||
await user.click(trigger);
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((option) => option.textContent)).toEqual([
|
||||
"Icon only",
|
||||
"Icons and labels",
|
||||
"Expanded with metrics",
|
||||
]);
|
||||
await user.click(
|
||||
screen.getByRole("option", { name: "Expanded with metrics" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe(
|
||||
"expanded",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps one file input mounted, resets repeated selections, and exposes loading", () => {
|
||||
const onUploadImage = vi.fn();
|
||||
const { container, rerender, props } = renderToolbar({ onUploadImage });
|
||||
const input = container.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
expect(container.querySelectorAll('input[type="file"]')).toHaveLength(1);
|
||||
const file = new File(["image"], "same.png", { type: "image/png" });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
expect(onUploadImage).toHaveBeenCalledTimes(2);
|
||||
rerender(<TerminalToolbar {...props} isImageUploading />);
|
||||
expect(container.querySelector('input[type="file"]')).toBe(input);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Uploading image…");
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0],
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hides clearly, leaves recovery, and closes open UI on disconnect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender, props } = renderToolbar();
|
||||
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
|
||||
const collapsedMover = screen.getByRole("button", {
|
||||
name: "Move toolbar",
|
||||
});
|
||||
expect(collapsedMover).toHaveAttribute("aria-label", "Move toolbar");
|
||||
await user.click(screen.getByRole("button", { name: "Expand toolbar" }));
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Toolbar display mode" }),
|
||||
).toBeInTheDocument();
|
||||
rerender(<TerminalToolbar {...props} isConnected={false} />);
|
||||
expect(
|
||||
screen.queryByRole("combobox", { name: "Toolbar display mode" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls paste synchronously in the activation handler and does not cancel outside pointerdown", () => {
|
||||
const onPasteImage = vi.fn();
|
||||
renderToolbar({ onPasteImage });
|
||||
const paste = screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0];
|
||||
fireEvent.click(paste);
|
||||
expect(onPasteImage).toHaveBeenCalledOnce();
|
||||
const outside = new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.body.dispatchEvent(outside);
|
||||
expect(outside.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("shows an actionable image error and retries the failed activation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPasteImage = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("Clipboard permission denied"))
|
||||
.mockResolvedValueOnce();
|
||||
renderToolbar({ onPasteImage });
|
||||
await user.click(
|
||||
screen.getAllByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
})[0],
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Clipboard permission denied",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onPasteImage).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes synchronous image failures through the alert flow", async () => {
|
||||
renderToolbar({
|
||||
onPasteImage: vi.fn(() => {
|
||||
throw new Error("Synchronous clipboard failure");
|
||||
}),
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Synchronous clipboard failure",
|
||||
);
|
||||
});
|
||||
|
||||
it("orders desktop actions and keeps the grab handle persistent", () => {
|
||||
const { container } = renderToolbar();
|
||||
const labels = Array.from(
|
||||
container.querySelectorAll("[data-terminal-toolbar-wide] button"),
|
||||
).map((button) => button.getAttribute("aria-label"));
|
||||
expect(labels).toEqual([
|
||||
"Detach tmux session",
|
||||
"Upload an image to the host",
|
||||
"Paste image from clipboard (images only)",
|
||||
"Toolbar display mode",
|
||||
"Hide toolbar",
|
||||
"Move toolbar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows three modes and fades the expanded toolbar", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderToolbar();
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
expect(toolbar).toHaveClass(
|
||||
"opacity-30",
|
||||
"transition-opacity",
|
||||
"hover:opacity-100",
|
||||
"focus-within:opacity-100",
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: "Toolbar display mode" }),
|
||||
);
|
||||
expect(await screen.findAllByRole("option")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("gives the collapsed toolbar idle opacity and keyboard recovery", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderToolbar();
|
||||
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
expect(toolbar).toHaveClass(
|
||||
"opacity-100",
|
||||
"hover:opacity-100",
|
||||
"focus-within:opacity-100",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Expand toolbar" }),
|
||||
).toBeVisible();
|
||||
const collapsedMover = screen.getByRole("button", {
|
||||
name: "Move toolbar",
|
||||
});
|
||||
expect(collapsedMover).toBeVisible();
|
||||
expect(
|
||||
container.querySelector(".terminal-toolbar-collapsed-shell"),
|
||||
).toBeInTheDocument();
|
||||
collapsedMover.focus();
|
||||
expect(toolbar).toHaveClass("focus-within:opacity-100");
|
||||
const expandButton = screen.getByRole("button", {
|
||||
name: "Expand toolbar",
|
||||
});
|
||||
expandButton.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Hide toolbar" }),
|
||||
).toHaveFocus(),
|
||||
);
|
||||
}, 15_000);
|
||||
|
||||
it("keeps the mover on the right edge across collapse and expansion", async () => {
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
const collapsed = !this.querySelector(
|
||||
'button[aria-label="Hide toolbar"]',
|
||||
);
|
||||
return DOMRect.fromRect({
|
||||
x: 225 + Number(match?.[1] ?? 0),
|
||||
y: 290 + Number(match?.[2] ?? 0),
|
||||
width: collapsed ? 44 : 250,
|
||||
height: collapsed ? 44 : 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
localStorage.setItem(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
JSON.stringify({ x: 117, y: 2 }),
|
||||
);
|
||||
const { container } = renderToolbar();
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole("button", { name: "Hide toolbar" }));
|
||||
expect(toolbar.style.transform).toBe("translate(323px, 2px)");
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole("button", { name: "Expand toolbar" }));
|
||||
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
|
||||
});
|
||||
|
||||
it("moves and persists the toolbar position, then stops after pointerup", () => {
|
||||
const { container, unmount } = renderToolbar();
|
||||
const grab = screen.getByRole("button", { name: "Move toolbar" });
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 1,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 1,
|
||||
clientX: 140,
|
||||
clientY: 90,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
|
||||
fireEvent.pointerUp(grab, { pointerId: 1 });
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
|
||||
).toEqual({ x: 40, y: -10 });
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 1,
|
||||
clientX: 500,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
|
||||
unmount();
|
||||
const second = renderToolbar();
|
||||
expect(
|
||||
(
|
||||
second.container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement
|
||||
).style.transform,
|
||||
).toBe("translate(40px, -10px)");
|
||||
});
|
||||
|
||||
it("moves on both axes, clamps safely, ignores a different pointer ID, and persists", () => {
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
return DOMRect.fromRect({
|
||||
x: 225 + Number(match?.[1] ?? 0),
|
||||
y: 290 + Number(match?.[2] ?? 0),
|
||||
width: 250,
|
||||
height: 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
const { container } = renderToolbar();
|
||||
const grab = screen.getByRole("button", { name: "Move toolbar" });
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 11,
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 12,
|
||||
clientX: 30,
|
||||
clientY: -500,
|
||||
});
|
||||
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 11,
|
||||
clientX: 30,
|
||||
clientY: 120,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(20px, 2px)");
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 11,
|
||||
clientX: 500,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
|
||||
fireEvent.pointerMove(grab, {
|
||||
pointerId: 11,
|
||||
clientX: 500,
|
||||
clientY: 500,
|
||||
});
|
||||
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
|
||||
fireEvent.pointerUp(grab, { pointerId: 11 });
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
|
||||
).toEqual({ x: 117, y: 2 });
|
||||
});
|
||||
|
||||
it("avoids ResizeObserver write churn and writes one real reclamp", async () => {
|
||||
let resizeCallback: ResizeObserverCallback | undefined;
|
||||
class ControlledResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", ControlledResizeObserver);
|
||||
let hostWidth = 500;
|
||||
const original = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
||||
function () {
|
||||
if (this.hasAttribute("data-terminal-toolbar-host")) {
|
||||
return DOMRect.fromRect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: hostWidth,
|
||||
height: 300,
|
||||
});
|
||||
}
|
||||
if (this.hasAttribute("data-terminal-toolbar-wide")) {
|
||||
const match = this.style.transform.match(
|
||||
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
|
||||
);
|
||||
return DOMRect.fromRect({
|
||||
x: 125 + Number(match?.[1] ?? 0),
|
||||
y: 242 + Number(match?.[2] ?? 0),
|
||||
width: 250,
|
||||
height: 50,
|
||||
});
|
||||
}
|
||||
return original.call(this);
|
||||
},
|
||||
);
|
||||
const writes = vi.spyOn(Storage.prototype, "setItem");
|
||||
renderToolbar();
|
||||
writes.mockClear();
|
||||
act(() => {
|
||||
resizeCallback?.([], {} as ResizeObserver);
|
||||
resizeCallback?.([], {} as ResizeObserver);
|
||||
});
|
||||
expect(writes).not.toHaveBeenCalledWith(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
expect.any(String),
|
||||
);
|
||||
hostWidth = 200;
|
||||
act(() => resizeCallback?.([], {} as ResizeObserver));
|
||||
expect(writes).not.toHaveBeenCalledWith(
|
||||
"termix-terminal-toolbar-position-v2",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(
|
||||
writes.mock.calls.filter(
|
||||
([key]) => key === "termix-terminal-toolbar-position-v2",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([undefined, true])(
|
||||
"does not mount or run desktop side effects when mobile is %s",
|
||||
(isMobile) => {
|
||||
mobileApi.isMobile = isMobile;
|
||||
localStorage.setItem("termix-terminal-toolbar-density", "expanded");
|
||||
const writes = vi.spyOn(Storage.prototype, "setItem");
|
||||
const addWindowListener = vi.spyOn(window, "addEventListener");
|
||||
const resizeObserver = vi.fn();
|
||||
vi.stubGlobal("ResizeObserver", resizeObserver);
|
||||
|
||||
const { container } = renderToolbar();
|
||||
|
||||
expect(
|
||||
container.querySelector("[data-terminal-toolbar-host]"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(writes).not.toHaveBeenCalled();
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(addWindowListener).not.toHaveBeenCalledWith(
|
||||
"resize",
|
||||
expect.any(Function),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("ends dragging on pointer cancellation and lost capture, and rejects touch", () => {
|
||||
const { container } = renderToolbar();
|
||||
const grab = screen.getByRole("button", { name: "Move toolbar" });
|
||||
const toolbar = container.querySelector(
|
||||
"[data-terminal-toolbar-wide]",
|
||||
) as HTMLElement;
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerMove(grab, { pointerId: 2, clientX: 50, clientY: 50 });
|
||||
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
|
||||
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 3,
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerCancel(grab, { pointerId: 3 });
|
||||
fireEvent.pointerMove(grab, { pointerId: 3, clientX: 50, clientY: 50 });
|
||||
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
|
||||
|
||||
fireEvent.pointerDown(grab, {
|
||||
pointerId: 4,
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.lostPointerCapture(grab, { pointerId: 4 });
|
||||
fireEvent.pointerMove(grab, { pointerId: 4, clientX: 50, clientY: 50 });
|
||||
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
|
||||
});
|
||||
|
||||
it("ignores stale image completions after an instance change in Strict Mode", async () => {
|
||||
let reject!: (error: Error) => void;
|
||||
const pending = new Promise<void>((_, rejectPromise) => {
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const props = {
|
||||
host,
|
||||
isConnected: true,
|
||||
isTmuxAttached: false,
|
||||
onTmuxDetach: vi.fn(),
|
||||
isImageUploading: false,
|
||||
onUploadImage: vi.fn(),
|
||||
onPasteImage: vi.fn(() => pending),
|
||||
isFocused: true,
|
||||
};
|
||||
const { rerender } = render(
|
||||
<StrictMode>
|
||||
<TerminalToolbar {...props} />
|
||||
</StrictMode>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Paste image from clipboard (images only)",
|
||||
}),
|
||||
);
|
||||
rerender(
|
||||
<StrictMode>
|
||||
<TerminalToolbar
|
||||
{...props}
|
||||
host={{ ...host, id: 8 } as unknown as Host}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
reject(new Error("old instance"));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("contributed toolbar actions", () => {
|
||||
it("shows no contributed button and no stray separator when nothing contributed", () => {
|
||||
const { container } = renderToolbar();
|
||||
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||
const separatorsWithout = container.querySelectorAll(".w-px").length;
|
||||
|
||||
cleanup();
|
||||
slots.toolbar = [
|
||||
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
|
||||
];
|
||||
const withButton = renderToolbar().container;
|
||||
expect(withButton.querySelectorAll(".w-px").length).toBe(
|
||||
separatorsWithout + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the action the terminal slot api", async () => {
|
||||
slots.toolbar = [
|
||||
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
|
||||
];
|
||||
const slotApi = {
|
||||
host: undefined,
|
||||
getBufferText: vi.fn(() => "$ whoami\nroot"),
|
||||
sessionId: () => null,
|
||||
openSidePanel: vi.fn(),
|
||||
runCommand: vi.fn(),
|
||||
};
|
||||
renderToolbar({ slotApi });
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "AI Assistant" }),
|
||||
);
|
||||
expect(slots.invoked).toEqual([[slotApi]]);
|
||||
});
|
||||
|
||||
it("hides the slot when the host has contributed actions disabled", () => {
|
||||
slots.toolbar = [
|
||||
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
|
||||
];
|
||||
renderToolbar({ actionsEnabled: false });
|
||||
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getAndroidHardwareKeySequence } from "../../../src/frontend/terminal/android-hardware-keyboard";
|
||||
|
||||
const key = (
|
||||
value: string,
|
||||
modifiers: Partial<KeyboardEvent> = {},
|
||||
): Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
|
||||
> => ({
|
||||
key: value,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
describe("getAndroidHardwareKeySequence", () => {
|
||||
it("maps cursor keys in normal and application modes", () => {
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(key("ArrowUp"), false, undefined),
|
||||
).toBe("\x1b[A");
|
||||
expect(getAndroidHardwareKeySequence(key("ArrowUp"), true, undefined)).toBe(
|
||||
"\x1bOA",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps Delete and the default Backspace mode", () => {
|
||||
expect(getAndroidHardwareKeySequence(key("Delete"), false, undefined)).toBe(
|
||||
"\x1b[3~",
|
||||
);
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(key("Backspace"), false, undefined),
|
||||
).toBe("\x7f");
|
||||
});
|
||||
|
||||
it("leaves control-h Backspace and modified keys to existing handlers", () => {
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(key("Backspace"), false, "control-h"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getAndroidHardwareKeySequence(
|
||||
key("ArrowLeft", { ctrlKey: true }),
|
||||
false,
|
||||
undefined,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
describe("Android IME composition", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it("forwards a shorter Vietnamese replacement to the shell", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
textarea.value = "Hoar";
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionstart"));
|
||||
textarea.dispatchEvent(
|
||||
new CompositionEvent("compositionupdate", { data: "Hoar" }),
|
||||
);
|
||||
await tick();
|
||||
|
||||
textarea.value = "Hỏa";
|
||||
textarea.selectionStart = textarea.value.length;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
textarea.dispatchEvent(
|
||||
new CompositionEvent("compositionupdate", { data: "Hỏa" }),
|
||||
);
|
||||
await tick();
|
||||
textarea.dispatchEvent(new CompositionEvent("compositionend"));
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("\x7f\x7f\x7fỏa");
|
||||
});
|
||||
|
||||
it("coalesces rapid textarea changes without dropping or duplicating input", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
const type = (value: string) => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
|
||||
);
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = value.length;
|
||||
textarea.selectionEnd = value.length;
|
||||
};
|
||||
|
||||
type("t");
|
||||
type("te");
|
||||
type("ter");
|
||||
type("termix");
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("termix");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
fontSizeStorageKey,
|
||||
readFontSize,
|
||||
saveFontSize,
|
||||
} from "../../../src/frontend/terminal/font-size-storage";
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
describe("persistent terminal zoom", () => {
|
||||
it("survives repeated mounts and option refreshes", () => {
|
||||
const key = fontSizeStorageKey("host-sync-id");
|
||||
saveFontSize(localStorage, key, 14, 18);
|
||||
for (let mount = 0; mount < 3; mount++)
|
||||
expect(readFontSize(localStorage, key, 14)).toBe(18);
|
||||
});
|
||||
it("invalidates an override when defaults change while unmounted", () => {
|
||||
const key = fontSizeStorageKey(1);
|
||||
saveFontSize(localStorage, key, 14, 18);
|
||||
expect(readFontSize(localStorage, key, 16)).toBeNull();
|
||||
expect(readFontSize(localStorage, key, 14)).toBeNull();
|
||||
});
|
||||
it("does not share storage for missing host identities", () => {
|
||||
const key = fontSizeStorageKey(undefined);
|
||||
saveFontSize(localStorage, key, 14, 18);
|
||||
expect(readFontSize(localStorage, key, 14)).toBeNull();
|
||||
expect(localStorage.length).toBe(0);
|
||||
});
|
||||
it("ignores legacy values without a known configured size", () => {
|
||||
localStorage.setItem("legacy", "18");
|
||||
expect(readFontSize(localStorage, "legacy", 14)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = value.length;
|
||||
textarea.selectionEnd = value.length;
|
||||
}
|
||||
|
||||
// iOS Safari/WKWebView reports keyCode 229 for ordinary software-keyboard
|
||||
// input, not just true IME composition, so xterm routes typing through
|
||||
// CompositionHelper.keydown -> _handleAnyTextareaChanges instead of the
|
||||
// normal keypress path. That handler snapshots the textarea value on
|
||||
// keydown, then diffs it against the value a setTimeout(0) later.
|
||||
function dispatchIOSKeydown(textarea: HTMLTextAreaElement) {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
|
||||
);
|
||||
}
|
||||
|
||||
describe("iOS rapid typing (keyCode 229 outside composition)", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it("forwards a mid-word autocorrect rewrite instead of dropping it", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
|
||||
// keydown fires while the textarea still holds the pre-keystroke value;
|
||||
// the browser (or, on iOS, autocorrect) mutates the value afterward.
|
||||
// Autocorrect can rewrite characters earlier in the word, not just
|
||||
// append at the cursor, so the old value is no longer a literal
|
||||
// substring of the new one.
|
||||
dispatchIOSKeydown(textarea);
|
||||
setTextareaValue(textarea, "wrold");
|
||||
await tick();
|
||||
|
||||
dispatchIOSKeydown(textarea);
|
||||
setTextareaValue(textarea, "world");
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("wrold" + "orld");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { getTerminalBufferText } from "../../../src/frontend/terminal/terminal-buffer-text";
|
||||
|
||||
interface FakeLine {
|
||||
text: string;
|
||||
wrapped?: boolean;
|
||||
}
|
||||
|
||||
/** Minimal stand-in for the parts of xterm this reads. */
|
||||
function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal {
|
||||
return {
|
||||
rows,
|
||||
buffer: {
|
||||
active: {
|
||||
baseY: Math.max(0, lines.length - rows),
|
||||
getLine: (i: number) => {
|
||||
const line = lines[i];
|
||||
if (!line) return undefined;
|
||||
return {
|
||||
isWrapped: !!line.wrapped,
|
||||
translateToString: () => line.text,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Terminal;
|
||||
}
|
||||
|
||||
describe("getTerminalBufferText", () => {
|
||||
it("returns an empty string for a missing terminal", () => {
|
||||
expect(getTerminalBufferText(null)).toBe("");
|
||||
expect(getTerminalBufferText(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("joins buffer rows with newlines", () => {
|
||||
const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot");
|
||||
});
|
||||
|
||||
it("joins a wrapped row onto the line it continues", () => {
|
||||
const terminal = fakeTerminal([
|
||||
{ text: "$ echo start" },
|
||||
{ text: "a-very-long-command " },
|
||||
{ text: "that-wrapped", wrapped: true },
|
||||
]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe(
|
||||
"$ echo start\na-very-long-command that-wrapped",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops trailing blank lines", () => {
|
||||
const terminal = fakeTerminal([
|
||||
{ text: "output" },
|
||||
{ text: "" },
|
||||
{ text: "" },
|
||||
]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe("output");
|
||||
});
|
||||
|
||||
it("keeps blank lines in the middle", () => {
|
||||
const terminal = fakeTerminal([
|
||||
{ text: "one" },
|
||||
{ text: "" },
|
||||
{ text: "two" },
|
||||
]);
|
||||
|
||||
expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo");
|
||||
});
|
||||
|
||||
it("bounds the result to maxLines, keeping the tail", () => {
|
||||
const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` }));
|
||||
const terminal = fakeTerminal(lines, 50);
|
||||
|
||||
const result = getTerminalBufferText(terminal, 3);
|
||||
|
||||
expect(result).toBe("line 47\nline 48\nline 49");
|
||||
});
|
||||
|
||||
it("returns an empty string for a non-positive maxLines", () => {
|
||||
const terminal = fakeTerminal([{ text: "anything" }]);
|
||||
|
||||
expect(getTerminalBufferText(terminal, 0)).toBe("");
|
||||
});
|
||||
|
||||
it("returns an empty string when the buffer throws", () => {
|
||||
const disposed = {
|
||||
rows: 1,
|
||||
get buffer(): never {
|
||||
throw new Error("terminal disposed");
|
||||
},
|
||||
} as unknown as Terminal;
|
||||
|
||||
expect(getTerminalBufferText(disposed)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveTerminalContextMenuAction,
|
||||
selectedTextToCopy,
|
||||
} from "../../../src/frontend/terminal/terminal-clipboard-actions";
|
||||
|
||||
describe("terminal clipboard actions", () => {
|
||||
it("copies a completed left-button selection when enabled", () => {
|
||||
expect(
|
||||
selectedTextToCopy({
|
||||
copyOnSelect: true,
|
||||
button: 0,
|
||||
selection: "selected output",
|
||||
}),
|
||||
).toBe("selected output");
|
||||
expect(
|
||||
selectedTextToCopy({ copyOnSelect: false, button: 0, selection: "x" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("pastes on right-click after copy-on-select", () => {
|
||||
expect(
|
||||
resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste: true,
|
||||
copyOnSelect: true,
|
||||
hasSelection: true,
|
||||
}),
|
||||
).toBe("paste");
|
||||
});
|
||||
|
||||
it("preserves existing right-click and native-menu behavior", () => {
|
||||
expect(
|
||||
resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste: true,
|
||||
copyOnSelect: false,
|
||||
hasSelection: true,
|
||||
}),
|
||||
).toBe("copy");
|
||||
expect(
|
||||
resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste: false,
|
||||
copyOnSelect: true,
|
||||
hasSelection: true,
|
||||
}),
|
||||
).toBe("native");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
|
||||
const getCookieMock = vi.fn<(name: string) => string | undefined>();
|
||||
|
||||
vi.mock("@termix/plugin-sdk/ui", () => ({
|
||||
getCookie: (name: string) => getCookieMock(name),
|
||||
}));
|
||||
|
||||
import {
|
||||
getUseRightClickCopyPaste,
|
||||
handleTerminalClipboardKeyEvent,
|
||||
createTerminalContextMenuHandler,
|
||||
} from "../../../src/frontend/terminal/terminal-clipboard";
|
||||
|
||||
function createFakeTerminal({ selection = "" }: { selection?: string } = {}) {
|
||||
return {
|
||||
hasSelection: vi.fn(() => selection.length > 0),
|
||||
getSelection: vi.fn(() => selection),
|
||||
clearSelection: vi.fn(),
|
||||
paste: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createActions() {
|
||||
return {
|
||||
writeTextToClipboard: vi.fn().mockResolvedValue(true),
|
||||
readTextFromClipboard: vi.fn().mockResolvedValue("clipboard-text"),
|
||||
};
|
||||
}
|
||||
|
||||
function createKeyEvent(init: {
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
key?: string;
|
||||
code?: string;
|
||||
}) {
|
||||
return {
|
||||
type: "keydown",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
key: "",
|
||||
code: "",
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...init,
|
||||
} as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
function createMouseEvent(init: { ctrlKey?: boolean } = {}) {
|
||||
return {
|
||||
ctrlKey: false,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...init,
|
||||
} as unknown as MouseEvent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getCookieMock.mockReset();
|
||||
getCookieMock.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("getUseRightClickCopyPaste", () => {
|
||||
it("defaults to enabled when the cookie is unset", () => {
|
||||
getCookieMock.mockReturnValue(undefined);
|
||||
expect(getUseRightClickCopyPaste()).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled only when the cookie is explicitly "false"', () => {
|
||||
getCookieMock.mockReturnValue("false");
|
||||
expect(getUseRightClickCopyPaste()).toBe(false);
|
||||
|
||||
getCookieMock.mockReturnValue("true");
|
||||
expect(getUseRightClickCopyPaste()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleTerminalClipboardKeyEvent", () => {
|
||||
it("copies the selection on Ctrl+C and clears it", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(e.stopPropagation).toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves Ctrl+C alone (SIGINT) when there is no selection", () => {
|
||||
const terminal = createFakeTerminal({ selection: "" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies via Ctrl+Insert without clearing the selection", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, key: "Insert" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies and clears the selection on Ctrl+Shift+C", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
code: "KeyC",
|
||||
key: "C",
|
||||
});
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op for Ctrl+Shift+C without a selection", () => {
|
||||
const terminal = createFakeTerminal({ selection: "" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
code: "KeyC",
|
||||
key: "C",
|
||||
});
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("always pastes on Ctrl+Shift+V regardless of selection", async () => {
|
||||
const terminal = createFakeTerminal({ selection: "irrelevant" });
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
code: "KeyV",
|
||||
key: "V",
|
||||
});
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).toHaveBeenCalled();
|
||||
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
|
||||
});
|
||||
|
||||
it("leaves plain Ctrl+V to the browser's native paste event by default", () => {
|
||||
const terminal = createFakeTerminal();
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
expect(e.stopPropagation).not.toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the clipboard explicitly for plain Ctrl+V when configured", async () => {
|
||||
const terminal = createFakeTerminal();
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
{ plainPasteMode: "explicit" },
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).toHaveBeenCalled();
|
||||
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
|
||||
});
|
||||
|
||||
it("ignores keys with no clipboard shortcut", () => {
|
||||
const terminal = createFakeTerminal();
|
||||
const actions = createActions();
|
||||
const e = createKeyEvent({ key: "a", code: "KeyA" });
|
||||
|
||||
const handled = handleTerminalClipboardKeyEvent(
|
||||
e,
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTerminalContextMenuHandler", () => {
|
||||
it("routes Ctrl+right-click to the passthrough callback instead of the clipboard", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const onCtrlClick = vi.fn();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
{ onCtrlClick },
|
||||
);
|
||||
const e = createMouseEvent({ ctrlKey: true });
|
||||
|
||||
handler(e);
|
||||
|
||||
expect(onCtrlClick).toHaveBeenCalledTimes(1);
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to normal right-click behavior when Ctrl is held with no passthrough configured", () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent({ ctrlKey: true });
|
||||
|
||||
handler(e);
|
||||
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
});
|
||||
|
||||
it("does nothing when the right-click preference is disabled", () => {
|
||||
getCookieMock.mockReturnValue("false");
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent();
|
||||
|
||||
handler(e);
|
||||
|
||||
expect(e.preventDefault).not.toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("copies the current selection when right-click copy/paste is enabled", async () => {
|
||||
const terminal = createFakeTerminal({ selection: "hello" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent();
|
||||
|
||||
handler(e);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(e.preventDefault).toHaveBeenCalled();
|
||||
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
|
||||
expect(terminal.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes when there is no selection", async () => {
|
||||
const terminal = createFakeTerminal({ selection: "" });
|
||||
const actions = createActions();
|
||||
const handler = createTerminalContextMenuHandler(
|
||||
terminal as unknown as Terminal,
|
||||
actions,
|
||||
);
|
||||
const e = createMouseEvent();
|
||||
|
||||
handler(e);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(actions.readTextFromClipboard).toHaveBeenCalled();
|
||||
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { quoteTerminalImagePath } from "../../../src/frontend/terminal/terminal-image-path";
|
||||
|
||||
describe("quoteTerminalImagePath", () => {
|
||||
it("preserves spaces as one shell argument", () => {
|
||||
expect(quoteTerminalImagePath("/mnt/my images/clipboard.png")).toBe(
|
||||
"'/mnt/my images/clipboard.png'",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not expose shell metacharacters as syntax", () => {
|
||||
expect(
|
||||
quoteTerminalImagePath("/tmp/$(touch /tmp/pwned);`id`; 'quoted'.png"),
|
||||
).toBe("'/tmp/$(touch /tmp/pwned);`id`; '\\''quoted'\\''.png'");
|
||||
});
|
||||
|
||||
it("quotes an empty path instead of returning executable input", () => {
|
||||
expect(quoteTerminalImagePath("")).toBe("''");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildImageUploadFormData } from "../../../src/frontend/terminal/terminal-image-upload";
|
||||
|
||||
describe("buildImageUploadFormData", () => {
|
||||
it("tags a picked-file upload with source=file and a client timestamp", () => {
|
||||
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
|
||||
|
||||
const form = buildImageUploadFormData(file, "tab-1", "file");
|
||||
|
||||
expect(form.get("image")).toBe(file);
|
||||
expect(form.get("instanceId")).toBe("tab-1");
|
||||
expect(form.get("source")).toBe("file");
|
||||
const timestamp = form.get("clientUploadTimestamp");
|
||||
expect(typeof timestamp).toBe("string");
|
||||
expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);
|
||||
});
|
||||
|
||||
it("tags a clipboard upload with source=clipboard", () => {
|
||||
const file = new File(["png-bytes"], "clipboard-image.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
const form = buildImageUploadFormData(file, "tab-1", "clipboard");
|
||||
|
||||
expect(form.get("source")).toBe("clipboard");
|
||||
});
|
||||
|
||||
it("uses the caller-provided client timestamp unchanged", () => {
|
||||
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
|
||||
|
||||
const form = buildImageUploadFormData(
|
||||
file,
|
||||
"tab-1",
|
||||
"file",
|
||||
"2026-08-15T12:00:00.000Z",
|
||||
);
|
||||
|
||||
expect(form.get("clientUploadTimestamp")).toBe("2026-08-15T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isPhysicalShortcutKey,
|
||||
isTabKeyEvent,
|
||||
} from "../../../src/frontend/terminal/terminal-key-event";
|
||||
|
||||
describe("isTabKeyEvent", () => {
|
||||
it.each([
|
||||
["key", new KeyboardEvent("keydown", { key: "Tab" })],
|
||||
["code", new KeyboardEvent("keydown", { code: "Tab" })],
|
||||
[
|
||||
"legacy keyCode",
|
||||
new KeyboardEvent("keydown", { keyCode: 9 } as KeyboardEventInit),
|
||||
],
|
||||
])("recognizes Tab from %s", (_source, event) => {
|
||||
expect(isTabKeyEvent(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat another key as Tab", () => {
|
||||
expect(isTabKeyEvent(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPhysicalShortcutKey", () => {
|
||||
it("matches copy and paste by physical code under a Cyrillic layout", () => {
|
||||
expect(isPhysicalShortcutKey({ key: "с", code: "KeyC" }, "KeyC", "c")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isPhysicalShortcutKey({ key: "м", code: "KeyV" }, "KeyV", "v")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not confuse another physical key with a translated character", () => {
|
||||
expect(isPhysicalShortcutKey({ key: "c", code: "KeyV" }, "KeyC", "c")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to key when code is unavailable", () => {
|
||||
expect(isPhysicalShortcutKey({ key: "C", code: "" }, "KeyC", "c")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installTouchWheelCoordinator,
|
||||
TouchWheelCoordinator,
|
||||
} from "../../../src/frontend/terminal/touch-wheel-coordinator";
|
||||
import { TOUCH_INPUT_DEFAULTS } from "../../../src/shared/touch-input-settings";
|
||||
|
||||
const point = (clientY: number, clientX = 10) => ({ clientX, clientY });
|
||||
|
||||
const touchEvent = (type: string, clientYValues: number[]) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
|
||||
Object.defineProperty(event, "touches", {
|
||||
value: Object.assign(touches, {
|
||||
item: (index: number) => touches[index] ?? null,
|
||||
}),
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
const createTiming = (momentumAllowed = true) => {
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
return {
|
||||
timing: {
|
||||
now: () => now,
|
||||
requestFrame: (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
callbacks.set(handle, callback);
|
||||
return handle;
|
||||
},
|
||||
cancelFrame: (handle: number) => callbacks.delete(handle),
|
||||
momentumAllowed: () => momentumAllowed,
|
||||
},
|
||||
advance(milliseconds: number) {
|
||||
now += milliseconds;
|
||||
const pending = [...callbacks.values()];
|
||||
callbacks.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
},
|
||||
pendingFrames: () => callbacks.size,
|
||||
};
|
||||
};
|
||||
|
||||
describe("TouchWheelCoordinator", () => {
|
||||
it("consumes configured drag and wheel limits", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit, 2, 3, undefined, {
|
||||
...TOUCH_INPUT_DEFAULTS,
|
||||
dragThresholdPx: 2,
|
||||
maxWheelDeltaPx: 3,
|
||||
});
|
||||
coordinator.start(point(20));
|
||||
coordinator.move(point(10));
|
||||
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([
|
||||
3, 3, 3, 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it("accumulates the drag threshold and preserves delta direction", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
expect(coordinator.move(point(97.5))).toBe(true);
|
||||
expect(coordinator.move(point(93))).toBe(true);
|
||||
coordinator.move(point(101));
|
||||
|
||||
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([7, -8]);
|
||||
});
|
||||
|
||||
it("keeps movement below the tap threshold from scrolling", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
expect(coordinator.move(point(95))).toBe(true);
|
||||
coordinator.end();
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the gesture when more than one touch is represented", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
coordinator.move(undefined);
|
||||
expect(coordinator.move(point(80))).toBe(false);
|
||||
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up cancelled state before the next gesture", () => {
|
||||
const emit = vi.fn();
|
||||
const coordinator = new TouchWheelCoordinator(emit);
|
||||
|
||||
coordinator.start(point(100));
|
||||
coordinator.cancel();
|
||||
coordinator.start(point(50));
|
||||
expect(coordinator.move(point(43.5))).toBe(true);
|
||||
|
||||
expect(emit).toHaveBeenCalledOnce();
|
||||
expect(emit.mock.calls[0][0].deltaY).toBe(6.5);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ start: 100, end: 20, direction: 1 },
|
||||
{ start: 20, end: 100, direction: -1 },
|
||||
])(
|
||||
"emits multiple $direction line ticks after release",
|
||||
({ start, end, direction }) => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(start));
|
||||
clock.advance(20);
|
||||
coordinator.move(point((start + end) / 2));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(end));
|
||||
coordinator.end();
|
||||
const dragCallCount = emit.mock.calls.length;
|
||||
|
||||
for (let index = 0; index < 12; index++) clock.advance(16);
|
||||
const momentum = emit.mock.calls
|
||||
.slice(dragCallCount)
|
||||
.map(([event]) => event);
|
||||
|
||||
expect(momentum.length).toBeGreaterThan(3);
|
||||
expect(
|
||||
momentum.every(
|
||||
(event) =>
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE &&
|
||||
event.deltaY === direction,
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("decays the number of line ticks emitted over time", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
coordinator.start(point(200));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(100));
|
||||
coordinator.end();
|
||||
const dragCalls = emit.mock.calls.length;
|
||||
|
||||
const frameCounts: number[] = [];
|
||||
for (let index = 0; index < 24; index++) {
|
||||
const before = emit.mock.calls.length;
|
||||
clock.advance(16);
|
||||
frameCounts.push(emit.mock.calls.length - before);
|
||||
}
|
||||
|
||||
expect(emit.mock.calls.length - dragCalls).toBeGreaterThan(3);
|
||||
expect(frameCounts.slice(0, 8).reduce((a, b) => a + b, 0)).toBeGreaterThan(
|
||||
frameCounts.slice(-8).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the recent movement window despite a short touchend delay", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
clock.advance(80);
|
||||
coordinator.end();
|
||||
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
});
|
||||
|
||||
it("bounds momentum velocity, duration, and total travel", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(1000));
|
||||
clock.advance(1);
|
||||
coordinator.move(point(0));
|
||||
coordinator.end();
|
||||
const dragCallCount = emit.mock.calls.length;
|
||||
clock.advance(200);
|
||||
const firstFrameTicks = emit.mock.calls.length - dragCallCount;
|
||||
for (let index = 0; index < 30; index++) clock.advance(20);
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
for (let index = 0; index < 10; index++) clock.advance(20);
|
||||
const totalTicks = emit.mock.calls.length - dragCallCount;
|
||||
|
||||
expect(firstFrameTicks).toBeLessThanOrEqual(4);
|
||||
expect(totalTicks).toBeLessThanOrEqual(Math.floor(720 / 12));
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels momentum on a new touch or cancellation", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
coordinator.end();
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
coordinator.start(point(60));
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
|
||||
clock.advance(20);
|
||||
coordinator.move(point(20));
|
||||
coordinator.end();
|
||||
expect(clock.pendingFrames()).toBe(1);
|
||||
coordinator.cancel();
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
it("disables momentum when reduced motion is requested", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming(false);
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
coordinator.end();
|
||||
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
expect(emit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops momentum if the active terminal mode changes", () => {
|
||||
const emit = vi.fn();
|
||||
let momentumAllowed = true;
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(emit, undefined, undefined, {
|
||||
...clock.timing,
|
||||
momentumAllowed: () => momentumAllowed,
|
||||
});
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(20);
|
||||
coordinator.move(point(50));
|
||||
coordinator.end();
|
||||
momentumAllowed = false;
|
||||
clock.advance(20);
|
||||
|
||||
expect(emit).toHaveBeenCalledOnce();
|
||||
expect(clock.pendingFrames()).toBe(0);
|
||||
});
|
||||
|
||||
it("retains fractional pixel distance until it forms a whole line tick", () => {
|
||||
const emit = vi.fn();
|
||||
const clock = createTiming();
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
emit,
|
||||
undefined,
|
||||
undefined,
|
||||
clock.timing,
|
||||
);
|
||||
|
||||
coordinator.start(point(100));
|
||||
clock.advance(100);
|
||||
coordinator.move(point(80));
|
||||
coordinator.end();
|
||||
const dragCallCount = emit.mock.calls.length;
|
||||
for (let index = 0; index < 4; index++) clock.advance(16);
|
||||
expect(emit.mock.calls.length).toBe(dragCallCount);
|
||||
clock.advance(16);
|
||||
expect(emit.mock.calls.length).toBeGreaterThan(dragCallCount);
|
||||
expect(emit.mock.calls[dragCallCount][0]).toMatchObject({
|
||||
deltaY: 1,
|
||||
deltaMode: WheelEvent.DOM_DELTA_LINE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("installTouchWheelCoordinator", () => {
|
||||
it("claims the first single-touch move but preserves taps and multi-touch", () => {
|
||||
const terminalElement = document.createElement("div");
|
||||
const scrollableElement = document.createElement("div");
|
||||
scrollableElement.className = "xterm-scrollable-element";
|
||||
const screenElement = document.createElement("div");
|
||||
screenElement.className = "xterm-screen";
|
||||
scrollableElement.append(screenElement);
|
||||
terminalElement.append(scrollableElement);
|
||||
const dispose = installTouchWheelCoordinator(terminalElement);
|
||||
|
||||
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
const earlyMove = touchEvent("touchmove", [99]);
|
||||
terminalElement.dispatchEvent(earlyMove);
|
||||
expect(earlyMove.defaultPrevented).toBe(true);
|
||||
|
||||
terminalElement.dispatchEvent(touchEvent("touchend", []));
|
||||
const tapEnd = touchEvent("touchend", []);
|
||||
terminalElement.dispatchEvent(tapEnd);
|
||||
expect(tapEnd.defaultPrevented).toBe(false);
|
||||
|
||||
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
const multiTouchMove = touchEvent("touchmove", [90, 80]);
|
||||
terminalElement.dispatchEvent(multiTouchMove);
|
||||
expect(multiTouchMove.defaultPrevented).toBe(false);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("dispatches wheel events at xterm's normal scrollback seam", () => {
|
||||
const terminalElement = document.createElement("div");
|
||||
const scrollableElement = document.createElement("div");
|
||||
scrollableElement.className = "xterm-scrollable-element";
|
||||
const screenElement = document.createElement("div");
|
||||
const canvas = document.createElement("canvas");
|
||||
screenElement.className = "xterm-screen";
|
||||
screenElement.append(canvas);
|
||||
scrollableElement.append(screenElement);
|
||||
terminalElement.append(scrollableElement);
|
||||
const scrollableWheel = vi.fn();
|
||||
const canvasWheel = vi.fn();
|
||||
scrollableElement.addEventListener("wheel", scrollableWheel);
|
||||
canvas.addEventListener("wheel", canvasWheel);
|
||||
const dispose = installTouchWheelCoordinator(terminalElement);
|
||||
|
||||
canvas.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
canvas.dispatchEvent(touchEvent("touchmove", [90]));
|
||||
|
||||
expect(scrollableWheel).toHaveBeenCalledOnce();
|
||||
expect(canvasWheel).not.toHaveBeenCalled();
|
||||
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaY).toBe(10);
|
||||
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaMode).toBe(
|
||||
WheelEvent.DOM_DELTA_PIXEL,
|
||||
);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("dispatches momentum as line-mode wheel ticks from the scrollback seam", () => {
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
callbacks.set(handle, callback);
|
||||
return handle;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
|
||||
callbacks.delete(handle);
|
||||
});
|
||||
|
||||
const terminalElement = document.createElement("div");
|
||||
const scrollableElement = document.createElement("div");
|
||||
scrollableElement.className = "xterm-scrollable-element";
|
||||
const screenElement = document.createElement("div");
|
||||
const canvas = document.createElement("canvas");
|
||||
screenElement.className = "xterm-screen";
|
||||
screenElement.append(canvas);
|
||||
scrollableElement.append(screenElement);
|
||||
terminalElement.append(scrollableElement);
|
||||
const wheels: WheelEvent[] = [];
|
||||
scrollableElement.addEventListener("wheel", (event) =>
|
||||
wheels.push(event as WheelEvent),
|
||||
);
|
||||
const dispose = installTouchWheelCoordinator(terminalElement);
|
||||
|
||||
canvas.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
now = 20;
|
||||
canvas.dispatchEvent(touchEvent("touchmove", [40]));
|
||||
canvas.dispatchEvent(touchEvent("touchend", []));
|
||||
for (let index = 0; index < 8; index++) {
|
||||
now += 16;
|
||||
const pending = [...callbacks.values()];
|
||||
callbacks.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
}
|
||||
|
||||
const momentum = wheels.filter(
|
||||
(event) => event.deltaMode === WheelEvent.DOM_DELTA_LINE,
|
||||
);
|
||||
expect(momentum.length).toBeGreaterThan(1);
|
||||
expect(momentum.every((event) => event.deltaY === 1)).toBe(true);
|
||||
expect(wheels.every((event) => event.currentTarget === null)).toBe(true);
|
||||
dispose();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { installTouchWheelCoordinator } from "../../../src/frontend/terminal/touch-wheel-coordinator";
|
||||
|
||||
const touchEvent = (type: string, clientYValues: number[]) => {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
|
||||
Object.defineProperty(event, "touches", {
|
||||
value: Object.assign(touches, {
|
||||
item: (index: number) => touches[index] ?? null,
|
||||
}),
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
const write = (terminal: Terminal, data: string) =>
|
||||
new Promise<void>((resolve) => terminal.write(data, resolve));
|
||||
|
||||
describe("touch wheel coordinator with xterm", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const installXtermCharacterMeasurements = () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillStyle: "",
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D);
|
||||
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains("xterm-char-measure-element")
|
||||
? 32 * 9
|
||||
: 0;
|
||||
},
|
||||
);
|
||||
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains("xterm-char-measure-element") ? 18 : 0;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("moves real normal-buffer scrollback during a drag and momentum", async () => {
|
||||
installXtermCharacterMeasurements();
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
|
||||
frames.delete(handle);
|
||||
});
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal({ cols: 80, rows: 10, scrollback: 1_000 });
|
||||
terminal.open(container);
|
||||
await write(
|
||||
terminal,
|
||||
Array.from({ length: 500 }, (_, index) => `${index + 1}\r\n`).join(""),
|
||||
);
|
||||
|
||||
const element = terminal.element!;
|
||||
const dispose = installTouchWheelCoordinator(element);
|
||||
terminal.scrollToBottom();
|
||||
const bottom = terminal.buffer.active.viewportY;
|
||||
|
||||
element.dispatchEvent(touchEvent("touchstart", [20]));
|
||||
now = 20;
|
||||
element.dispatchEvent(touchEvent("touchmove", [100]));
|
||||
const afterDrag = terminal.buffer.active.viewportY;
|
||||
element.dispatchEvent(touchEvent("touchend", []));
|
||||
for (let index = 0; index < 8; index++) {
|
||||
now += 16;
|
||||
const pending = [...frames.values()];
|
||||
frames.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
}
|
||||
|
||||
expect(bottom).toBeGreaterThan(400);
|
||||
expect(afterDrag).toBeLessThan(bottom);
|
||||
expect(terminal.buffer.active.viewportY).toBeLessThan(afterDrag);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it("preserves alternate-buffer wheel line ticks", async () => {
|
||||
installXtermCharacterMeasurements();
|
||||
let now = 0;
|
||||
let nextHandle = 1;
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
const handle = nextHandle++;
|
||||
frames.set(handle, callback);
|
||||
return handle;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
|
||||
frames.delete(handle);
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal({ cols: 80, rows: 10 });
|
||||
terminal.open(container);
|
||||
const data: string[] = [];
|
||||
terminal.onData((value) => data.push(value));
|
||||
await write(terminal, "\x1b[?1049h");
|
||||
|
||||
const element = terminal.element!;
|
||||
const dispose = installTouchWheelCoordinator(element);
|
||||
element.dispatchEvent(touchEvent("touchstart", [100]));
|
||||
now = 20;
|
||||
element.dispatchEvent(touchEvent("touchmove", [0]));
|
||||
data.length = 0;
|
||||
element.dispatchEvent(touchEvent("touchend", []));
|
||||
for (let index = 0; index < 8; index++) {
|
||||
now += 16;
|
||||
const pending = [...frames.values()];
|
||||
frames.clear();
|
||||
pending.forEach((callback) => callback(now));
|
||||
}
|
||||
|
||||
expect(terminal.buffer.active.type).toBe("alternate");
|
||||
expect(data.length).toBeGreaterThan(1);
|
||||
expect(data.every((value) => value === "\x1b[B")).toBe(true);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user