mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
This commit is contained in:
1 parent
99cffc9ba6
commit
9801cc7fc7
10 files changed
+183
-44
No files matched your search
@@ -77,6 +77,7 @@ export interface TmuxSessionsV1 {
|
|||||||
stream: unknown,
|
stream: unknown,
|
||||||
name?: string,
|
name?: string,
|
||||||
newName?: string,
|
newName?: string,
|
||||||
|
hostId?: number,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
/** Waits for a new session to exist and returns its confirmed name. */
|
/** Waits for a new session to exist and returns its confirmed name. */
|
||||||
waitForSession: (client: unknown, name: string) => Promise<string>;
|
waitForSession: (client: unknown, name: string) => Promise<string>;
|
||||||
|
|||||||
@@ -781,7 +781,12 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
|||||||
const existingName = tmuxData.sessionName || undefined;
|
const existingName = tmuxData.sessionName || undefined;
|
||||||
if (existingName) {
|
if (existingName) {
|
||||||
void tmux
|
void tmux
|
||||||
.attachOrCreate(session.sshStream, existingName)
|
.attachOrCreate(
|
||||||
|
session.sshStream,
|
||||||
|
existingName,
|
||||||
|
undefined,
|
||||||
|
session.hostId,
|
||||||
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
session.tmuxSessionName = existingName;
|
session.tmuxSessionName = existingName;
|
||||||
sshLogger.info("User selected tmux session to attach", {
|
sshLogger.info("User selected tmux session to attach", {
|
||||||
@@ -798,7 +803,12 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
|||||||
} else {
|
} else {
|
||||||
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
|
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
|
||||||
void tmux
|
void tmux
|
||||||
.attachOrCreate(session.sshStream, undefined, newName)
|
.attachOrCreate(
|
||||||
|
session.sshStream,
|
||||||
|
undefined,
|
||||||
|
newName,
|
||||||
|
session.hostId,
|
||||||
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const sshConn = session.sshConn;
|
const sshConn = session.sshConn;
|
||||||
if (sshConn) {
|
if (sshConn) {
|
||||||
@@ -2159,7 +2169,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
|||||||
// skip detection and reuse the same path as the manual
|
// skip detection and reuse the same path as the manual
|
||||||
// "tmux_attach" websocket message.
|
// "tmux_attach" websocket message.
|
||||||
void tmux
|
void tmux
|
||||||
.attachOrCreate(stream, tmuxAttachSession)
|
.attachOrCreate(stream, tmuxAttachSession, undefined, id)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
{
|
{
|
||||||
const session = sessionManager.getSession(boundSessionId);
|
const session = sessionManager.getSession(boundSessionId);
|
||||||
@@ -2196,7 +2206,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
|||||||
} else if (detection.sessions.length === 0) {
|
} else if (detection.sessions.length === 0) {
|
||||||
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
|
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
|
||||||
void tmux
|
void tmux
|
||||||
.attachOrCreate(stream, undefined, newName)
|
.attachOrCreate(stream, undefined, newName, id)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
const confirmed = await tmux.waitForSession(
|
const confirmed = await tmux.waitForSession(
|
||||||
conn,
|
conn,
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
"enable": {
|
"enable": {
|
||||||
"label": "Enable Tmux Monitor",
|
"label": "Enable Tmux Monitor",
|
||||||
"description": "Watch and attach to tmux sessions running on this host."
|
"description": "Watch and attach to tmux sessions running on this host."
|
||||||
|
},
|
||||||
|
"mouseEnabled": {
|
||||||
|
"label": "Enable tmux mouse support",
|
||||||
|
"description": "Apply mouse support to the tmux session when attaching or creating a session. Other sessions and global tmux settings are unchanged."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,15 @@
|
|||||||
"enableKey": "enableTmuxMonitor",
|
"enableKey": "enableTmuxMonitor",
|
||||||
"enableLabelKey": "settings.host.enable.label",
|
"enableLabelKey": "settings.host.enable.label",
|
||||||
"enableDescriptionKey": "settings.host.enable.description",
|
"enableDescriptionKey": "settings.host.enable.description",
|
||||||
"fields": []
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "mouseEnabled",
|
||||||
|
"type": "boolean",
|
||||||
|
"labelKey": "settings.host.mouseEnabled.label",
|
||||||
|
"descriptionKey": "settings.host.mouseEnabled.description",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hostCapability": {
|
"hostCapability": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface TmuxSessionsV1 {
|
|||||||
stream: unknown,
|
stream: unknown,
|
||||||
name?: string,
|
name?: string,
|
||||||
newName?: string,
|
newName?: string,
|
||||||
|
hostId?: number,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
waitForSession: (client: unknown, name: string) => Promise<string>;
|
waitForSession: (client: unknown, name: string) => Promise<string>;
|
||||||
}
|
}
|
||||||
@@ -36,8 +37,16 @@ export function createTmuxSessionsService(ctx: PluginContext): TmuxSessionsV1 {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async attachOrCreate(stream, name, newName) {
|
async attachOrCreate(stream, name, newName, hostId) {
|
||||||
attachOrCreateTmuxSession(stream as ClientChannel, name, newName);
|
const mouseEnabled =
|
||||||
|
hostId === undefined ||
|
||||||
|
(await ctx.settings.getHost<boolean>(hostId, "mouseEnabled")) !== false;
|
||||||
|
attachOrCreateTmuxSession(
|
||||||
|
stream as ClientChannel,
|
||||||
|
name,
|
||||||
|
newName,
|
||||||
|
mouseEnabled,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async waitForSession(client, name) {
|
async waitForSession(client, name) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { Client, ClientChannel } from "ssh2";
|
import type { Client, ClientChannel } from "ssh2";
|
||||||
|
|
||||||
const TMUX_PATH_DIRS = [
|
const TMUX_PATH_DIRS = [
|
||||||
@@ -104,30 +105,6 @@ export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
|
|||||||
return { available: true, sessions };
|
return { available: true, sessions };
|
||||||
}
|
}
|
||||||
|
|
||||||
// tmux options applied on every attach/create:
|
|
||||||
// - mouse on: enables mouse wheel / touch scrollback through tmux history
|
|
||||||
// - history-limit: deep scrollback buffer on the remote host
|
|
||||||
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
|
|
||||||
// - mode-keys vi: use vi-style keys in copy mode
|
|
||||||
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
|
|
||||||
// adjust and press Enter to copy (or drag again)
|
|
||||||
// - Enter: copy the (possibly adjusted) selection and exit copy mode
|
|
||||||
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
|
|
||||||
// know to press Enter to copy the selection
|
|
||||||
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
|
|
||||||
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
|
|
||||||
const TMUX_OPTS =
|
|
||||||
`set -gq mouse on` +
|
|
||||||
` \\; set -gq history-limit 50000` +
|
|
||||||
` \\; set -gq set-clipboard on` +
|
|
||||||
` \\; set -gq aggressive-resize on` +
|
|
||||||
` \\; set -gq mode-keys vi` +
|
|
||||||
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
|
|
||||||
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
|
|
||||||
` \\; set-hook -g pane-mode-changed` +
|
|
||||||
` 'if -F "#{pane_in_mode}"` +
|
|
||||||
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for a tmux session to appear by polling via exec channel.
|
* Wait for a tmux session to appear by polling via exec channel.
|
||||||
* Returns the session name once found, or null on timeout.
|
* Returns the session name once found, or null on timeout.
|
||||||
@@ -157,20 +134,29 @@ export async function waitForTmuxSession(
|
|||||||
/**
|
/**
|
||||||
* Write tmux attach or new-session command to the interactive shell stream.
|
* Write tmux attach or new-session command to the interactive shell stream.
|
||||||
* Uses && exit so the shell only closes if tmux started successfully.
|
* Uses && exit so the shell only closes if tmux started successfully.
|
||||||
|
*
|
||||||
|
* Options are set on this session only so the user's own tmux config stays
|
||||||
|
* untouched. -q keeps an older tmux that lacks an option from aborting the
|
||||||
|
* attach.
|
||||||
*/
|
*/
|
||||||
export function attachOrCreateTmuxSession(
|
export function attachOrCreateTmuxSession(
|
||||||
stream: ClientChannel,
|
stream: ClientChannel,
|
||||||
existingSessionName?: string,
|
existingSessionName?: string,
|
||||||
newSessionName?: string,
|
newSessionName?: string,
|
||||||
|
mouseEnabled = true,
|
||||||
): void {
|
): void {
|
||||||
let command: string;
|
const name =
|
||||||
if (existingSessionName) {
|
existingSessionName || newSessionName || `termix-${randomUUID()}`;
|
||||||
command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`;
|
const target = shellEscape(`=${name}`);
|
||||||
} else {
|
const commands = existingSessionName
|
||||||
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : "";
|
? []
|
||||||
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`;
|
: [`new-session -d -s ${shellEscape(name)}`];
|
||||||
}
|
commands.push(
|
||||||
stream.write(command);
|
`set-option -q -t ${target} mouse ${mouseEnabled ? "on" : "off"}`,
|
||||||
|
`set-option -q -t ${target} history-limit 50000`,
|
||||||
|
`attach-session -t ${target}`,
|
||||||
|
);
|
||||||
|
stream.write(`${tmuxCommand(commands.join(" \\; "))} && exit\r`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shellEscape(s: string): string {
|
export function shellEscape(s: string): string {
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ export function HostTmuxMonitorSection({
|
|||||||
] ?? {}) as Record<string, unknown>;
|
] ?? {}) as Record<string, unknown>;
|
||||||
const enabled = settings.enableTmuxMonitor === true;
|
const enabled = settings.enableTmuxMonitor === true;
|
||||||
|
|
||||||
const setEnabled = (value: boolean) =>
|
const setSetting = (key: string, value: boolean) =>
|
||||||
updateForm((current) => {
|
updateForm((current) => {
|
||||||
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
|
const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
pluginSettings: {
|
pluginSettings: {
|
||||||
...all,
|
...all,
|
||||||
"tmux-monitor": { ...all["tmux-monitor"], enableTmuxMonitor: value },
|
"tmux-monitor": { ...all["tmux-monitor"], [key]: value },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -58,7 +58,19 @@ export function HostTmuxMonitorSection({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FakeSwitch checked={enabled} onChange={setEnabled} />
|
<FakeSwitch
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(value) => setSetting("enableTmuxMonitor", value)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label={t("settings.host.mouseEnabled.label")}
|
||||||
|
description={t("settings.host.mouseEnabled.description")}
|
||||||
|
>
|
||||||
|
<FakeSwitch
|
||||||
|
checked={settings.mouseEnabled !== false}
|
||||||
|
onChange={(value) => setSetting("mouseEnabled", value)}
|
||||||
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -65,6 +65,27 @@ describe("createTmuxSessionsService", () => {
|
|||||||
expect(writes[0]).toContain("main");
|
expect(writes[0]).toContain("main");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the host's saved mouse setting when attaching", async () => {
|
||||||
|
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
|
||||||
|
await ctx.settings.setHost(7, "mouseEnabled", false);
|
||||||
|
const writes: string[] = [];
|
||||||
|
const service = createTmuxSessionsService(ctx);
|
||||||
|
await service.attachOrCreate(
|
||||||
|
{ write: (value: string) => writes.push(value) },
|
||||||
|
"main",
|
||||||
|
undefined,
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
await service.attachOrCreate(
|
||||||
|
{ write: (value: string) => writes.push(value) },
|
||||||
|
"other",
|
||||||
|
undefined,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
expect(writes[0]).toContain("mouse off");
|
||||||
|
expect(writes[1]).toContain("mouse on");
|
||||||
|
});
|
||||||
|
|
||||||
it("waitForSession() falls back to the requested name on timeout", async () => {
|
it("waitForSession() falls back to the requested name on timeout", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import type { Client } from "ssh2";
|
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { Client, ClientChannel } from "ssh2";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
attachOrCreateTmuxSession,
|
||||||
detectTmux,
|
detectTmux,
|
||||||
tmuxCommand,
|
tmuxCommand,
|
||||||
withTmuxPath,
|
withTmuxPath,
|
||||||
@@ -76,3 +80,81 @@ describe("tmux command path handling", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session-scoped tmux mouse", () => {
|
||||||
|
it.skipIf(process.platform === "win32")(
|
||||||
|
"passes an exact session target and literal separator through both shells",
|
||||||
|
() => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "termix-tmux-"));
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, "tmux"), '#!/bin/sh\nprintf "%s\\n" "$@"\n', {
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
let command = "";
|
||||||
|
const stream = {
|
||||||
|
write: (value: string) => {
|
||||||
|
command = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const name = "qa's $(echo injected)";
|
||||||
|
attachOrCreateTmuxSession(
|
||||||
|
stream as unknown as ClientChannel,
|
||||||
|
name,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(command.endsWith("\r")).toBe(true);
|
||||||
|
const args = execFileSync("/bin/sh", ["-c", command.trim()], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, PATH: `${dir}:/usr/bin:/bin` },
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.split("\n");
|
||||||
|
expect(args).toEqual([
|
||||||
|
"-u",
|
||||||
|
"set-option",
|
||||||
|
"-q",
|
||||||
|
"-t",
|
||||||
|
`=${name}`,
|
||||||
|
"mouse",
|
||||||
|
"off",
|
||||||
|
";",
|
||||||
|
"set-option",
|
||||||
|
"-q",
|
||||||
|
"-t",
|
||||||
|
`=${name}`,
|
||||||
|
"history-limit",
|
||||||
|
"50000",
|
||||||
|
";",
|
||||||
|
"attach-session",
|
||||||
|
"-t",
|
||||||
|
`=${name}`,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("creates the named session before configuring and attaching it", () => {
|
||||||
|
let command = "";
|
||||||
|
attachOrCreateTmuxSession(
|
||||||
|
{
|
||||||
|
write: (value: string) => {
|
||||||
|
command = value;
|
||||||
|
},
|
||||||
|
} as unknown as ClientChannel,
|
||||||
|
undefined,
|
||||||
|
"work",
|
||||||
|
);
|
||||||
|
expect(command.indexOf("new-session")).toBeLessThan(
|
||||||
|
command.indexOf("set-option"),
|
||||||
|
);
|
||||||
|
expect(command.indexOf("set-option")).toBeLessThan(
|
||||||
|
command.indexOf("attach-session"),
|
||||||
|
);
|
||||||
|
expect(command).toContain("mouse on");
|
||||||
|
expect(command).toContain("history-limit 50000");
|
||||||
|
expect(command).not.toMatch(/set -g|set-hook|bind-key|set-clipboard/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,11 +92,17 @@ describe("host editor section", () => {
|
|||||||
protocols: { ssh: true },
|
protocols: { ssh: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button"));
|
fireEvent.click(screen.getAllByRole("button")[0]);
|
||||||
|
|
||||||
const settings = (form.pluginSettings as Record<string, unknown>)[
|
const settings = (form.pluginSettings as Record<string, unknown>)[
|
||||||
"tmux-monitor"
|
"tmux-monitor"
|
||||||
] as { enableTmuxMonitor: boolean };
|
] as { enableTmuxMonitor: boolean };
|
||||||
expect(settings.enableTmuxMonitor).toBe(true);
|
expect(settings.enableTmuxMonitor).toBe(true);
|
||||||
|
fireEvent.click(screen.getAllByRole("button")[1]);
|
||||||
|
expect(
|
||||||
|
(form.pluginSettings as Record<string, Record<string, unknown>>)[
|
||||||
|
"tmux-monitor"
|
||||||
|
],
|
||||||
|
).toEqual({ enableTmuxMonitor: true, mouseEnabled: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user