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
101 changes: 89 additions & 12 deletions app/src/components/AppConsole/AppConsole.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useEffect, useMemo, useRef } from "react";

import type { ConsoleCell, PersistedConsoleCellRow } from "./model";
import type { PersistedConsoleCellRow } from "./model";

const ALT = 1 << 9;
const SHIFT = 1 << 10;
const ENTER = 1;
const UP = 2;
const DOWN = 3;
const KEY_N = 4;
const KEY_P = 5;

let storedSessionId = "session-1";
let storedCells: PersistedConsoleCellRow[] = [];
Expand Down Expand Up @@ -166,6 +169,7 @@ vi.mock("../Actions/Editor", () => ({
onMount?: (editor: any, monaco: any) => void;
}) => {
const commandsRef = useRef(new Map<number, () => void>());

const editorRef = useMemo(
() => ({
addCommand: (key: number, handler: () => void) => {
Expand All @@ -178,14 +182,19 @@ vi.mock("../Actions/Editor", () => ({

useEffect(() => {
onMount?.(editorRef, {
KeyMod: { Shift: SHIFT },
KeyMod: { Alt: ALT, Shift: SHIFT },
KeyCode: {
Enter: ENTER,
UpArrow: UP,
DownArrow: DOWN,
KeyN: KEY_N,
KeyP: KEY_P,
},
});
}, [editorRef, onMount]);
// Monaco only invokes the consumer mount callback once per editor instance.
// Keep the mock aligned so closure-related regressions are exercised.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<textarea
Expand All @@ -195,22 +204,26 @@ vi.mock("../Actions/Editor", () => ({
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
onKeyDown={(event) => {
if (!event.shiftKey) {
return;
}
const code =
event.key === "Enter"
? ENTER
: event.key === "ArrowUp"
? UP
: event.key === "ArrowDown"
? DOWN
: event.key.toLowerCase() === "n"
? KEY_N
: event.key.toLowerCase() === "p"
? KEY_P
: null;
if (!code) {
const modifier = event.shiftKey ? SHIFT : event.altKey ? ALT : null;
const handler =
code && modifier !== null ? commandsRef.current.get(modifier | code) : undefined;
if (!handler) {
return;
}
event.preventDefault();
commandsRef.current.get(SHIFT | code)?.();
handler();
}}
/>
);
Expand Down Expand Up @@ -293,19 +306,83 @@ describe("AppConsole", () => {

fireEvent.change(currentInput(), { target: { value: "partial draft" } });

fireEvent.keyDown(currentInput(), { key: "ArrowUp", shiftKey: true });
fireEvent.click(screen.getByTestId("app-console-history-previous"));
expect(currentInput().value).toBe("second()");

fireEvent.keyDown(currentInput(), { key: "ArrowUp", shiftKey: true });
fireEvent.click(screen.getByTestId("app-console-history-previous"));
expect(currentInput().value).toBe("first()");

fireEvent.keyDown(currentInput(), { key: "ArrowDown", shiftKey: true });
fireEvent.click(screen.getByTestId("app-console-history-next"));
expect(currentInput().value).toBe("second()");

fireEvent.keyDown(currentInput(), { key: "ArrowDown", shiftKey: true });
fireEvent.click(screen.getByTestId("app-console-history-next"));
expect(currentInput().value).toBe("partial draft");
});

it("renders history controls with tooltips", async () => {
render(<AppConsole showHeader={false} />);

await screen.findByLabelText("App Console input");
expect(screen.getByTestId("app-console-history-previous").getAttribute("title")).toBe(
"Previous history entry (Alt+P)",
);
expect(screen.getByTestId("app-console-history-next").getAttribute("title")).toBe(
"Next history entry (Alt+N)",
);
});

it("supports Alt+P and Alt+N history shortcuts", async () => {
render(<AppConsole showHeader={false} />);

await screen.findByLabelText("App Console input");

fireEvent.change(currentInput(), { target: { value: "first()" } });
fireEvent.keyDown(currentInput(), { key: "Enter", shiftKey: true });
await waitFor(() => expect(screen.getAllByTestId("app-console-cell")).toHaveLength(2));

fireEvent.change(currentInput(), { target: { value: "second()" } });
fireEvent.keyDown(currentInput(), { key: "Enter", shiftKey: true });
await waitFor(() => expect(screen.getAllByTestId("app-console-cell")).toHaveLength(3));

fireEvent.change(currentInput(), { target: { value: "partial draft" } });

fireEvent.keyDown(currentInput(), { key: "p", altKey: true });
expect(currentInput().value).toBe("second()");

fireEvent.keyDown(currentInput(), { key: "p", altKey: true });
expect(currentInput().value).toBe("first()");

fireEvent.keyDown(currentInput(), { key: "n", altKey: true });
expect(currentInput().value).toBe("second()");

fireEvent.keyDown(currentInput(), { key: "n", altKey: true });
expect(currentInput().value).toBe("partial draft");
});

it("updates history button state even when browsing does not change the draft text", async () => {
render(<AppConsole showHeader={false} />);

await screen.findByLabelText("App Console input");

fireEvent.change(currentInput(), { target: { value: "first()" } });
fireEvent.keyDown(currentInput(), { key: "Enter", shiftKey: true });
await waitFor(() => expect(screen.getAllByTestId("app-console-cell")).toHaveLength(2));

fireEvent.change(currentInput(), { target: { value: "first()" } });

const previousButton = screen.getByTestId("app-console-history-previous");
const nextButton = screen.getByTestId("app-console-history-next");

expect(previousButton.getAttribute("disabled")).toBeNull();
expect(nextButton.getAttribute("disabled")).not.toBeNull();

fireEvent.click(previousButton);

expect(currentInput().value).toBe("first()");
expect(previousButton.getAttribute("disabled")).not.toBeNull();
expect(nextButton.getAttribute("disabled")).toBeNull();
});

it("copies a frozen cell back into the current draft", async () => {
render(<AppConsole showHeader={false} />);

Expand Down
101 changes: 76 additions & 25 deletions app/src/components/AppConsole/AppConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,24 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean

const draftEditorRef = useRef<any>(null);
const bodyRef = useRef<HTMLDivElement | null>(null);
const historyBrowseRef = useRef<{ index: number | null; draftBuffer: string }>({
const [historyBrowseState, setHistoryBrowseState] = useState<{
index: number | null;
draftBuffer: string;
}>({
index: null,
draftBuffer: "",
});
const historyBrowseStateRef = useRef(historyBrowseState);
const pendingFocusCellIdRef = useRef<string | null>(null);

const updateHistoryBrowseState = useCallback(
(nextState: { index: number | null; draftBuffer: string }) => {
historyBrowseStateRef.current = nextState;
setHistoryBrowseState(nextState);
},
[],
);

useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, collapsed ? "true" : "false");
Expand Down Expand Up @@ -247,6 +259,13 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
);

const currentCell = cells[cells.length - 1] ?? null;
const historySources = useMemo(() => getHistorySources(cells), [cells]);
const historyIndex = historyBrowseState.index;
const canBrowsePrevious =
currentCell?.status === "draft" &&
historySources.length > 0 &&
(historyIndex === null || historyIndex < historySources.length - 1);
const canBrowseNext = currentCell?.status === "draft" && historyIndex !== null;

useEffect(() => {
if (!currentCell || currentCell.status !== "draft") {
Expand Down Expand Up @@ -282,13 +301,13 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
appConsoleData.setDraftSource(source);

if (clearHistoryBrowse) {
historyBrowseRef.current = {
updateHistoryBrowseState({
index: null,
draftBuffer: "",
};
});
}
},
[appConsoleData],
[appConsoleData, updateHistoryBrowseState],
);

const browseHistory = useCallback(
Expand All @@ -304,16 +323,16 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
return;
}

const state = historyBrowseRef.current;
const state = historyBrowseStateRef.current;
if (direction === "previous") {
const nextIndex =
state.index === null ? 0 : Math.min(state.index + 1, history.length - 1);
const draftBuffer = state.index === null ? draft.source : state.draftBuffer;
const nextSource = history[history.length - 1 - nextIndex] ?? draft.source;
historyBrowseRef.current = {
updateHistoryBrowseState({
index: nextIndex,
draftBuffer,
};
});
if (nextSource === draft.source) {
return;
}
Expand All @@ -331,7 +350,7 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
? history[history.length - 1 - nextIndex] ?? draft.source
: state.draftBuffer;

historyBrowseRef.current =
updateHistoryBrowseState(
nextIndex >= 0
? {
index: nextIndex,
Expand All @@ -340,15 +359,16 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
: {
index: null,
draftBuffer: "",
};
},
);

if (nextSource === draft.source) {
return;
}

appConsoleData.setDraftSource(nextSource);
},
[appConsoleData],
[appConsoleData, updateHistoryBrowseState],
);

const executeCurrentCell = useCallback(async () => {
Expand All @@ -359,10 +379,10 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
return;
}

historyBrowseRef.current = {
updateHistoryBrowseState({
index: null,
draftBuffer: "",
};
});

const globals = createAppJsGlobals({
runme,
Expand Down Expand Up @@ -442,6 +462,7 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
runme,
setCurrentDoc,
setDefaultRunner,
updateHistoryBrowseState,
updateRunner,
]);

Expand All @@ -459,13 +480,13 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
},
);
editor.addCommand(
monaco.KeyMod.Shift | monaco.KeyCode.UpArrow,
monaco.KeyMod.Alt | monaco.KeyCode.KeyP,
() => {
browseHistory("previous");
},
);
editor.addCommand(
monaco.KeyMod.Shift | monaco.KeyCode.DownArrow,
monaco.KeyMod.Alt | monaco.KeyCode.KeyN,
() => {
browseHistory("next");
},
Expand Down Expand Up @@ -564,16 +585,46 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
</button>
) : null}
{isEditable ? (
<button
type="button"
data-testid="app-console-cell-run"
className="rounded border border-sky-300/40 bg-sky-400/10 px-2 py-1 text-[11px] font-medium text-sky-100 transition hover:bg-sky-400/20"
onClick={() => {
void executeCurrentCell();
}}
>
Run
</button>
<>
<button
type="button"
data-testid="app-console-history-previous"
aria-label="Previous history entry (Alt+P)"
title="Previous history entry (Alt+P)"
disabled={!canBrowsePrevious}
className="rounded border border-white/15 px-2 py-1 text-[11px] font-medium text-slate-200 transition hover:border-sky-300/50 hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
browseHistory("previous");
draftEditorRef.current?.focus?.();
}}
>
Prev
</button>
<button
type="button"
data-testid="app-console-history-next"
aria-label="Next history entry (Alt+N)"
title="Next history entry (Alt+N)"
disabled={!canBrowseNext}
className="rounded border border-white/15 px-2 py-1 text-[11px] font-medium text-slate-200 transition hover:border-sky-300/50 hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
browseHistory("next");
draftEditorRef.current?.focus?.();
}}
>
Next
</button>
<button
type="button"
data-testid="app-console-cell-run"
className="rounded border border-sky-300/40 bg-sky-400/10 px-2 py-1 text-[11px] font-medium text-sky-100 transition hover:bg-sky-400/20"
onClick={() => {
void executeCurrentCell();
}}
>
Run
</button>
</>
) : null}
</div>
</div>
Expand Down Expand Up @@ -620,7 +671,7 @@ export default function AppConsole({ showHeader = true }: { showHeader?: boolean
{isCurrent && cell.status === "draft" ? (
<div className="mt-3 text-[11px] text-slate-400">
<span className="font-semibold text-slate-300">Shortcuts:</span>{" "}
<span>Shift+Enter to run, Shift+Up/Shift+Down to browse history.</span>
<span>Shift+Enter to run. Alt+P/Alt+N browse history.</span>
</div>
) : null}
</article>
Expand Down
Loading