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
180 changes: 179 additions & 1 deletion apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,191 @@
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("drops an action when the menu owner aborts before it resolves", async () => {
const abortController = new AbortController();
let resolveMenu: (action: "copy") => void = () => {};
const open = vi.fn(
() =>
new Promise<"copy">((resolve) => {
resolveMenu = resolve;
}),
);
const perform = vi.fn(async () => {});
const reportOpenError = vi.fn();
const focusTerminal = vi.fn();
const request = runTerminalMenuRequest({
signal: abortController.signal,
isCurrentRequest: () => true,
open,
perform,
reportOpenError,
focusTerminal,
});

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

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

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

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

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

it("suppresses a rejection from a superseded menu request", async () => {
const reportOpenError = vi.fn();

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

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

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("does nothing when the menu is dismissed without an action", 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.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);
});
});

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 terminal actions and disables selection-only actions without 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("enables copy and add to chat when terminal text is selected", () => {
expect(terminalContextMenuItems({ canAddToChat: true, canCopy: true }, "Win32")).toEqual([
{ id: "add-to-chat", label: "Add to chat", disabled: false },
{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: false },
{ id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" },
]);
});

it("keeps Copy enabled for selections that cannot be added to chat", () => {
expect(terminalContextMenuItems({ canAddToChat: false, canCopy: true }, "Win32")).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 native Command shortcuts 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