Skip to content
Merged
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
242 changes: 242 additions & 0 deletions src/features/terminal/Terminal.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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(<Terminal profileId="profile-1" sessionId="session-1" isActive={false} />);
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);
});
});
30 changes: 26 additions & 4 deletions src/features/terminal/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let explicit copy retry current selection

When text has been auto-copied on pointer release, lastCopiedSelection stays set while the selection remains highlighted. If the clipboard is later changed elsewhere and the user presses Ctrl+C on that still-selected terminal text, this helper returns before writing, so the shortcut no longer restores the terminal selection; the previous keyboard path always wrote the current selection. Scope the de-dupe to the pointer-up auto-copy path or otherwise allow explicit copy to write again.

Useful? React with 👍 / 👎.

lastCopiedSelection = selection;
void writeClipboardText(selection)
.then(() => {
toaster.create({
title: "Text copied",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

User-facing string must use i18n.

The toast title "Text copied" is hardcoded. As per coding guidelines, all user-facing strings should use Paraglide.js: import * as m from '@/paraglide/messages.js'.

🌐 Proposed fix to use i18n

First, ensure the message key exists in messages/en.json and messages/zh.json:

{
  "textCopied": "Text copied"
}

Then update the code:

+import * as m from "`@/paraglide/messages.js`";
...
 toaster.create({
-  title: "Text copied",
+  title: m.textCopied(),
   type: "success",
   closable: true,
 });

As per coding guidelines, use i18n via Paraglide.js with import * as m from '@/paraglide/messages.js' pattern for all user-facing strings.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
title: "Text copied",
import * as m from "`@/paraglide/messages.js`";
// ... rest of the file ...
toaster.create({
title: m.textCopied(),
type: "success",
closable: true,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/terminal/Terminal.tsx` at line 299, The hardcoded string "Text
copied" in the toast title property at line 299 of Terminal.tsx is not using
i18n and needs to be internationalized using Paraglide.js. Import the messages
module using the pattern `import * as m from '`@/paraglide/messages.js`'` at the
top of the file, then replace the hardcoded "Text copied" string with the
corresponding message key from the imported messages object (e.g.,
`m.textCopied` or similar, depending on the actual message key name defined in
your messages files).

Source: Coding guidelines

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) => {
Expand Down Expand Up @@ -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") {
Expand Down