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 { TerminalState } from "@server/shared/messages";
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
|
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||||
import { openExternalUrl } from "../utils/open-external-url";
|
import { openExternalUrl } from "../utils/open-external-url";
|
||||||
import { focusWithRetries } from "../utils/web-focus";
|
import { focusWithRetries } from "../utils/web-focus";
|
||||||
import {
|
import {
|
||||||
@@ -127,6 +128,7 @@ interface TerminalEmulatorProps {
|
|||||||
meta: boolean;
|
meta: boolean;
|
||||||
}) => Promise<void> | void;
|
}) => Promise<void> | void;
|
||||||
onPendingModifiersConsumed?: () => Promise<void> | void;
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
|
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
|
||||||
pendingModifiers?: PendingTerminalModifiers;
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
focusRequestToken?: number;
|
focusRequestToken?: number;
|
||||||
resizeRequestToken?: number;
|
resizeRequestToken?: number;
|
||||||
@@ -181,6 +183,7 @@ export default function TerminalEmulator({
|
|||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
onPendingModifiersConsumed,
|
onPendingModifiersConsumed,
|
||||||
|
onRendererReadyChange,
|
||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
resizeRequestToken = 0,
|
resizeRequestToken = 0,
|
||||||
@@ -198,6 +201,8 @@ export default function TerminalEmulator({
|
|||||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||||
const xtermThemeRef = useRef(xtermTheme);
|
const xtermThemeRef = useRef(xtermTheme);
|
||||||
xtermThemeRef.current = xtermTheme;
|
xtermThemeRef.current = xtermTheme;
|
||||||
|
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
|
||||||
|
onRendererReadyChangeRef.current = onRendererReadyChange;
|
||||||
const mountCallbacksRef = useRef({
|
const mountCallbacksRef = useRef({
|
||||||
onInput,
|
onInput,
|
||||||
onResize,
|
onResize,
|
||||||
@@ -389,9 +394,11 @@ export default function TerminalEmulator({
|
|||||||
initialSnapshot: initialSnapshotRef.current,
|
initialSnapshot: initialSnapshotRef.current,
|
||||||
theme: mountedThemeRef.current,
|
theme: mountedThemeRef.current,
|
||||||
});
|
});
|
||||||
|
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
runtime.unmount();
|
runtime.unmount();
|
||||||
|
onRendererReadyChangeRef.current?.({ streamKey, isReady: false });
|
||||||
if (runtimeRef.current === runtime) {
|
if (runtimeRef.current === runtime) {
|
||||||
runtimeRef.current = null;
|
runtimeRef.current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import {
|
||||||
|
applyTerminalRendererReadyChange,
|
||||||
|
shouldReplayTerminalSnapshotForRenderer,
|
||||||
|
shouldShowTerminalLoadingOverlay,
|
||||||
|
type TerminalRendererReadyChange,
|
||||||
|
} from "@/utils/terminal-renderer-readiness";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -164,6 +170,7 @@ export function TerminalPane({
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
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,
|
// Keep the latest measured size for whichever client currently owns the pane,
|
||||||
// but only dedupe resizes that this specific client has already pushed.
|
// but only dedupe resizes that this specific client has already pushed.
|
||||||
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
@@ -175,6 +182,7 @@ export function TerminalPane({
|
|||||||
);
|
);
|
||||||
const [isAttaching, setIsAttaching] = useState(false);
|
const [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
|
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||||
@@ -195,6 +203,20 @@ export function TerminalPane({
|
|||||||
const requestTerminalReflow = useCallback(() => {
|
const requestTerminalReflow = useCallback(() => {
|
||||||
setResizeRequestToken((current) => current + 1);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||||
@@ -617,6 +639,13 @@ export function TerminalPane({
|
|||||||
if (!swipeGesturesEnabled) return;
|
if (!swipeGesturesEnabled) return;
|
||||||
onOpenFileExplorer();
|
onOpenFileExplorer();
|
||||||
}, [swipeGesturesEnabled, onOpenFileExplorer]);
|
}, [swipeGesturesEnabled, onOpenFileExplorer]);
|
||||||
|
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
|
||||||
|
isWorkspaceFocused,
|
||||||
|
hasStreamError: Boolean(streamError),
|
||||||
|
isAttaching,
|
||||||
|
rendererReadyStreamKey,
|
||||||
|
terminalStreamKey,
|
||||||
|
});
|
||||||
|
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
return (
|
return (
|
||||||
@@ -634,11 +663,12 @@ export function TerminalPane({
|
|||||||
<TerminalEmulator
|
<TerminalEmulator
|
||||||
ref={emulatorRef}
|
ref={emulatorRef}
|
||||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||||
streamKey={`${scopeKey}:${terminalId}`}
|
streamKey={terminalStreamKey}
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
xtermTheme={xtermTheme}
|
xtermTheme={xtermTheme}
|
||||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
initialSnapshot={initialSnapshot}
|
initialSnapshot={initialSnapshot}
|
||||||
|
onRendererReadyChange={handleRendererReadyChange}
|
||||||
onSwipeRight={handleSwipeRight}
|
onSwipeRight={handleSwipeRight}
|
||||||
onSwipeLeft={handleSwipeLeft}
|
onSwipeLeft={handleSwipeLeft}
|
||||||
onInput={handleTerminalData}
|
onInput={handleTerminalData}
|
||||||
@@ -654,7 +684,7 @@ export function TerminalPane({
|
|||||||
<View style={styles.terminalGestureContainer} />
|
<View style={styles.terminalGestureContainer} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAttaching && isWorkspaceFocused ? (
|
{showLoadingOverlay ? (
|
||||||
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</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