дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Shift+Enter went through xterm.js as a plain CR, so Claude Code and similar TUIs submitted the prompt instead of starting a new line. Terminal sessions and embedded Run terminals now send ESC CR, the same as Alt+Enter, without needing /terminal-setup. In a plain shell Shift+Enter no longer runs the command; Enter is unchanged, and Shift+Enter still restarts an exited session. Closes #1244 Co-authored-by: mansur-akhmadi <[email protected]>
213 рядки
8.0 KiB
TypeScript
213 рядки
8.0 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { FIT_INTERVAL_MS } from "./fit";
|
|
import { findTerminalLinks, type TerminalLink } from "./links";
|
|
import { openFileLink, resolveStartCwd, TerminalSession } from "./session";
|
|
|
|
const ipc = vi.hoisted(() => ({
|
|
call: vi.fn(async (v: unknown) => v),
|
|
commands: {
|
|
fsReadDir: vi.fn(),
|
|
fsEntryKind: vi.fn(),
|
|
findFiles: vi.fn(),
|
|
termSpawn: vi.fn(),
|
|
termKill: vi.fn(),
|
|
termResize: vi.fn(),
|
|
termIsBusy: vi.fn(),
|
|
},
|
|
}));
|
|
const workbench = vi.hoisted(() => ({
|
|
openFile: vi.fn(async () => undefined),
|
|
notify: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/platform/ipc", () => ({ call: ipc.call, commands: ipc.commands, isTauri: () => true }));
|
|
vi.mock("@/workbench/contributions", () => ({
|
|
editorService: () => ({ openFile: workbench.openFile }),
|
|
notifications: () => ({ notify: workbench.notify }),
|
|
OWNS_FILE_DROP: { "data-owner": true },
|
|
}));
|
|
// `Channel` needs the Tauri runtime; a start() test only hands it to the mocked spawn call.
|
|
vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
Channel: class {
|
|
onmessage: ((chunk: string) => void) | null = null;
|
|
},
|
|
}));
|
|
|
|
const fileLink = (text: string): Extract<TerminalLink, { kind: "file" }> => {
|
|
const [l] = findTerminalLinks(text);
|
|
if (!l || l.kind !== "file") throw new Error(`not a file link: ${text}`);
|
|
return l;
|
|
};
|
|
|
|
describe("openFileLink", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("opens the resolved path when the directory lists it", async () => {
|
|
ipc.commands.fsReadDir.mockResolvedValue([{ name: "main.php", kind: "file" }]);
|
|
await openFileLink(fileLink("a/main.php:2"), "C:\\Users\\me\\proj");
|
|
expect(ipc.commands.fsReadDir).toHaveBeenCalledWith("C:/Users/me/proj/a");
|
|
expect(workbench.openFile).toHaveBeenCalledWith("C:/Users/me/proj/a/main.php", {
|
|
selection: { line: 1, col: 0 },
|
|
focus: true,
|
|
});
|
|
expect(ipc.commands.findFiles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ranks the link's directory over the bare name in the project-wide fallback (#705)", async () => {
|
|
ipc.commands.fsReadDir.mockResolvedValue([]);
|
|
ipc.commands.findFiles.mockImplementation(async (q: string) =>
|
|
q === "a/main.php"
|
|
? [
|
|
{ path: "/p/b/main.php", relPath: "b/main.php" },
|
|
{ path: "/p/a/main.php", relPath: "a/main.php" },
|
|
]
|
|
: [{ path: "/p/c/main.php", relPath: "c/main.php" }],
|
|
);
|
|
await openFileLink(fileLink("a/main.php:2"), "/p");
|
|
expect(ipc.commands.findFiles).toHaveBeenCalledWith("a/main.php", 5);
|
|
expect(workbench.openFile).toHaveBeenCalledWith("/p/a/main.php", {
|
|
selection: { line: 1, col: 0 },
|
|
focus: true,
|
|
});
|
|
});
|
|
|
|
it("falls back to the bare name for a basename-only link", async () => {
|
|
ipc.commands.fsReadDir.mockResolvedValue([]);
|
|
ipc.commands.findFiles.mockResolvedValue([{ path: "/p/x/main.php", relPath: "x/main.php" }]);
|
|
await openFileLink(fileLink("main.php:1"), "/p");
|
|
expect(ipc.commands.findFiles).toHaveBeenCalledTimes(1);
|
|
expect(workbench.openFile).toHaveBeenCalledWith("/p/x/main.php", { selection: { line: 0, col: 0 }, focus: true });
|
|
});
|
|
|
|
it("warns when nothing matches", async () => {
|
|
ipc.commands.fsReadDir.mockResolvedValue([]);
|
|
ipc.commands.findFiles.mockResolvedValue([]);
|
|
await openFileLink(fileLink("gone.php:1"), "/p");
|
|
expect(workbench.openFile).not.toHaveBeenCalled();
|
|
expect(workbench.notify).toHaveBeenCalledWith({ level: "warning", title: "File not found", body: "gone.php" });
|
|
});
|
|
});
|
|
|
|
describe("resolveStartCwd", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("keeps a saved directory that still exists", async () => {
|
|
ipc.commands.fsEntryKind.mockResolvedValue("dir");
|
|
await expect(resolveStartCwd("/repo/logs", "/repo")).resolves.toBe("/repo/logs");
|
|
expect(ipc.commands.fsEntryKind).toHaveBeenCalledWith("/repo/logs");
|
|
});
|
|
|
|
it("falls back to the project root when the saved directory is gone", async () => {
|
|
ipc.commands.fsEntryKind.mockRejectedValue(new Error("not found"));
|
|
await expect(resolveStartCwd("/repo/logs", "/repo")).resolves.toBe("/repo");
|
|
});
|
|
|
|
it("falls back when the saved path is a file rather than a directory", async () => {
|
|
ipc.commands.fsEntryKind.mockResolvedValue("file");
|
|
await expect(resolveStartCwd("/repo/logs", "/repo")).resolves.toBe("/repo");
|
|
});
|
|
|
|
it("does not touch the file system without a fallback or when the paths match", async () => {
|
|
await expect(resolveStartCwd("/repo", "/repo")).resolves.toBe("/repo");
|
|
await expect(resolveStartCwd("/anywhere", undefined)).resolves.toBe("/anywhere");
|
|
expect(ipc.commands.fsEntryKind).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("terminal start directory fallback", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("spawns a restored session in the project root when its saved directory is gone", async () => {
|
|
ipc.commands.fsEntryKind.mockRejectedValue(new Error("gone"));
|
|
ipc.commands.termSpawn.mockResolvedValue({ id: 7, title: "zsh" });
|
|
const session = new TerminalSession({
|
|
key: "s-fallback",
|
|
cwd: "/repo/gone",
|
|
fallbackRoot: "/repo",
|
|
onChange: () => undefined,
|
|
onFocus: () => undefined,
|
|
});
|
|
await session.start();
|
|
expect(ipc.commands.termSpawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/repo" }), expect.anything());
|
|
expect(session.state.cwd).toBe("/repo");
|
|
expect(session.state.status).toBe("running");
|
|
session.dispose();
|
|
expect(ipc.commands.termKill).toHaveBeenCalledWith(7);
|
|
});
|
|
|
|
it("keeps spawning in a saved directory that still exists", async () => {
|
|
ipc.commands.fsEntryKind.mockResolvedValue("dir");
|
|
ipc.commands.termSpawn.mockResolvedValue({ id: 8, title: "zsh" });
|
|
const session = new TerminalSession({
|
|
key: "s-kept",
|
|
cwd: "/repo/logs",
|
|
fallbackRoot: "/repo",
|
|
onChange: () => undefined,
|
|
onFocus: () => undefined,
|
|
});
|
|
await session.start();
|
|
expect(ipc.commands.termSpawn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cwd: "/repo/logs" }),
|
|
expect.anything(),
|
|
);
|
|
expect(session.state.cwd).toBe("/repo/logs");
|
|
session.dispose();
|
|
});
|
|
});
|
|
|
|
describe("terminal restart", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("restarts a session that failed to start on Shift+Enter's Esc+Return as on Enter", async () => {
|
|
ipc.commands.fsEntryKind.mockResolvedValue("dir");
|
|
ipc.commands.termSpawn.mockRejectedValueOnce(new Error("no shell")).mockRejectedValueOnce(new Error("no shell"));
|
|
const session = new TerminalSession({
|
|
key: "s-restart",
|
|
cwd: "/repo",
|
|
onChange: () => undefined,
|
|
onFocus: () => undefined,
|
|
});
|
|
await session.start();
|
|
expect(session.state.status).toBe("error");
|
|
session.send("\x1b");
|
|
expect(session.state.status).toBe("error");
|
|
session.send("\x1b\r");
|
|
await vi.waitFor(() => expect(ipc.commands.termSpawn).toHaveBeenCalledTimes(2));
|
|
session.dispose();
|
|
});
|
|
});
|
|
|
|
describe("terminal open", () => {
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("waits to open xterm until the pane has a size", () => {
|
|
vi.useFakeTimers();
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const session = new TerminalSession({
|
|
key: "s-open",
|
|
cwd: "/tmp",
|
|
onChange: () => undefined,
|
|
onFocus: () => undefined,
|
|
});
|
|
let width = 0;
|
|
let height = 0;
|
|
Object.defineProperty(session.host, "offsetWidth", { configurable: true, get: () => width });
|
|
Object.defineProperty(session.host, "offsetHeight", { configurable: true, get: () => height });
|
|
// jsdom has no canvas, so xterm's real open() cannot build a renderer.
|
|
const open = vi.spyOn(session.term, "open").mockImplementation(() => undefined);
|
|
session.attach(container);
|
|
session.focus();
|
|
vi.advanceTimersToNextFrame();
|
|
expect(open).not.toHaveBeenCalled();
|
|
|
|
width = 400;
|
|
height = 300;
|
|
vi.advanceTimersByTime(FIT_INTERVAL_MS);
|
|
expect(open).toHaveBeenCalledTimes(1);
|
|
session.dispose();
|
|
container.remove();
|
|
});
|
|
});
|