diff --git a/src/features/terminal/Terminal.test.tsx b/src/features/terminal/Terminal.test.tsx new file mode 100644 index 00000000..590d8a43 --- /dev/null +++ b/src/features/terminal/Terminal.test.tsx @@ -0,0 +1,242 @@ +import { cleanup, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Terminal } from "./Terminal"; + +const { + readClipboardTextMock, + TerminalMock, + terminalInstances, + toasterCreateMock, + writeClipboardTextMock, +} = vi.hoisted(() => { + interface MockTerminalInstance { + fireSelectionChange: () => void; + setSelection: (selection: string) => void; + } + + const terminalInstances: MockTerminalInstance[] = []; + const writeClipboardTextMock = vi.fn(); + const readClipboardTextMock = vi.fn(); + const toasterCreateMock = vi.fn(); + + class MockTerminal { + cols: number; + rows: number; + element: HTMLElement | null = null; + options: Record; + private selection = ""; + private selectionListeners: Array<() => void> = []; + + constructor(options: { cols: number; rows: number }) { + this.cols = options.cols; + this.rows = options.rows; + this.options = { ...options }; + terminalInstances.push(this); + } + + open(element: HTMLElement) { + this.element = element; + } + + focus() {} + refresh() {} + clear() {} + write(_data: unknown, callback?: () => void) { + callback?.(); + } + + hasSelection() { + return this.selection.length > 0; + } + + getSelection() { + return this.selection; + } + + setSelection(selection: string) { + this.selection = selection; + } + + fireSelectionChange() { + for (const listener of this.selectionListeners) listener(); + } + + onSelectionChange(listener: () => void) { + this.selectionListeners.push(listener); + return { dispose: vi.fn() }; + } + + onTitleChange() { + return { dispose: vi.fn() }; + } + + onData() { + return { dispose: vi.fn() }; + } + + onResize() { + return { dispose: vi.fn() }; + } + + attachCustomKeyEventHandler() {} + + registerLinkProvider() { + return { dispose: vi.fn() }; + } + } + + return { + readClipboardTextMock, + terminalInstances, + toasterCreateMock, + writeClipboardTextMock, + TerminalMock: MockTerminal, + }; +}); + +vi.mock("@xterm/xterm", () => ({ + Terminal: TerminalMock, +})); + +vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ + readText: readClipboardTextMock, + writeText: writeClipboardTextMock, +})); + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(), +})); + +vi.mock("@/generated", () => ({ + clearPtyOutput: vi.fn(() => Promise.resolve()), + flushPtyOutput: vi.fn(() => Promise.resolve()), + getPtySessionHistory: vi.fn(() => Promise.resolve([])), + listProjectSessions: vi.fn(() => Promise.resolve([])), + listProjects: vi.fn(() => Promise.resolve([])), + resizePty: vi.fn(() => Promise.resolve()), + restorePtySession: vi.fn(() => + Promise.resolve({ newSessionId: "mock-session-id", history: [] }), + ), + writeToPty: vi.fn(() => Promise.resolve()), +})); + +vi.mock("@/shared/providers/appToaster", () => ({ + toaster: { + create: toasterCreateMock, + }, +})); + +vi.mock("./FileLinkProvider", () => ({ + FileLinkProvider: class { + setTerminal() {} + }, +})); + +vi.mock("./TerminalLinkConfirmDialog", () => ({ + TerminalLinkConfirmDialog: () => null, +})); + +vi.mock("./hooks", () => ({ + useTerminalTheme: () => ({ background: "#000000" }), +})); + +vi.mock("./lib", () => ({ + applyTerminalFontFamilyCssVariable: vi.fn(), + buildFontFamilyCss: (fontFamily: string) => fontFamily, + createResizeScheduler: () => ({ + observe: vi.fn(), + dispose: vi.fn(), + }), + createTerminalKeyEventHandler: () => () => true, + getTerminalParkingContainer: () => document.body, + installImagePasteFallback: () => vi.fn(), + loadAddons: () => ({ + fitAddon: { fit: vi.fn() }, + serializeAddon: { serialize: vi.fn(() => "") }, + webglAddon: () => null, + dispose: vi.fn(), + }), + measureAndResize: vi.fn(() => false), + scheduleFontSettleRefit: vi.fn(), + suppressQueryResponses: () => vi.fn(), + TitleDebouncer: class { + value = ""; + set(value: string) { + this.value = value; + } + subscribe() {} + dispose() {} + }, +})); + +function renderTerminal() { + render(); + return terminalInstances[terminalInstances.length - 1]; +} + +describe("terminal select to copy", () => { + beforeEach(() => { + terminalInstances.length = 0; + writeClipboardTextMock.mockReset(); + writeClipboardTextMock.mockResolvedValue(undefined); + readClipboardTextMock.mockReset(); + toasterCreateMock.mockReset(); + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("does not copy before xterm reports a selection change", () => { + const terminal = renderTerminal(); + + terminal.setSelection("selected text"); + + expect(writeClipboardTextMock).not.toHaveBeenCalled(); + expect(toasterCreateMock).not.toHaveBeenCalled(); + }); + + it("copies the selected text and shows a toast after xterm reports a selection change", async () => { + const terminal = renderTerminal(); + + terminal.setSelection("selected text"); + terminal.fireSelectionChange(); + + await waitFor(() => { + expect(writeClipboardTextMock).toHaveBeenCalledWith("selected text"); + }); + expect(toasterCreateMock).toHaveBeenCalledWith({ + title: "Text copied", + type: "success", + closable: true, + }); + }); + + it("does not copy empty selection", () => { + const terminal = renderTerminal(); + + terminal.setSelection(""); + terminal.fireSelectionChange(); + + expect(writeClipboardTextMock).not.toHaveBeenCalled(); + expect(toasterCreateMock).not.toHaveBeenCalled(); + }); + + it("does not copy the same selection twice", async () => { + const terminal = renderTerminal(); + + terminal.setSelection("selected text"); + terminal.fireSelectionChange(); + + await waitFor(() => { + expect(writeClipboardTextMock).toHaveBeenCalledTimes(1); + }); + + terminal.setSelection("selected text"); + terminal.fireSelectionChange(); + + expect(writeClipboardTextMock).toHaveBeenCalledTimes(1); + expect(toasterCreateMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/terminal/Terminal.tsx b/src/features/terminal/Terminal.tsx index faded3ca..024b4ff0 100644 --- a/src/features/terminal/Terminal.tsx +++ b/src/features/terminal/Terminal.tsx @@ -17,6 +17,7 @@ import { resizePty, writeToPty, } from "@/generated"; +import { toaster } from "@/shared/providers/appToaster"; import { FileLinkProvider } from "./FileLinkProvider"; import { TerminalLinkConfirmDialog } from "./TerminalLinkConfirmDialog"; import { useTerminalTheme } from "./hooks"; @@ -224,6 +225,7 @@ export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { pendingEventsRef.current = []; const liveOutputBuffer: string[] = []; let liveOutputFrame: number | null = null; + let lastCopiedSelection = ""; // --- Wrapper-div pattern (SuperSet) --- // xterm opens into a persistent wrapper div that can be moved @@ -287,6 +289,29 @@ export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { // 4. Image paste fallback — send ^V for non-text clipboard payloads cleanups.push(installImagePasteFallback(term, wrapper)); + function copyTerminalSelection(selection: string) { + if (!selection || selection === lastCopiedSelection) return; + lastCopiedSelection = selection; + void writeClipboardText(selection) + .then(() => { + toaster.create({ + title: "Text copied", + type: "success", + closable: true, + }); + }) + .catch(() => {}); + } + + const selectionDisposable = term.onSelectionChange(() => { + if (!term.hasSelection()) { + lastCopiedSelection = ""; + return; + } + copyTerminalSelection(term.getSelection()); + }); + cleanups.push(() => selectionDisposable.dispose()); + // 5. Combined key handler: app-specific shortcuts + kitty protocol suppression const kittyHandler = createTerminalKeyEventHandler(term); term.attachCustomKeyEventHandler((event) => { @@ -322,10 +347,7 @@ export function Terminal({ profileId, sessionId, isActive }: TerminalProps) { return false; } if (action.type === "copy-selection-or-interrupt") { - const selection = term.getSelection(); - if (selection) { - void writeClipboardText(selection).catch(() => {}); - } + copyTerminalSelection(term.getSelection()); return false; } if (action.type === "paste-clipboard") {