fix(terminal): configure ConPTY compatibility on Windows (#2180)

Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 09:12:20 +08:00
1 джерело 047cac209b
коміт 337bf4b1ca
13 files changed
+163 -3

No files matched your search

+1
Переглянути файл
@@ -1861,6 +1861,7 @@ export type AndroidDevice = {
export type AppInfo = {
version: string,
platform: string,
windowsBuildNumber: number | null,
configDir: IpcPath,
/** Linux on a Wayland display: GTK draws the frameless windows' shadow and resize edges. */
wayland: boolean,
+23 -2
Переглянути файл
@@ -4,17 +4,22 @@ import { installTerminalKeys } from "./keys";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { registerAction } from "@/platform/actions";
import { usePlatform } from "@/platform/platformInfo";
import type { ITerminalOptions } from "@xterm/xterm";
const terminal = vi.hoisted(() => ({
selection: "selected run output",
handler: (_e: KeyboardEvent): boolean => true,
input: vi.fn(),
options: {} as ITerminalOptions,
}));
vi.mock("@xterm/xterm", () => ({
Terminal: class {
cols = 80;
rows = 24;
options = {};
constructor(options: ITerminalOptions) {
terminal.options = options;
}
textarea?: HTMLTextAreaElement;
hasSelection() {
return !!terminal.selection;
@@ -56,13 +61,29 @@ vi.mock("./fit", () => ({
settledResize: () => ({ resized() {}, cancel() {} }),
}));
beforeEach(() => usePlatform.setState({ os: "windows" }));
afterEach(() => usePlatform.setState({ os: "macos" }));
beforeEach(() => usePlatform.setState({ os: "windows", windowsBuildNumber: null }));
afterEach(() => usePlatform.setState({ os: "macos", windowsBuildNumber: null }));
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
it.each([
["windows", 19045, { backend: "conpty", buildNumber: 19045 }],
["windows", 22631, { backend: "conpty", buildNumber: 22631 }],
["windows", null, { backend: "conpty", buildNumber: undefined }],
["macos", null, undefined],
["linux", null, undefined],
] as const)("configures embedded terminals for %s build %s", (os, windowsBuildNumber, expected) => {
usePlatform.setState({ os, windowsBuildNumber });
const term = createEmbeddedTerminal(document.createElement("div"), { onData() {}, onResize() {} });
try {
expect(terminal.options.windowsPty).toEqual(expected);
} finally {
term.dispose();
}
});
it("copies embedded Run terminal selections before xterm can send Ctrl+C", () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
+3
Переглянути файл
@@ -17,6 +17,7 @@ import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import { workflowSettings } from "@/platform/workflowSettings";
import { fitScheduler, settledResize } from "./fit";
import { watchWindowsPty, windowsPty } from "./windowsPty";
import { findTerminalLinks } from "./links";
import { LINK_HANDLER, fontFor, openExternal, openFileLink } from "./session";
import { MIN_CONTRAST_RATIO, readTerminalTheme, remeasureWhenFontsLoad } from "./theme";
@@ -33,6 +34,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
const settings = workflowSettings().terminal;
const font = fontFor(settings);
const term = new Terminal({
windowsPty: windowsPty(),
linkHandler: LINK_HANDLER,
fontFamily: font.family,
fontSize: font.size,
@@ -76,6 +78,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
opts.onResize(cols, rows);
});
const disposables: IDisposable[] = [
watchWindowsPty(term),
term.onData((d) => opts.onData(d)),
term.onBinary((d) => opts.onData(d)),
term.registerLinkProvider({
+32
Переглянути файл
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FIT_SETTLE_MS } from "./fit";
import { findTerminalLinks, type TerminalLink } from "./links";
import { openFileLink, resolveStartCwd, TerminalSession } from "./session";
import { usePlatform } from "@/platform/platformInfo";
const ipc = vi.hoisted(() => ({
call: vi.fn(async (v: unknown) => v),
@@ -45,6 +46,37 @@ const fileLink = (text: string): Extract<TerminalLink, { kind: "file" }> => {
return l;
};
describe("Windows PTY metadata", () => {
const initialPlatform = usePlatform.getState();
afterEach(() => usePlatform.setState(initialPlatform));
it.each([
["windows", 19045, { backend: "conpty", buildNumber: 19045 }],
["windows", 22631, { backend: "conpty", buildNumber: 22631 }],
["windows", null, { backend: "conpty", buildNumber: undefined }],
["macos", null, {}],
["linux", null, {}],
] as const)("configures %s build %s", (os, windowsBuildNumber, expected) => {
usePlatform.setState({ os, windowsBuildNumber });
const session = new TerminalSession({ key: "pty-options", cwd: "/repo", onChange() {}, onFocus() {} });
try {
expect(session.term.options.windowsPty).toEqual(expected);
} finally {
session.dispose();
}
});
it("updates a restored terminal when platform info arrives and unsubscribes on disposal", () => {
usePlatform.setState({ os: "windows", windowsBuildNumber: null });
const session = new TerminalSession({ key: "restored", cwd: "/repo", onChange() {}, onFocus() {} });
usePlatform.setState({ windowsBuildNumber: 22631 });
expect(session.term.options.windowsPty).toEqual({ backend: "conpty", buildNumber: 22631 });
session.dispose();
usePlatform.setState({ windowsBuildNumber: 19045 });
expect(session.term.options.windowsPty).toEqual({ backend: "conpty", buildNumber: 22631 });
});
});
describe("openFileLink", () => {
beforeEach(() => vi.clearAllMocks());
+3
Переглянути файл
@@ -12,6 +12,7 @@ import { call, commands, isTauri, type TerminalId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { editorService, notifications, registerFontZoomSurface } from "@/workbench/contributions";
import { fitScheduler, settledResize } from "./fit";
import { watchWindowsPty, windowsPty } from "./windowsPty";
import { installTerminalDrop } from "./drop";
import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
import { ImeCommitGate } from "./imeCommit";
@@ -248,6 +249,7 @@ export class TerminalSession {
const settings = workflowSettings().terminal;
const font = fontFor(settings);
this.term = new Terminal({
windowsPty: windowsPty(),
fontFamily: font.family,
fontSize: font.size,
lineHeight: font.lineHeight,
@@ -275,6 +277,7 @@ export class TerminalSession {
this.term.loadAddon(this.searchAddon);
this.disposables.push(
watchWindowsPty(this.term),
this.searchAddon.onDidChangeResults((r) => {
for (const l of this.searchListeners) l({ index: r.resultIndex, count: r.resultCount });
}),
+19
Переглянути файл
@@ -0,0 +1,19 @@
import { afterEach, expect, it } from "vitest";
import { windowsPty } from "./windowsPty";
import { usePlatform } from "@/platform/platformInfo";
import { useRemote } from "@/platform/remote";
const initialPlatform = usePlatform.getState();
const initialRemote = useRemote.getState();
afterEach(() => {
useRemote.setState(initialRemote);
usePlatform.setState(initialPlatform);
});
it.each(["linux", "windows"])("does not apply the desktop build to a remote %s PTY", (os) => {
usePlatform.setState({ os: "windows", windowsBuildNumber: 22631 });
useRemote.setState({
session: { os, host: "test", root: "/repo", target: { kind: "ssh", config: "test" }, commands: [] },
});
expect(windowsPty()).toBeUndefined();
});
+21
Переглянути файл
@@ -0,0 +1,21 @@
import type { ITerminalOptions, Terminal } from "@xterm/xterm";
import { usePlatform } from "@/platform/platformInfo";
import { useRemote } from "@/platform/remote";
/** Report the local ConPTY backend even when the OS build is unavailable. */
export function windowsPty(): ITerminalOptions["windowsPty"] {
const { os, windowsBuildNumber } = usePlatform.getState();
// Remote PTYs must not inherit the desktop's Windows build or backend.
if (useRemote.getState().session) return undefined;
return os === "windows" ? { backend: "conpty", buildNumber: windowsBuildNumber ?? undefined } : undefined;
}
/** Restored terminals can be constructed before appInfo answers. */
export function watchWindowsPty(term: Terminal) {
const unsubscribe = usePlatform.subscribe((next, previous) => {
if (next.os !== previous.os || next.windowsBuildNumber !== previous.windowsBuildNumber) {
term.options.windowsPty = windowsPty() ?? {};
}
});
return { dispose: unsubscribe };
}
+25
Переглянути файл
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from "vitest";
import { loadPlatformInfo, usePlatform } from "./platformInfo";
const appInfo = vi.hoisted(() => vi.fn());
vi.mock("@/platform/ipc", () => ({ isTauri: () => true, commands: { appInfo } }));
const initialPlatform = usePlatform.getState();
afterEach(() => usePlatform.setState(initialPlatform));
it.each([
["windows", 19045],
["windows", 22631],
["macos", null],
["linux", null],
] as const)("loads %s OS build %s separately from the app version", async (platform, windowsBuildNumber) => {
appInfo.mockResolvedValue({ platform, windowsBuildNumber, version: "0.1.39", wayland: false });
await loadPlatformInfo();
expect(usePlatform.getState()).toMatchObject({ os: platform, windowsBuildNumber, version: "0.1.39" });
});
it("keeps platform defaults when the query fails", async () => {
appInfo.mockRejectedValue(new Error("unavailable"));
await loadPlatformInfo();
expect(usePlatform.getState()).toEqual(initialPlatform);
});
+3
Переглянути файл
@@ -5,6 +5,7 @@ interface PlatformInfo {
/** `macos` | `windows` | `linux` | `web`. */
os: string;
version: string | null;
windowsBuildNumber: number | null;
/** Native traffic lights overlay the title bar (Tauri, macOS, Overlay style). */
overlayTitleBar: boolean;
/** Frameless window: the title bar draws the main menu and caption buttons (Tauri, Windows and Linux). */
@@ -28,6 +29,7 @@ export const usePlatform = create<PlatformInfo>(() => {
return {
os,
version: null,
windowsBuildNumber: null,
overlayTitleBar: isTauri() && os === "macos",
framelessTitleBar: isTauri() && (os === "windows" || os === "linux"),
wayland: false,
@@ -41,6 +43,7 @@ export async function loadPlatformInfo() {
usePlatform.setState({
os: info.platform,
version: info.version,
windowsBuildNumber: info.windowsBuildNumber,
overlayTitleBar: info.platform === "macos",
framelessTitleBar: info.platform === "windows" || info.platform === "linux",
wayland: info.wayland,