Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/desktop/src/electron/ElectronMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe("ElectronMenu", () => {
const electronMenu = yield* ElectronMenu.ElectronMenu;
const selectedItemId = yield* electronMenu.showContextMenu({
window: makeWindow(),
items: [{ id: "copy", label: "Copy" }],
items: [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }],
position: Option.none(),
});

Expand All @@ -99,7 +99,7 @@ describe("ElectronMenu", () => {
const selectedItemId = yield* electronMenu.showContextMenu({
window: makeWindow(2),
items: [
{ id: "copy", label: "Copy" },
{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" },
{ id: "delete", label: "Delete", destructive: true, separatorBefore: true },
],
position: Option.some({ x: 10.8, y: 20.2 }),
Expand All @@ -110,6 +110,7 @@ describe("ElectronMenu", () => {
assert.equal(popupOptions?.y, 40);
assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
label: "Copy",
accelerator: "Ctrl+Shift+C",
enabled: true,
click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
});
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/electron/ElectronMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
const normalizedItem: ContextMenuItem = {
id: sourceItem.id,
label: sourceItem.label,
...(sourceItem.accelerator ? { accelerator: sourceItem.accelerator } : {}),
destructive: sourceItem.destructive === true,
disabled: sourceItem.disabled === true,
...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
Expand Down Expand Up @@ -165,6 +166,7 @@ export const make = Effect.gen(function* () {

const itemOption: Electron.MenuItemConstructorOptions = {
label: item.label,
...(item.accelerator ? { accelerator: item.accelerator } : {}),
enabled: !item.disabled,
};
if (item.children && item.children.length > 0) {
Expand Down
171 changes: 170 additions & 1 deletion apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,182 @@
import { describe, expect, it } from "vite-plus/test";
import { describe, expect, it, vi } from "vite-plus/test";

import {
resolveTerminalSelectionActionPosition,
runTerminalMenuRequest,
shouldHandleTerminalExit,
shouldHandleTerminalSelectionMouseUp,
shouldRestoreTerminalFocusAfterMenuAction,
terminalContextMenuItems,
terminalSelectionActionDelayForClickCount,
terminalSelectionLineRange,
} from "./ThreadTerminalDrawer";

describe("runTerminalMenuRequest", () => {
it("reports a failure from the current menu request", async () => {
const error = new Error("menu failed");
const reportOpenError = vi.fn();

await runTerminalMenuRequest({
signal: new AbortController().signal,
isCurrentRequest: () => true,
open: () => Promise.reject(error),
perform: vi.fn(async () => {}),
reportOpenError,
focusTerminal: vi.fn(),
});

expect(reportOpenError).toHaveBeenCalledWith(error);
});

it("suppresses failures and actions from superseded requests", async () => {
const reportOpenError = vi.fn();
const perform = vi.fn(async () => {});

await runTerminalMenuRequest({
signal: new AbortController().signal,
isCurrentRequest: () => false,
open: () => Promise.reject(new Error("stale menu failure")),
perform,
reportOpenError,
focusTerminal: vi.fn(),
});
await runTerminalMenuRequest({
signal: new AbortController().signal,
isCurrentRequest: () => false,
open: async () => "copy",
perform,
reportOpenError,
focusTerminal: vi.fn(),
});

expect(reportOpenError).not.toHaveBeenCalled();
expect(perform).not.toHaveBeenCalled();
});

it.each([
["copy", true],
["paste", true],
["add-to-chat", false],
] as const)("performs %s and restores focus only when required", async (action, shouldFocus) => {
const perform = vi.fn(async () => {});
const focusTerminal = vi.fn();

await runTerminalMenuRequest({
signal: new AbortController().signal,
isCurrentRequest: () => true,
open: async () => action,
perform,
reportOpenError: vi.fn(),
focusTerminal,
});

expect(perform).toHaveBeenCalledWith(action, expect.any(Function));
expect(focusTerminal).toHaveBeenCalledTimes(shouldFocus ? 1 : 0);
});

it("does nothing when the menu is dismissed", async () => {
const perform = vi.fn(async () => {});
const focusTerminal = vi.fn();

await runTerminalMenuRequest({
signal: new AbortController().signal,
isCurrentRequest: () => true,
open: async () => null,
perform,
reportOpenError: vi.fn(),
focusTerminal,
});

expect(perform).not.toHaveBeenCalled();
expect(focusTerminal).not.toHaveBeenCalled();
});

it("drops an action when the menu owner aborts before it resolves", async () => {
const abortController = new AbortController();
let resolveMenu: (action: "copy") => void = () => {};
const perform = vi.fn(async () => {});
const request = runTerminalMenuRequest({
signal: abortController.signal,
isCurrentRequest: () => true,
open: () =>
new Promise<"copy">((resolve) => {
resolveMenu = resolve;
}),
perform,
reportOpenError: vi.fn(),
focusTerminal: vi.fn(),
});

abortController.abort();
resolveMenu("copy");
await request;

expect(perform).not.toHaveBeenCalled();
});

it("does not restore focus when teardown aborts an action in flight", async () => {
const abortController = new AbortController();
let finishAction: (() => void) | undefined;
const focusTerminal = vi.fn();
const request = runTerminalMenuRequest({
signal: abortController.signal,
isCurrentRequest: () => true,
open: async () => "copy",
perform: vi.fn(
(_action: "add-to-chat" | "copy" | "paste", isCurrent: () => boolean) =>
new Promise<void>((resolve) => {
expect(isCurrent()).toBe(true);
finishAction = resolve;
}),
),
reportOpenError: vi.fn(),
focusTerminal,
});
await vi.waitFor(() => expect(finishAction).toBeDefined());

abortController.abort();
finishAction?.();
await request;

expect(focusTerminal).not.toHaveBeenCalled();
});
});

describe("shouldRestoreTerminalFocusAfterMenuAction", () => {
it("restores focus only after terminal-local actions", () => {
expect(shouldRestoreTerminalFocusAfterMenuAction("copy")).toBe(true);
expect(shouldRestoreTerminalFocusAfterMenuAction("paste")).toBe(true);
expect(shouldRestoreTerminalFocusAfterMenuAction("add-to-chat")).toBe(false);
expect(shouldRestoreTerminalFocusAfterMenuAction(null)).toBe(false);
});
});

describe("terminalContextMenuItems", () => {
it("offers all actions and disables only actions that need a selection", () => {
expect(terminalContextMenuItems({ canAddToChat: false, canCopy: false }, "Win32")).toEqual([
{ id: "add-to-chat", label: "Add to chat", disabled: true },
{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: true },
{ id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" },
]);
});

it("keeps Copy available when selected whitespace cannot be added to chat", () => {
expect(terminalContextMenuItems({ canAddToChat: false, canCopy: true }, "Linux")).toEqual([
{ id: "add-to-chat", label: "Add to chat", disabled: true },
{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: false },
{ id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" },
]);
});

it("uses Command accelerators on macOS", () => {
expect(terminalContextMenuItems({ canAddToChat: true, canCopy: true }, "MacIntel")).toEqual([
{ id: "add-to-chat", label: "Add to chat", disabled: false },
{ id: "copy", label: "Copy", accelerator: "Command+C", disabled: false },
{ id: "paste", label: "Paste", accelerator: "Command+V" },
]);
});
});

describe("resolveTerminalSelectionActionPosition", () => {
it("prefers the selection rect over the last pointer position", () => {
expect(
Expand Down
Loading
Loading