mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
feat: add terminal copy-on-select option (#1346)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user