mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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 { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||||
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||||
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
||||||
|
import {
|
||||||
|
resolveTerminalContextMenuAction,
|
||||||
|
selectedTextToCopy,
|
||||||
|
} from "@/features/terminal/terminal-clipboard-actions";
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
@@ -1108,6 +1112,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return getCookie("rightClickCopyPaste") !== "false";
|
return getCookie("rightClickCopyPaste") !== "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCopyOnSelect() {
|
||||||
|
return getCookie("copyOnSelect") === "true";
|
||||||
|
}
|
||||||
|
|
||||||
function attemptReconnection() {
|
function attemptReconnection() {
|
||||||
if (
|
if (
|
||||||
isUnmountingRef.current ||
|
isUnmountingRef.current ||
|
||||||
@@ -2597,10 +2605,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getUseRightClickCopyPaste()) {
|
const action = resolveTerminalContextMenuAction({
|
||||||
|
rightClickCopyPaste: getUseRightClickCopyPaste(),
|
||||||
|
copyOnSelect: getCopyOnSelect(),
|
||||||
|
hasSelection: terminal.hasSelection(),
|
||||||
|
});
|
||||||
|
if (action !== "native") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (terminal.hasSelection()) {
|
if (action === "copy") {
|
||||||
const text = terminal.getSelection();
|
const text = terminal.getSelection();
|
||||||
writeTextToClipboard(text).then(() => terminal.clearSelection());
|
writeTextToClipboard(text).then(() => terminal.clearSelection());
|
||||||
} else {
|
} else {
|
||||||
@@ -2613,6 +2626,32 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
};
|
};
|
||||||
element?.addEventListener("contextmenu", handleContextMenu);
|
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 handlePaste = (e: ClipboardEvent) => {
|
||||||
const text = e.clipboardData?.getData("text");
|
const text = e.clipboardData?.getData("text");
|
||||||
if (text) {
|
if (text) {
|
||||||
@@ -2698,6 +2737,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
clipboardProvider.dispose();
|
clipboardProvider.dispose();
|
||||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||||
|
element?.removeEventListener("mouseup", handleSelectionMouseUp);
|
||||||
|
element?.removeEventListener("auxclick", handleMiddleClick);
|
||||||
element?.removeEventListener("paste", handlePaste);
|
element?.removeEventListener("paste", handlePaste);
|
||||||
element?.removeEventListener("mousedown", handleTmuxDragStart);
|
element?.removeEventListener("mousedown", handleTmuxDragStart);
|
||||||
element?.removeEventListener("mousemove", handleTmuxDragMove);
|
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",
|
"stopRecording": "Stop Recording",
|
||||||
"startRecording": "Start Recording",
|
"startRecording": "Start Recording",
|
||||||
"settingsTitle": "Settings",
|
"settingsTitle": "Settings",
|
||||||
"enableRightClickCopyPaste": "Enable right-click copy/paste"
|
"enableRightClickCopyPaste": "Enable right-click copy/paste",
|
||||||
|
"copyOnSelect": "Copy selected text automatically"
|
||||||
},
|
},
|
||||||
"splitScreen": {
|
"splitScreen": {
|
||||||
"layoutTitle": "Layout",
|
"layoutTitle": "Layout",
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ if (isElectron()) {
|
|||||||
const electronAPI = (window as ElectronWindow).electronAPI;
|
const electronAPI = (window as ElectronWindow).electronAPI;
|
||||||
|
|
||||||
if (electronAPI?.getSetting) {
|
if (electronAPI?.getSetting) {
|
||||||
const settingsToLoad = ["rightClickCopyPaste"];
|
const settingsToLoad = ["rightClickCopyPaste", "copyOnSelect"];
|
||||||
for (const key of settingsToLoad) {
|
for (const key of settingsToLoad) {
|
||||||
const value = await electronAPI.getSetting(key);
|
const value = await electronAPI.getSetting(key);
|
||||||
if (value !== null && value !== undefined) {
|
if (value !== null && value !== undefined) {
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export function SshToolsPanel({
|
|||||||
const [rightClickPaste, setRightClickPaste] = useState(
|
const [rightClickPaste, setRightClickPaste] = useState(
|
||||||
() => getCookie("rightClickCopyPaste") !== "false",
|
() => getCookie("rightClickCopyPaste") !== "false",
|
||||||
);
|
);
|
||||||
|
const [copyOnSelect, setCopyOnSelect] = useState(
|
||||||
|
() => getCookie("copyOnSelect") === "true",
|
||||||
|
);
|
||||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||||
() =>
|
() =>
|
||||||
new Set(
|
new Set(
|
||||||
@@ -349,6 +352,27 @@ export function SshToolsPanel({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</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