Skip to content
Merged
39 changes: 38 additions & 1 deletion desktop/frontend/src/components/TerminalView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe("TerminalView right-click menu", () => {
expect(source).toContain('@click="onMenuClear"');
expect(source).toMatch(/function\s+onMenuClear\s*\(\s*\)/);
expect(source).toContain("term.clear()");
expect(source).toMatch(/const\s+MENU_HEIGHT\s*=\s*110/);
expect(source).toMatch(/const\s+MENU_HEIGHT\s*=\s*150/);
});
});

Expand Down Expand Up @@ -239,3 +239,40 @@ test("viewer overlay has a take-control button wired to claimDriver", () => {
expect(source).toMatch(/data-testid="take-control"/);
expect(source).toMatch(/function takeControl[\s\S]*?claimDriver/);
});

describe("TerminalView right-click send", () => {
test("imports the send predicate and payload helper", () => {
expect(source).toMatch(/canSendSelection[\s\S]*from\s+["']\.\.\/lib\/terminalContextMenu["']/);
expect(source).toMatch(/prepareSendPayload[\s\S]*from\s+["']\.\.\/lib\/terminalContextMenu["']/);
});

test("renders a send menu item between paste and clear", () => {
const pasteIdx = source.indexOf('t("common.paste")');
const sendIdx = source.indexOf('t("terminal.sendSelection")');
const clearIdx = source.indexOf('t("terminal.clearBuffer")');
expect(pasteIdx).toBeGreaterThan(-1);
expect(sendIdx).toBeGreaterThan(pasteIdx);
expect(clearIdx).toBeGreaterThan(sendIdx);
});

test("binds the send button's disabled state to menuCanSend", () => {
expect(source).toContain('@click="onMenuSend"');
expect(source).toContain(':disabled="!menuCanSend"');
expect(source).toMatch(/const\s+menuCanSend\s*=\s*computed/);
});

test("menuCanSend feeds canSendSelection with selection + status + permission + isDriver", () => {
expect(source).toMatch(
/canSendSelection\(\s*\{[^}]*hasSelection[^}]*status[^}]*permission[^}]*isDriver[^}]*\}\s*\)/,
);
});

test("onMenuSend writes through SessionConnection.sendInput, not term.paste", () => {
expect(source).toMatch(/function\s+onMenuSend\s*\(\s*\)/);
expect(source).toMatch(/prepareSendPayload\s*\(/);
expect(source).toMatch(/conn\??\.sendInput\s*\(/);
const sendBody = source.match(/function\s+onMenuSend\s*\([^)]*\)\s*\{[\s\S]*?\n\}/);
expect(sendBody).not.toBeNull();
expect(sendBody![0]).not.toMatch(/term\.paste\b/);
});
});
26 changes: 24 additions & 2 deletions desktop/frontend/src/components/TerminalView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
shouldNotifyCommand,
formatElapsed,
} from "../lib/commandFinish";
import { clampContextMenuPosition, isPasteAllowed } from "../lib/terminalContextMenu";
import {
canSendSelection,
clampContextMenuPosition,
isPasteAllowed,
prepareSendPayload,
} from "../lib/terminalContextMenu";
import { pasteFromClipboard } from "../lib/terminalPaste";
import { stripC1Controls } from "../lib/stripC1Controls";
import { broadcastCommandFinished, getHostInfo, getWebglRendererEnabled, showNotification } from "../lib/api";
Expand Down Expand Up @@ -99,9 +104,17 @@ let resizeObserver: ResizeObserver | null = null;
let copyKeyTarget: HTMLDivElement | null = null;

const MENU_WIDTH = 150;
const MENU_HEIGHT = 110;
const MENU_HEIGHT = 150;

const menuCanPaste = computed(() => isPasteAllowed(status.value, props.remotePermission));
const menuCanSend = computed(() =>
canSendSelection({
hasSelection: menuHasSelection.value,
status: status.value,
permission: props.remotePermission,
isDriver: isDriver.value,
}),
);

function handleViewerKeydown(event: KeyboardEvent) {
if (isDriver.value) return; // driver mode passes through
Expand Down Expand Up @@ -247,6 +260,14 @@ function applyViewerSize() {
}
}

function onMenuSend() {
closeContextMenu();
if (!term || !conn) return;
const payload = prepareSendPayload(term.getSelection());
if (payload === null) return;
conn.sendInput(payload);
}

function onMenuClear() {
closeContextMenu();
if (!term) return;
Expand Down Expand Up @@ -554,6 +575,7 @@ watch(status, (nextStatus) => {
>
<button class="term-context-item" :disabled="!menuHasSelection" @click="onMenuCopy">{{ t("common.copy") }}</button>
<button class="term-context-item" :disabled="!menuCanPaste || pasteBusy" @click="onMenuPaste">{{ t("common.paste") }}</button>
<button class="term-context-item" :disabled="!menuCanSend" @click="onMenuSend">{{ t("terminal.sendSelection") }}</button>
<button class="term-context-item" @click="onMenuClear">{{ t("terminal.clearBuffer") }}</button>
<button
v-for="item in pluginMenuItems"
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const en = {
pressSpaceToTakeBack: "press space to take back",
takeControl: "Take control",
clearBuffer: "clear buffer",
sendSelection: "send",
emptyPaneHint: "[empty pane - press Cmd+N / Ctrl+N to fill]",
remote: "remote",
remotePrefix: "[remote] ",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const zhCN = {
pressSpaceToTakeBack: "按空格取回控制",
takeControl: "接管控制",
clearBuffer: "清空缓冲区",
sendSelection: "发送",
emptyPaneHint: "[空面板 - 按 Cmd+N / Ctrl+N 填充]",
remote: "远端",
remotePrefix: "[远端] ",
Expand Down
53 changes: 53 additions & 0 deletions desktop/frontend/src/lib/terminalContextMenu.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
import {
canSendSelection,
clampContextMenuPosition,
effectiveRemotePermission,
imagePasteBlockedReason,
isPasteAllowed,
prepareSendPayload,
} from "./terminalContextMenu";

describe("terminal context menu helpers", () => {
Expand All @@ -29,4 +31,55 @@ describe("terminal context menu helpers", () => {
top: 512,
});
});

it("allows send only when selection + writeable + driver", () => {
expect(
canSendSelection({ hasSelection: true, status: "attached", permission: "full", isDriver: true }),
).toBe(true);
});

it("blocks send with no selection", () => {
expect(
canSendSelection({ hasSelection: false, status: "attached", permission: "full", isDriver: true }),
).toBe(false);
});

it("blocks send for read-only or detached sessions", () => {
expect(
canSendSelection({ hasSelection: true, status: "attached", permission: "view", isDriver: true }),
).toBe(false);
expect(
canSendSelection({ hasSelection: true, status: "connecting", permission: "full", isDriver: true }),
).toBe(false);
});

it("blocks send for non-driver clients even when permission allows writes", () => {
expect(
canSendSelection({ hasSelection: true, status: "attached", permission: "control", isDriver: false }),
).toBe(false);
});

it("appends a single CR to a one-line selection", () => {
expect(prepareSendPayload("ls -la")).toBe("ls -la\r");
});

it("converts internal LF and CRLF newlines to CR", () => {
expect(prepareSendPayload("a\nb\r\nc")).toBe("a\rb\rc\r");
});

it("collapses trailing newlines to a single CR", () => {
expect(prepareSendPayload("ls -la\n")).toBe("ls -la\r");
expect(prepareSendPayload("ls -la\r\n\n")).toBe("ls -la\r");
});

it("strips C1 controls before normalizing", () => {
// U+0093 = Ctrl-S | 0x80 — see stripC1Controls.ts.
expect(prepareSendPayload("ls“ -la")).toBe("ls -la\r");
});

it("returns null for empty or whitespace-only-after-strip input", () => {
expect(prepareSendPayload("")).toBeNull();
expect(prepareSendPayload("\n\n")).toBeNull();
expect(prepareSendPayload("")).toBeNull();
});
});
27 changes: 27 additions & 0 deletions desktop/frontend/src/lib/terminalContextMenu.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Status } from "./connection";
import { t } from "../i18n";
import { stripC1Controls } from "./stripC1Controls";

export type EffectiveRemotePermission = "view" | "control" | "full";

Expand All @@ -18,6 +19,19 @@ export function isPasteAllowed(status: Status, permission?: string): boolean {
return status === "attached" && effectiveRemotePermission(permission) !== "view";
}

export interface CanSendSelectionInput {
hasSelection: boolean;
status: Status;
permission?: string;
isDriver: boolean;
}

export function canSendSelection(input: CanSendSelectionInput): boolean {
if (!input.hasSelection) return false;
if (!input.isDriver) return false;
return isPasteAllowed(input.status, input.permission);
}

export function imagePasteBlockedReason(permission?: string): string | null {
const effective = effectiveRemotePermission(permission);
if (effective === "full") return null;
Expand All @@ -39,3 +53,16 @@ export function clampContextMenuPosition(
top: Math.min(Math.max(y, margin), Math.max(margin, viewportHeight - height - margin)),
};
}

// prepareSendPayload shapes a raw xterm selection into bytes ready for
// SessionConnection.sendInput: strips C1 controls (same as typed input),
// normalizes every newline shape to \r (PTY input semantics), collapses
// any run of trailing \r down to one. Returns null if nothing meaningful
// remains — callers should treat null as "no-op".
export function prepareSendPayload(selection: string): string | null {
const { cleaned } = stripC1Controls(selection);
if (!cleaned) return null;
const normalized = cleaned.replace(/\r\n?|\n/g, "\r").replace(/\r+$/, "");
if (!normalized) return null;
return normalized + "\r";
}
Loading
Loading