Skip to content
Open
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
31 changes: 27 additions & 4 deletions frontend/src/renderer/components/XtermTerminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const state = vi.hoisted(() => ({
modes: { bracketedPasteMode: boolean; mouseTrackingMode: string };
buffer: { active: { type: string } };
scrollLines: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
focus: ReturnType<typeof vi.fn>;
selectAll: ReturnType<typeof vi.fn>;
dataListeners: Set<(data: string) => void>;
Expand All @@ -39,7 +39,7 @@ vi.mock("@xterm/xterm", () => ({
modes = { bracketedPasteMode: false, mouseTrackingMode: "vt200" };
buffer = { active: { type: "normal" } };
scrollLines = vi.fn();
clear = vi.fn();
write = vi.fn();
focus = vi.fn();
selectAll = vi.fn();
dataListeners = new Set<(data: string) => void>();
Expand All @@ -62,7 +62,6 @@ vi.mock("@xterm/xterm", () => ({
open(host: HTMLElement) {
host.appendChild(document.createElement("textarea"));
}
write() {}
writeln() {}
dispose() {}
onData(listener: (data: string) => void) {
Expand Down Expand Up @@ -217,6 +216,29 @@ describe("XtermTerminal", () => {
expect(trigger.style.top).toBe("88px");
});

it("enables Copy on the context menu when the terminal has a selection", async () => {
const { container } = render(<XtermTerminal theme="dark" />);
state.lastTerminal!.selection = "selected text";

fireEvent.contextMenu(container.firstElementChild!);

expect(await screen.findByText("Copy")).not.toHaveAttribute("data-disabled");
});

it("restores terminal focus when the context menu is dismissed with Escape", async () => {
const { container } = render(<XtermTerminal theme="dark" />);
const host = container.firstElementChild!;

fireEvent.contextMenu(host);
expect(await screen.findByText("Paste")).toBeInTheDocument();
state.lastTerminal!.focus.mockClear();

fireEvent.keyDown(document, { key: "Escape" });

await waitFor(() => expect(screen.queryByText("Paste")).not.toBeInTheDocument());
expect(state.lastTerminal!.focus).toHaveBeenCalled();
});

it("runs context menu copy, select all, and clear against the xterm instance", async () => {
const { container } = render(<XtermTerminal theme="dark" />);
const host = container.firstElementChild!;
Expand All @@ -233,7 +255,8 @@ describe("XtermTerminal", () => {

fireEvent.contextMenu(host);
fireEvent.click(await screen.findByText("Clear"));
expect(state.lastTerminal!.clear).toHaveBeenCalled();
// Same local wipe as AttachableTerminal.clear — not term.clear().
expect(state.lastTerminal!.write).toHaveBeenCalledWith("\x1b[3J\x1b[2J\x1b[H");
});

it("pastes from the context menu through the terminal paste path", async () => {
Expand Down
39 changes: 25 additions & 14 deletions frontend/src/renderer/components/XtermTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,10 +237,29 @@ export function XtermTerminal(props: XtermTerminalProps) {
// changed between renders.
const callbacksRef = useRef(props);

const setContextMenuOpen = useCallback((open: boolean) => {
setContextMenu((current) => ({ ...current, open }));
// Prefer termRef over a mount-effect closure so dismiss paths outside the
// effect (Escape, outside click, onOpenChange) can restore focus too.
const focusTerminal = useCallback(() => {
try {
termRef.current?.focus();
} catch {
// Terminal is being torn down or its hidden textarea is unavailable.
}
}, []);

const setContextMenuOpen = useCallback(
(open: boolean) => {
setContextMenu((current) => ({ ...current, open }));
if (!open) {
// Radix moves focus into the menu content. Always return it to the
// xterm helper textarea on dismiss — item select, Escape, or outside
// click — otherwise typing silently stops until the user clicks.
focusTerminal();
}
},
[focusTerminal],
);

const runContextMenuAction = useCallback(
(action: TerminalContextMenuAction) => {
contextMenuActionsRef.current?.[action]();
Expand Down Expand Up @@ -388,29 +407,21 @@ export function XtermTerminal(props: XtermTerminalProps) {
console.warn("Unable to paste terminal clipboard text", error);
});
};
const focusTerminal = () => {
try {
term.focus();
} catch {
// Terminal is being torn down or its hidden textarea is unavailable.
}
};
// Actions only mutate selection/clipboard/buffer; focus is restored by
// setContextMenuOpen(false) so Escape/outside dismiss share the same path.
// Clear matches AttachableTerminal.clear (CLEAR_SEQUENCE), not term.clear().
contextMenuActionsRef.current = {
clear: () => {
term.clear();
focusTerminal();
term.write(CLEAR_SEQUENCE);
},
copy: () => {
copySelection();
focusTerminal();
},
paste: () => {
pasteFromClipboard();
focusTerminal();
},
selectAll: () => {
term.selectAll();
focusTerminal();
},
};
const openContextMenu = (event: MouseEvent) => {
Expand Down