-
Notifications
You must be signed in to change notification settings - Fork 3
feat(terminal): copy selection to clipboard #345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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", | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. User-facing string must use i18n. The toast title 🌐 Proposed fix to use i18nFirst, ensure the message key exists in {
"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 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: 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) => { | ||||||||||||||||||||||
|
|
@@ -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") { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When text has been auto-copied on pointer release,
lastCopiedSelectionstays 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 👍 / 👎.