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
29 changes: 23 additions & 6 deletions app/packages/simulator/src/react/MockTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,22 @@ export interface TerminalEvent {
type: "state" | "reset";
}

/** Options for {@link MockTerminalHandle.runCommand}. */
export interface RunCommandOptions {
/** Re-focus the terminal input after the command completes. Default true. */
focus?: boolean;
}

/** Imperative API for driving the terminal from outside (Run / Save buttons). */
export interface MockTerminalHandle {
/** Runs a command line as if it were typed at the prompt. */
runCommand: (text: string) => void;
/**
* Runs a command line as if it were typed at the prompt. By default the
* input is re-focused once the command completes, as it is after a typed
* command. Pass `{ focus: false }` when the caller is a Run button: focus
* stays where it is, so on a touch device the keyboard does not open and
* the page does not zoom or jump away from the button that was pressed.
*/
runCommand: (text: string, options?: RunCommandOptions) => void;
/** Writes content to a path in the virtual filesystem. */
saveFile: (path: string, content: string) => void;
}
Expand Down Expand Up @@ -217,6 +229,8 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prevBusyRef = useRef(false);
// Set for the one run that must leave focus where it is (see runCommand).
const skipRefocusRef = useRef(false);
const completionRef = useRef<{
candidates: string[];
index: number;
Expand Down Expand Up @@ -316,9 +330,9 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
// in the submit() finally block because setBusy(false) only queues a render —
// the input is still disabled when finally runs, so focus() would be a no-op.
// This effect fires after React commits the busy=false update, when the input
// is already re-enabled.
// is already re-enabled. A run started with `focus: false` skips it.
useEffect(() => {
if (prevBusyRef.current && !busy) {
if (prevBusyRef.current && !busy && !skipRefocusRef.current) {
inputRef.current?.focus();
}
prevBusyRef.current = busy;
Expand Down Expand Up @@ -585,7 +599,7 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
// Runs a raw line through the terminal exactly as if it had been typed and
// submitted. Shared by the Enter handler and the imperative runCommand().
const runLine = useCallback(
async (raw: string) => {
async (raw: string, { focus = true }: RunCommandOptions = {}) => {
if (busy || !simulator) return;
const line = raw.trim();

Expand All @@ -609,6 +623,7 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
// While collecting input, every line is a value (empty included); only
// the abort words are special. Dispatch to the input handler.
if (inInput) {
skipRefocusRef.current = !focus;
setBusy(true);
try {
await runInputTurn(line, currentMode);
Expand All @@ -627,6 +642,7 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
return;
}

skipRefocusRef.current = !focus;
setBusy(true);
try {
if (currentMode.kind === "session") {
Expand Down Expand Up @@ -787,7 +803,8 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
useImperativeHandle(
ref,
() => ({
runCommand: (text: string) => void runLine(text),
runCommand: (text: string, options?: RunCommandOptions) =>
void runLine(text, options),
saveFile,
}),
[runLine, saveFile],
Expand Down
1 change: 1 addition & 0 deletions app/packages/simulator/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { MockTerminal } from "./MockTerminal";
export type {
MockTerminalHandle,
MockTerminalProps,
RunCommandOptions,
TerminalChange,
TerminalEvent,
} from "./MockTerminal";
Expand Down
69 changes: 69 additions & 0 deletions app/packages/simulator/test/react/MockTerminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,75 @@ scenarios:
});
});

describe("MockTerminal focus after a run", () => {
/*
The refocus is a reaction to busy going true and then false, so a run has to
take real time for React to commit both. That needs streaming on (the
fixture turns it off) and the run outside `act`, which would batch the two
renders into one. Waiting for the input to come back says the run is over.
*/
const TypingTerminal = ({
handle,
}: {
handle: React.RefObject<MockTerminalHandle | null>;
}) => (
<MockTerminal
ref={handle}
simulator={new Simulator({ spec: SPEC })}
streaming
streamDelayMs={30}
/>
);

const run = (
ref: React.RefObject<MockTerminalHandle | null>,
line: string,
options?: { focus?: boolean },
) => {
ref.current!.runCommand(line, options);
};

it("re-focuses the input after a command by default", async () => {
const ref = createRef<MockTerminalHandle>();
render(<TypingTerminal handle={ref} />);
run(ref, "docker ps");
await screen.findByText("abc123 nginx");
const input = await screen.findByLabelText("terminal input");
await waitFor(() => expect(document.activeElement).toBe(input));
});

it("leaves focus alone when the run asks not to take it", async () => {
const ref = createRef<MockTerminalHandle>();
render(
<div>
<button type="button">Run</button>
<TypingTerminal handle={ref} />
</div>,
);
const button = screen.getByRole("button", { name: "Run" });
button.focus();
run(ref, "docker ps", { focus: false });
await screen.findByText("abc123 nginx");
await screen.findByLabelText("terminal input");
expect(document.activeElement).toBe(button);
});

it("does not let a skipped refocus leak into the next run", async () => {
const ref = createRef<MockTerminalHandle>();
render(<TypingTerminal handle={ref} />);
run(ref, "docker ps", { focus: false });
await screen.findByText("abc123 nginx");
await screen.findByLabelText("terminal input");
// Two output lines, so the run outlasts a render and the input unmounts.
run(ref, "docker ps");
await waitFor(() =>
expect(screen.getAllByText("abc123 nginx")).toHaveLength(2),
);
const input = await screen.findByLabelText("terminal input");
await waitFor(() => expect(document.activeElement).toBe(input));
});
});

describe("MockTerminal persistence", () => {
// The behaviour this refactor introduced: persistence is opt-in via an
// explicit key, so an embedded terminal on a docs page starts clean.
Expand Down
Loading