Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/hooks/__fixtures__/fakeXterm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Shared xterm doubles for the useTerminal tests.
*
* Every one of those tests needs the same stub of the slice of the Terminal and
* addon surface useTerminal touches, so it lives here once: a new xterm API has
* to be taught to a single fake rather than to each test file that mounts a
* terminal.
*
* `vi.mock` factories are hoisted per file, so a test still declares its own
* mocks — they just point at these classes:
*
* vi.mock("@xterm/xterm", async () => ({
* Terminal: (await import("@/hooks/__fixtures__/fakeXterm")).FakeTerminal,
* }));
*
* The factory has to do that import itself — vi.mock is hoisted above any
* top-level binding the file might otherwise share.
*/

/**
* Handlers registered through `attachCustomKeyEventHandler`, keyed by terminal
* id. Calling one is how a test delivers a key to the terminal that owns it.
*/
export const keyHandlers = new Map<string, (e: KeyboardEvent) => boolean>();

let seq = 0;

/** Terminal ids run `term-1`, `term-2`, ... in creation order. */
export class FakeTerminal {
id = `term-${(seq += 1)}`;
element: HTMLElement | null = null;
options: Record<string, unknown> = {};
cols = 80;
rows = 24;
modes = { applicationCursorKeysMode: false, mouseTrackingMode: "none" };
buffer = {
active: { length: 0, viewportY: 0, baseY: 0, cursorY: 0, type: "normal", getLine: () => null },
onBufferChange: () => ({ dispose() {} }),
};
open(container: HTMLElement) {
this.element = document.createElement("div");
container.appendChild(this.element);
}
parser = { registerOscHandler: () => ({ dispose() {} }) };
loadAddon() {}
write() {}
focus() {}
getSelection() { return ""; }
dispose() {}
attachCustomKeyEventHandler(fn: (e: KeyboardEvent) => boolean) { keyHandlers.set(this.id, fn); }
attachCustomWheelEventHandler() {}
onData() { return { dispose() {} }; }
onBinary() { return { dispose() {} }; }
onResize() { return { dispose() {} }; }
onScroll() { return { dispose() {} }; }
onLineFeed() { return { dispose() {} }; }
onRender() { return { dispose() {} }; }
onWriteParsed() { return { dispose() {} }; }
registerLinkProvider() { return { dispose() {} }; }
registerDecoration() { return null; }
}

export class FakeFitAddon {
fit() {}
proposeDimensions() { return { cols: 80, rows: 24 }; }
}

export class FakeWebglAddon {
onContextLoss() { return { dispose() {} }; }
dispose() {}
}

export class FakeWebLinksAddon {}

/**
* Records searches instead of running them — the real addon reports hits back
* through `onDidChangeResults`, which a stub cannot do, so `searches` is the
* only observable that a find actually ran.
*/
export class FakeSearchAddon {
static instances: FakeSearchAddon[] = [];
searches: { direction: "next" | "prev"; query: string }[] = [];
constructor() { FakeSearchAddon.instances.push(this); }
findNext(query: string) { this.searches.push({ direction: "next", query }); return false; }
findPrevious(query: string) { this.searches.push({ direction: "prev", query }); return false; }
clearDecorations() {}
onDidChangeResults() { return { dispose() {} }; }
}

/**
* The handler the most recently created terminal registered. useTerminal caches
* terminals by session id for the lifetime of the module, so a test that wants a
* freshly constructed one has to use a session id no earlier test has mounted.
*/
export function lastKeyHandler(): (e: KeyboardEvent) => boolean {
// Indexed rather than .at(-1): the project's tsconfig lib predates ES2022.
const handlers = [...keyHandlers.values()];
const last = handlers[handlers.length - 1];
if (!last) throw new Error("no terminal registered a key handler — was one mounted?");
return last;
}

/** Drop state a previous test left behind (ids restart at `term-1`). */
export function resetFakeXterm(): void {
seq = 0;
keyHandlers.clear();
FakeSearchAddon.instances = [];
}
16 changes: 7 additions & 9 deletions src/hooks/useKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/sessionStore";
import { useTeamSessionStore } from "@/stores/teamSessionStore";
import { matchShortcut } from "@/stores/shortcutStore";
import { useHistoryStore } from "@/stores/historyStore";
import { openTerminalSearch, getTerminalSearchController } from "@/hooks/useTerminal";
import { openTerminalSearch, isTerminalSearchNavKey, handleTerminalSearchNav } from "@/hooks/useTerminal";
import { handleDuplicateShortcut } from "@/services/duplicateSession";

const CLIPBOARD_TABS = new Set(["hosts", "keychain", "port-forwarding", "snippets"]);
Expand Down Expand Up @@ -65,17 +65,15 @@ export function useKeyboard() {

// Ctrl+G / Shift+Ctrl+G: always prevent the native webview find-next dialog.
// When the terminal search widget is open, drive it to next/prev result.
if (e.ctrlKey && !e.altKey && (e.key === "g" || e.key === "G")) {
// useTerminal gets first refusal and claims the chord whenever the canvas
// has focus: xterm cancels the pass-through ^G, and useTerminal stops the
// event itself when it drives an open widget. What is left for this
// listener is focus elsewhere — the search input, or outside the canvas.
if (isTerminalSearchNavKey(e)) {
e.preventDefault();
if (useUIStore.getState().activeNav === "terminal") {
const activeId = useSessionStore.getState().activeSessionId;
if (activeId) {
const ctrl = getTerminalSearchController(activeId);
if (ctrl?.getSnapshot().open) {
if (e.shiftKey) ctrl.prev();
else ctrl.next();
}
}
if (activeId) handleTerminalSearchNav(activeId, e);
}
return;
}
Expand Down
55 changes: 6 additions & 49 deletions src/hooks/useTerminal.clipboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { useTerminal } from "@/hooks/useTerminal";
import { keyHandlers } from "@/hooks/__fixtures__/fakeXterm";

const keyHandlers = new Map<string, (e: KeyboardEvent) => boolean>();

vi.mock("@xterm/xterm", () => {
let seq = 0;
class FakeTerminal {
id = `term-${(seq += 1)}`;
element: HTMLElement | null = null;
options: Record<string, unknown> = {};
cols = 80;
rows = 24;
modes = { applicationCursorKeysMode: false, mouseTrackingMode: "none" };
buffer = {
active: { length: 0, viewportY: 0, baseY: 0, cursorY: 0, type: "normal", getLine: () => null },
onBufferChange: () => ({ dispose() {} }),
};
open(container: HTMLElement) {
this.element = document.createElement("div");
container.appendChild(this.element);
}
parser = { registerOscHandler: () => ({ dispose() {} }) };
loadAddon() {}
write() {}
focus() {}
dispose() {}
attachCustomKeyEventHandler(fn: (e: KeyboardEvent) => boolean) { keyHandlers.set(this.id, fn); }
attachCustomWheelEventHandler() {}
onData() { return { dispose() {} }; }
onBinary() { return { dispose() {} }; }
onResize() { return { dispose() {} }; }
onScroll() { return { dispose() {} }; }
onLineFeed() { return { dispose() {} }; }
onRender() { return { dispose() {} }; }
onWriteParsed() { return { dispose() {} }; }
registerLinkProvider() { return { dispose() {} }; }
registerDecoration() { return null; }
}
return { Terminal: FakeTerminal };
});
vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} proposeDimensions() { return { cols: 80, rows: 24 }; } } }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: class { onContextLoss() { return { dispose() {} }; } dispose() {} } }));
vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} }));
vi.mock("@xterm/addon-search", () => ({
SearchAddon: class {
findNext() { return false; }
findPrevious() { return false; }
clearDecorations() {}
onDidChangeResults() { return { dispose() {} }; }
},
}));
vi.mock("@xterm/xterm", async () => ({ Terminal: (await import("@/hooks/__fixtures__/fakeXterm")).FakeTerminal }));
vi.mock("@xterm/addon-fit", async () => ({ FitAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeFitAddon }));
vi.mock("@xterm/addon-webgl", async () => ({ WebglAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeWebglAddon }));
vi.mock("@xterm/addon-web-links", async () => ({ WebLinksAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeWebLinksAddon }));
vi.mock("@xterm/addon-search", async () => ({ SearchAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeSearchAddon }));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
vi.mock("@/services/ssh", () => ({
sshSendInput: vi.fn(), sshResize: vi.fn(),
Expand Down
49 changes: 5 additions & 44 deletions src/hooks/useTerminal.reattach.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,11 @@ import { render } from "@testing-library/react";
import { useTerminal } from "@/hooks/useTerminal";
import { localReady, onLocalClosed, onLocalOutput } from "@/services/local";

vi.mock("@xterm/xterm", () => {
class FakeTerminal {
element: HTMLElement | null = null;
options: Record<string, unknown> = {};
cols = 80;
rows = 24;
buffer = {
active: { length: 0, viewportY: 0, baseY: 0, cursorY: 0, getLine: () => null },
onBufferChange: () => ({ dispose() {} }),
};
open(container: HTMLElement) {
this.element = document.createElement("div");
container.appendChild(this.element);
}
parser = { registerOscHandler: () => ({ dispose() {} }) };
loadAddon() {}
write() {}
focus() {}
dispose() {}
attachCustomKeyEventHandler() {}
attachCustomWheelEventHandler() {}
onData() { return { dispose() {} }; }
onBinary() { return { dispose() {} }; }
onResize() { return { dispose() {} }; }
onScroll() { return { dispose() {} }; }
onLineFeed() { return { dispose() {} }; }
onRender() { return { dispose() {} }; }
onWriteParsed() { return { dispose() {} }; }
registerLinkProvider() { return { dispose() {} }; }
registerDecoration() { return null; }
}
return { Terminal: FakeTerminal };
});
vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} proposeDimensions() { return { cols: 80, rows: 24 }; } } }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: class { onContextLoss() { return { dispose() {} }; } dispose() {} } }));
vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} }));
vi.mock("@xterm/addon-search", () => ({
SearchAddon: class {
findNext() { return false; }
findPrevious() { return false; }
clearDecorations() {}
onDidChangeResults() { return { dispose() {} }; }
},
}));
vi.mock("@xterm/xterm", async () => ({ Terminal: (await import("@/hooks/__fixtures__/fakeXterm")).FakeTerminal }));
vi.mock("@xterm/addon-fit", async () => ({ FitAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeFitAddon }));
vi.mock("@xterm/addon-webgl", async () => ({ WebglAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeWebglAddon }));
vi.mock("@xterm/addon-web-links", async () => ({ WebLinksAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeWebLinksAddon }));
vi.mock("@xterm/addon-search", async () => ({ SearchAddon: (await import("@/hooks/__fixtures__/fakeXterm")).FakeSearchAddon }));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
vi.mock("@/services/ssh", () => ({
sshSendInput: vi.fn(), sshResize: vi.fn(),
Expand Down
Loading