feat: add terminal copy-on-select option (#1346)

This commit is contained in:
ZacharyZcR
2026-08-27 06:39:05 +08:00
committed by GitHub
parent 6406c3a923
commit 6323459af2
6 changed files with 143 additions and 4 deletions
+43 -2
View File
@@ -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<TerminalHandle, SSHTerminalProps>(
return getCookie("rightClickCopyPaste") !== "false";
}
function getCopyOnSelect() {
return getCookie("copyOnSelect") === "true";
}
function attemptReconnection() {
if (
isUnmountingRef.current ||
@@ -2597,10 +2605,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
};
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<TerminalHandle, SSHTerminalProps>(
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);
@@ -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;
}
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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) {
+24
View File
@@ -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<Set<string>>(
() =>
new Set(
@@ -349,6 +352,27 @@ export function SshToolsPanel({
/>
</button>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">
{t("newUi.sidebar.sshTools.copyOnSelect")}
</span>
<button
onClick={() => {
const next = !copyOnSelect;
setCopyOnSelect(next);
setCookie("copyOnSelect", next ? "true" : "false");
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
copyOnSelect
? "bg-accent-brand border-accent-brand"
: "bg-muted border-border"
}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${copyOnSelect ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
</div>
</div>
);
@@ -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");
});
});