Skip to content

Commit 8d40bf1

Browse files
authored
Merge pull request #4 from dockersamples/fix/run-command-no-refocus
Let a programmatic runCommand leave focus where it is
2 parents 155fe96 + 0d5cacf commit 8d40bf1

3 files changed

Lines changed: 93 additions & 6 deletions

File tree

‎app/packages/simulator/src/react/MockTerminal.tsx‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,22 @@ export interface TerminalEvent {
2525
type: "state" | "reset";
2626
}
2727

28+
/** Options for {@link MockTerminalHandle.runCommand}. */
29+
export interface RunCommandOptions {
30+
/** Re-focus the terminal input after the command completes. Default true. */
31+
focus?: boolean;
32+
}
33+
2834
/** Imperative API for driving the terminal from outside (Run / Save buttons). */
2935
export interface MockTerminalHandle {
30-
/** Runs a command line as if it were typed at the prompt. */
31-
runCommand: (text: string) => void;
36+
/**
37+
* Runs a command line as if it were typed at the prompt. By default the
38+
* input is re-focused once the command completes, as it is after a typed
39+
* command. Pass `{ focus: false }` when the caller is a Run button: focus
40+
* stays where it is, so on a touch device the keyboard does not open and
41+
* the page does not zoom or jump away from the button that was pressed.
42+
*/
43+
runCommand: (text: string, options?: RunCommandOptions) => void;
3244
/** Writes content to a path in the virtual filesystem. */
3345
saveFile: (path: string, content: string) => void;
3446
}
@@ -217,6 +229,8 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
217229
const scrollRef = useRef<HTMLDivElement>(null);
218230
const inputRef = useRef<HTMLInputElement>(null);
219231
const prevBusyRef = useRef(false);
232+
// Set for the one run that must leave focus where it is (see runCommand).
233+
const skipRefocusRef = useRef(false);
220234
const completionRef = useRef<{
221235
candidates: string[];
222236
index: number;
@@ -316,9 +330,9 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
316330
// in the submit() finally block because setBusy(false) only queues a render —
317331
// the input is still disabled when finally runs, so focus() would be a no-op.
318332
// This effect fires after React commits the busy=false update, when the input
319-
// is already re-enabled.
333+
// is already re-enabled. A run started with `focus: false` skips it.
320334
useEffect(() => {
321-
if (prevBusyRef.current && !busy) {
335+
if (prevBusyRef.current && !busy && !skipRefocusRef.current) {
322336
inputRef.current?.focus();
323337
}
324338
prevBusyRef.current = busy;
@@ -585,7 +599,7 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
585599
// Runs a raw line through the terminal exactly as if it had been typed and
586600
// submitted. Shared by the Enter handler and the imperative runCommand().
587601
const runLine = useCallback(
588-
async (raw: string) => {
602+
async (raw: string, { focus = true }: RunCommandOptions = {}) => {
589603
if (busy || !simulator) return;
590604
const line = raw.trim();
591605

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

645+
skipRefocusRef.current = !focus;
630646
setBusy(true);
631647
try {
632648
if (currentMode.kind === "session") {
@@ -787,7 +803,8 @@ export const MockTerminal = forwardRef<MockTerminalHandle, MockTerminalProps>(
787803
useImperativeHandle(
788804
ref,
789805
() => ({
790-
runCommand: (text: string) => void runLine(text),
806+
runCommand: (text: string, options?: RunCommandOptions) =>
807+
void runLine(text, options),
791808
saveFile,
792809
}),
793810
[runLine, saveFile],

‎app/packages/simulator/src/react/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export { MockTerminal } from "./MockTerminal";
2020
export type {
2121
MockTerminalHandle,
2222
MockTerminalProps,
23+
RunCommandOptions,
2324
TerminalChange,
2425
TerminalEvent,
2526
} from "./MockTerminal";

‎app/packages/simulator/test/react/MockTerminal.test.tsx‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,75 @@ scenarios:
210210
});
211211
});
212212

213+
describe("MockTerminal focus after a run", () => {
214+
/*
215+
The refocus is a reaction to busy going true and then false, so a run has to
216+
take real time for React to commit both. That needs streaming on (the
217+
fixture turns it off) and the run outside `act`, which would batch the two
218+
renders into one. Waiting for the input to come back says the run is over.
219+
*/
220+
const TypingTerminal = ({
221+
handle,
222+
}: {
223+
handle: React.RefObject<MockTerminalHandle | null>;
224+
}) => (
225+
<MockTerminal
226+
ref={handle}
227+
simulator={new Simulator({ spec: SPEC })}
228+
streaming
229+
streamDelayMs={30}
230+
/>
231+
);
232+
233+
const run = (
234+
ref: React.RefObject<MockTerminalHandle | null>,
235+
line: string,
236+
options?: { focus?: boolean },
237+
) => {
238+
ref.current!.runCommand(line, options);
239+
};
240+
241+
it("re-focuses the input after a command by default", async () => {
242+
const ref = createRef<MockTerminalHandle>();
243+
render(<TypingTerminal handle={ref} />);
244+
run(ref, "docker ps");
245+
await screen.findByText("abc123 nginx");
246+
const input = await screen.findByLabelText("terminal input");
247+
await waitFor(() => expect(document.activeElement).toBe(input));
248+
});
249+
250+
it("leaves focus alone when the run asks not to take it", async () => {
251+
const ref = createRef<MockTerminalHandle>();
252+
render(
253+
<div>
254+
<button type="button">Run</button>
255+
<TypingTerminal handle={ref} />
256+
</div>,
257+
);
258+
const button = screen.getByRole("button", { name: "Run" });
259+
button.focus();
260+
run(ref, "docker ps", { focus: false });
261+
await screen.findByText("abc123 nginx");
262+
await screen.findByLabelText("terminal input");
263+
expect(document.activeElement).toBe(button);
264+
});
265+
266+
it("does not let a skipped refocus leak into the next run", async () => {
267+
const ref = createRef<MockTerminalHandle>();
268+
render(<TypingTerminal handle={ref} />);
269+
run(ref, "docker ps", { focus: false });
270+
await screen.findByText("abc123 nginx");
271+
await screen.findByLabelText("terminal input");
272+
// Two output lines, so the run outlasts a render and the input unmounts.
273+
run(ref, "docker ps");
274+
await waitFor(() =>
275+
expect(screen.getAllByText("abc123 nginx")).toHaveLength(2),
276+
);
277+
const input = await screen.findByLabelText("terminal input");
278+
await waitFor(() => expect(document.activeElement).toBe(input));
279+
});
280+
});
281+
213282
describe("MockTerminal persistence", () => {
214283
// The behaviour this refactor introduced: persistence is opt-in via an
215284
// explicit key, so an embedded terminal on a docs page starts clean.

0 commit comments

Comments
 (0)