Fix terminal renderer remount loading
This commit is contained in:
@@ -18,6 +18,7 @@ import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||
import { openExternalUrl } from "../utils/open-external-url";
|
||||
import { focusWithRetries } from "../utils/web-focus";
|
||||
import {
|
||||
@@ -127,6 +128,7 @@ interface TerminalEmulatorProps {
|
||||
meta: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||
pendingModifiers?: PendingTerminalModifiers;
|
||||
focusRequestToken?: number;
|
||||
resizeRequestToken?: number;
|
||||
@@ -181,6 +183,7 @@ export default function TerminalEmulator({
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
onRendererReadyChange,
|
||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||
focusRequestToken = 0,
|
||||
resizeRequestToken = 0,
|
||||
@@ -198,6 +201,8 @@ export default function TerminalEmulator({
|
||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||
const xtermThemeRef = useRef(xtermTheme);
|
||||
xtermThemeRef.current = xtermTheme;
|
||||
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
|
||||
onRendererReadyChangeRef.current = onRendererReadyChange;
|
||||
const mountCallbacksRef = useRef({
|
||||
onInput,
|
||||
onResize,
|
||||
@@ -389,9 +394,11 @@ export default function TerminalEmulator({
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
});
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||
|
||||
return () => {
|
||||
runtime.unmount();
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
|
||||
if (runtimeRef.current === runtime) {
|
||||
runtimeRef.current = null;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
type TerminalRendererReadyChange,
|
||||
} from "@/utils/terminal-renderer-readiness";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
@@ -164,6 +170,7 @@ export function TerminalPane({
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
|
||||
// Keep the latest measured size for whichever client currently owns the pane,
|
||||
// but only dedupe resizes that this specific client has already pushed.
|
||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
@@ -175,6 +182,7 @@ export function TerminalPane({
|
||||
);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
@@ -195,6 +203,20 @@ export function TerminalPane({
|
||||
const requestTerminalReflow = useCallback(() => {
|
||||
setResizeRequestToken((current) => current + 1);
|
||||
}, []);
|
||||
const handleRendererReadyChange = useCallback(
|
||||
(change: TerminalRendererReadyChange) => {
|
||||
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
|
||||
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = workspaceTerminalSession.snapshots.get({ terminalId });
|
||||
if (snapshot) {
|
||||
emulatorRef.current?.renderSnapshot(snapshot);
|
||||
}
|
||||
},
|
||||
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||
@@ -617,6 +639,13 @@ export function TerminalPane({
|
||||
if (!swipeGesturesEnabled) return;
|
||||
onOpenFileExplorer();
|
||||
}, [swipeGesturesEnabled, onOpenFileExplorer]);
|
||||
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused,
|
||||
hasStreamError: Boolean(streamError),
|
||||
isAttaching,
|
||||
rendererReadyStreamKey,
|
||||
terminalStreamKey,
|
||||
});
|
||||
|
||||
if (!client || !isConnected) {
|
||||
return (
|
||||
@@ -634,11 +663,12 @@ export function TerminalPane({
|
||||
<TerminalEmulator
|
||||
ref={emulatorRef}
|
||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
streamKey={terminalStreamKey}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
onSwipeRight={handleSwipeRight}
|
||||
onSwipeLeft={handleSwipeLeft}
|
||||
onInput={handleTerminalData}
|
||||
@@ -654,7 +684,7 @@ export function TerminalPane({
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
)}
|
||||
|
||||
{isAttaching && isWorkspaceFocused ? (
|
||||
{showLoadingOverlay ? (
|
||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyTerminalRendererReadyChange,
|
||||
shouldReplayTerminalSnapshotForRenderer,
|
||||
shouldShowTerminalLoadingOverlay,
|
||||
} from "./terminal-renderer-readiness";
|
||||
|
||||
describe("terminal-renderer-readiness", () => {
|
||||
it("preserves the attach loader even after the renderer is ready", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: false,
|
||||
isAttaching: true,
|
||||
rendererReadyStreamKey: "scope:terminal-1",
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the loader visible until the current renderer is ready", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: false,
|
||||
isAttaching: false,
|
||||
rendererReadyStreamKey: null,
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the loader only after attach completes and the current renderer is ready", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: false,
|
||||
isAttaching: false,
|
||||
rendererReadyStreamKey: "scope:terminal-1",
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not cover stream errors", () => {
|
||||
expect(
|
||||
shouldShowTerminalLoadingOverlay({
|
||||
isWorkspaceFocused: true,
|
||||
hasStreamError: true,
|
||||
isAttaching: true,
|
||||
rendererReadyStreamKey: null,
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores stale unready events from an old renderer", () => {
|
||||
const current = applyTerminalRendererReadyChange("scope:terminal-2", {
|
||||
streamKey: "scope:terminal-1",
|
||||
isReady: false,
|
||||
});
|
||||
|
||||
expect(current).toBe("scope:terminal-2");
|
||||
});
|
||||
|
||||
it("clears readiness when the current renderer unmounts", () => {
|
||||
const current = applyTerminalRendererReadyChange("scope:terminal-1", {
|
||||
streamKey: "scope:terminal-1",
|
||||
isReady: false,
|
||||
});
|
||||
|
||||
expect(current).toBeNull();
|
||||
});
|
||||
|
||||
it("replays snapshots only for ready events from the current renderer", () => {
|
||||
expect(
|
||||
shouldReplayTerminalSnapshotForRenderer({
|
||||
change: { streamKey: "scope:terminal-1", isReady: true },
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldReplayTerminalSnapshotForRenderer({
|
||||
change: { streamKey: "scope:terminal-1", isReady: false },
|
||||
terminalStreamKey: "scope:terminal-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldReplayTerminalSnapshotForRenderer({
|
||||
change: { streamKey: "scope:terminal-1", isReady: true },
|
||||
terminalStreamKey: "scope:terminal-2",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface TerminalRendererReadyChange {
|
||||
streamKey: string;
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
export function applyTerminalRendererReadyChange(
|
||||
currentReadyStreamKey: string | null,
|
||||
change: TerminalRendererReadyChange,
|
||||
): string | null {
|
||||
if (change.isReady) {
|
||||
return change.streamKey;
|
||||
}
|
||||
|
||||
return currentReadyStreamKey === change.streamKey ? null : currentReadyStreamKey;
|
||||
}
|
||||
|
||||
export function shouldReplayTerminalSnapshotForRenderer(input: {
|
||||
change: TerminalRendererReadyChange;
|
||||
terminalStreamKey: string;
|
||||
}): boolean {
|
||||
return input.change.isReady && input.change.streamKey === input.terminalStreamKey;
|
||||
}
|
||||
|
||||
export function shouldShowTerminalLoadingOverlay(input: {
|
||||
isWorkspaceFocused: boolean;
|
||||
hasStreamError: boolean;
|
||||
isAttaching: boolean;
|
||||
rendererReadyStreamKey: string | null;
|
||||
terminalStreamKey: string;
|
||||
}): boolean {
|
||||
return (
|
||||
input.isWorkspaceFocused &&
|
||||
!input.hasStreamError &&
|
||||
(input.isAttaching || input.rendererReadyStreamKey !== input.terminalStreamKey)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user