diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 90e0e08f..2dd15e29 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -14,6 +14,10 @@ import { FitAddon } from "@xterm/addon-fit"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { RobustClipboardProvider } from "@/lib/clipboard-provider"; import { copyToClipboard, readFromClipboard } from "@/lib/clipboard"; +import { + resolveTerminalContextMenuAction, + selectedTextToCopy, +} from "@/features/terminal/terminal-clipboard-actions"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { SearchAddon } from "@xterm/addon-search"; @@ -1108,6 +1112,10 @@ const TerminalInner = forwardRef( return getCookie("rightClickCopyPaste") !== "false"; } + function getCopyOnSelect() { + return getCookie("copyOnSelect") === "true"; + } + function attemptReconnection() { if ( isUnmountingRef.current || @@ -2597,10 +2605,15 @@ const TerminalInner = forwardRef( return; } - if (getUseRightClickCopyPaste()) { + const action = resolveTerminalContextMenuAction({ + rightClickCopyPaste: getUseRightClickCopyPaste(), + copyOnSelect: getCopyOnSelect(), + hasSelection: terminal.hasSelection(), + }); + if (action !== "native") { e.preventDefault(); e.stopPropagation(); - if (terminal.hasSelection()) { + if (action === "copy") { const text = terminal.getSelection(); writeTextToClipboard(text).then(() => terminal.clearSelection()); } else { @@ -2613,6 +2626,32 @@ const TerminalInner = forwardRef( }; element?.addEventListener("contextmenu", handleContextMenu); + const handleSelectionMouseUp = (e: MouseEvent) => { + const text = selectedTextToCopy({ + copyOnSelect: getCopyOnSelect(), + button: e.button, + selection: terminal.getSelection(), + }); + if (text) void writeTextToClipboard(text); + }; + element?.addEventListener("mouseup", handleSelectionMouseUp); + + const handleMiddleClick = (e: MouseEvent) => { + if ( + e.button !== 1 || + !getCopyOnSelect() || + !getUseRightClickCopyPaste() + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + readTextFromClipboard().then((text) => { + if (text) terminal.paste(text); + }); + }; + element?.addEventListener("auxclick", handleMiddleClick); + const handlePaste = (e: ClipboardEvent) => { const text = e.clipboardData?.getData("text"); if (text) { @@ -2698,6 +2737,8 @@ const TerminalInner = forwardRef( resizeObserver.disconnect(); clipboardProvider.dispose(); element?.removeEventListener("contextmenu", handleContextMenu); + element?.removeEventListener("mouseup", handleSelectionMouseUp); + element?.removeEventListener("auxclick", handleMiddleClick); element?.removeEventListener("paste", handlePaste); element?.removeEventListener("mousedown", handleTmuxDragStart); element?.removeEventListener("mousemove", handleTmuxDragMove); diff --git a/src/ui/features/terminal/terminal-clipboard-actions.ts b/src/ui/features/terminal/terminal-clipboard-actions.ts new file mode 100644 index 00000000..a4469134 --- /dev/null +++ b/src/ui/features/terminal/terminal-clipboard-actions.ts @@ -0,0 +1,26 @@ +export type TerminalContextMenuAction = "native" | "copy" | "paste"; + +export function resolveTerminalContextMenuAction({ + rightClickCopyPaste, + copyOnSelect, + hasSelection, +}: { + rightClickCopyPaste: boolean; + copyOnSelect: boolean; + hasSelection: boolean; +}): TerminalContextMenuAction { + if (!rightClickCopyPaste) return "native"; + return hasSelection && !copyOnSelect ? "copy" : "paste"; +} + +export function selectedTextToCopy({ + copyOnSelect, + button, + selection, +}: { + copyOnSelect: boolean; + button: number; + selection: string; +}): string | null { + return copyOnSelect && button === 0 && selection ? selection : null; +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index c3602e1b..bf467b7d 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -3808,7 +3808,8 @@ "stopRecording": "Stop Recording", "startRecording": "Start Recording", "settingsTitle": "Settings", - "enableRightClickCopyPaste": "Enable right-click copy/paste" + "enableRightClickCopyPaste": "Enable right-click copy/paste", + "copyOnSelect": "Copy selected text automatically" }, "splitScreen": { "layoutTitle": "Layout", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 9466f351..860708de 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -291,7 +291,7 @@ if (isElectron()) { const electronAPI = (window as ElectronWindow).electronAPI; if (electronAPI?.getSetting) { - const settingsToLoad = ["rightClickCopyPaste"]; + const settingsToLoad = ["rightClickCopyPaste", "copyOnSelect"]; for (const key of settingsToLoad) { const value = await electronAPI.getSetting(key); if (value !== null && value !== undefined) { diff --git a/src/ui/sidebar/SshToolsPanel.tsx b/src/ui/sidebar/SshToolsPanel.tsx index ed158048..9af0ad9b 100644 --- a/src/ui/sidebar/SshToolsPanel.tsx +++ b/src/ui/sidebar/SshToolsPanel.tsx @@ -19,6 +19,9 @@ export function SshToolsPanel({ const [rightClickPaste, setRightClickPaste] = useState( () => getCookie("rightClickCopyPaste") !== "false", ); + const [copyOnSelect, setCopyOnSelect] = useState( + () => getCookie("copyOnSelect") === "true", + ); const [selectedTabIds, setSelectedTabIds] = useState>( () => new Set( @@ -349,6 +352,27 @@ export function SshToolsPanel({ /> +
+ + {t("newUi.sidebar.sshTools.copyOnSelect")} + + +
); diff --git a/src/ui/tests/features/terminal/terminal-clipboard-actions.test.ts b/src/ui/tests/features/terminal/terminal-clipboard-actions.test.ts new file mode 100644 index 00000000..00cc99c5 --- /dev/null +++ b/src/ui/tests/features/terminal/terminal-clipboard-actions.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + resolveTerminalContextMenuAction, + selectedTextToCopy, +} from "@/features/terminal/terminal-clipboard-actions"; + +describe("terminal clipboard actions", () => { + it("copies a completed left-button selection when enabled", () => { + expect( + selectedTextToCopy({ + copyOnSelect: true, + button: 0, + selection: "selected output", + }), + ).toBe("selected output"); + expect( + selectedTextToCopy({ copyOnSelect: false, button: 0, selection: "x" }), + ).toBeNull(); + }); + + it("pastes on right-click after copy-on-select", () => { + expect( + resolveTerminalContextMenuAction({ + rightClickCopyPaste: true, + copyOnSelect: true, + hasSelection: true, + }), + ).toBe("paste"); + }); + + it("preserves existing right-click and native-menu behavior", () => { + expect( + resolveTerminalContextMenuAction({ + rightClickCopyPaste: true, + copyOnSelect: false, + hasSelection: true, + }), + ).toBe("copy"); + expect( + resolveTerminalContextMenuAction({ + rightClickCopyPaste: false, + copyOnSelect: true, + hasSelection: true, + }), + ).toBe("native"); + }); +});